[
  {
    "path": ".clinerules/01-basic.md",
    "content": "# LightRAG Project Intelligence (.clinerules)\n\n## Project Overview\nLightRAG is a mature, production-ready Retrieval-Augmented Generation (RAG) system with comprehensive knowledge graph capabilities. The system has evolved from experimental to production-ready status with extensive functionality across all major components.\n\n## Current System State (August 15, 2025)\n- **Status**: Production Ready - Stable and Mature\n- **Configuration**: Gemini 2.5 Flash + BAAI/bge-m3 embeddings via custom endpoints\n- **Storage**: Default in-memory with file persistence (JsonKVStorage, NetworkXStorage, NanoVectorDBStorage)\n- **Language**: Chinese for summaries\n- **Workspace**: `space1` for data isolation\n- **Authentication**: JWT-based with admin/user accounts\n\n## Critical Implementation Patterns\n\n### 1. Embedding Format Compatibility (CRITICAL)\n**Pattern**: Always handle both base64 and raw array embedding formats\n**Location**: `lightrag/llm/openai.py` - `openai_embed` function\n**Issue**: Custom OpenAI-compatible endpoints return embeddings as raw arrays, not base64 strings\n**Solution**:\n```python\nnp.array(dp.embedding, dtype=np.float32) if isinstance(dp.embedding, list)\nelse np.frombuffer(base64.b64decode(dp.embedding), dtype=np.float32)\n```\n**Impact**: Document processing fails completely without this dual format support\n\n### 2. Async Pattern Consistency (CRITICAL)\n**Pattern**: Always await coroutines before calling methods on the result\n**Common Error**: `coroutine.method()` instead of `(await coroutine).method()`\n**Locations**: MongoDB implementations, Neo4j operations\n**Example**: `await self._data.list_indexes()` then `await cursor.to_list()`\n\n### 3. Storage Layer Data Compatibility (CRITICAL)\n**Pattern**: Always filter deprecated/incompatible fields during deserialization\n**Common Fields to Remove**: `content`, `_id` (MongoDB), database-specific fields\n**Implementation**: `data.pop('field_name', None)` before creating dataclass objects\n**Locations**: All storage implementations (JSON, Redis, MongoDB, PostgreSQL)\n\n### 4. Lock Key Generation (CRITICAL)\n**Pattern**: Always sort relationship pairs for consistent lock keys\n**Implementation**: `sorted_key_parts = sorted([src, tgt])` then `f\"{sorted_key_parts[0]}-{sorted_key_parts[1]}\"`\n**Impact**: Prevents deadlocks in concurrent relationship processing\n\n### 5. Event Loop Management (CRITICAL)\n**Pattern**: Handle event loop mismatches during shutdown gracefully\n**Implementation**: Timeout + specific RuntimeError handling for \"attached to a different loop\"\n**Location**: Neo4j storage finalization\n**Impact**: Prevents application shutdown failures\n\n### 6. Async Generator Lock Management (CRITICAL)\n**Pattern**: Never hold locks across async generator yields - create snapshots instead\n**Issue**: Holding locks while yielding causes deadlock when consumers need the same lock\n**Location**: `lightrag/tools/migrate_llm_cache.py` - `stream_default_caches_json`\n**Solution**: Create snapshot of data while holding lock, release lock, then iterate over snapshot\n```python\n# WRONG - Deadlock prone:\nasync with storage._storage_lock:\n    for key, value in storage._data.items():\n        batch[key] = value\n        if len(batch) >= batch_size:\n            yield batch  # Lock still held!\n\n# CORRECT - Snapshot approach:\nasync with storage._storage_lock:\n    matching_items = [(k, v) for k, v in storage._data.items() if condition]\n# Lock released here\nfor key, value in matching_items:\n    batch[key] = value\n    if len(batch) >= batch_size:\n        yield batch  # No lock held\n```\n**Impact**: Prevents deadlocks in Json→Json migrations and similar scenarios where source/target share locks\n**Applicable To**: Any async generator that needs to access shared resources while yielding\n\n## Architecture Patterns\n\n### 1. Dependency Injection\n**Pattern**: Pass configuration through object constructors, not direct imports\n**Example**: OllamaAPI receives configuration through LightRAG object\n**Benefit**: Better testability and modularity\n\n### 2. Memory Bank Documentation\n**Pattern**: Maintain comprehensive memory bank for development continuity\n**Structure**: Core files (projectbrief.md, activeContext.md, progress.md, etc.)\n**Purpose**: Essential for context preservation across development sessions\n\n### 3. Configuration Management\n**Pattern**: Centralize defaults in constants.py, use environment variables for runtime config\n**Implementation**: Default values in constants, override via .env file\n**Benefit**: Consistent configuration across components\n\n## Development Workflow Patterns\n\n### 1. Frontend Development (CRITICAL)\n**Package Manager**: **ALWAYS USE BUN** - Never use npm or yarn unless Bun is unavailable\n**Commands**:\n- `bun install` - Install dependencies\n- `bun run dev` - Start development server\n- `bun run build` - Build for production\n- `bun run lint` - Run linting\n- `bun test` - Run tests\n- `bun run preview` - Preview production build\n\n**Pattern**: All frontend operations must use Bun commands\n**Fallback**: Only use npm/yarn if Bun installation fails\n**Testing**: Use `bun test` for all frontend testing\n\n### 2. Bug Fix Approach\n1. **Identify root cause** - Don't just fix symptoms\n2. **Implement robust solution** - Handle edge cases and format variations\n3. **Maintain backward compatibility** - Preserve existing functionality\n4. **Add comprehensive error handling** - Graceful degradation\n5. **Document the fix** - Update memory bank with technical details\n\n### 3. Feature Implementation\n1. **Follow existing patterns** - Maintain architectural consistency\n2. **Use dependency injection** - Avoid direct imports between modules\n3. **Implement comprehensive error handling** - Handle all failure modes\n4. **Add proper logging** - Debug and warning messages\n5. **Update documentation** - Memory bank and code comments\n6. **Comment Language** - Use English for comments and documentation\n\n### 4. Performance Optimization\n1. **Profile before optimizing** - Identify actual bottlenecks\n2. **Maintain algorithmic correctness** - Don't sacrifice functionality for speed\n3. **Use appropriate data structures** - Match structure to access patterns\n4. **Implement caching strategically** - Cache expensive operations\n5. **Monitor memory usage** - Prevent memory leaks\n\n### 5. Testing Workflow (CRITICAL)\n**Pattern**: All tests must use pytest markers for proper CI/CD execution\n**Test Categories**:\n- **Offline Tests**: Use `@pytest.mark.offline` - No external dependencies (runs in CI)\n- **Integration Tests**: Use `@pytest.mark.integration` - Requires databases/APIs (skipped by default)\n\n**Commands**:\n- `pytest tests/ -m offline -v` - CI default (~3 seconds for 21 tests)\n- `pytest tests/ --run-integration -v` - Full test suite (all 46 tests)\n\n**Best Practices**:\n1. **Prefer offline tests** - Use mocks for LLM, embeddings, databases\n2. **Mock external dependencies** - AsyncMock for async functions\n3. **Test isolation** - Each test should be independent\n4. **Documentation** - Add docstrings explaining purpose and scope\n\n**Configuration**:\n- `tests/pytest.ini` - Marker definitions and test discovery\n- `tests/conftest.py` - Fixtures and custom options\n- `.github/workflows/tests.yml` - CI/CD workflow (Python 3.10/3.11/3.12)\n\n**Documentation**: See `memory-bank/testing-guidelines.md` for complete testing guidelines\n\n**Impact**: Ensures all tests run reliably in CI without external services while maintaining comprehensive integration test coverage for local development\n\n## Technology Stack Intelligence\n\n### 1. LLM Integration\n- **Primary**: Gemini 2.5 Flash via custom endpoint\n- **Embedding**: BAAI/bge-m3 via custom endpoint\n- **Reranking**: BAAI/bge-reranker-v2-m3\n- **Pattern**: Always handle multiple provider formats\n\n### 2. Storage Backends\n- **Default**: In-memory with file persistence\n- **Production Options**: PostgreSQL, MongoDB, Redis, Neo4j\n- **Pattern**: Abstract storage interface with multiple implementations\n\n### 3. API Architecture\n- **Framework**: FastAPI with Gunicorn for production\n- **Authentication**: JWT-based with role support\n- **Compatibility**: Ollama-compatible endpoints for easy integration\n\n### 4. Frontend\n- **Framework**: React with TypeScript\n- **Package Manager**: **BUN (REQUIRED)** - Always use Bun for all frontend operations\n- **Build Tool**: Vite with Bun runtime\n- **Visualization**: Sigma.js for graph rendering\n- **State Management**: React hooks with context\n- **Internationalization**: i18next for multi-language support\n\n## Common Pitfalls and Solutions\n\n### 1. Embedding Format Issues\n**Pitfall**: Assuming all endpoints return base64-encoded embeddings\n**Solution**: Always check format and handle both base64 and raw arrays\n\n### 2. Async/Await Patterns\n**Pitfall**: Calling methods on coroutines instead of awaited results\n**Solution**: Always await coroutines before accessing their methods\n\n### 3. Data Model Evolution\n**Pitfall**: Breaking changes when removing fields from dataclasses\n**Solution**: Filter deprecated fields during deserialization, don't break storage\n\n### 4. Concurrency Issues\n**Pitfall**: Inconsistent lock key generation causing deadlocks\n**Solution**: Always sort keys for deterministic lock ordering\n\n### 5. Event Loop Management\n**Pitfall**: Event loop mismatches during shutdown\n**Solution**: Implement timeout and specific error handling for loop issues\n\n## Performance Considerations\n\n### 1. Query Context Building\n- **Algorithm**: Linear gradient weighted polling for fair resource allocation\n- **Optimization**: Round-robin merging to eliminate mode bias\n- **Pattern**: Smart chunk selection based on cross-entity occurrence\n\n### 2. Graph Operations\n- **Optimization**: Batch operations where possible\n- **Pattern**: Use appropriate indexing for large datasets\n- **Consideration**: Memory usage with large graphs\n\n### 3. LLM Request Management\n- **Pattern**: Priority-based queue for request ordering\n- **Optimization**: Connection pooling and retry mechanisms\n- **Consideration**: Rate limiting and cost management\n\n## Security Patterns\n\n### 1. Authentication\n- **Implementation**: JWT tokens with role-based access\n- **Pattern**: Stateless authentication with configurable expiration\n- **Security**: Proper token validation and refresh mechanisms\n\n### 2. API Security\n- **Pattern**: Input validation and sanitization\n- **Implementation**: FastAPI dependency injection for auth\n- **Consideration**: Rate limiting and abuse prevention\n\n## Maintenance Guidelines\n\n### 1. Memory Bank Updates\n- **Trigger**: After significant changes or bug fixes\n- **Pattern**: Update activeContext.md and progress.md\n- **Purpose**: Maintain development continuity\n\n### 2. Configuration Management\n- **Pattern**: Environment-based configuration with sensible defaults\n- **Implementation**: .env files with example templates\n- **Consideration**: Security for production deployments\n\n### 3. Error Handling\n- **Pattern**: Comprehensive logging with appropriate levels\n- **Implementation**: Graceful degradation where possible\n- **Consideration**: User-friendly error messages\n\n## Project Evolution Notes\n\nThe project has evolved from experimental to production-ready status. Key milestones:\n- **Early 2025**: Basic RAG implementation\n- **Mid 2025**: Multiple storage backends and LLM providers\n- **July 2025**: Major query optimization and algorithm improvements\n- **August 2025**: Production-ready stable state\n\nThe system now supports enterprise-level deployments with comprehensive functionality across all components.\n"
  },
  {
    "path": ".dockerignore",
    "content": "# Python-related files and directories\n__pycache__\n.cache\n\n# Virtual environment directories\n*.venv\n\n# Env\nenv/\n*.env*\n.env_example\n\n# Distribution / build files\nsite\ndist/\nbuild/\n.eggs/\n*.egg-info/\n*.tgz\n*.tar.gz\n\n# Exclude siles and folders\n*.yml\n.dockerignore\nDockerfile\nMakefile\n\n# Exclude other projects\n/tests\n/scripts\n/data\n/dickens\n/reproduce\n/output_complete\n/rag_storage\n/inputs\n\n# Python version manager file\n.python-version\n\n# Reports\n*.coverage/\n*.log\nlog/\n*.logfire\n\n# Cache\n.cache/\n.mypy_cache\n.pytest_cache\n.ruff_cache\n.gradio\n.logfire\ntemp/\n\n# MacOS-related files\n.DS_Store\n\n# VS Code settings (local configuration files)\n.vscode\n\n# file\nTODO.md\n\n# Exclude Git-related files\n.git\n.github\n.gitignore\n.pre-commit-config.yaml\n"
  },
  {
    "path": ".gitattributes",
    "content": "lightrag/api/webui/** binary\nlightrag/api/webui/** linguist-generated\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: Bug Report\ndescription: File a bug report\ntitle: \"[Bug]:\"\nlabels: [\"bug\", \"triage\"]\n\nbody:\n  - type: checkboxes\n    id: existingcheck\n    attributes:\n      label: Do you need to file an issue?\n      description: Please help us manage our time by avoiding duplicates and common bugs with the steps below.\n      options:\n        - label: I have searched the existing issues and this bug is not already filed.\n        - label: I believe this is a legitimate bug, not just a question or feature request.\n  - type: textarea\n    id: description\n    attributes:\n      label: Describe the bug\n      description: A clear and concise description of what the bug is.\n      placeholder: What went wrong?\n  - type: textarea\n    id: reproduce\n    attributes:\n      label: Steps to reproduce\n      description: Steps to reproduce the behavior.\n      placeholder: How can we replicate the issue?\n  - type: textarea\n    id: expected_behavior\n    attributes:\n      label: Expected Behavior\n      description: A clear and concise description of what you expected to happen.\n      placeholder: What should have happened?\n  - type: textarea\n    id: configused\n    attributes:\n      label: LightRAG Config Used\n      description: The LightRAG configuration used for the run.\n      placeholder: The settings content or LightRAG configuration\n      value: |\n        # Paste your config here\n  - type: textarea\n    id: screenshotslogs\n    attributes:\n      label: Logs and screenshots\n      description: If applicable, add screenshots and logs to help explain your problem.\n      placeholder: Add logs and screenshots here\n  - type: textarea\n    id: additional_information\n    attributes:\n      label: Additional Information\n      description: |\n        - LightRAG Version: e.g., v0.1.1\n        - Operating System: e.g., Windows 10, Ubuntu 20.04\n        - Python Version: e.g., 3.8\n        - Related Issues: e.g., #1\n        - Any other relevant information.\n      value: |\n        - LightRAG Version:\n        - Operating System:\n        - Python Version:\n        - Related Issues:\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: Feature Request\ndescription: File a feature request\nlabels: [\"enhancement\"]\ntitle: \"[Feature Request]:\"\n\nbody:\n  - type: checkboxes\n    id: existingcheck\n    attributes:\n      label: Do you need to file a feature request?\n      description: Please help us manage our time by avoiding duplicates and common feature request with the steps below.\n      options:\n        - label: I have searched the existing feature request and this feature request is not already filed.\n        - label: I believe this is a legitimate feature request, not just a question or bug.\n  - type: textarea\n    id: feature_request_description\n    attributes:\n      label: Feature Request Description\n      description: A clear and concise description of the feature request you would like.\n      placeholder: What this feature request add more or improve?\n  - type: textarea\n    id: additional_context\n    attributes:\n      label: Additional Context\n      description: Add any other context or screenshots about the feature request here.\n      placeholder: Any additional information\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/question.yml",
    "content": "name: Question\ndescription: Ask a general question\nlabels: [\"question\"]\ntitle: \"[Question]:\"\n\nbody:\n  - type: checkboxes\n    id: existingcheck\n    attributes:\n      label: Do you need to ask a question?\n      description: Please help us manage our time by avoiding duplicates and common questions with the steps below.\n      options:\n        - label: I have searched the existing question and discussions and this question is not already answered.\n        - label: I believe this is a legitimate question, not just a bug or feature request.\n  - type: textarea\n    id: question\n    attributes:\n      label: Your Question\n      description: A clear and concise description of your question.\n      placeholder: What is your question?\n  - type: textarea\n    id: context\n    attributes:\n      label: Additional Context\n      description: Provide any additional context or details that might help us understand your question better.\n      placeholder: Add any relevant information here\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# Keep GitHub Actions up to date with GitHub's Dependabot...\n# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot\n# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem\nversion: 2\nupdates:\n  # ============================================================\n  # GitHub Actions\n  # PR Strategy:\n  #   - All updates (major/minor/patch): Grouped into a single PR\n  # ============================================================\n  - package-ecosystem: github-actions\n    directory: /\n    groups:\n      github-actions:\n        patterns:\n          - \"*\"  # Group all Actions updates into a single larger pull request\n    schedule:\n      interval: weekly\n      day: monday\n      time: \"02:00\"\n      timezone: \"Asia/Shanghai\"\n    labels:\n      - \"dependencies\"\n      - \"github-actions\"\n    open-pull-requests-limit: 2\n\n  # ============================================================\n  # Python (pip) Dependencies\n  # PR Strategy:\n  #   - Major updates: Individual PR per package (except numpy which is ignored)\n  #   - Minor updates: Grouped by category (llm-providers, storage, etc.)\n  #   - Patch updates: Grouped by category\n  # ============================================================\n  - package-ecosystem: \"pip\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n      day: \"wednesday\"\n      time: \"02:00\"\n      timezone: \"Asia/Shanghai\"\n    cooldown:\n      default-days: 5\n      semver-major-days: 30\n      semver-minor-days: 7\n      semver-patch-days: 3\n    groups:\n      # Core dependencies - LLM providers and embeddings\n      llm-providers:\n        patterns:\n          - \"openai\"\n          - \"anthropic\"\n          - \"google-*\"\n          - \"boto3\"\n          - \"botocore\"\n          - \"ollama\"\n        update-types:\n          - \"minor\"\n          - \"patch\"\n      # Storage backends\n      storage:\n        patterns:\n          - \"neo4j\"\n          - \"pymongo\"\n          - \"redis\"\n          - \"psycopg*\"\n          - \"asyncpg\"\n          - \"milvus*\"\n          - \"qdrant*\"\n        update-types:\n          - \"minor\"\n          - \"patch\"\n      # Data processing and ML\n      data-processing:\n        patterns:\n          - \"numpy\"\n          - \"scipy\"\n          - \"pandas\"\n          - \"tiktoken\"\n          - \"transformers\"\n          - \"torch*\"\n        update-types:\n          - \"minor\"\n          - \"patch\"\n      # Web framework and API\n      web-framework:\n        patterns:\n          - \"fastapi\"\n          - \"uvicorn\"\n          - \"gunicorn\"\n          - \"starlette\"\n          - \"pydantic*\"\n        update-types:\n          - \"minor\"\n          - \"patch\"\n      # Development and testing tools\n      dev-tools:\n        patterns:\n          - \"pytest*\"\n          - \"ruff\"\n          - \"pre-commit\"\n          - \"black\"\n          - \"mypy\"\n        update-types:\n          - \"minor\"\n          - \"patch\"\n      # Minor and patch updates for everything else\n      python-minor-patch:\n        patterns:\n          - \"*\"\n        update-types:\n          - \"minor\"\n          - \"patch\"\n    ignore:\n      - dependency-name: \"numpy\"\n        update-types:\n          - \"version-update:semver-major\"\n    labels:\n      - \"dependencies\"\n      - \"python\"\n    open-pull-requests-limit: 5\n\n  # ============================================================\n  # Frontend (bun) Dependencies\n  # PR Strategy:\n  #   - Major updates: Individual PR per package\n  #   - Minor updates: Grouped by category (react, ui-components, etc.)\n  #   - Patch updates: Grouped by category\n  # ============================================================\n  - package-ecosystem: \"bun\"\n    directory: \"/lightrag_webui\"\n    schedule:\n      interval: \"weekly\"\n      day: \"friday\"\n      time: \"02:00\"\n      timezone: \"Asia/Shanghai\"\n    cooldown:\n      default-days: 5\n      semver-major-days: 30\n      semver-minor-days: 7\n      semver-patch-days: 3\n    groups:\n      # React ecosystem\n      react:\n        patterns:\n          - \"react\"\n          - \"react-dom\"\n          - \"react-router*\"\n          - \"@types/react*\"\n        update-types:\n          - \"minor\"\n          - \"patch\"\n      # UI components and styling\n      ui-components:\n        patterns:\n          - \"@radix-ui/*\"\n          - \"tailwind*\"\n          - \"@tailwindcss/*\"\n          - \"lucide-react\"\n          - \"class-variance-authority\"\n          - \"clsx\"\n        update-types:\n          - \"minor\"\n          - \"patch\"\n      # Graph visualization\n      graph-viz:\n        patterns:\n          - \"sigma\"\n          - \"@sigma/*\"\n          - \"graphology*\"\n        update-types:\n          - \"minor\"\n          - \"patch\"\n      # Build tools and dev dependencies\n      build-tools:\n        patterns:\n          - \"vite\"\n          - \"@vitejs/*\"\n          - \"typescript\"\n          - \"eslint*\"\n          - \"@eslint/*\"\n          - \"typescript-eslint\"\n          - \"prettier\"\n          - \"prettier-*\"\n          - \"@types/bun\"\n        update-types:\n          - \"minor\"\n          - \"patch\"\n      # Content rendering libraries (math, diagrams, etc.)\n      content-rendering:\n        patterns:\n          - \"katex\"\n          - \"mermaid\"\n        update-types:\n          - \"minor\"\n          - \"patch\"\n      # All other minor and patch updates\n      frontend-minor-patch:\n        patterns:\n          - \"*\"\n        update-types:\n          - \"minor\"\n          - \"patch\"\n    labels:\n      - \"dependencies\"\n      - \"frontend\"\n    open-pull-requests-limit: 5\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "<!--\nThanks for contributing to LightRAG!\n\nPlease ensure your pull request is ready for review before submitting.\n\nAbout this template\n\nThis template helps contributors provide a clear and concise description of their changes. Feel free to adjust it as needed.\n-->\n\n## Description\n\n[Briefly describe the changes made in this pull request.]\n\n## Related Issues\n\n[Reference any related issues or tasks addressed by this pull request.]\n\n## Changes Made\n\n[List the specific changes made in this pull request.]\n\n## Checklist\n\n- [ ] Changes tested locally\n- [ ] Code reviewed\n- [ ] Documentation updated (if necessary)\n- [ ] Unit tests added (if applicable)\n\n## Additional Notes\n\n[Add any additional notes or context for the reviewer(s).]\n"
  },
  {
    "path": ".github/workflows/claude.yml",
    "content": "name: Claude Code\n\non:\n  issue_comment:\n    types: [created]\n  pull_request_review_comment:\n    types: [created]\n  issues:\n    types: [opened, assigned]\n  pull_request_review:\n    types: [submitted]\n\njobs:\n  claude:\n    if: |\n      (\n        (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||\n        (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||\n        (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||\n        (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))\n      ) && (\n        github.event.comment.author_association == 'OWNER' ||\n        github.event.comment.author_association == 'MEMBER' ||\n        github.event.comment.author_association == 'COLLABORATOR' ||\n        github.event.review.author_association == 'OWNER' ||\n        github.event.review.author_association == 'MEMBER' ||\n        github.event.review.author_association == 'COLLABORATOR' ||\n        github.event.issue.author_association == 'OWNER' ||\n        github.event.issue.author_association == 'MEMBER' ||\n        github.event.issue.author_association == 'COLLABORATOR'\n      )\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n      pull-requests: write\n      issues: write\n      id-token: write\n      actions: read # Required for Claude to read CI results on PRs\n    steps:\n      - name: Get PR details for checkout\n        if: github.event.issue.pull_request || github.event_name == 'pull_request_review_comment' || github.event_name == 'pull_request_review'\n        id: pr_details\n        env:\n          GH_TOKEN: ${{ github.token }}\n        run: |\n          # Get PR number from the event\n          if [ \"${{ github.event_name }}\" == \"issue_comment\" ]; then\n            PR_NUMBER=${{ github.event.issue.number }}\n          elif [ \"${{ github.event_name }}\" == \"pull_request_review_comment\" ]; then\n            PR_NUMBER=${{ github.event.pull_request.number }}\n          elif [ \"${{ github.event_name }}\" == \"pull_request_review\" ]; then\n            PR_NUMBER=${{ github.event.pull_request.number }}\n          fi\n\n          if [ -n \"$PR_NUMBER\" ]; then\n            echo \"Fetching PR #$PR_NUMBER details\"\n            PR_DATA=$(gh pr view $PR_NUMBER -R ${{ github.repository }} --json headRefName,headRepository,headRepositoryOwner)\n            HEAD_REF=$(echo \"$PR_DATA\" | jq -r '.headRefName')\n            HEAD_REPO=$(echo \"$PR_DATA\" | jq -r '.headRepository.name')\n            HEAD_OWNER=$(echo \"$PR_DATA\" | jq -r '.headRepositoryOwner.login')\n\n            echo \"pr_number=$PR_NUMBER\" >> $GITHUB_OUTPUT\n            echo \"head_ref=$HEAD_REF\" >> $GITHUB_OUTPUT\n            echo \"head_repo=$HEAD_REPO\" >> $GITHUB_OUTPUT\n            echo \"head_owner=$HEAD_OWNER\" >> $GITHUB_OUTPUT\n            echo \"repository=$HEAD_OWNER/$HEAD_REPO\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Checkout repository\n        uses: actions/checkout@v6\n        with:\n          repository: ${{ steps.pr_details.outputs.repository || github.repository }}\n          ref: ${{ steps.pr_details.outputs.head_ref || github.ref }}\n          fetch-depth: 0\n\n      - name: Run Claude Code\n        id: claude\n        uses: anthropics/claude-code-action@v1\n        with:\n          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}\n\n          # This is an optional setting that allows Claude to read CI results on PRs\n          additional_permissions: |\n            actions: read\n\n          # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.\n          # prompt: 'Update the pull request description to include a summary of changes.'\n\n          # Optional: Add claude_args to customize behavior and configuration\n          # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md\n          # or https://code.claude.com/docs/en/cli-reference for available options\n          # claude_args: '--allowed-tools Bash(gh pr:*)'\n"
  },
  {
    "path": ".github/workflows/copilot-setup-steps.yml",
    "content": "name: \"Copilot Setup Steps\"\n\n# Automatically run the setup steps when they are changed to allow for easy validation, and\n# allow manual testing through the repository's \"Actions\" tab\non:\n  workflow_dispatch:\n  push:\n    paths:\n      - .github/workflows/copilot-setup-steps.yml\n  pull_request:\n    paths:\n      - .github/workflows/copilot-setup-steps.yml\n\njobs:\n  # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.\n  copilot-setup-steps:\n    runs-on: ubuntu-latest\n\n    # Timeout after 30 minutes (maximum is 59)\n    timeout-minutes: 30\n\n    # You can define any steps you want, and they will run before the agent starts.\n    # If you do not check out your code, Copilot will do this for you.\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Python 3.11\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.11'\n\n      - name: Cache pip packages\n        uses: actions/cache@v5\n        with:\n          path: ~/.cache/pip\n          key: ${{ runner.os }}-pip-copilot-${{ hashFiles('**/pyproject.toml') }}\n          restore-keys: |\n            ${{ runner.os }}-pip-copilot-\n            ${{ runner.os }}-pip-\n\n      - name: Install Python dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install -e \".[api]\"\n          pip install pytest pytest-asyncio httpx\n\n      - name: Create minimal frontend stub for Copilot agent\n        run: |\n          mkdir -p lightrag/api/webui\n          echo '<!DOCTYPE html><html><head><title>LightRAG - Copilot Agent</title></head><body><h1>Copilot Agent Mode</h1></body></html>' > lightrag/api/webui/index.html\n          echo \"Created minimal frontend stub for Copilot agent environment\"\n\n      - name: Verify installation\n        run: |\n          python --version\n          pip list | grep lightrag\n          lightrag-server --help || echo \"Note: Server requires .env configuration to run\"\n"
  },
  {
    "path": ".github/workflows/docker-build-lite.yml",
    "content": "name: Build Lite Docker Image\n\non:\n  workflow_dispatch:\n    inputs:\n      _notes_:\n        description: '⚠️ Create lite Docker images only after non-trivial version releases.'\n        required: false\n        type: boolean\n        default: false\n\npermissions:\n  contents: read\n  packages: write\n\njobs:\n  build-and-push-lite:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Get latest tag\n        id: get_tag\n        run: |\n          LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo \"\")\n          if [ -z \"$LATEST_TAG\" ]; then\n            LATEST_TAG=\"sha-$(git rev-parse --short HEAD)\"\n            echo \"No tags found, using commit SHA: $LATEST_TAG\"\n          else\n            echo \"Latest tag found: $LATEST_TAG\"\n          fi\n          echo \"tag=$LATEST_TAG\" >> $GITHUB_OUTPUT\n\n      - name: Prepare lite tag\n        id: lite_tag\n        run: |\n          LITE_TAG=\"${{ steps.get_tag.outputs.tag }}-lite\"\n          echo \"Lite image tag: $LITE_TAG\"\n          echo \"lite_tag=$LITE_TAG\" >> $GITHUB_OUTPUT\n\n      - name: Update version in __init__.py\n        run: |\n          sed -i \"s/__version__ = \\\".*\\\"/__version__ = \\\"${{ steps.get_tag.outputs.tag }}\\\"/\" lightrag/__init__.py\n          cat lightrag/__init__.py | grep __version__\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata for Docker\n        id: meta\n        uses: docker/metadata-action@v6\n        with:\n          images: ghcr.io/${{ github.repository }}\n          tags: |\n            type=raw,value=${{ steps.lite_tag.outputs.lite_tag }}\n            type=raw,value=lite\n\n      - name: Build and push lite Docker image\n        uses: docker/build-push-action@v7\n        with:\n          context: .\n          file: ./Dockerfile.lite\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=min\n\n      - name: Output image details\n        run: |\n          echo \"Lite Docker image built and pushed successfully!\"\n          echo \"Image tag: ghcr.io/${{ github.repository }}:${{ steps.lite_tag.outputs.lite_tag }}\"\n          echo \"Base Git tag used: ${{ steps.get_tag.outputs.tag }}\"\n"
  },
  {
    "path": ".github/workflows/docker-build-manual.yml",
    "content": "name: Build Test Docker Image manually\n\non:\n  workflow_dispatch:\n    inputs:\n      _notes_:\n        description: '⚠️ Please create a new git tag before building the docker image.'\n        required: false\n        type: boolean\n        default: false\n\npermissions:\n  contents: read\n  packages: write\n\njobs:\n  build-and-push:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0  # Fetch all history for tags\n\n      - name: Get latest tag\n        id: get_tag\n        run: |\n          # Get the latest tag, fallback to commit SHA if no tags exist\n          LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo \"\")\n          if [ -z \"$LATEST_TAG\" ]; then\n            LATEST_TAG=\"sha-$(git rev-parse --short HEAD)\"\n            echo \"No tags found, using commit SHA: $LATEST_TAG\"\n          else\n            echo \"Latest tag found: $LATEST_TAG\"\n          fi\n          echo \"tag=$LATEST_TAG\" >> $GITHUB_OUTPUT\n          echo \"image_tag=$LATEST_TAG\" >> $GITHUB_OUTPUT\n\n      - name: Update version in __init__.py\n        run: |\n          sed -i \"s/__version__ = \\\".*\\\"/__version__ = \\\"${{ steps.get_tag.outputs.tag }}\\\"/\" lightrag/__init__.py\n          echo \"Updated __init__.py with version ${{ steps.get_tag.outputs.tag }}\"\n          cat lightrag/__init__.py | grep __version__\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata for Docker\n        id: meta\n        uses: docker/metadata-action@v6\n        with:\n          images: ghcr.io/${{ github.repository }}\n          tags: |\n            type=raw,value=${{ steps.get_tag.outputs.tag }}\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v7\n        with:\n          context: .\n          file: ./Dockerfile\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n\n      - name: Output image details\n        run: |\n          echo \"Docker image built and pushed successfully!\"\n          echo \"Image tags:\"\n          echo \"  - ghcr.io/${{ github.repository }}:${{ steps.get_tag.outputs.tag }}\"\n          echo \"Latest Git tag used: ${{ steps.get_tag.outputs.tag }}\"\n"
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "content": "name: Build Latest Docker Image on Release\n\non:\n  release:\n    types: [published]\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  packages: write\n\njobs:\n  build-and-push:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0  # Fetch all history for tags\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Get latest tag\n        id: get_tag\n        run: |\n          TAG=$(git describe --tags --abbrev=0)\n          echo \"Found tag: $TAG\"\n          echo \"tag=$TAG\" >> $GITHUB_OUTPUT\n\n      - name: Check if pre-release\n        id: check_prerelease\n        run: |\n          TAG=\"${{ steps.get_tag.outputs.tag }}\"\n          if [[ \"$TAG\" == *\"rc\"* ]] || [[ \"$TAG\" == *\"dev\"* ]]; then\n            echo \"is_prerelease=true\" >> $GITHUB_OUTPUT\n            echo \"This is a pre-release version: $TAG\"\n          else\n            echo \"is_prerelease=false\" >> $GITHUB_OUTPUT\n            echo \"This is a stable release: $TAG\"\n          fi\n\n      - name: Update version in __init__.py\n        run: |\n          sed -i \"s/__version__ = \\\".*\\\"/__version__ = \\\"${{ steps.get_tag.outputs.tag }}\\\"/\" lightrag/__init__.py\n          echo \"Updated __init__.py with version ${{ steps.get_tag.outputs.tag }}\"\n          cat lightrag/__init__.py | grep __version__\n\n      - name: Extract metadata for Docker\n        id: meta\n        uses: docker/metadata-action@v6\n        with:\n          images: ghcr.io/${{ github.repository }}\n          tags: |\n            type=raw,value=${{ steps.get_tag.outputs.tag }}\n            type=raw,value=latest,enable=${{ steps.check_prerelease.outputs.is_prerelease == 'false' }}\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v7\n        with:\n          context: .\n          file: ./Dockerfile\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n"
  },
  {
    "path": ".github/workflows/linting.yaml",
    "content": "name: Linting and Formatting\n\non:\n    push:\n        branches:\n            - main\n    pull_request:\n        branches:\n            - main\n\njobs:\n    lint-and-format:\n        name: Linting and Formatting\n        runs-on: ubuntu-latest\n\n        steps:\n            - name: Checkout code\n              uses: actions/checkout@v6\n\n            - name: Set up Python\n              uses: actions/setup-python@v6\n              with:\n                python-version: '3.x'\n\n            - name: Install dependencies\n              run: |\n                python -m pip install --upgrade pip\n                pip install pre-commit\n\n            - name: Run pre-commit\n              run: pre-commit run --all-files --show-diff-on-failure\n"
  },
  {
    "path": ".github/workflows/pypi-publish.yml",
    "content": "name: Upload LightRAG-hku Package\n\non:\n  release:\n    types: [published]\n  workflow_dispatch:\n\npermissions:\n  contents: read\n\njobs:\n  release-build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          fetch-depth: 0  # Fetch all history for tags\n\n      # Build frontend WebUI\n      - name: Setup Bun\n        uses: oven-sh/setup-bun@v2\n        with:\n          bun-version: latest\n\n      - name: Build Frontend WebUI\n        run: |\n          cd lightrag_webui\n          bun install --frozen-lockfile\n          bun run build\n          cd ..\n\n      - name: Verify Frontend Build\n        run: |\n          if [ ! -f \"lightrag/api/webui/index.html\" ]; then\n            echo \"❌ Error: Frontend build failed - index.html not found\"\n            exit 1\n          fi\n          echo \"✅ Frontend build verified\"\n          echo \"Frontend files:\"\n          ls -lh lightrag/api/webui/ | head -10\n\n      - uses: actions/setup-python@v6\n        with:\n          python-version: \"3.x\"\n\n      - name: Get version from tag\n        id: get_version\n        run: |\n          TAG=$(git describe --tags --abbrev=0)\n          echo \"Found tag: $TAG\"\n          echo \"Extracted version: $TAG\"\n          echo \"version=$TAG\" >> $GITHUB_OUTPUT\n\n      - name: Update version in __init__.py\n        run: |\n          sed -i \"s/__version__ = \\\".*\\\"/__version__ = \\\"${{ steps.get_version.outputs.version }}\\\"/\" lightrag/__init__.py\n          echo \"Updated __init__.py with version ${{ steps.get_version.outputs.version }}\"\n          cat lightrag/__init__.py | grep __version__\n\n      - name: Build release distributions\n        run: |\n          python -m pip install build\n          python -m build\n\n      - name: Upload distributions\n        uses: actions/upload-artifact@v7\n        with:\n          name: release-dists\n          path: dist/\n\n  pypi-publish:\n    runs-on: ubuntu-latest\n    needs:\n      - release-build\n    permissions:\n      id-token: write\n\n    environment:\n      name: pypi\n\n    steps:\n      - name: Retrieve release distributions\n        uses: actions/download-artifact@v8\n        with:\n          name: release-dists\n          path: dist/\n\n      - name: Publish release distributions to PyPI\n        uses: pypa/gh-action-pypi-publish@release/v1\n        with:\n          packages-dir: dist/\n"
  },
  {
    "path": ".github/workflows/stale.yaml",
    "content": "# .github/workflows/stale.yml\nname: Mark stale issues and pull requests\n\non:\n  schedule:\n    - cron: '30 22 * * *' # run at 22:30+08 every day\n\npermissions:\n  issues: write\n  pull-requests: write\n\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/stale@v10\n        with:\n          days-before-stale: 90 # 90 days\n          days-before-close: 7 # 7 days after marked as stale\n          stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.'\n          close-issue-message: 'This issue has been automatically closed because it has not had recent activity. Please open a new issue if you still have this problem.'\n          stale-pr-message: 'This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.'\n          close-pr-message: 'This pull request has been automatically closed because it has not had recent activity.'\n          # If there are specific labels, exempt them from being marked as stale, for example:\n          exempt-issue-labels: 'enhancement,tracked'\n          # exempt-pr-labels: 'bug,enhancement,help wanted'\n          repo-token: ${{ secrets.GITHUB_TOKEN }} # token provided by GitHub\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: Offline Unit Tests\n\non:\n  push:\n    branches: [ main, dev ]\n  pull_request:\n    branches: [ main, dev ]\n\njobs:\n  offline-tests:\n    name: Offline Tests\n    runs-on: ubuntu-latest\n\n    strategy:\n      matrix:\n        python-version: ['3.12', '3.14']\n\n    steps:\n    - uses: actions/checkout@v6\n\n    - name: Set up Python ${{ matrix.python-version }}\n      uses: actions/setup-python@v6\n      with:\n        python-version: ${{ matrix.python-version }}\n\n    - name: Cache pip packages\n      uses: actions/cache@v5\n      with:\n        path: ~/.cache/pip\n        key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt', '**/pyproject.toml') }}\n        restore-keys: |\n          ${{ runner.os }}-pip-\n\n    - name: Install dependencies\n      run: |\n        python -m pip install --upgrade pip\n        pip install -e \".[api]\"\n        pip install pytest pytest-asyncio\n\n    - name: Run offline tests\n      run: |\n        # Run only tests marked as 'offline' (no external dependencies)\n        # Integration tests requiring databases/APIs are skipped by default\n        pytest tests/ -m offline -v --tb=short\n\n    - name: Upload test results\n      if: always()\n      uses: actions/upload-artifact@v7\n      with:\n        name: test-results-py${{ matrix.python-version }}\n        path: |\n          .pytest_cache/\n          test-results.xml\n        retention-days: 7\n"
  },
  {
    "path": ".gitignore",
    "content": "# Python-related files\n__pycache__/\n*.py[cod]\n*.egg-info/\n.eggs/\n*.tgz\n*.tar.gz\n*.ini\n\n# Virtual Environment\n.venv/\nvenv/\n\n# Enviroment Variable Files\n.env\n.env.backup.*\n\n# Generated Docker Compose files (output of setup wizard)\ndocker-compose.*.yml\n\n# Build / Distribution\ndist/\nbuild/\nsite/\n\n# Logs / Reports\n*.log\n*.log.*\n*.logfire\n*.coverage/\nlog/\n\n# Caches\n.cache/\n.mypy_cache/\n.pytest_cache/\n.ruff_cache/\n.gradio/\n.history/\ntemp/\n\n# IDE / Editor Files\n.idea/\n.vscode/\n.vscode/settings.json\n\n# Framework-specific files\nlocal_neo4jWorkDir/\nneo4jWorkDir/\n\n# Data & Storage\ninputs/\noutput/\nrag_storage/\ndata/\n\n# Evaluation results\nlightrag/evaluation/results/\n\n# Miscellaneous\n.DS_Store\nTODO.md\nignore_this.txt\n*.ignore.*\n\n# Project-specific files\n/dickens*/\n/book.txt\ndownload_models_hf.py\n\n# Frontend build output (built during PyPI release)\n/lightrag/api/webui/\n\n# temporary test files in project root\n/test_*\n\n# Cline files\nmemory-bank\n.claude/CLAUDE.md\n.claude/\n\n# Claude Code\nCLAUDE.md\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v5.0.0\n    hooks:\n      - id: trailing-whitespace\n        exclude: ^lightrag/api/webui/\n      - id: end-of-file-fixer\n        exclude: ^lightrag/api/webui/\n      - id: requirements-txt-fixer\n        exclude: ^lightrag/api/webui/\n\n\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.6.4\n    hooks:\n      - id: ruff-format\n        exclude: ^lightrag/api/webui/\n      - id: ruff\n        args: [--fix, --ignore=E402]\n        exclude: ^lightrag/api/webui/\n\n\n  - repo: https://github.com/mgedmin/check-manifest\n    rev: \"0.49\"\n    hooks:\n      - id: check-manifest\n        stages: [manual]\n        exclude: ^lightrag/api/webui/\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# Repository Guidelines\n\nLightRAG is an advanced Retrieval-Augmented Generation (RAG) framework designed to enhance information retrieval and generation through graph-based knowledge representation.\n\n## Project Structure & Module Organization\n- `lightrag/`: Core Python package with orchestrators (`lightrag/lightrag.py`), storage adapters in `kg/`, LLM bindings in `llm/`, and helpers such as `operate.py` and `utils_*.py`.\n- `lightrag-api/`: FastAPI service (`lightrag_server.py`) with routers under `routers/` and Gunicorn launcher `run_with_gunicorn.py`.\n- `lightrag_webui/`: React 19 + TypeScript client driven by Bun + Vite; UI components live in `src/`.\n- `scripts/setup/`: Interactive environment setup wizard. `setup.sh` orchestrates staged `--base` / `--storage` / `--server` / validation flows, `lib/` holds prompt/validation/file helpers, and `templates/*.yml` contains compose fragments for bundled services.\n- Tests live in `tests/` and root-level `test_*.py`. Working datasets stay in `inputs/`, `rag_storage/`, `temp/`; deployment collateral lives in `docs/`, `k8s-deploy/`, and `docker-compose.yml`.\n- `Makefile`: Canonical entry point for the setup wizard and local developer shortcuts; prefer documented targets over invoking ad hoc shell snippets.\n\n## Build, Test, and Development Commands\n- `python -m venv .venv && source .venv/bin/activate`: set up the Python runtime.\n- `pip install -e .` / `pip install -e .[api]`: install the package and API extras in editable mode.\n- `make env-base`: first-run interactive setup for LLM, embedding, and reranker configuration; writes `.env` and may generate `docker-compose.final.yml`.\n- `make env-storage`, `make env-server`: optional follow-up wizard stages for storage backends and server/security/SSL settings; both reuse the existing `.env`.\n- `make env-validate`, `make env-security-check`, `make env-backup`: validate, audit, or back up the current `.env` via the setup wizard.\n- `lightrag-server` or `uvicorn lightrag.api.lightrag_server:app --reload`: start the API locally; ensure `.env` is present.\n- `python -m pytest tests` (offline markers apply by default) or `python -m pytest tests --run-integration` / `python test_graph_storage.py`: run the full suite, opt into integration coverage, or target an individual script.\n- `ruff check .`: lint Python sources before committing.\n- `bun install`, `bun run dev`, `bun run build`, `bun test`: manage the web UI workflow (Bun is mandatory).\n\n## Coding Style & Naming Conventions\n- Backend code follow PEP 8 with four-space indentation, annotate functions, and reach for dataclasses when modelling state.\n- Use `lightrag.utils.logger` instead of `print`; respect logger configuration flags.\n- Extend storage or pipeline abstractions via `lightrag.base` and keep reusable helpers in the existing `utils_*.py`.\n- Python modules remain lowercase with underscores; React components use `PascalCase.tsx` and hooks-first patterns.\n- Front-end code should remain in TypeScript with two-space indentation, rely on functional React components with hooks, and follow Tailwind utility style.\n\n## Testing Guidelines\n- Keep pytest additions close to the code you touch (`tests/` mirrors feature folders and there are root-level `test_*.py` helpers); functions must start with `test_`.\n- Follow `tests/pytest.ini`: markers include `offline`, `integration`, `requires_db`, and `requires_api`, and the suite runs with `-m \"not integration\"` by default—pass `--run-integration` (or set `LIGHTRAG_RUN_INTEGRATION=true`) when external services are available.\n- Use the custom CLI toggles from `tests/conftest.py`: `--keep-artifacts`/`LIGHTRAG_KEEP_ARTIFACTS=true`, `--stress-test`/`LIGHTRAG_STRESS_TEST=true`, and `--test-workers N`/`LIGHTRAG_TEST_WORKERS` to dial up workloads or preserve temp files during investigations.\n- Export other required `LIGHTRAG_*` environment variables before running integration or storage tests so adapters can reach configured backends.\n- For UI updates, pair changes with Vitest specs and run `bun test`.\n\n## Commit & Pull Request Guidelines\n- Use concise, imperative commit subjects (e.g., `Fix lock key normalization`) and add body context only when necessary.\n- PRs should include a summary, operational impact, linked issues, and screenshots or API samples for user-facing work.\n- Verify `ruff check .`, `python -m pytest`, and affected Bun commands succeed before requesting review; note the runs in the PR text.\n- This repo is a fork of `HKUDS/LightRAG`. Always target **`HKUDS/LightRAG:main`** (upstream) when creating PRs, not the fork's own main.\n\n## Security & Configuration Tips\n- Copy `.env.example` and `config.ini.example`; never commit secrets or real connection strings.\n- Configure storage backends through `LIGHTRAG_*` variables and validate them with `docker-compose` services when needed.\n- Treat `lightrag.log*` as local artefacts; purge sensitive information before sharing logs or outputs.\n\n## Automation & Agent Workflow\n- Use repo-relative `workdir` arguments for every shell command and prefer `rg`/`rg --files` for searches since they are faster under the CLI harness.\n- Default edits to ASCII, rely on `apply_patch` for single-file changes, and only add concise comments that aid comprehension of complex logic.\n- Honor existing local modifications; never revert or discard user changes (especially via `git reset --hard`) unless explicitly asked.\n- Follow the planning tool guidance: skip it for trivial fixes, but provide multi-step plans for non-trivial work and keep the plan updated as steps progress.\n- Validate changes by running the relevant `ruff`/`pytest`/`bun test` commands whenever feasible, and describe any unrun checks with follow-up guidance.\n- For Codex and other fresh-shell automation, prefer `./scripts/test.sh` instead of bare `pytest`; the script falls back through `PYTHON`, the active virtualenv, `uv`, `.venv`, and `venv` before trying `python` or `python3`.\n- For setup workflow changes, prefer `make env-*` targets over calling `scripts/setup/setup.sh` directly; the `Makefile` resolves a Bash 4+ interpreter for macOS/Linux compatibility.\n- When editing setup logic, keep `.env` host-usable and treat `docker-compose.final.yml` as generated output assembled from `scripts/setup/templates/*.yml`; compose-only overrides belong in the wizard-managed compose layer rather than being persisted back into `.env`.\n"
  },
  {
    "path": "Dockerfile",
    "content": "# syntax=docker/dockerfile:1\n\n# Frontend build stage\n# Build frontend assets on the native build platform to avoid\n# cross-architecture emulation issues during multi-platform builds.\nFROM --platform=$BUILDPLATFORM oven/bun:1 AS frontend-builder\n\nWORKDIR /app\n\n# Copy frontend source code\nCOPY lightrag_webui/ ./lightrag_webui/\n\n# Build frontend assets for inclusion in the API package\nRUN --mount=type=cache,target=/root/.bun/install/cache \\\n    cd lightrag_webui \\\n    && bun install --frozen-lockfile \\\n    && bun run build\n\n# Python build stage - using uv for faster package installation\nFROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder\n\nENV DEBIAN_FRONTEND=noninteractive\nENV UV_SYSTEM_PYTHON=1\nENV UV_COMPILE_BYTECODE=1\n\nWORKDIR /app\n\n# Install system deps (Rust is required by some wheels)\nRUN apt-get update \\\n    && apt-get install -y --no-install-recommends \\\n        curl \\\n        build-essential \\\n        pkg-config \\\n    && rm -rf /var/lib/apt/lists/* \\\n    && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y\n\nENV PATH=\"/root/.cargo/bin:/root/.local/bin:${PATH}\"\n\n# Ensure shared data directory exists for uv caches\nRUN mkdir -p /root/.local/share/uv\n\n# Copy project metadata and sources\nCOPY pyproject.toml .\nCOPY setup.py .\nCOPY uv.lock .\n\n# Install base, API, and offline extras without the project to improve caching\nRUN --mount=type=cache,target=/root/.local/share/uv \\\n    uv sync --frozen --no-dev --extra api --extra offline --no-install-project --no-editable\n\n# Copy project sources after dependency layer\nCOPY lightrag/ ./lightrag/\n\n# Include pre-built frontend assets from the previous stage\nCOPY --from=frontend-builder /app/lightrag/api/webui ./lightrag/api/webui\n\n# Sync project in non-editable mode and ensure pip is available for runtime installs\nRUN --mount=type=cache,target=/root/.local/share/uv \\\n    uv sync --frozen --no-dev --extra api --extra offline --no-editable \\\n    && /app/.venv/bin/python -m ensurepip --upgrade\n\n# Prepare offline cache directory and pre-populate tiktoken data\n# Use uv run to execute commands from the virtual environment\nRUN mkdir -p /app/data/tiktoken \\\n    && uv run lightrag-download-cache --cache-dir /app/data/tiktoken || status=$?; \\\n    if [ -n \"${status:-}\" ] && [ \"$status\" -ne 0 ] && [ \"$status\" -ne 2 ]; then exit \"$status\"; fi\n\n# Final stage\nFROM python:3.12-slim\n\nWORKDIR /app\n\n# Install uv for package management\nCOPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv\n\nENV UV_SYSTEM_PYTHON=1\n\n# Copy installed packages and application code\nCOPY --from=builder /root/.local /root/.local\nCOPY --from=builder /app/.venv /app/.venv\nCOPY --from=builder /app/lightrag ./lightrag\nCOPY pyproject.toml .\nCOPY setup.py .\nCOPY uv.lock .\n\n# Ensure the installed scripts are on PATH\nENV PATH=/app/.venv/bin:/root/.local/bin:$PATH\n\n# Install dependencies with uv sync (uses locked versions from uv.lock)\n# And ensure pip is available for runtime installs\nRUN --mount=type=cache,target=/root/.local/share/uv \\\n    uv sync --frozen --no-dev --extra api --extra offline --no-editable \\\n    && /app/.venv/bin/python -m ensurepip --upgrade\n\n# Create persistent data directories AFTER package installation\nRUN mkdir -p /app/data/rag_storage /app/data/inputs /app/data/tiktoken\n\n# Copy offline cache into the newly created directory\nCOPY --from=builder /app/data/tiktoken /app/data/tiktoken\n\n# Point to the prepared cache\nENV TIKTOKEN_CACHE_DIR=/app/data/tiktoken\nENV WORKING_DIR=/app/data/rag_storage\nENV INPUT_DIR=/app/data/inputs\n\n# Expose API port\nEXPOSE 9621\n\nENTRYPOINT [\"python\", \"-m\", \"lightrag.api.lightrag_server\"]\n"
  },
  {
    "path": "Dockerfile.lite",
    "content": "# syntax=docker/dockerfile:1\n\n# Frontend build stage\n# Build frontend assets on the native build platform to avoid\n# cross-architecture emulation issues during multi-platform builds.\nFROM --platform=$BUILDPLATFORM oven/bun:1 AS frontend-builder\n\nWORKDIR /app\n\n# Copy frontend source code\nCOPY lightrag_webui/ ./lightrag_webui/\n\n# Build frontend assets for inclusion in the API package\nRUN --mount=type=cache,target=/root/.bun/install/cache \\\n    cd lightrag_webui \\\n    && bun install --frozen-lockfile \\\n    && bun run build\n\n# Python build stage - using uv for package installation\nFROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder\n\nENV DEBIAN_FRONTEND=noninteractive\nENV UV_SYSTEM_PYTHON=1\nENV UV_COMPILE_BYTECODE=1\n\nWORKDIR /app\n\n# Install system dependencies required by some wheels\nRUN apt-get update \\\n    && apt-get install -y --no-install-recommends \\\n        curl \\\n        build-essential \\\n        pkg-config \\\n    && rm -rf /var/lib/apt/lists/* \\\n    && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y\n\nENV PATH=\"/root/.cargo/bin:/root/.local/bin:${PATH}\"\n\n# Ensure shared data directory exists for uv caches\nRUN mkdir -p /root/.local/share/uv\n\n# Copy project metadata and sources\nCOPY pyproject.toml .\nCOPY setup.py .\nCOPY uv.lock .\n\n# Install project dependencies (base + API extras) without the project to improve caching\nRUN --mount=type=cache,target=/root/.local/share/uv \\\n    uv sync --frozen --no-dev --extra api --no-install-project --no-editable\n\n# Copy project sources after dependency layer\nCOPY lightrag/ ./lightrag/\n\n# Include pre-built frontend assets from the previous stage\nCOPY --from=frontend-builder /app/lightrag/api/webui ./lightrag/api/webui\n\n# Sync project in non-editable mode and ensure pip is available for runtime installs\nRUN --mount=type=cache,target=/root/.local/share/uv \\\n    uv sync --frozen --no-dev --extra api --no-editable \\\n    && /app/.venv/bin/python -m ensurepip --upgrade\n\n# Prepare tiktoken cache directory and pre-populate tokenizer data\n# Ignore exit code 2 which indicates assets already cached\nRUN mkdir -p /app/data/tiktoken \\\n    && uv run lightrag-download-cache --cache-dir /app/data/tiktoken || status=$?; \\\n    if [ -n \"${status:-}\" ] && [ \"$status\" -ne 0 ] && [ \"$status\" -ne 2 ]; then exit \"$status\"; fi\n\n# Final stage\nFROM python:3.12-slim\n\nWORKDIR /app\n\n# Install uv for package management\nCOPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv\n\nENV UV_SYSTEM_PYTHON=1\n\n# Copy installed packages and application code\nCOPY --from=builder /root/.local /root/.local\nCOPY --from=builder /app/.venv /app/.venv\nCOPY --from=builder /app/lightrag ./lightrag\nCOPY pyproject.toml .\nCOPY setup.py .\nCOPY uv.lock .\n\n# Ensure the installed scripts are on PATH\nENV PATH=/app/.venv/bin:/root/.local/bin:$PATH\n\n# Sync dependencies inside the final image using uv\n# And ensure pip is available for runtime installs\nRUN --mount=type=cache,target=/root/.local/share/uv \\\n    uv sync --frozen --no-dev --extra api --no-editable \\\n    && /app/.venv/bin/python -m ensurepip --upgrade\n\n# Create persistent data directories\nRUN mkdir -p /app/data/rag_storage /app/data/inputs /app/data/tiktoken\n\n# Copy cached tokenizer assets prepared in the builder stage\nCOPY --from=builder /app/data/tiktoken /app/data/tiktoken\n\n# Docker data directories\nENV TIKTOKEN_CACHE_DIR=/app/data/tiktoken\nENV WORKING_DIR=/app/data/rag_storage\nENV INPUT_DIR=/app/data/inputs\n\n# Expose API port\nEXPOSE 9621\n\n# Set entrypoint\nENTRYPOINT [\"python\", \"-m\", \"lightrag.api.lightrag_server\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 LightRAG Team\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "include requirements.txt\ninclude lightrag/api/requirements.txt\nrecursive-include lightrag/api/webui *\nrecursive-include lightrag/api/static *\n"
  },
  {
    "path": "Makefile",
    "content": "SHELL := /bin/bash\nSETUP_SCRIPT := scripts/setup/setup.sh\nSETUP_BASH ?= $(or $(firstword $(wildcard /opt/homebrew/bin/bash /usr/local/bin/bash /opt/local/bin/bash)),$(shell command -v bash 2>/dev/null),bash)\nSETUP_OPTS ?=\nCOLOR_RESET := \\033[0m\nCOLOR_BOLD := \\033[1m\nCOLOR_BLUE := \\033[34m\nCOLOR_GREEN := \\033[32m\nCOLOR_YELLOW := \\033[33m\n\nifeq ($(NO_COLOR),1)\nCOLOR_RESET :=\nCOLOR_BOLD :=\nCOLOR_BLUE :=\nCOLOR_GREEN :=\nCOLOR_YELLOW :=\nendif\n\n.PHONY: help configure env-base env-storage env-server env-validate env-backup env-security-check env-base-rewrite env-storage-rewrite env base storage server validate backup security security-check base-rewrite storage-rewrite\n\nhelp:\n\t@printf \"$(COLOR_BOLD)Interactive setup targets$(COLOR_RESET)\\n\"\n\t@printf \"  $(COLOR_GREEN)make env-base$(COLOR_RESET)               Configure LLM, embedding, and reranker (run first)\\n\"\n\t@printf \"  $(COLOR_GREEN)make env-storage$(COLOR_RESET)            Configure storage backends and databases\\n\"\n\t@printf \"  $(COLOR_GREEN)make env-server$(COLOR_RESET)             Configure server, security, and SSL\\n\"\n\t@printf \"  $(COLOR_GREEN)make env-validate$(COLOR_RESET)           Validate existing .env\\n\"\n\t@printf \"  $(COLOR_GREEN)make env-security-check$(COLOR_RESET)     Audit existing .env for security risks\\n\"\n\t@printf \"  $(COLOR_GREEN)make env-backup$(COLOR_RESET)             Backup current .env\\n\"\n\t@printf \"  $(COLOR_GREEN)make env-base-rewrite$(COLOR_RESET)       Force-regenerate wizard-managed compose services during base setup\\n\"\n\t@printf \"  $(COLOR_GREEN)make env-storage-rewrite$(COLOR_RESET)    Force-regenerate wizard-managed compose services during storage setup\\n\"\n\t@printf \"  $(COLOR_GREEN)make base$(COLOR_RESET)                   Short form of make env-base (all env prefix can be stripped)\\n\"\n\t@printf \"\\n\"\n\t@printf \"$(COLOR_BOLD)Typical workflow$(COLOR_RESET)\\n\"\n\t@printf \"  1. make env-base       # set LLM/embedding/reranker\\n\"\n\t@printf \"  2. make env-storage    # set storage backends (optional)\\n\"\n\t@printf \"  3. make env-server     # set port/security/SSL (optional)\\n\\n\"\n\t@printf \"$(COLOR_BOLD)Examples$(COLOR_RESET)\\n\"\n\t@printf \"  make env-base\\n\"\n\t@printf \"  make env-storage SETUP_OPTS=--debug\\n\"\n\t@printf \"  make env-server\\n\\n\"\n\t@printf \"  make env-storage-rewrite\\n\\n\"\n\t@printf \"  make env-security-check\\n\\n\"\n\t@printf \"$(COLOR_BOLD)Compose Output$(COLOR_RESET)\\n\"\n\t@printf \"  Bundled service images are defined in scripts/setup/templates/*.yml.\\n\"\n\t@printf \"  Compose file output: docker-compose.final.yml\\n\"\n\nenv-base env base configure:\n\t@$(SETUP_BASH) $(SETUP_SCRIPT) --base $(SETUP_OPTS)\n\nenv-storage storage:\n\t@$(SETUP_BASH) $(SETUP_SCRIPT) --storage $(SETUP_OPTS)\n\nenv-base-rewrite base-rewrite:\n\t@$(SETUP_BASH) $(SETUP_SCRIPT) --base --rewrite-compose $(SETUP_OPTS)\n\nenv-storage-rewrite storage-rewrite:\n\t@$(SETUP_BASH) $(SETUP_SCRIPT) --storage --rewrite-compose $(SETUP_OPTS)\n\nenv-server server:\n\t@$(SETUP_BASH) $(SETUP_SCRIPT) --server $(SETUP_OPTS)\n\nenv-validate validate:\n\t@$(SETUP_BASH) $(SETUP_SCRIPT) --validate $(SETUP_OPTS)\n\nenv-security-check security security-check:\n\t@$(SETUP_BASH) $(SETUP_SCRIPT) --security-check $(SETUP_OPTS)\n\nenv-backup backup:\n\t@$(SETUP_BASH) $(SETUP_SCRIPT) --backup $(SETUP_OPTS)\n"
  },
  {
    "path": "README-zh.md",
    "content": "<div align=\"center\">\n\n<div style=\"margin: 20px 0;\">\n  <img src=\"./assets/logo.png\" width=\"120\" height=\"120\" alt=\"LightRAG Logo\" style=\"border-radius: 20px; box-shadow: 0 8px 32px rgba(0, 217, 255, 0.3);\">\n</div>\n\n# 🚀 LightRAG: 简单且快速的检索增强生成（RAG）框架\n\n<div align=\"center\">\n    <a href=\"https://trendshift.io/repositories/13043\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/13043\" alt=\"HKUDS%2FLightRAG | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n</div>\n\n<div align=\"center\">\n  <div style=\"width: 100%; height: 2px; margin: 20px 0; background: linear-gradient(90deg, transparent, #00d9ff, transparent);\"></div>\n</div>\n\n<div align=\"center\">\n  <div style=\"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 15px; padding: 25px; text-align: center;\">\n    <p>\n      <a href='https://github.com/HKUDS/LightRAG'><img src='https://img.shields.io/badge/🔥项目-主页-00d9ff?style=for-the-badge&logo=github&logoColor=white&labelColor=1a1a2e'></a>\n      <a href='https://arxiv.org/abs/2410.05779'><img src='https://img.shields.io/badge/📄arXiv-2410.05779-ff6b6b?style=for-the-badge&logo=arxiv&logoColor=white&labelColor=1a1a2e'></a>\n      <a href=\"https://github.com/HKUDS/LightRAG/stargazers\"><img src='https://img.shields.io/github/stars/HKUDS/LightRAG?color=00d9ff&style=for-the-badge&logo=star&logoColor=white&labelColor=1a1a2e' /></a>\n    </p>\n    <p>\n      <img src=\"https://img.shields.io/badge/🐍Python-3.10-4ecdc4?style=for-the-badge&logo=python&logoColor=white&labelColor=1a1a2e\">\n      <a href=\"https://pypi.org/project/lightrag-hku/\"><img src=\"https://img.shields.io/pypi/v/lightrag-hku.svg?style=for-the-badge&logo=pypi&logoColor=white&labelColor=1a1a2e&color=ff6b6b\"></a>\n    </p>\n    <p>\n      <a href=\"https://discord.gg/yF2MmDJyGJ\"><img src=\"https://img.shields.io/badge/💬Discord-社区-7289da?style=for-the-badge&logo=discord&logoColor=white&labelColor=1a1a2e\"></a>\n      <a href=\"https://github.com/HKUDS/LightRAG/issues/285\"><img src=\"https://img.shields.io/badge/💬微信群-交流-07c160?style=for-the-badge&logo=wechat&logoColor=white&labelColor=1a1a2e\"></a>\n    </p>\n    <p>\n      <a href=\"README-zh.md\"><img src=\"https://img.shields.io/badge/🇨🇳中文版-1a1a2e?style=for-the-badge\"></a>\n      <a href=\"README.md\"><img src=\"https://img.shields.io/badge/🇺🇸English-1a1a2e?style=for-the-badge\"></a>\n    </p>\n    <p>\n      <a href=\"https://pepy.tech/projects/lightrag-hku\"><img src=\"https://static.pepy.tech/personalized-badge/lightrag-hku?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads\"></a>\n    </p>\n  </div>\n</div>\n\n</div>\n\n<div align=\"center\" style=\"margin: 30px 0;\">\n  <img src=\"https://user-images.githubusercontent.com/74038190/212284100-561aa473-3905-4a80-b561-0d28506553ee.gif\" width=\"800\">\n</div>\n\n<div align=\"center\" style=\"margin: 30px 0;\">\n    <img src=\"./README.assets/b2aaf634151b4706892693ffb43d9093.png\" width=\"800\" alt=\"LightRAG Diagram\">\n</div>\n\n---\n\n<div align=\"center\">\n  <table>\n    <tr>\n      <td style=\"vertical-align: middle;\">\n        <img src=\"./assets/LiteWrite.png\"\n             width=\"56\"\n             height=\"56\"\n             alt=\"LiteWrite\"\n             style=\"border-radius: 12px;\" />\n      </td>\n      <td style=\"vertical-align: middle; padding-left: 12px;\">\n        <a href=\"https://litewrite.ai\">\n          <img src=\"https://img.shields.io/badge/🚀%20LiteWrite-AI%20原生%20LaTeX%20编辑器-ff6b6b?style=for-the-badge&logoColor=white&labelColor=1a1a2e\">\n        </a>\n      </td>\n    </tr>\n  </table>\n</div>\n\n---\n\n## 🎉 新闻\n- [2025.11]🎯[新功能]: 集成了 **RAGAS 评估**和 **Langfuse 追踪**。更新了 API 以在查询结果中返回召回上下文，支持上下文精度指标。\n- [2025.10]🎯[可扩展性增强]: 消除了处理瓶颈，以高效支持**大规模数据集**。\n- [2025.09]🎯[新功能]: 显著提升了 Qwen3-30B-A3B 等**开源 LLM** 的知识图谱提取准确性。\n- [2025.08]🎯[新功能]: 现已支持 **Reranker**，显著提升混合查询性能（已设为默认查询模式）。\n- [2025.08]🎯[新功能]: 添加了**文档删除**功能，并支持自动重新生成知识图谱，以确保最佳查询性能。\n- [2025.06]🎯[新发布]: 我们的团队发布了 [RAG-Anything](https://github.com/HKUDS/RAG-Anything) —— 一个用于无缝处理文本、图像、表格和方程式的**全功能多模态 RAG** 系统。\n- [2025.06]🎯[新功能]: LightRAG 现已集成 [RAG-Anything](https://github.com/HKUDS/RAG-Anything)，支持全面的多模态数据处理，实现对 PDF、图像、Office 文档、表格和公式等多种格式的无缝文档解析和 RAG 能力。详见[多模态文档处理部分](https://github.com/HKUDS/LightRAG/?tab=readme-ov-file#multimodal-document-processing-rag-anything-integration)。\n- [2025.03]🎯[新功能]: LightRAG 现已支持引用功能，实现了准确的源归因和增强的文档可追溯性。\n- [2025.02]🎯[新功能]: 现在您可以使用 MongoDB 作为一体化存储解决方案，实现统一的数据管理。\n- [2025.02]🎯[新发布]: 我们的团队发布了 [VideoRAG](https://github.com/HKUDS/VideoRAG) —— 一个用于理解超长上下文视频的 RAG 系统。\n- [2025.01]🎯[新发布]: 我们的团队发布了 [MiniRAG](https://github.com/HKUDS/MiniRAG)，使用小型模型简化 RAG。\n- [2025.01]🎯现在您可以使用 PostgreSQL 作为一体化存储解决方案进行数据管理。\n- [2024.11]🎯[新资源]: LightRAG 的综合指南现已在 [LearnOpenCV](https://learnopencv.com/lightrag) 上发布 —— 探索深入的教程和最佳实践。非常感谢博客作者的杰出贡献！\n- [2024.11]🎯[新功能]: 推出 LightRAG WebUI —— 一个允许您通过直观的 Web 界面插入、查询和可视化 LightRAG 知识的仪表板。\n- [2024.11]🎯[新功能]: 现在您可以[使用 Neo4J 进行存储](https://github.com/HKUDS/LightRAG?tab=readme-ov-file#using-neo4j-for-storage) —— 开启图数据库支持。\n- [2024.10]🎯[新功能]: 我们添加了 [LightRAG 介绍视频](https://youtu.be/oageL-1I0GE) 的链接 —— 演示 LightRAG 的各项功能。感谢作者的杰出贡献！\n- [2024.10]🎯[新频道]: 我们创建了一个 [Discord 频道](https://discord.gg/yF2MmDJyGJ)！💬 欢迎加入我们的社区进行分享、讨论和协作！ 🎉🎉\n- [2024.10]🎯[新功能]: LightRAG 现在支持 [Ollama 模型](https://github.com/HKUDS/LightRAG?tab=readme-ov-file#quick-start)！\n\n<details>\n  <summary style=\"font-size: 1.4em; font-weight: bold; cursor: pointer; display: list-item;\">\n    算法流程图\n  </summary>\n\n![LightRAG索引流程图](https://learnopencv.com/wp-content/uploads/2024/11/LightRAG-VectorDB-Json-KV-Store-Indexing-Flowchart-scaled.jpg)\n*图1：LightRAG索引流程图 - 图片来源：[Source](https://learnopencv.com/lightrag/)*\n![LightRAG检索和查询流程图](https://learnopencv.com/wp-content/uploads/2024/11/LightRAG-Querying-Flowchart-Dual-Level-Retrieval-Generation-Knowledge-Graphs-scaled.jpg)\n*图2：LightRAG检索和查询流程图 - 图片来源：[Source](https://learnopencv.com/lightrag/)*\n\n</details>\n\n## 安装\n\n> **💡 使用 uv 进行包管理**: 本项目使用 [uv](https://docs.astral.sh/uv/) 进行快速可靠的 Python 包管理。\n> 首先安装 uv: `curl -LsSf https://astral.sh/uv/install.sh | sh` (Unix/macOS) 或 `powershell -c \"irm https://astral.sh/uv/install.ps1 | iex\"` (Windows)\n>\n> **注意**：如果您愿意，也可以使用 pip，但为了获得更好的性能 and 更可靠的依赖管理，建议使用 uv。\n>\n> **📦 离线部署**: 对于离线或隔离环境，请参阅[离线部署指南](./docs/OfflineDeployment.md)，了解预安装所有依赖项和缓存文件的说明。\n\n### 安装LightRAG服务器\n\nLightRAG服务器旨在提供Web UI和API支持。Web UI便于文档索引、知识图谱探索和简单的RAG查询界面。LightRAG服务器还提供兼容Ollama的接口，旨在将LightRAG模拟为Ollama聊天模型。这使得AI聊天机器人（如Open WebUI）可以轻松访问LightRAG。\n\n* 从PyPI安装\n\n```bash\n### 使用 uv 安装 LightRAG 服务器（作为工具，推荐)\nuv tool install \"lightrag-hku[api]\"\n\n### 或使用 pip\n# python -m venv .venv\n# source .venv/bin/activate  # Windows: .venv\\Scripts\\activate\n# pip install \"lightrag-hku[api]\"\n\n### 构建前端代码\ncd lightrag_webui\nbun install --frozen-lockfile\nbun run build\ncd ..\n\n# 配置 env 文件\n# 从 GitHub 仓库的根目录上下载 env.example 文件\n# 或从本地检出的源代码中获取 env.example 文件\ncp env.example .env  # 使用你的LLM和Embedding模型访问参数更新.env文件\n# 启动API-WebUI服务\nlightrag-server\n```\n\n* 从源代码安装\n\n```bash\ngit clone https://github.com/HKUDS/LightRAG.git\ncd LightRAG\n\n# 使用 uv (推荐)\n# 注意: uv sync 会自动在 .venv/ 目录创建虚拟环境\nuv sync --extra api\nsource .venv/bin/activate  # 激活虚拟环境 (Linux/macOS)\n# Windows 系统: .venv\\Scripts\\activate\n\n### 或使用 pip 和虚拟环境\n# python -m venv .venv\n# source .venv/bin/activate  # Windows: .venv\\Scripts\\activate\n# pip install -e \".[api]\"\n\n# 构建前端代码\ncd lightrag_webui\nbun install --frozen-lockfile\nbun run build\ncd ..\n\n# 配置 env 文件\ncp env.example .env  # 使用你的LLM和Embedding模型访问参数更新.env文件\n# 启动API-WebUI服务\nlightrag-server\n```\n\n* 使用 Docker Compose 启动 LightRAG 服务器\n\n```bash\ngit clone https://github.com/HKUDS/LightRAG.git\ncd LightRAG\ncp env.example .env  # 使用你的LLM和Embedding模型访问参数更新.env文件\n# modify LLM and Embedding settings in .env\ndocker compose up\n```\n\n> 在此获取LightRAG docker镜像历史版本: [LightRAG Docker Images]( https://github.com/HKUDS/LightRAG/pkgs/container/lightrag)\n\n### 使用 Setup 工具创建 .env 文件\n\n除了手动编辑 `env.example` 之外，您还可以使用交互式向导生成配置好的 `.env`，并在需要时生成 `docker-compose.final.yml`：\n\n```bash\nmake env-base           # 必跑第一步：配置 LLM、Embedding、Reranker\nmake env-storage        # 可选：配置存储后端和数据库服务\nmake env-server         # 可选：配置服务端口、鉴权和 SSL\nmake env-base-rewrite   # 可选：强制重建向导托管的 compose 服务块\nmake env-storage-rewrite # 可选：强制重建向导托管的 compose 服务块\nmake env-security-check # 可选：审计当前 .env 中的安全风险\n```\n\n每个目标的详细说明请参阅 [docs/InteractiveSetup.md](./docs/InteractiveSetup.md)。\n这些 setup 向导只负责更新配置；如需在部署前审计当前 `.env` 的安全风险，请额外运行\n`make env-security-check`。\n默认情况下，重新运行 setup 会保留未变化的向导托管 compose 服务块；只有在需要按模板强制重建这些托管块时，才使用\n`*-rewrite` 目标。\n\n### 安装LightRAG Core\n\n* 从源代码安装（推荐）\n\n```bash\ncd LightRAG\n# 注意: uv sync 会自动在 .venv/ 目录创建虚拟环境\nuv sync\nsource .venv/bin/activate  # 激活虚拟环境 (Linux/macOS)\n# Windows 系统: .venv\\Scripts\\activate\n\n# 或: pip install -e .\n```\n\n* 从PyPI安装\n\n```bash\nuv pip install lightrag-hku\n# 或: pip install lightrag-hku\n```\n\n## 快速开始\n\n### LightRAG的LLM及配套技术栈要求\n\nLightRAG对大型语言模型（LLM）的能力要求远高于传统RAG，因为它需要LLM执行文档中的实体关系抽取任务。配置合适的Embedding和Reranker模型对提高查询表现也至关重要。\n\n- **LLM选型**：\n  - 推荐选用参数量至少为32B的LLM。\n  - 上下文长度至少为32KB，推荐达到64KB。\n  - 在文档索引阶段不建议选择推理模型。\n  - 在查询阶段建议选择比索引阶段能力更强的模型，以达到更高的查询效果。\n- **Embedding模型**：\n  - 高性能的Embedding模型对RAG至关重要。\n  - 推荐使用主流的多语言Embedding模型，例如：BAAI/bge-m3 和 text-embedding-3-large。\n  - **重要提示**：在文档索引前必须确定使用的Embedding模型，且在文档查询阶段必须沿用与索引阶段相同的模型。有些存储（例如PostgreSQL）在首次建立数表的时候需要确定向量维度，因此更换Embedding模型后需要删除向量相关库表，以便让LightRAG重建新的库表。\n- **Reranker模型配置**：\n  - 配置Reranker模型能够显著提升LightRAG的检索效果。\n  - 启用Reranker模型后，推荐将“mix模式”设为默认查询模式。\n  - 推荐选用主流的Reranker模型，例如：BAAI/bge-reranker-v2-m3 或 Jina 等服务商提供的模型。\n\n### 使用LightRAG服务器\n\n**有关LightRAG服务器的更多信息，请参阅[LightRAG服务器](./lightrag/api/README.md)。**\n\n### 使用LightRAG Core\n\nLightRAG核心功能的示例代码请参见`examples`目录。您还可参照[视频](https://www.youtube.com/watch?v=g21royNJ4fw)视频完成环境配置。若已持有OpenAI API密钥，可以通过以下命令运行演示代码：\n\n```bash\n### you should run the demo code with project folder\ncd LightRAG\n### provide your API-KEY for OpenAI\nexport OPENAI_API_KEY=\"sk-...your_opeai_key...\"\n### download the demo document of \"A Christmas Carol\" by Charles Dickens\ncurl https://raw.githubusercontent.com/gusye1234/nano-graphrag/main/tests/mock_data.txt > ./book.txt\n### run the demo code\npython examples/lightrag_openai_demo.py\n```\n\n如需流式响应示例的实现代码，请参阅 `examples/lightrag_openai_compatible_demo.py`。运行前，请确保根据需求修改示例代码中的LLM及嵌入模型配置。\n\n**注意1**：在运行demo程序的时候需要注意，不同的测试程序可能使用的是不同的embedding模型，更换不同的embeding模型的时候需要把清空数据目录（`./dickens`），否则层序执行会出错。如果你想保留LLM缓存，可以在清除数据目录时保留`kv_store_llm_response_cache.json`文件。\n\n**注意2**：官方支持的示例代码仅为 `lightrag_openai_demo.py` 和 `lightrag_openai_compatible_demo.py` 两个文件。其他示例文件均为社区贡献内容，尚未经过完整测试与优化。\n\n## 使用LightRAG Core进行编程\n\n> ⚠️ **如果您希望将LightRAG集成到您的项目中，建议您使用LightRAG Server提供的REST API**。LightRAG Core通常用于嵌入式应用，或供希望进行研究与评估的学者使用。\n\n### ⚠️ 重要：初始化要求\n\nLightRAG 在使用前需要显式初始化。 创建 LightRAG 实例后，您必须调用 await rag.initialize_storages()，否则将出现错误。\n\n### 一个简单程序\n\n以下Python代码片段演示了如何初始化LightRAG、插入文本并进行查询：\n\n```python\nimport os\nimport asyncio\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.openai import gpt_4o_mini_complete, gpt_4o_complete, openai_embed\nfrom lightrag.utils import setup_logger\n\nsetup_logger(\"lightrag\", level=\"INFO\")\n\nWORKING_DIR = \"./rag_storage\"\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        embedding_func=openai_embed,\n        llm_model_func=gpt_4o_mini_complete,\n    )\n    # IMPORTANT: Both initialization calls are required!\n    await rag.initialize_storages()  # Initialize storage backends\n    return rag\n\nasync def main():\n    try:\n        # 初始化RAG实例\n        rag = await initialize_rag()\n        await rag.ainsert(\"Your text\")\n\n        # 执行混合检索\n        mode = \"hybrid\"\n        print(\n          await rag.aquery(\n              \"What are the top themes in this story?\",\n              param=QueryParam(mode=mode)\n          )\n        )\n\n    except Exception as e:\n        print(f\"发生错误: {e}\")\n    finally:\n        if rag:\n            await rag.finalize_storages()\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\n重要说明：\n\n- 运行脚本前请先导出你的OPENAI_API_KEY环境变量。\n- 该程序使用LightRAG的默认存储设置，所有数据将持久化在WORKING_DIR/rag_storage目录下。\n- 该示例仅展示了初始化LightRAG对象的最简单方式：注入embedding和LLM函数，并在创建LightRAG对象后初始化存储和管道状态。\n\n### LightRAG初始化参数\n\n以下是完整的LightRAG对象初始化参数清单：\n\n<details>\n<summary> 参数 </summary>\n\n| **参数** | **类型** | **说明** | **默认值** |\n| -------------- | ---------- | ----------------- | ------------- |\n| **working_dir** | `str` | 存储缓存的目录 | `lightrag_cache+timestamp` |\n| **workspace** | str | 用于不同 LightRAG 实例之间数据隔离的工作区名称 | |\n| **kv_storage** | `str` | Storage type for documents and text chunks. Supported types: `JsonKVStorage`,`PGKVStorage`,`RedisKVStorage`,`MongoKVStorage`,`OpenSearchKVStorage` | `JsonKVStorage` |\n| **vector_storage** | `str` | Storage type for embedding vectors. Supported types: `NanoVectorDBStorage`,`PGVectorStorage`,`MilvusVectorDBStorage`,`ChromaVectorDBStorage`,`FaissVectorDBStorage`,`MongoVectorDBStorage`,`QdrantVectorDBStorage`,`OpenSearchVectorDBStorage` | `NanoVectorDBStorage` |\n| **graph_storage** | `str` | Storage type for graph edges and nodes. Supported types: `NetworkXStorage`,`Neo4JStorage`,`PGGraphStorage`,`AGEStorage`,`OpenSearchGraphStorage` | `NetworkXStorage` |\n| **doc_status_storage** | `str` | Storage type for documents process status. Supported types: `JsonDocStatusStorage`,`PGDocStatusStorage`,`MongoDocStatusStorage`,`OpenSearchDocStatusStorage` | `JsonDocStatusStorage` |\n| **chunk_token_size** | `int` | 拆分文档时每个块的最大令牌大小 | `1200` |\n| **chunk_overlap_token_size** | `int` | 拆分文档时两个块之间的重叠令牌大小 | `100` |\n| **tokenizer** | `Tokenizer` | 用于将文本转换为 tokens（数字）以及使用遵循 TokenizerInterface 协议的 .encode() 和 .decode() 函数将 tokens 转换回文本的函数。 如果您不指定，它将使用默认的 Tiktoken tokenizer。 | `TiktokenTokenizer` |\n| **tiktoken_model_name** | `str` | 如果您使用的是默认的 Tiktoken tokenizer，那么这是要使用的特定 Tiktoken 模型的名称。如果您提供自己的 tokenizer，则忽略此设置。 | `gpt-4o-mini` |\n| **entity_extract_max_gleaning** | `int` | 实体提取过程中的循环次数，附加历史消息 | `1` |\n| **node_embedding_algorithm** | `str` | 节点嵌入算法（当前未使用） | `node2vec` |\n| **node2vec_params** | `dict` | 节点嵌入的参数 | `{\"dimensions\": 1536,\"num_walks\": 10,\"walk_length\": 40,\"window_size\": 2,\"iterations\": 3,\"random_seed\": 3,}` |\n| **embedding_func** | `EmbeddingFunc` | 从文本生成嵌入向量的函数 | `openai_embed` |\n| **embedding_batch_num** | `int` | 嵌入过程的最大批量大小（每批发送多个文本） | `32` |\n| **embedding_func_max_async** | `int` | 最大并发异步嵌入进程数 | `16` |\n| **llm_model_func** | `callable` | LLM生成的函数 | `gpt_4o_mini_complete` |\n| **llm_model_name** | `str` | 用于生成的LLM模型名称 | `meta-llama/Llama-3.2-1B-Instruct` |\n| **summary_context_size** | `int` | 合并实体关系摘要时送给LLM的最大令牌数 | `10000`（由环境变量 SUMMARY_MAX_CONTEXT 设置） |\n| **summary_max_tokens** | `int` | 合并实体关系描述的最大令牌数长度 | `500`（由环境变量 SUMMARY_MAX_TOKENS 设置） |\n| **llm_model_max_async** | `int` | 最大并发异步LLM进程数 | `4`（默认值由环境变量MAX_ASYNC更改） |\n| **llm_model_kwargs** | `dict` | LLM生成的附加参数 | |\n| **vector_db_storage_cls_kwargs** | `dict` | 向量数据库的附加参数，如设置节点和关系检索的阈值 | cosine_better_than_threshold: 0.2（默认值由环境变量COSINE_THRESHOLD更改） |\n| **enable_llm_cache** | `bool` | 如果为`TRUE`，将LLM结果存储在缓存中；重复的提示返回缓存的响应 | `TRUE` |\n| **enable_llm_cache_for_entity_extract** | `bool` | 如果为`TRUE`，将实体提取的LLM结果存储在缓存中；适合初学者调试应用程序 | `TRUE` |\n| **addon_params** | `dict` | 附加参数，例如`{\"language\": \"Simplified Chinese\", \"entity_types\": [\"organization\", \"person\", \"location\", \"event\"]}`：设置示例限制、输出语言和文档处理的批量大小 | language: English` |\n| **embedding_cache_config** | `dict` | 问答缓存的配置。包含三个参数：`enabled`：布尔值，启用/禁用缓存查找功能。启用时，系统将在生成新答案之前检查缓存的响应。`similarity_threshold`：浮点值（0-1），相似度阈值。当新问题与缓存问题的相似度超过此阈值时，将直接返回缓存的答案而不调用LLM。`use_llm_check`：布尔值，启用/禁用LLM相似度验证。启用时，在返回缓存答案之前，将使用LLM作为二次检查来验证问题之间的相似度。 | 默认：`{\"enabled\": False, \"similarity_threshold\": 0.95, \"use_llm_check\": False}` |\n\n</details>\n\n### 查询参数\n\n使用QueryParam控制你的查询行为：\n\n```python\nclass QueryParam:\n    \"\"\"Configuration parameters for query execution in LightRAG.\"\"\"\n\n    mode: Literal[\"local\", \"global\", \"hybrid\", \"naive\", \"mix\", \"bypass\"] = \"global\"\n    \"\"\"Specifies the retrieval mode:\n    - \"local\": Focuses on context-dependent information.\n    - \"global\": Utilizes global knowledge.\n    - \"hybrid\": Combines local and global retrieval methods.\n    - \"naive\": Performs a basic search without advanced techniques.\n    - \"mix\": Integrates knowledge graph and vector retrieval.\n    \"\"\"\n\n    only_need_context: bool = False\n    \"\"\"If True, only returns the retrieved context without generating a response.\"\"\"\n\n    only_need_prompt: bool = False\n    \"\"\"If True, only returns the generated prompt without producing a response.\"\"\"\n\n    response_type: str = \"Multiple Paragraphs\"\n    \"\"\"Defines the response format. Examples: 'Multiple Paragraphs', 'Single Paragraph', 'Bullet Points'.\"\"\"\n\n    stream: bool = False\n    \"\"\"If True, enables streaming output for real-time responses.\"\"\"\n\n    top_k: int = int(os.getenv(\"TOP_K\", \"60\"))\n    \"\"\"Number of top items to retrieve. Represents entities in 'local' mode and relationships in 'global' mode.\"\"\"\n\n    chunk_top_k: int = int(os.getenv(\"CHUNK_TOP_K\", \"20\"))\n    \"\"\"Number of text chunks to retrieve initially from vector search and keep after reranking.\n    If None, defaults to top_k value.\n    \"\"\"\n\n    max_entity_tokens: int = int(os.getenv(\"MAX_ENTITY_TOKENS\", \"6000\"))\n    \"\"\"Maximum number of tokens allocated for entity context in unified token control system.\"\"\"\n\n    max_relation_tokens: int = int(os.getenv(\"MAX_RELATION_TOKENS\", \"8000\"))\n    \"\"\"Maximum number of tokens allocated for relationship context in unified token control system.\"\"\"\n\n    max_total_tokens: int = int(os.getenv(\"MAX_TOTAL_TOKENS\", \"30000\"))\n    \"\"\"Maximum total tokens budget for the entire query context (entities + relations + chunks + system prompt).\"\"\"\n\n    # History messages are only sent to LLM for context, not used for retrieval\n    conversation_history: list[dict[str, str]] = field(default_factory=list)\n    \"\"\"Stores past conversation history to maintain context.\n    Format: [{\"role\": \"user/assistant\", \"content\": \"message\"}].\n    \"\"\"\n\n    # Deprecated (ids filter lead to potential hallucination effects)\n    ids: list[str] | None = None\n    \"\"\"List of ids to filter the results.\"\"\"\n\n    model_func: Callable[..., object] | None = None\n    \"\"\"Optional override for the LLM model function to use for this specific query.\n    If provided, this will be used instead of the global model function.\n    This allows using different models for different query modes.\n    \"\"\"\n\n    user_prompt: str | None = None\n    \"\"\"User-provided prompt for the query.\n    Addition instructions for LLM. If provided, this will be inject into the prompt template.\n    It's purpose is the let user customize the way LLM generate the response.\n    \"\"\"\n\n    enable_rerank: bool = True\n    \"\"\"Enable reranking for retrieved text chunks. If True but no rerank model is configured, a warning will be issued.\n    Default is True to enable reranking when rerank model is available.\n    \"\"\"\n```\n\n> top_k的默认值可以通过环境变量TOP_K更改。\n\n### LLM and Embedding注入\n\nLightRAG 需要利用LLM和Embeding模型来完成文档索引和知识库查询工作。在初始化LightRAG的时候需要把阶段，需要把LLM和Embedding的操作函数注入到对象中：\n\n<details>\n<summary> <b>使用类OpenAI的API</b> </summary>\n\n* LightRAG还支持类OpenAI的聊天/嵌入API：\n\n```python\nimport os\nimport numpy as np\nfrom lightrag.utils import wrap_embedding_func_with_attrs\nfrom lightrag.llm.openai import openai_complete_if_cache, openai_embed\n\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], keyword_extraction=False, **kwargs\n) -> str:\n    return await openai_complete_if_cache(\n        \"solar-mini\",\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=os.getenv(\"UPSTAGE_API_KEY\"),\n        base_url=\"https://api.upstage.ai/v1/solar\",\n        **kwargs\n    )\n\n@wrap_embedding_func_with_attrs(embedding_dim=4096, max_token_size=8192, model_name=\"solar-embedding-1-large-query\")\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await openai_embed.func(\n        texts,\n        model=\"solar-embedding-1-large-query\",\n        api_key=os.getenv(\"UPSTAGE_API_KEY\"),\n        base_url=\"https://api.upstage.ai/v1/solar\"\n    )\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=llm_model_func,\n        embedding_func=embedding_func  # 直接传入装饰后的函数\n    )\n\n    await rag.initialize_storages()\n    return rag\n```\n\n> **关于嵌入函数封装的重要说明：**\n>\n> `EmbeddingFunc` 不能嵌套封装。已经被 `@wrap_embedding_func_with_attrs` 装饰过的嵌入函数（如 `openai_embed`、`ollama_embed` 等）不能再次使用 `EmbeddingFunc()` 封装。这就是为什么在创建自定义嵌入函数时，我们调用 `xxx_embed.func`（底层未封装的函数）而不是直接调用 `xxx_embed`。\n\n</details>\n\n<details>\n<summary> <b>使用 Hugging Face 模型</b> </summary>\n\n* 如果您想使用 Hugging Face 模型，只需要按如下方式设置 LightRAG：\n\n参见 `lightrag_hf_demo.py`\n\n```python\nfrom functools import partial\nfrom transformers import AutoTokenizer, AutoModel\n\n# Pre-load tokenizer and model\ntokenizer = AutoTokenizer.from_pretrained(\"sentence-transformers/all-MiniLM-L6-v2\")\nembed_model = AutoModel.from_pretrained(\"sentence-transformers/all-MiniLM-L6-v2\")\n\n# 使用 Hugging Face 模型初始化 LightRAG\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=hf_model_complete,  # 使用 Hugging Face 模型进行文本生成\n    llm_model_name='meta-llama/Llama-3.1-8B-Instruct',  # Hugging Face 的模型名称\n    # 使用 Hugging Face 嵌入函数\n    embedding_func=EmbeddingFunc(\n        embedding_dim=384,\n        max_token_size=2048,\n        model_name=\"sentence-transformers/all-MiniLM-L6-v2\",\n        func=partial(\n            hf_embed.func,  # 使用 .func 访问底层未封装的函数\n            tokenizer=tokenizer,\n            embed_model=embed_model\n        )\n    ),\n)\n```\n\n</details>\n\n<details>\n<summary> <b>使用Ollama模型</b> </summary>\n\n**综述**\n\n如果您想使用Ollama模型，您需要拉取计划使用的模型和嵌入模型，例如`nomic-embed-text`。\n\n然后您只需要按如下方式设置LightRAG：\n\n```python\nimport numpy as np\nfrom lightrag.utils import wrap_embedding_func_with_attrs\nfrom lightrag.llm.ollama import ollama_model_complete, ollama_embed\n\n@wrap_embedding_func_with_attrs(embedding_dim=768, max_token_size=8192, model_name=\"nomic-embed-text\")\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await ollama_embed.func(texts, embed_model=\"nomic-embed-text\")\n\n# Initialize LightRAG with Ollama model\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=ollama_model_complete,  # Use Ollama model for text generation\n    llm_model_name='your_model_name', # Your model name\n    embedding_func=embedding_func,  # Pass the decorated function directly\n)\n```\n\n* **增加上下文大小**\n\n为了使 LightRAG 正常工作，上下文大小至少需要 32k tokens。默认情况下，Ollama 模型的上下文大小为 8k。您可以通过以下两种方式之一来实现：\n\n* **在 Modelfile 中增加 `num_ctx` 参数**\n\n1. 拉取模型：\n\n```bash\nollama pull qwen2\n```\n\n2. 显示模型文件：\n\n```bash\nollama show --modelfile qwen2 > Modelfile\n```\n\n3. 编辑 Modelfile，添加以下行：\n\n```bash\nPARAMETER num_ctx 32768\n```\n\n4. 创建修改后的模型：\n\n```bash\nollama create -f Modelfile qwen2m\n```\n\n* **通过 Ollama API 设置 `num_ctx`**\n\n您可以使用 `llm_model_kwargs` 参数来配置 Ollama：\n\n```python\nimport numpy as np\nfrom lightrag.utils import wrap_embedding_func_with_attrs\nfrom lightrag.llm.ollama import ollama_model_complete, ollama_embed\n\n@wrap_embedding_func_with_attrs(embedding_dim=768, max_token_size=8192, model_name=\"nomic-embed-text\")\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await ollama_embed.func(texts, embed_model=\"nomic-embed-text\")\n\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=ollama_model_complete,  # 使用 Ollama 模型进行文本生成\n    llm_model_name='your_model_name', # 您的模型名称\n    llm_model_kwargs={\"options\": {\"num_ctx\": 32768}},\n    embedding_func=embedding_func,  # 直接传入装饰后的函数\n)\n```\n\n> **关于嵌入函数封装的重要说明：**\n>\n> `EmbeddingFunc` 不能嵌套封装。已经被 `@wrap_embedding_func_with_attrs` 装饰过的嵌入函数（如 `openai_embed`、`ollama_embed` 等）不能再次使用 `EmbeddingFunc()` 封装。这就是为什么在创建自定义嵌入函数时，我们调用 `xxx_embed.func`（底层未封装的函数）而不是直接调用 `xxx_embed`。\n\n* **低显存 GPU**\n\n如果要在低显存 GPU 上运行此实验，您应该选择较小的模型并调整上下文窗口（增加上下文会增加内存消耗）。例如，在一块改装的 6GB 显存的挖矿 GPU 上运行此 Ollama 示例，需要在使用 `gemma2:2b` 时将上下文大小设置为 26k。它能够在 `book.txt` 中找到 197 个实体和 19 个关系。\n\n</details>\n\n<details>\n<summary> <b>LlamaIndex</b> </summary>\n\nLightRAG 支持与 LlamaIndex 集成（`llm/llama_index_impl.py`）：\n\n- 通过 LlamaIndex 与 OpenAI 和其他提供商集成\n- 详细设置请参阅 [LlamaIndex 文档](https://developers.llamaindex.ai/python/framework/) 或 [示例](examples/unofficial-sample/)\n\n**示例用法**\n\n```python\n# 使用 LlamaIndex 直接访问 OpenAI\nimport asyncio\nfrom lightrag import LightRAG\nfrom lightrag.llm.llama_index_impl import llama_index_complete_if_cache, llama_index_embed\nfrom llama_index.embeddings.openai import OpenAIEmbedding\nfrom llama_index.llms.openai import OpenAI\nfrom lightrag.utils import setup_logger\n\n# 为 LightRAG 设置日志处理器\nsetup_logger(\"lightrag\", level=\"INFO\")\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=\"your/path\",\n        llm_model_func=llama_index_complete_if_cache,  # 与 LlamaIndex 兼容的补全函数\n        embedding_func=EmbeddingFunc(    # 与 LlamaIndex 兼容的嵌入函数\n            embedding_dim=1536,\n            max_token_size=2048,\n            model_name=embed_model,\n            func=partial(llama_index_embed.func, embed_model=embed_model)  # 使用 .func 访问未封装的原始函数\n        ),\n    )\n\n    await rag.initialize_storages()\n    return rag\n\ndef main():\n    # 初始化 RAG 实例\n    rag = asyncio.run(initialize_rag())\n\n    with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n        rag.insert(f.read())\n\n    # 执行朴素搜索\n    print(\n        rag.query(\"What are the top themes in this story?\", param=QueryParam(mode=\"naive\"))\n    )\n\n    # 执行本地搜索\n    print(\n        rag.query(\"What are the top themes in this story?\", param=QueryParam(mode=\"local\"))\n    )\n\n    # 执行全局搜索\n    print(\n        rag.query(\"What are the top themes in this story?\", param=QueryParam(mode=\"global\"))\n    )\n\n    # 执行混合搜索\n    print(\n        rag.query(\"What are the top themes in this story?\", param=QueryParam(mode=\"hybrid\"))\n    )\n\nif __name__ == \"__main__\":\n    main()\n```\n\n**详细文档和示例请参阅：**\n\n- [LlamaIndex 文档](https://developers.llamaindex.ai/python/framework/)\n- [直接使用 OpenAI 示例](examples/unofficial-sample/lightrag_llamaindex_direct_demo.py)\n- [LiteLLM 代理示例](examples/unofficial-sample/lightrag_llamaindex_litellm_demo.py)\n- [LiteLLM 代理与 Opik 集成示例](examples/unofficial-sample/lightrag_llamaindex_litellm_opik_demo.py)\n\n</details>\n\n<details>\n<summary> <b>使用 Azure OpenAI 模型</b> </summary>\n\n如果您想使用 Azure OpenAI 模型，您只需要按如下方式设置 LightRAG：\n\n```python\nimport os\nimport numpy as np\nfrom lightrag.utils import wrap_embedding_func_with_attrs\nfrom lightrag.llm.azure_openai import azure_openai_complete_if_cache, azure_openai_embed\n\n# 配置生成模型\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], keyword_extraction=False, **kwargs\n) -> str:\n    return await azure_openai_complete_if_cache(\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=os.getenv(\"AZURE_OPENAI_API_KEY\"),\n        azure_endpoint=os.getenv(\"AZURE_OPENAI_ENDPOINT\"),\n        api_version=os.getenv(\"AZURE_OPENAI_API_VERSION\"),\n        deployment_name=os.getenv(\"AZURE_OPENAI_DEPLOYMENT_NAME\"),\n        **kwargs\n    )\n\n# 配置嵌入模型\n@wrap_embedding_func_with_attrs(\n    embedding_dim=1536,\n    max_token_size=8192,\n    model_name=os.getenv(\"AZURE_OPENAI_EMBEDDING_MODEL\")\n)\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await azure_openai_embed.func(\n        texts,\n        api_key=os.getenv(\"AZURE_OPENAI_API_KEY\"),\n        azure_endpoint=os.getenv(\"AZURE_OPENAI_ENDPOINT\"),\n        api_version=os.getenv(\"AZURE_OPENAI_API_VERSION\"),\n        deployment_name=os.getenv(\"AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME\")\n    )\n\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=llm_model_func,\n    embedding_func=embedding_func\n)\n```\n\n</details>\n\n<details>\n<summary> <b>使用 Google Gemini 模型</b> </summary>\n\n如果您想使用 Google Gemini 模型，您只需要按如下方式设置 LightRAG：\n\n```python\nimport os\nimport numpy as np\nfrom lightrag.utils import wrap_embedding_func_with_attrs\nfrom lightrag.llm.gemini import gemini_complete, gemini_embed\n\n# 配置生成模型\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], keyword_extraction=False, **kwargs\n) -> str:\n    return await gemini_complete(\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=os.getenv(\"GEMINI_API_KEY\"),\n        model=\"gemini-1.5-flash\",\n        **kwargs\n    )\n\n# 配置嵌入模型\n@wrap_embedding_func_with_attrs(\n    embedding_dim=768,\n    max_token_size=2048,\n    model_name=\"models/text-embedding-004\"\n)\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await gemini_embed.func(\n        texts,\n        api_key=os.getenv(\"GEMINI_API_KEY\"),\n        model=\"models/text-embedding-004\"\n    )\n\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=llm_model_func,\n    llm_model_name=\"gemini-2.0-flash\",\n    embedding_func=embedding_func\n)\n```\n\n</details>\n\n### Rerank 函数注入\n\n为了提升检索质量，可以基于更有效的相关性评分模型对文档进行重新排序。`rerank.py` 文件提供了三个 Reranker 服务商的驱动函数：\n\n* **Cohere / vLLM**: `cohere_rerank`\n* **Jina AI**: `jina_rerank`\n* **阿里云**: `ali_rerank`\n\n您可以将其中一个函数注入到 LightRAG 对象的 `rerank_model_func` 属性中。这将使 LightRAG 的查询函数能够使用注入的函数对检索到的文本块进行重新排序。详细用法请参考 `examples/rerank_example.py` 文件。\n\n### User Prompt 与 Query 的区别\n\n使用 LightRAG 进行内容查询时，应避免将搜索过程与不相关的输出处理混合在一起，因为这会显著影响查询效果。QueryParam 中的 `user_prompt` 参数专门用于解决此问题 - 它不参与 RAG 检索阶段，而是在查询完成后指导 LLM 如何处理检索到的结果。使用方法如下：\n\n```python\n# 创建查询参数\nquery_param = QueryParam(\n    mode = \"hybrid\",  # 其他模式：local, global, hybrid, mix, naive\n    user_prompt = \"对于图表，使用 mermaid 格式，节点名称使用英文或拼音，显示标签使用中文\",\n)\n\n# 查询并处理\nresponse_default = rag.query(\n    \"请为斯克鲁奇绘制人物关系图\",\n    param=query_param\n)\nprint(response_default)\n```\n\n### 插入\n\n<details>\n  <summary> <b> 基本插入 </b></summary>\n\n```python\n# 基本插入\nrag.insert(\"文本\")\n```\n\n</details>\n\n<details>\n  <summary> <b> 批量插入 </b></summary>\n\n```python\n# 基本批量插入：一次插入多个文本\nrag.insert([\"文本1\", \"文本2\",...])\n\n# 自定义批量大小配置的批量插入\nrag = LightRAG(\n    ...\n    working_dir=WORKING_DIR,\n    max_parallel_insert = 4\n)\n\nrag.insert([\"文本1\", \"文本2\", \"文本3\", ...])  # 文档将以每批 4 个的方式处理\n```\n\n`max_parallel_insert` 参数决定了文档索引管道中并发处理的文档数量。如果未指定，默认值为 **2**。我们建议将此设置保持在 **10 以下**，因为性能瓶颈通常在于大语言模型（LLM）的处理能力。\n\n</details>\n\n<details>\n  <summary> <b> 带 ID 插入 </b></summary>\n\n如果您想为文档提供自定义 ID，文档数量和 ID 数量必须相同。\n\n```python\n# 插入单个文本，并为其提供 ID\nrag.insert(\"文本1\", ids=[\"文本1的ID\"])\n\n# 插入多个文本，并为它们提供 ID\nrag.insert([\"文本1\", \"文本2\",...], ids=[\"文本1的ID\", \"文本2的ID\"])\n```\n\n</details>\n\n<details>\n  <summary><b>使用管道插入</b></summary>\n\n`apipeline_enqueue_documents` 和 `apipeline_process_enqueue_documents` 函数允许您将文档增量插入到图中。这对于希望在后台处理文档同时允许主线程继续执行的场景非常有用。\n\n```python\nrag = LightRAG(..)\n\nawait rag.apipeline_enqueue_documents(input)\n# 在循环中的例程\nawait rag.apipeline_process_enqueue_documents(input)\n```\n\n</details>\n\n<details>\n  <summary><b>多文件类型支持插入</b></summary>\n\n`textract` 支持读取 TXT、DOCX、PPTX、CSV 和 PDF 等文件类型。\n\n```python\nimport textract\n\nfile_path = 'TEXT.pdf'\ntext_content = textract.process(file_path)\n\nrag.insert(text_content.decode('utf-8'))\n```\n\n</details>\n\n<details>\n  <summary><b>引用功能</b></summary>\n\n通过提供文件路径，系统可以确保来源可以追溯到原始文档。\n\n```python\n# 定义文档及其文件路径\ndocuments = [\"文档内容 1\", \"文档内容 2\"]\nfile_paths = [\"path/to/doc1.txt\", \"path/to/doc2.txt\"]\n\n# 带文件路径插入文档\nrag.insert(documents, file_paths=file_paths)\n```\n\n</details>\n\n### 存储方案\n\nLightRAG 使用 4 种类型的存储来满足不同用途：\n\n* KV_STORAGE：LLM 响应缓存、文本块、文档信息\n* VECTOR_STORAGE：实体向量、关系向量、文本块向量\n* GRAPH_STORAGE：实体关系图\n* DOC_STATUS_STORAGE：文档索引状态\n\n每种存储类型都有多种实现：\n\n* KV_STORAGE 支持的实现：\n\n```\nJsonKVStorage        JsonFile（默认）\nPGKVStorage          Postgres\nRedisKVStorage       Redis\nMongoKVStorage       MongoDB\nOpenSearchKVStorage  OpenSearch\n```\n\n* GRAPH_STORAGE 支持的实现：\n\n```\nNetworkXStorage          NetworkX（默认）\nNeo4JStorage             Neo4J\nPGGraphStorage           PostgreSQL with AGE 插件\nMemgraphStorage          Memgraph\nOpenSearchGraphStorage   OpenSearch\n```\n\n> 测试表明，Neo4J 在生产环境中的性能优于带有 AGE 插件的 PostgreSQL。\n\n* VECTOR_STORAGE 支持的实现：\n\n```\nNanoVectorDBStorage         NanoVector（默认）\nPGVectorStorage             Postgres\nMilvusVectorDBStorage       Milvus\nFaissVectorDBStorage        Faiss\nQdrantVectorDBStorage       Qdrant\nMongoVectorDBStorage        MongoDB\nOpenSearchVectorDBStorage   OpenSearch\n```\n\n* DOC_STATUS_STORAGE 支持的实现：\n\n```\nJsonDocStatusStorage        JsonFile（默认）\nPGDocStatusStorage          Postgres\nMongoDocStatusStorage       MongoDB\nOpenSearchDocStatusStorage  OpenSearch\n```\n\n各存储类型的示例连接配置可在仓库中的 `env.example` 文件里找到。连接字符串中的数据库实例需要您预先在数据库服务器上创建。LightRAG 仅负责在数据库实例中创建表，不负责创建数据库实例本身。如果使用 Redis 作为存储，请记住配置 Redis 的自动数据持久化规则，否则 Redis 服务重启后数据将会丢失。如果使用 PostgreSQL，建议使用 16.6 或更高版本。\n\n<details>\n<summary> <b>使用 Neo4J 存储</b> </summary>\n\n* 对于生产级场景，您很可能需要使用企业级解决方案\n* 用于知识图谱存储。推荐在 Docker 中运行 Neo4J 进行无缝本地测试。\n* 参见：https://hub.docker.com/_/neo4j\n\n```python\nexport NEO4J_URI=\"neo4j://localhost:7687\"\nexport NEO4J_USERNAME=\"neo4j\"\nexport NEO4J_PASSWORD=\"password\"\nexport NEO4J_DATABASE=\"neo4j\" #<----------- 使用 neo4j 社区版 docker 镜像时数据库实例必须为neo4j\n\n# 为 LightRAG 设置日志\nsetup_logger(\"lightrag\", level=\"INFO\")\n\n# 启动项目时，请确保通过指定 graph_storage=\"Neo4JStorage\" 来覆盖默认的 KG: NetworkX。\n# 使用 Neo4J 实现初始化 LightRAG。\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=gpt_4o_mini_complete,  # 使用 gpt_4o_mini_complete LLM 模型\n        graph_storage=\"Neo4JStorage\", #<-----------覆盖 KG 默认值\n    )\n\n    # 初始化数据库连接\n    await rag.initialize_storages()\n    # 初始化文档处理的管道状态\n    return rag\n```\n\n参见 test_neo4j.py 获取可运行的示例。\n\n</details>\n\n<details>\n<summary> <b>使用 PostgreSQL 存储</b> </summary>\n\n对于生产级场景，您很可能需要使用企业级解决方案。PostgreSQL 可以为您提供一站式解决方案，作为 KV 存储、VectorDB（pgvector）和 GraphDB（apache AGE）。支持 PostgreSQL 16.6 或更高版本。\n\n* PostgreSQL 很轻量，包含所有必要插件的完整二进制发行版可以压缩到 40MB：参考 [Windows Release](https://github.com/ShanGor/apache-age-windows/releases/tag/PG17%2Fv1.5.0-rc0)，Linux/Mac 也很容易安装。\n* 如果您喜欢 docker，建议初学者使用此镜像以避免出现问题（默认用户密码：rag/rag）：https://hub.docker.com/r/gzdaniel/postgres-for-rag\n* 如何开始？参考：[examples/lightrag_gemini_postgres_demo.py](https://github.com/HKUDS/LightRAG/blob/main/examples/lightrag_gemini_postgres_demo.py)\n* 对于高性能图数据库需求，推荐使用 Neo4j，因为 Apache AGE 的性能不够理想。\n\n</details>\n\n<details>\n<summary> <b>使用 Faiss 存储</b> </summary>\n\n在使用 Faiss 向量数据库之前，您必须手动安装 `faiss-cpu` 或 `faiss-gpu`。\n\n- 安装所需依赖：\n\n```\npip install faiss-cpu\n```\n\n如果您有 GPU 支持，也可以安装 `faiss-gpu`。\n\n- 这里我们使用 `sentence-transformers`，但您也可以使用 `3072` 维度的 `OpenAIEmbedding` 模型。\n\n```python\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    model = SentenceTransformer('all-MiniLM-L6-v2')\n    embeddings = model.encode(texts, convert_to_numpy=True)\n    return embeddings\n\n# 使用 LLM 模型函数和嵌入函数初始化 LightRAG\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=llm_model_func,\n    embedding_func=EmbeddingFunc(\n        embedding_dim=384,\n        max_token_size=2048,\n        model_name=\"all-MiniLM-L6-v2\",\n        func=embedding_func,\n    ),\n    vector_storage=\"FaissVectorDBStorage\",\n    vector_db_storage_cls_kwargs={\n        \"cosine_better_than_threshold\": 0.3  # 您期望的阈值\n    }\n)\n```\n\n</details>\n\n<details>\n<summary> <b>使用 Memgraph 存储</b> </summary>\n\n* Memgraph 是一个高性能的内存图数据库，兼容 Neo4j Bolt 协议。\n* 您可以使用 Docker 在本地运行 Memgraph 进行简单测试：\n* 参见：https://memgraph.com/download\n\n```python\nexport MEMGRAPH_URI=\"bolt://localhost:7687\"\n\n# 为 LightRAG 设置日志\nsetup_logger(\"lightrag\", level=\"INFO\")\n\n# 启动项目时，通过指定 kg=\"MemgraphStorage\" 来覆盖默认的 KG: NetworkX。\n\n# 注意：默认设置使用 NetworkX\n# 使用 Memgraph 实现初始化 LightRAG。\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=gpt_4o_mini_complete,  # 使用 gpt_4o_mini_complete LLM 模型\n        graph_storage=\"MemgraphStorage\", #<-----------覆盖 KG 默认值\n    )\n\n    # 初始化数据库连接\n    await rag.initialize_storages()\n    # 初始化文档处理的管道状态\n    return rag\n```\n\n</details>\n\n<details>\n<summary> <b>使用 Milvus 作为向量存储</b> </summary>\n\nMilvus 是一个高性能、可扩展的向量数据库，适用于生产环境的向量存储。LightRAG 提供了三种配置 Milvus 的方式，并支持可配置的索引类型，以优化性能和内存使用。\n\n### 支持的索引类型\n\n- `AUTOINDEX`（默认）：Milvus 自动选择最佳索引\n- `HNSW`：层次可导航小世界图，适用于高召回率\n- `HNSW_SQ`：使用标量量化技术的 HNSW，可节省内存（需 Milvus 2.6.8+）\n- `HNSW_PQ`、`HNSW_PRQ`：使用乘积量化/残差乘积量化技术的 HNSW\n- `IVF_FLAT`、`IVF_SQ8`、`IVF_PQ`：倒排文件族索引\n- `DISKANN`：基于磁盘的近似最近邻索引\n- `SCANN`：可扩展的最近邻索引\n\n### 支持的度量类型\n\n`COSINE` (默认), `L2`, `IP`\n\n---\n\n### 配置方法1 — 环境变量 (`.env` file)\n\n适用于: **LightRAG Server 部署和 Docker/k8s 设置**.\n\n```bash\n# Connection\nMILVUS_URI=http://localhost:19530\nMILVUS_DB_NAME=lightrag\n# MILVUS_USER=root\n# MILVUS_PASSWORD=your_password\n# MILVUS_TOKEN=your_token\n\n# Storage selection\nLIGHTRAG_VECTOR_STORAGE=MilvusVectorDBStorage\n\n# Index configuration (all optional — sensible defaults apply)\nMILVUS_INDEX_TYPE=HNSW              # Default: AUTOINDEX\nMILVUS_METRIC_TYPE=COSINE           # Default: COSINE\nMILVUS_HNSW_M=16                    # Default: 16, range [2-2048]\nMILVUS_HNSW_EF_CONSTRUCTION=360     # Default: 360\nMILVUS_HNSW_EF=200                  # Default: 200\n\n# HNSW_SQ options (requires Milvus 2.6.8+)\n# MILVUS_INDEX_TYPE=HNSW_SQ\n# MILVUS_HNSW_SQ_TYPE=SQ8           # SQ4U, SQ6, SQ8, BF16, FP16\n# MILVUS_HNSW_SQ_REFINE=false       # Enable refinement\n# MILVUS_HNSW_SQ_REFINE_TYPE=FP32   # Refinement precision\n# MILVUS_HNSW_SQ_REFINE_K=10        # Refinement expansion factor\n\n# IVF options\n# MILVUS_IVF_NLIST=1024\n# MILVUS_IVF_NPROBE=16\n```\n\n然后再Python代码中:\n\n```python\nfrom lightrag import LightRAG\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=\"./rag_storage\",\n        llm_model_func=...,\n        embedding_func=...,\n        vector_storage=\"MilvusVectorDBStorage\",\n    )\n    await rag.initialize_storages()\n    return rag\n```\n\n### 配置方案2 — `vector_db_storage_cls_kwargs` (Python SDK)\n\n适用于: **Python SDK / framework integration** （使用代码进行配置）\n\n```python\nfrom lightrag import LightRAG\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=\"./rag_storage\",\n        llm_model_func=...,\n        embedding_func=...,\n        vector_storage=\"MilvusVectorDBStorage\",\n        vector_db_storage_cls_kwargs={\n            \"milvus_uri\": \"http://localhost:19530\",\n            \"milvus_db_name\": \"lightrag\",\n            \"index_type\": \"HNSW\",\n            \"metric_type\": \"COSINE\",\n            \"hnsw_m\": 16,\n            \"hnsw_ef_construction\": 360,\n            \"hnsw_ef\": 200,\n            \"cosine_better_than_threshold\": 0.2,\n        },\n    )\n    await rag.initialize_storages()\n    return rag\n```\n\n### 配置方案3 — `config.ini` (遗留方案)\n\n仅适用于连接参数配资；索引方式配资依然需要使用环境变量或kwargs.\n\n```ini\n[milvus]\nuri = http://localhost:19530\ndb_name = lightrag\n# user = root\n# password = your_password\n# token = your_token\n```\n\n### 配置优先级\n\n| 配置 | 1st (highest) | 2nd | 3rd (lowest) |\n|---|---|---|---|\n| 连接方式 (`uri`, …) | `vector_db_storage_cls_kwargs` | Environment variables | `config.ini` |\n| 索引方法 (`index_type`, …) | `vector_db_storage_cls_kwargs` | Environment variables | defaults |\n\n### HNSW_SQ 压缩的权衡\n\n| SQ Type | Compression | Precision | Notes |\n|---|---|---|---|\n| `SQ4U` | ~8× | Lower | Best memory savings |\n| `SQ6` | ~5.3× | Balanced | Good trade-off |\n| `SQ8` | ~4× | Good | **Recommended** |\n| `BF16` / `FP16` | ~2× | High | Near-lossless |\n\n**版本要求:**\n- HNSW_SQ 缩影方式要求 **Milvus 2.6.8 或更高版本**\n- LightRAG 将自动检查服务的版本并在不符合要求的时候抛出错误\n- 其它缩影方式要求Milvus 2.0+\n\n**向后兼容性:**\n- 现有数据集合不受影响；索引配置仅适用于新创建的集合\n- 有关完整的配置选项，请参阅 env.example 和 docs/MilvusConfigurationGuide.md。\n\n完整的配资选项请参考 `env.example` 和 `docs/MilvusConfigurationGuide.md`.\n\n</details>\n\n<details>\n<summary> <b>使用 MongoDB 存储</b> </summary>\n\nMongoDB 为 LightRAG 提供了一站式存储解决方案。MongoDB 提供原生的 KV 存储和向量存储。LightRAG 使用 MongoDB 集合来实现简单的图存储。`MongoVectorDBStorage` 需要目标 MongoDB 部署具备 Atlas Search / Vector Search 能力，例如 MongoDB Atlas 或 Atlas local。交互式 setup 向导内置的本地 Docker MongoDB 服务是 MongoDB Community Edition，因此它可以用于 KV / 图 / 文档状态存储，但不能作为 `MongoVectorDBStorage` 的后端。\n\n</details>\n\n<details>\n<summary> <b>使用 Redis 存储</b> </summary>\n\nLightRAG 支持使用 Redis 作为 KV 存储。使用 Redis 存储时，需要注意持久化配置和内存使用配置。以下是推荐的 Redis 配置：\n\n```\nsave 900 1\nsave 300 10\nsave 60 1000\nstop-writes-on-bgsave-error yes\nmaxmemory 4gb\nmaxmemory-policy noeviction\nmaxclients 500\n```\n\n当交互式 setup 管理本地 Redis 容器时，它会在 `./data/config/redis.conf` 生成一个可直接修改的配置文件，并将其挂载到容器内。后续重新运行 setup 时会保留该文件，避免覆盖用户的手工调整。\n\n</details>\n\n<details>\n<summary> <b>使用 OpenSearch 存储</b> </summary>\n\nOpenSearch 为 LightRAG 的全部四种存储类型（KV、向量、图、文档状态）提供了统一的存储解决方案。它提供原生 k-NN 向量搜索、全文搜索和水平扩展能力，且无云服务限制。\n\n* **环境要求**：OpenSearch 3.x 或更高版本，需启用 k-NN 插件。\n\n使用 Docker 安装 (不含插件)：\n```bash\ndocker run -d -p 9200:9200 -e \"discovery.type=single-node\" \\\n  -e \"OPENSEARCH_INITIAL_ADMIN_PASSWORD=<custom-admin-password>\" \\\n  opensearchproject/opensearch:latest\n```\n\n使用 Docker Compose 安装 (推荐，含插件)：\n```bash\ncurl -O https://raw.githubusercontent.com/opensearch-project/opensearch-build/main/docker/release/dockercomposefiles/docker-compose-3.x.yml\n# 启动 OpenSearch 集群\nOPENSEARCH_INITIAL_ADMIN_PASSWORD=<custom-admin-password> docker-compose -f docker-compose-3.x.yml up -d\n```\n\n* **配置**：设置环境变量（完整列表请参见 `env.example`）：\n\n```bash\nexport OPENSEARCH_HOSTS=localhost:9200\nexport OPENSEARCH_USER=admin\nexport OPENSEARCH_PASSWORD=<custom-admin-password>\nexport OPENSEARCH_USE_SSL=true\nexport OPENSEARCH_VERIFY_CERTS=false\n```\n\n* **使用方式**：\n\n```python\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=your_llm_func,\n    embedding_func=your_embed_func,\n    kv_storage=\"OpenSearchKVStorage\",\n    doc_status_storage=\"OpenSearchDocStatusStorage\",\n    graph_storage=\"OpenSearchGraphStorage\",\n    vector_storage=\"OpenSearchVectorDBStorage\",\n)\n```\n\n* **图遍历**：当 OpenSearch SQL 插件支持 PPL 时，图查询会使用 `graphlookup` 命令进行服务端 BFS 遍历以获得最佳性能。否则，将回退到客户端批量 BFS。此功能在启动时自动检测，也可通过 `OPENSEARCH_USE_PPL_GRAPHLOOKUP=true|false` 强制设置。\n\n* **集成测试**：针对实际运行的 OpenSearch 集群进行集成测试：\n\n1. 使用 Docker Compose 启动 OpenSearch（下载 [`docker-compose-3.x.yml`](https://raw.githubusercontent.com/opensearch-project/opensearch-build/main/docker/release/dockercomposefiles/docker-compose-3.x.yml)）：\n\n```bash\nOPENSEARCH_INITIAL_ADMIN_PASSWORD=<custom-admin-password> docker-compose -f docker-compose-3.x.yml up -d\n```\n\n2. 验证集群是否正常运行：\n\n```bash\ncurl -sk -u admin:<custom-admin-password> https://localhost:9200\ncurl -sk -u admin:<custom-admin-password> https://localhost:9200/_cat/plugins?v\n```\n\n3. 运行单元测试（无需 OpenSearch 实例，使用 mock）：\n\n```bash\npython -m pytest tests/test_opensearch_storage.py -v\n```\n\n4. 使用实际集群以OpenSearch作为存储的演示：\n\n```bash\nexport OPENSEARCH_HOSTS=localhost:9200\nexport OPENSEARCH_USER=admin\nexport OPENSEARCH_PASSWORD=<custom-admin-password>\nexport OPENSEARCH_USE_SSL=true\nexport OPENSEARCH_VERIFY_CERTS=false\npython examples/opensearch_storage_demo.py\n```\n\n5. 运行完整的 OpenAI + OpenSearch 示例（需要 `OPENAI_API_KEY`）：\n\n```bash\nexport OPENAI_API_KEY=your-api-key\npython examples/lightrag_openai_opensearch_graph_demo.py\n```\n\n6. 通过 LightRAG WebUI 或独立 HTML 文件可视化知识图谱：\n\n启动 LightRAG 服务器之前，需要[构建前端组建](https://github.com/HKUDS/LightRAG/blob/main/lightrag/api/README.md).\n```bash\n# 带上 OpenSearch 存储的配置，启动 LightRAG 服务器\nLIGHTRAG_KV_STORAGE=OpenSearchKVStorage \\\nLIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage \\\nLIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage \\\nLIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage \\\nLLM_BINDING=openai \\\nEMBEDDING_BINDING=openai \\\nEMBEDDING_MODEL=text-embedding-3-large \\\nEMBEDDING_DIM=3072 \\\nOPENAI_API_KEY=your-api-key \\\nlightrag-server\n\n# 执行该脚本读取 OpenSearch 存储的数据，生成知识图谱\npython examples/graph_visual_with_opensearch.py\n\n# 打开 http://localhost:9621/webui/ -> 知识图谱标签\n# 或执行该脚本生成独立 HTML 文件\npython examples/graph_visual_with_opensearch.py --html\n```\n\n</details>\n\n### LightRAG 实例之间的数据隔离\n\n`workspace` 参数确保不同 LightRAG 实例之间的数据隔离。一旦初始化，`workspace` 是不可变的，无法更改。以下是不同类型存储实现工作区的方式：\n\n- **对于基于本地文件的数据库，通过工作区子目录实现数据隔离**：`JsonKVStorage`、`JsonDocStatusStorage`、`NetworkXStorage`、`NanoVectorDBStorage`、`FaissVectorDBStorage`。\n- **对于以集合方式存储数据的数据库，通过在集合名称前添加工作区前缀来实现**：`RedisKVStorage`、`RedisDocStatusStorage`、`MilvusVectorDBStorage`、`MongoKVStorage`、`MongoDocStatusStorage`、`MongoVectorDBStorage`、`MongoGraphStorage`、`PGGraphStorage`。\n- **对于 Qdrant 向量数据库，通过基于 payload 的分区实现数据隔离（Qdrant 推荐的多租户方法）**：`QdrantVectorDBStorage` 使用带有 payload 过滤的共享集合，实现无限的工作区可扩展性。\n- **对于关系型数据库，通过在表中添加 `workspace` 字段实现逻辑数据分离**：`PGKVStorage`、`PGVectorStorage`、`PGDocStatusStorage`。\n- **对于 Neo4j 图数据库，通过标签实现逻辑数据隔离**：`Neo4JStorage`\n- **对于 OpenSearch，通过索引名称前缀实现数据隔离**：`OpenSearchKVStorage`、`OpenSearchDocStatusStorage`、`OpenSearchGraphStorage`、`OpenSearchVectorDBStorage`\n\n为了保持与旧数据的兼容性，当未配置工作区时，PostgreSQL 非图存储的默认工作区为 `default`，PostgreSQL AGE 图存储的默认工作区为 null，Neo4j 图存储的默认工作区为 `base`。对于所有外部存储，系统提供专用的工作区环境变量来覆盖通用的 `WORKSPACE` 环境变量配置。这些存储特定的工作区环境变量包括：`REDIS_WORKSPACE`、`MILVUS_WORKSPACE`、`QDRANT_WORKSPACE`、`MONGODB_WORKSPACE`、`POSTGRES_WORKSPACE`、`NEO4J_WORKSPACE`、`OPENSEARCH_WORKSPACE`。\n\n**使用示例：**\n有关在单个应用程序中管理多个隔离知识库（例如，将\"书籍\"内容与\"人力资源政策\"分开）的实际演示，请参阅 [Workspace Demo](examples/lightrag_gemini_workspace_demo.py)。\n\n### AGENTS.md -- 指导编码代理\n\nAGENTS.md 是一种简单、开放的格式，用于指导编码代理（https://agents.md/）。它是一个专门的、可预测的地方，用于提供上下文和指令，帮助 AI 编码代理在 LightRAG 项目上工作。不同的 AI 编码器不应单独维护各自的指导文件。如果任何 AI 编码器无法自动识别 AGENTS.md，可以使用符号链接作为解决方案。建立符号链接后，可以通过配置本地的 `.gitignore_global` 来防止它们被提交到 Git 仓库。\n\n## 编辑实体和关系\n\nLightRAG 现在支持全面的知识图谱管理功能，允许您在知识图谱中创建、编辑和删除实体和关系。\n\n<details>\n  <summary> <b> 创建实体和关系 </b></summary>\n\n```python\n# 创建新实体\nentity = rag.create_entity(\"Google\", {\n    \"description\": \"Google 是一家专注于互联网相关服务和产品的跨国科技公司。\",\n    \"entity_type\": \"company\"\n})\n\n# 创建另一个实体\nproduct = rag.create_entity(\"Gmail\", {\n    \"description\": \"Gmail 是 Google 开发的电子邮件服务。\",\n    \"entity_type\": \"product\"\n})\n\n# 创建实体之间的关系\nrelation = rag.create_relation(\"Google\", \"Gmail\", {\n    \"description\": \"Google 开发和运营 Gmail。\",\n    \"keywords\": \"develops operates service\",\n    \"weight\": 2.0\n})\n```\n\n</details>\n\n<details>\n  <summary> <b> 手动修改实体与关系 </b></summary>\n\n```python\n# Edit an existing entity\nupdated_entity = rag.edit_entity(\"Google\", {\n    \"description\": \"Google is a subsidiary of Alphabet Inc., founded in 1998.\",\n    \"entity_type\": \"tech_company\"\n})\n\n# Rename an entity (with all its relationships properly migrated)\nrenamed_entity = rag.edit_entity(\"Gmail\", {\n    \"entity_name\": \"Google Mail\",\n    \"description\": \"Google Mail (formerly Gmail) is an email service.\"\n})\n\n# Edit a relation between entities\nupdated_relation = rag.edit_relation(\"Google\", \"Google Mail\", {\n    \"description\": \"Google created and maintains Google Mail service.\",\n    \"keywords\": \"creates maintains email service\",\n    \"weight\": 3.0\n})\n```\n\n所有操作均提供同步和异步两个版本。异步版本带有 \"a\" 前缀（例如：`acreate_entity`、`aedit_relation`）。\n\n</details>\n\n<details>\n  <summary> <b> 插入自定义知识图谱 </b></summary>\n\n```python\ncustom_kg = {\n        \"chunks\": [\n            {\n                \"content\": \"Alice and Bob are collaborating on quantum computing research.\",\n                \"source_id\": \"doc-1\",\n                \"file_path\": \"test_file\",\n            }\n        ],\n        \"entities\": [\n            {\n                \"entity_name\": \"Alice\",\n                \"entity_type\": \"person\",\n                \"description\": \"Alice is a researcher specializing in quantum physics.\",\n                \"source_id\": \"doc-1\",\n                \"file_path\": \"test_file\"\n            },\n            {\n                \"entity_name\": \"Bob\",\n                \"entity_type\": \"person\",\n                \"description\": \"Bob is a mathematician.\",\n                \"source_id\": \"doc-1\",\n                \"file_path\": \"test_file\"\n            },\n            {\n                \"entity_name\": \"Quantum Computing\",\n                \"entity_type\": \"technology\",\n                \"description\": \"Quantum computing utilizes quantum mechanical phenomena for computation.\",\n                \"source_id\": \"doc-1\",\n                \"file_path\": \"test_file\"\n            }\n        ],\n        \"relationships\": [\n            {\n                \"src_id\": \"Alice\",\n                \"tgt_id\": \"Bob\",\n                \"description\": \"Alice and Bob are research partners.\",\n                \"keywords\": \"collaboration research\",\n                \"weight\": 1.0,\n                \"source_id\": \"doc-1\",\n                \"file_path\": \"test_file\"\n            },\n            {\n                \"src_id\": \"Alice\",\n                \"tgt_id\": \"Quantum Computing\",\n                \"description\": \"Alice conducts research on quantum computing.\",\n                \"keywords\": \"research expertise\",\n                \"weight\": 1.0,\n                \"source_id\": \"doc-1\",\n                \"file_path\": \"test_file\"\n            },\n            {\n                \"src_id\": \"Bob\",\n                \"tgt_id\": \"Quantum Computing\",\n                \"description\": \"Bob researches quantum computing.\",\n                \"keywords\": \"research application\",\n                \"weight\": 1.0,\n                \"source_id\": \"doc-1\",\n                \"file_path\": \"test_file\"\n            }\n        ]\n    }\n\nrag.insert_custom_kg(custom_kg)\n```\n\n</details>\n\n<details>\n  <summary> <b>其它实体与关系操作</b></summary>\n\n- **create_entity**：创建具有指定属性的新实体\n- **edit_entity**：更新现有实体的属性或重命名它\n- **create_relation**：在现有实体之间创建新关系\n- **edit_relation**：更新现有关系的属性\n\n这些操作在图数据库和向量数据库组件之间保持数据一致性，确保您的知识图谱保持连贯。\n\n</details>\n\n## 删除功能\n\nLightRAG 提供了全面的删除能力，允许您删除文档、实体和关系。\n\n<details>\n<summary> <b>删除实体</b> </summary>\n\n您可以通过实体名称删除实体及其所有关联关系：\n\n```python\n# 删除实体及其所有关系（同步版本）\nrag.delete_by_entity(\"Google\")\n\n# 异步版本\nawait rag.adelete_by_entity(\"Google\")\n```\n\n删除实体时：\n- 从知识图谱中移除该实体节点\n- 删除所有关联的关系\n- 从向量数据库中移除相关的嵌入向量\n- 保持知识图谱的完整性\n\n</details>\n\n<details>\n<summary> <b>删除关系</b> </summary>\n\n您可以删除两个特定实体之间的关系：\n\n```python\n# 删除两个实体之间的关系（同步版本）\nrag.delete_by_relation(\"Google\", \"Gmail\")\n\n# 异步版本\nawait rag.adelete_by_relation(\"Google\", \"Gmail\")\n```\n\n删除关系时：\n- 移除指定的关系边\n- 从向量数据库中删除该关系的嵌入向量\n- 保留实体节点及其它关系\n\n</details>\n\n<details>\n<summary> <b>通过文档 ID 删除</b> </summary>\n\n您可以通过文档 ID 删除整个文档及其所有相关的知识：\n\n```python\n# 通过文档 ID 删除（异步版本）\nawait rag.adelete_by_doc_id(\"doc-12345\")\n```\n\n通过文档 ID 删除时的优化处理：\n- **智能清理**：自动识别并删除仅属于该文档的实体和关系\n- **保留共享知识**：如果实体或关系在其他文档中也存在，则会保留并重新构建其描述\n- **缓存优化**：清理相关的 LLM 缓存以减少存储开销\n- **增量重建**：从剩余文档中重新构建受影响的实体和关系描述\n\n删除过程包括：\n1. 删除与该文档相关的所有文本块\n2. 识别并删除仅属于该文档的实体和关系\n3. 重新构建在其他文档中仍存在的实体和关系\n4. 更新所有相关的向量索引\n5. 清理文档状态记录\n\n注意：由于涉及复杂的知识图谱重构过程，通过文档 ID 删除是一个异步操作。\n\n</details>\n\n**重要提醒：**\n\n1. **不可逆操作**：所有删除操作都是不可逆的，请谨慎使用\n2. **性能考虑**：删除大量数据可能需要一些时间，特别是通过文档 ID 删除\n3. **数据一致性**：删除操作会自动维护知识图谱与向量数据库之间的一致性\n4. **备份建议**：在执行重要删除操作前，请考虑备份数据\n\n**批量删除建议：**\n- 对于批量删除操作，建议使用异步方法以获得更好的性能\n- 对于大规模删除，建议分批处理以避免系统负载过高\n\n## 实体合并\n\n<details>\n<summary> <b>合并实体及其关系</b> </summary>\n\nLightRAG 现在支持将多个实体合并为单个实体，并自动处理所有关系：\n\n```python\n# 基础实体合并\nrag.merge_entities(\n    source_entities=[\"Artificial Intelligence\", \"AI\", \"Machine Intelligence\"],\n    target_entity=\"AI Technology\"\n)\n```\n\n使用自定义合并策略：\n\n```python\n# 为不同字段定义自定义合并策略\nrag.merge_entities(\n    source_entities=[\"John Smith\", \"Dr. Smith\", \"J. Smith\"],\n    target_entity=\"John Smith\",\n    merge_strategy={\n        \"description\": \"concatenate\",  # 合并所有描述\n        \"entity_type\": \"keep_first\",   # 保留第一个实体的类型\n        \"source_id\": \"join_unique\"     # 合并所有唯一的源 ID\n    }\n)\n```\n\n使用自定义目标实体数据：\n\n```python\n# 为合并后的实体指定精确值\nrag.merge_entities(\n    source_entities=[\"New York\", \"NYC\", \"Big Apple\"],\n    target_entity=\"New York City\",\n    target_entity_data={\n        \"entity_type\": \"LOCATION\",\n        \"description\": \"New York City is the most populous city in the United States.\",\n    }\n)\n```\n\n结合上述两种方式的高级用法：\n\n```python\n# 合并公司实体，同时使用策略和自定义数据\nrag.merge_entities(\n    source_entities=[\"Microsoft Corp\", \"Microsoft Corporation\", \"MSFT\"],\n    target_entity=\"Microsoft\",\n    merge_strategy={\n        \"description\": \"concatenate\",  # 合并所有描述\n        \"source_id\": \"join_unique\"     # 合并源 ID\n    },\n    target_entity_data={\n        \"entity_type\": \"ORGANIZATION\",\n    }\n)\n```\n\n合并实体时：\n\n* 所有来自源实体的关系都会重定向到目标实体\n* 重复的关系会被智能合并\n* 防止出现自我指向的关系（自环）\n* 合并完成后源实体会被移除\n* 关系权重和属性会被保留\n\n</details>\n\n## 多模态文档处理（RAG-Anything 集成）\n\nLightRAG 现已与 [RAG-Anything](https://github.com/HKUDS/RAG-Anything) 无缝集成，这是一个专门为 LightRAG 构建的**全能多模态文档处理 RAG 系统**。RAG-Anything 能够实现先进的解析和检索增强生成（RAG）能力，允许您无缝处理多模态文档，并从各种文档格式中提取结构化内容——包括文本、图像、表格和公式——以集成到您的 RAG 流程中。\n\n**核心特性：**\n- **端到端多模态流程**：从文档摄取解析到智能多模态问答的完整工作流程\n- **通用文档支持**：无缝处理 PDF、Office 文档（DOC/DOCX/PPT/PPTX/XLS/XLSX）、图像及多种文件格式\n- **专业内容分析**：针对图像、表格、数学公式及异构内容类型的专用处理器\n- **多模态知识图谱**：自动实体提取和跨模态关系发现，增强理解力\n- **混合智能检索**：跨越文本和多模态内容的高级搜索能力，具备上下文理解\n\n**快速开始：**\n1. 安装 RAG-Anything：\n   ```bash\n   pip install raganything\n   ```\n2. 处理多模态文档：\n    <details>\n    <summary> <b> RAGAnything 使用示例 </b></summary>\n\n    ```python\n        import asyncio\n        from raganything import RAGAnything\n        from lightrag import LightRAG\n        from lightrag.llm.openai import openai_complete_if_cache, openai_embed\n        from lightrag.utils import EmbeddingFunc\n        import os\n\n        async def load_existing_lightrag():\n            # 首先，创建或加载一个现有的 LightRAG 实例\n            lightrag_working_dir = \"./existing_lightrag_storage\"\n\n            # 检查先前的 LightRAG 实例是否存在\n            if os.path.exists(lightrag_working_dir) and os.listdir(lightrag_working_dir):\n                print(\"✅ Found existing LightRAG instance, loading...\")\n            else:\n                print(\"❌ No existing LightRAG instance found, will create new one\")\n\n            from functools import partial\n\n            # 使用您的配置创建/加载 LightRAG 实例\n            lightrag_instance = LightRAG(\n                working_dir=lightrag_working_dir,\n                llm_model_func=lambda prompt, system_prompt=None, history_messages=[], **kwargs: openai_complete_if_cache(\n                    \"gpt-4o-mini\",\n                    prompt,\n                    system_prompt=system_prompt,\n                    history_messages=history_messages,\n                    api_key=\"your-api-key\",\n                    **kwargs,\n                ),\n                embedding_func=EmbeddingFunc(\n                    embedding_dim=3072,\n                    max_token_size=8192,\n                    model=\"text-embedding-3-large\",\n                    func=partial(\n                        openai_embed.func,  # 使用 .func 访问未封装的原始函数\n                        model=\"text-embedding-3-large\",\n                        api_key=api_key,\n                        base_url=base_url,\n                    ),\n                )\n            )\n\n            # 初始化存储（这将加载现有数据，如果有的话）\n            await lightrag_instance.initialize_storages()\n\n            # 现在使用现有的 LightRAG 实例初始化 RAGAnything\n            rag = RAGAnything(\n                lightrag=lightrag_instance,  # 传入现有的 LightRAG 实例\n                # 仅在多模态处理时需要视觉模型\n                vision_model_func=lambda prompt, system_prompt=None, history_messages=[], image_data=None, **kwargs: openai_complete_if_cache(\n                    \"gpt-4o\",\n                    \"\",\n                    system_prompt=None,\n                    history_messages=[],\n                    messages=[\n                        {\"role\": \"system\", \"content\": system_prompt} if system_prompt else None,\n                        {\"role\": \"user\", \"content\": [\n                            {\"type\": \"text\", \"text\": prompt},\n                            {\"type\": \"image_url\", \"image_url\": {\"url\": f\"data:image/jpeg;base64,{image_data}\"}}\n                        ]} if image_data else {\"role\": \"user\", \"content\": prompt}\n                    ],\n                    api_key=\"your-api-key\",\n                    **kwargs,\n                ) if image_data else openai_complete_if_cache(\n                    \"gpt-4o-mini\",\n                    prompt,\n                    system_prompt=system_prompt,\n                    history_messages=history_messages,\n                    api_key=\"your-api-key\",\n                    **kwargs,\n                )\n                # 注意：working_dir, llm_model_func, embedding_func 等都继承自 lightrag_instance\n            )\n\n            # 查询现有的知识库\n            result = await rag.query_with_multimodal(\n                \"What data has been processed in this LightRAG instance?\",\n                mode=\"hybrid\"\n            )\n            print(\"Query result:\", result)\n\n            # 向现有的 LightRAG 实例添加新的多模态文档\n            await rag.process_document_complete(\n                file_path=\"path/to/new/multimodal_document.pdf\",\n                output_dir=\"./output\"\n            )\n\n        if __name__ == \"__main__\":\n            asyncio.run(load_existing_lightrag())\n    ```\n    </details>\n\n有关详细文档和高级用法，请参考 [RAG-Anything 仓库](https://github.com/HKUDS/RAG-Anything)。\n\n## Token 使用量跟踪\n\n<details>\n<summary> <b>概览与用法</b> </summary>\n\nLightRAG 提供了一个 TokenTracker 工具，用于监控和管理大语言模型的 token 消耗情况。此功能对于控制 API 成本和优化性能非常有用。\n\n### 用法\n\n```python\nfrom lightrag.utils import TokenTracker\n\n# 创建 TokenTracker 实例\ntoken_tracker = TokenTracker()\n\n# 方法 1：使用上下文管理器（推荐）\n# 适用于需要自动跟踪 token 使用量的场景\nwith token_tracker:\n    result1 = await llm_model_func(\"your question 1\")\n    result2 = await llm_model_func(\"your question 2\")\n\n# 方法 2：手动添加 token 使用记录\n# 适用于需要更精细控制 token 统计的场景\ntoken_tracker.reset()\n\nrag.insert()\n\nrag.query(\"your question 1\", param=QueryParam(mode=\"naive\"))\nrag.query(\"your question 2\", param=QueryParam(mode=\"mix\"))\n\n# 显示总 token 使用量（包括插入和查询操作）\nprint(\"Token usage:\", token_tracker.get_usage())\n```\n\n### 使用技巧\n- 在长会话或批量操作中使用上下文管理器，自动跟踪所有 token 消耗\n- 对于需要分段统计的场景，使用手动模式并在适当时候调用 reset()\n- 定期检查 token 使用量有助于及早发现异常消耗\n- 在开发和测试过程中积极使用此功能，以优化生产成本\n\n### 实践案例\n您可以参考以下示例来实施 token 跟踪：\n- `examples/lightrag_gemini_track_token_demo.py`：使用 Google Gemini 模型的 token 跟踪示例\n- `examples/lightrag_siliconcloud_track_token_demo.py`：使用 SiliconCloud 模型的 token 跟踪示例\n\n这些示例展示了如何在不同模型和场景下有效地使用 TokenTracker 功能。\n\n</details>\n\n## 数据导出功能\n\n### 概览\n\nLightRAG 允许您以各种格式导出知识图谱数据，用于分析、共享和备份。系统支持导出实体、关系及关系数据。\n\n### 导出函数\n\n<details>\n  <summary> <b> 基础用法 </b></summary>\n\n```python\n# 基础 CSV 导出（默认格式）\nrag.export_data(\"knowledge_graph.csv\")\n\n# 指定任意格式\nrag.export_data(\"output.xlsx\", file_format=\"excel\")\n```\n\n</details>\n\n<details>\n  <summary> <b> 支持的不同文件格式 </b></summary>\n\n```python\n# 以 CSV 格式导出数据\nrag.export_data(\"graph_data.csv\", file_format=\"csv\")\n\n# 导出到 Excel 工作表\nrag.export_data(\"graph_data.xlsx\", file_format=\"excel\")\n\n# 以 markdown 格式导出数据\nrag.export_data(\"graph_data.md\", file_format=\"md\")\n\n# 导出为纯文本\nrag.export_data(\"graph_data.txt\", file_format=\"txt\")\n```\n</details>\n\n<details>\n  <summary> <b> 附加选项 </b></summary>\n\n在导出中包含向量嵌入（可选）：\n\n```python\nrag.export_data(\"complete_data.csv\", include_vector_data=True)\n```\n</details>\n\n### 导出中包含的数据\n\n所有导出均包含：\n\n* 实体信息（名称、ID、元数据）\n* 关系数据（实体间的连接）\n* 来自向量数据库的关系信息\n\n## 缓存\n\n<details>\n  <summary> <b>清除缓存</b> </summary>\n\n您可以使用 `aclear_cache()` 清空当前配置的 LLM 响应缓存存储。该 API 会清除 `llm_response_cache` 中的全部缓存项，不支持按模式或缓存类型进行选择性清理。\n\n```python\n# 清除所有缓存\nawait rag.aclear_cache()\n\n# 同步版本\nrag.clear_cache()\n```\n\n如果需要按类型管理查询相关缓存，可以使用 `lightrag.tools.clean_llm_query_cache` 工具，并参考说明文档 [lightrag/tools/README_CLEAN_LLM_QUERY_CACHE.md](./lightrag/tools/README_CLEAN_LLM_QUERY_CACHE.md)。该工具可管理 `mix`、`hybrid`、`local` 和 `global` 模式下的查询缓存与关键词缓存；它不会清理 `default:extract:*` 和 `default:summary:*` 这类提取缓存。\n\n</details>\n\n## 故障排除\n\n### 常见初始化错误\n\n如果您在使用 LightRAG 时遇到以下错误：\n\n1. **`AttributeError: __aenter__`**\n   - **原因**：存储后端未初始化\n   - **解决方案**：在创建 LightRAG 实例后调用 `await rag.initialize_storages()`\n\n2. **`KeyError: 'history_messages'`**\n   - **原因**：流水线状态未初始化\n   - **解决方案**：在创建 LightRAG 实例后调用 `await rag.initialize_storages()`\n\n3. **两个错误相继出现**\n   - **原因**：两个初始化方法都未被调用\n   - **解决方案**：始终遵循以下模式：\n   ```python\n   rag = LightRAG(...)\n   await rag.initialize_storages()\n   ```\n\n### 模型切换问题\n\n在不同的嵌入模型（embedding models）之间切换时，您必须清空数据目录以避免错误。如果您希望保留 LLM 缓存，唯一可以保留的文件是 `kv_store_llm_response_cache.json`。\n\n## LightRAG API\n\nLightRAG 服务器旨在提供 Web UI 和 API 支持。**有关 LightRAG 服务器的更多信息，请参考 [LightRAG Server](./lightrag/api/README.md)。**\n\n## 图谱可视化\n\nLightRAG 服务器提供了全面的知识图谱可视化功能。它支持各种重力布局、节点查询、子图过滤等。**有关 LightRAG 服务器的更多信息，请参考 [LightRAG Server](./lightrag/api/README.md)。**\n\n![iShot_2025-03-23_12.40.08](./README.assets/iShot_2025-03-23_12.40.08.png)\n\n## Langfuse 可观测性集成\n\nLangfuse 提供了一个可以直接替换 OpenAI 客户端的方案，自动跟踪所有 LLM 交互，使开发者能够在不更改代码的情况下监控、调试和优化其 RAG 系统。\n\n### 安装可观测性选项\n\n```bash\npip install lightrag-hku\npip install lightrag-hku[observability]\n\n# 或从源代码安装并启用调试模式\npip install -e .\npip install -e \".[observability]\"\n```\n\n### 配置 Langfuse 环境变量\n\n修改 .env 文件：\n\n```bash\n## Langfuse Observability (Optional)\n# LLM observability and tracing platform\n# Install with: pip install lightrag-hku[observability]\n# Sign up at: https://cloud.langfuse.com or self-host\nLANGFUSE_SECRET_KEY=\"\"\nLANGFUSE_PUBLIC_KEY=\"\"\nLANGFUSE_HOST=\"https://cloud.langfuse.com\"  # 或您的自托管实例\nLANGFUSE_ENABLE_TRACE=true\n```\n\n### Langfuse 用法\n\n安装并配置完成后，Langfuse 会自动追踪所有 OpenAI LLM 调用。Langfuse 仪表板功能包括：\n\n- **追踪（Tracing）**：查看完整的 LLM 调用链\n- **分析（Analytics）**：Token 使用情况、延迟、成本指标\n- **调试（Debugging）**：检查提示词和响应\n- **评估（Evaluation）**：比较模型输出\n- **监控（Monitoring）**：实时告警\n\n### 重要通知\n\n**注意**：LightRAG 目前仅将 OpenAI 兼容的 API 调用与 Langfuse 集成。Ollama、Azure 和 AWS Bedrock 等 API 尚不支持 Langfuse 可观测性。\n\n## 基于 RAGAS 的评估\n\n**RAGAS** (Retrieval Augmented Generation Assessment) 是一个使用 LLM 对 RAG 系统进行无参考评估的框架。项目中包含一个基于 RAGAS 的评估脚本。有关详细信息，请参考 [基于 RAGAS 的评估框架](lightrag/evaluation/README_EVALUASTION_RAGAS.md)。\n\n## 评估\n\n### 数据集\n\nLightRAG 中使用的数据集可以从 [TommyChien/UltraDomain](https://huggingface.co/datasets/TommyChien/UltraDomain) 下载。\n\n### 生成查询\n\nLightRAG 使用以下提示（prompt）生成高层级查询，相应代码位于 `examples/generate_query.py`。\n\n<details>\n<summary> 提示词 </summary>\n\n```python\nGiven the following description of a dataset:\n\n{description}\n\nPlease identify 5 potential users who would engage with this dataset. For each user, list 5 tasks they would perform with this dataset. Then, for each (user, task) combination, generate 5 questions that require a high-level understanding of the entire dataset.\n\nOutput the results in the following structure:\n- User 1: [user description]\n    - Task 1: [task description]\n        - Question 1:\n        - Question 2:\n        - Question 3:\n        - Question 4:\n        - Question 5:\n    - Task 2: [task description]\n        ...\n    - Task 5: [task description]\n- User 2: [user description]\n    ...\n- User 5: [user description]\n    ...\n```\n\n</details>\n\n### 批量评估\n\n为了在处理高层级查询时评估两个 RAG 系统的性能，LightRAG 使用以下提示词，具体代码见 `reproduce/batch_eval.py`。\n\n<details>\n<summary> 提示词 </summary>\n\n```python\n---Role---\nYou are an expert tasked with evaluating two answers to the same question based on three criteria: **Comprehensiveness**, **Diversity**, and **Empowerment**.\n---Goal---\nYou will evaluate two answers to the same question based on three criteria: **Comprehensiveness**, **Diversity**, and **Empowerment**.\n\n- **Comprehensiveness**: How much detail does the answer provide to cover all aspects and details of the question?\n- **Diversity**: How varied and rich is the answer in providing different perspectives and insights on the question?\n- **Empowerment**: How well does the answer help the reader understand and make informed judgments about the topic?\n\nFor each criterion, choose the better answer (either Answer 1 or Answer 2) and explain why. Then, select an overall winner based on these three categories.\n\nHere is the question:\n{query}\n\nHere are the two answers:\n\n**Answer 1:**\n{answer1}\n\n**Answer 2:**\n{answer2}\n\nEvaluate both answers using the three criteria listed above and provide detailed explanations for each criterion.\n\nOutput your evaluation in the following JSON format:\n\n{{\n    \"Comprehensiveness\": {{\n        \"Winner\": \"[Answer 1 or Answer 2]\",\n        \"Explanation\": \"[Provide explanation here]\"\n    }},\n    \"Empowerment\": {{\n        \"Winner\": \"[Answer 1 or Answer 2]\",\n        \"Explanation\": \"[Provide explanation here]\"\n    }},\n    \"Overall Winner\": {{\n        \"Winner\": \"[Answer 1 or Answer 2]\",\n        \"Explanation\": \"[Summarize why this answer is the overall winner based on the three criteria]\"\n    }}\n}}\n```\n\n</details>\n\n### 总体性能表\n\n||**农业**||**计算机科学**||**法律**||**混合**||\n|----------------------|---------------|------------|------|------------|---------|------------|-------|------------|\n||NaiveRAG|**LightRAG**|NaiveRAG|**LightRAG**|NaiveRAG|**LightRAG**|NaiveRAG|**LightRAG**|\n|**全面性**|32.4%|**67.6%**|38.4%|**61.6%**|16.4%|**83.6%**|38.8%|**61.2%**|\n|**多样性**|23.6%|**76.4%**|38.0%|**62.0%**|13.6%|**86.4%**|32.4%|**67.6%**|\n|**赋能性**|32.4%|**67.6%**|38.8%|**61.2%**|16.4%|**83.6%**|42.8%|**57.2%**|\n|**总体**|32.4%|**67.6%**|38.8%|**61.2%**|15.2%|**84.8%**|40.0%|**60.0%**|\n||RQ-RAG|**LightRAG**|RQ-RAG|**LightRAG**|RQ-RAG|**LightRAG**|RQ-RAG|**LightRAG**|\n|**全面性**|31.6%|**68.4%**|38.8%|**61.2%**|15.2%|**84.8%**|39.2%|**60.8%**|\n|**多样性**|29.2%|**70.8%**|39.2%|**60.8%**|11.6%|**88.4%**|30.8%|**69.2%**|\n|**赋能性**|31.6%|**68.4%**|36.4%|**63.6%**|15.2%|**84.8%**|42.4%|**57.6%**|\n|**总体**|32.4%|**67.6%**|38.0%|**62.0%**|14.4%|**85.6%**|40.0%|**60.0%**|\n||HyDE|**LightRAG**|HyDE|**LightRAG**|HyDE|**LightRAG**|HyDE|**LightRAG**|\n|**全面性**|26.0%|**74.0%**|41.6%|**58.4%**|26.8%|**73.2%**|40.4%|**59.6%**|\n|**多样性**|24.0%|**76.0%**|38.8%|**61.2%**|20.0%|**80.0%**|32.4%|**67.6%**|\n|**赋能性**|25.2%|**74.8%**|40.8%|**59.2%**|26.0%|**74.0%**|46.0%|**54.0%**|\n|**总体**|24.8%|**75.2%**|41.6%|**58.4%**|26.4%|**73.6%**|42.4%|**57.6%**|\n||GraphRAG|**LightRAG**|GraphRAG|**LightRAG**|GraphRAG|**LightRAG**|GraphRAG|**LightRAG**|\n|**全面性**|45.6%|**54.4%**|48.4%|**51.6%**|48.4%|**51.6%**|**50.4%**|49.6%|\n|**多样性**|22.8%|**77.2%**|40.8%|**59.2%**|26.4%|**73.6%**|36.0%|**64.0%**|\n|**赋能性**|41.2%|**58.8%**|45.2%|**54.8%**|43.6%|**56.4%**|**50.8%**|49.2%|\n|**总体**|45.2%|**54.8%**|48.0%|**52.0%**|47.2%|**52.8%**|**50.4%**|49.6%|\n\n## 复现\n\n所有代码均可在 `./reproduce` 目录中找到。\n\n### Step-0 提取唯一上下文\n\n首先，我们需要提取数据集中的唯一上下文（unique contexts）。\n\n<details>\n<summary> 代码 </summary>\n\n```python\ndef extract_unique_contexts(input_directory, output_directory):\n\n    os.makedirs(output_directory, exist_ok=True)\n\n    jsonl_files = glob.glob(os.path.join(input_directory, '*.jsonl'))\n    print(f\"Found {len(jsonl_files)} JSONL files.\")\n\n    for file_path in jsonl_files:\n        filename = os.path.basename(file_path)\n        name, ext = os.path.splitext(filename)\n        output_filename = f\"{name}_unique_contexts.json\"\n        output_path = os.path.join(output_directory, output_filename)\n\n        unique_contexts_dict = {}\n\n        print(f\"Processing file: {filename}\")\n\n        try:\n            with open(file_path, 'r', encoding='utf-8') as infile:\n                for line_number, line in enumerate(infile, start=1):\n                    line = line.strip()\n                    if not line:\n                        continue\n                    try:\n                        json_obj = json.loads(line)\n                        context = json_obj.get('context')\n                        if context and context not in unique_contexts_dict:\n                            unique_contexts_dict[context] = None\n                    except json.JSONDecodeError as e:\n                        print(f\"JSON decoding error in file {filename} at line {line_number}: {e}\")\n        except FileNotFoundError:\n            print(f\"File not found: {filename}\")\n            continue\n        except Exception as e:\n            print(f\"An error occurred while processing file {filename}: {e}\")\n            continue\n\n        unique_contexts_list = list(unique_contexts_dict.keys())\n        print(f\"There are {len(unique_contexts_list)} unique `context` entries in the file {filename}.\")\n\n        try:\n            with open(output_path, 'w', encoding='utf-8') as outfile:\n                json.dump(unique_contexts_list, outfile, ensure_ascii=False, indent=4)\n            print(f\"Unique `context` entries have been saved to: {output_filename}\")\n        except Exception as e:\n            print(f\"An error occurred while saving to the file {output_filename}: {e}\")\n\n    print(\"All files have been processed.\")\n\n```\n\n</details>\n\n### Step-1 插入上下文\n\n我们将提取出的上下文插入到 LightRAG 系统中。\n\n<details>\n<summary> 代码 </summary>\n\n```python\ndef insert_text(rag, file_path):\n    with open(file_path, mode='r') as f:\n        unique_contexts = json.load(f)\n\n    retries = 0\n    max_retries = 3\n    while retries < max_retries:\n        try:\n            rag.insert(unique_contexts)\n            break\n        except Exception as e:\n            retries += 1\n            print(f\"Insertion failed, retrying ({retries}/{max_retries}), error: {e}\")\n            time.sleep(10)\n    if retries == max_retries:\n        print(\"Insertion failed after exceeding the maximum number of retries\")\n```\n\n</details>\n\n### Step-2 生成查询\n\n我们从数据集每个上下文的前半部分和后半部分提取 token，然后将它们组合作为数据集描述来生成查询。\n\n<details>\n<summary> 代码 </summary>\n\n```python\ntokenizer = GPT2Tokenizer.from_pretrained('gpt2')\n\ndef get_summary(context, tot_tokens=2000):\n    tokens = tokenizer.tokenize(context)\n    half_tokens = tot_tokens // 2\n\n    start_tokens = tokens[1000:1000 + half_tokens]\n    end_tokens = tokens[-(1000 + half_tokens):1000]\n\n    summary_tokens = start_tokens + end_tokens\n    summary = tokenizer.convert_tokens_to_string(summary_tokens)\n\n    return summary\n```\n\n</details>\n\n### Step-3 查询\n\n对于 Step-2 中生成的查询，我们将提取它们并对 LightRAG 进行查询。\n\n<details>\n<summary> 代码 </summary>\n\n```python\ndef extract_queries(file_path):\n    with open(file_path, 'r') as f:\n        data = f.read()\n\n    data = data.replace('**', '')\n\n    queries = re.findall(r'- Question \\d+: (.+)', data)\n\n    return queries\n```\n\n</details>\n\n## 🔗 相关项目\n\n*生态与扩展*\n\n<div align=\"center\">\n  <table>\n    <tr>\n      <td align=\"center\">\n        <a href=\"https://github.com/HKUDS/RAG-Anything\">\n          <div style=\"width: 100px; height: 100px; background: linear-gradient(135deg, rgba(0, 217, 255, 0.1) 0%, rgba(0, 217, 255, 0.05) 100%); border-radius: 15px; border: 1px solid rgba(0, 217, 255, 0.2); display: flex; align-items: center; justify-content: center; margin-bottom: 10px;\">\n            <span style=\"font-size: 32px;\">📸</span>\n          </div>\n          <b>RAG-Anything</b><br>\n          <sub>多模态 RAG</sub>\n        </a>\n      </td>\n      <td align=\"center\">\n        <a href=\"https://github.com/HKUDS/VideoRAG\">\n          <div style=\"width: 100px; height: 100px; background: linear-gradient(135deg, rgba(0, 217, 255, 0.1) 0%, rgba(0, 217, 255, 0.05) 100%); border-radius: 15px; border: 1px solid rgba(0, 217, 255, 0.2); display: flex; align-items: center; justify-content: center; margin-bottom: 10px;\">\n            <span style=\"font-size: 32px;\">🎥</span>\n          </div>\n          <b>VideoRAG</b><br>\n          <sub>极端长上下文视频 RAG</sub>\n        </a>\n      </td>\n      <td align=\"center\">\n        <a href=\"https://github.com/HKUDS/MiniRAG\">\n          <div style=\"width: 100px; height: 100px; background: linear-gradient(135deg, rgba(0, 217, 255, 0.1) 0%, rgba(0, 217, 255, 0.05) 100%); border-radius: 15px; border: 1px solid rgba(0, 217, 255, 0.2); display: flex; align-items: center; justify-content: center; margin-bottom: 10px;\">\n            <span style=\"font-size: 32px;\">✨</span>\n          </div>\n          <b>MiniRAG</b><br>\n          <sub>极简 RAG</sub>\n        </a>\n      </td>\n    </tr>\n  </table>\n</div>\n\n---\n\n## ⭐ Star 历史\n\n<a href=\"https://star-history.com/#HKUDS/LightRAG&Date\">\n <picture>\n   <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=HKUDS/LightRAG&type=Date&theme=dark\" />\n   <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=HKUDS/LightRAG&type=Date\" />\n   <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=HKUDS/LightRAG&type=Date\" />\n </picture>\n</a>\n\n## 🤝 贡献\n\n<div align=\"center\">\n  我们感谢所有贡献者做出的宝贵贡献。\n</div>\n\n<div align=\"center\">\n  <a href=\"https://github.com/HKUDS/LightRAG/graphs/contributors\">\n    <img src=\"https://contrib.rocks/image?repo=HKUDS/LightRAG\" style=\"border-radius: 15px; box-shadow: 0 0 20px rgba(0, 217, 255, 0.3);\" />\n  </a>\n</div>\n\n---\n\n## 📖 引用\n\n```python\n@article{guo2024lightrag,\ntitle={LightRAG: Simple and Fast Retrieval-Augmented Generation},\nauthor={Zirui Guo and Lianghao Xia and Yanhua Yu and Tu Ao and Chao Huang},\nyear={2024},\neprint={2410.05779},\narchivePrefix={arXiv},\nprimaryClass={cs.IR}\n}\n```\n\n---\n\n<div align=\"center\" style=\"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 15px; padding: 30px; margin: 30px 0;\">\n  <div>\n    <img src=\"https://user-images.githubusercontent.com/74038190/212284100-561aa473-3905-4a80-b561-0d28506553ee.gif\" width=\"500\">\n  </div>\n  <div style=\"margin-top: 20px;\">\n    <a href=\"https://github.com/HKUDS/LightRAG\" style=\"text-decoration: none;\">\n      <img src=\"https://img.shields.io/badge/⭐%20在%20GitHub%20上点亮星星-1a1a2e?style=for-the-badge&logo=github&logoColor=white\">\n    </a>\n    <a href=\"https://github.com/HKUDS/LightRAG/issues\" style=\"text-decoration: none;\">\n      <img src=\"https://img.shields.io/badge/🐛%20报告问题-ff6b6b?style=for-the-badge&logo=github&logoColor=white\">\n    </a>\n    <a href=\"https://github.com/HKUDS/LightRAG/discussions\" style=\"text-decoration: none;\">\n      <img src=\"https://img.shields.io/badge/💬%20讨论-4ecdc4?style=for-the-badge&logo=github&logoColor=white\">\n    </a>\n  </div>\n</div>\n\n<div align=\"center\">\n  <div style=\"width: 100%; max-width: 600px; margin: 20px auto; padding: 20px; background: linear-gradient(135deg, rgba(0, 217, 255, 0.1) 0%, rgba(0, 217, 255, 0.05) 100%); border-radius: 15px; border: 1px solid rgba(0, 217, 255, 0.2);\">\n    <div style=\"display: flex; justify-content: center; align-items: center; gap: 15px;\">\n      <span style=\"font-size: 24px;\">⭐</span>\n      <span style=\"color: #00d9ff; font-size: 18px;\">感谢您访问 LightRAG!</span>\n      <span style=\"font-size: 24px;\">⭐</span>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n\n<div style=\"margin: 20px 0;\">\n  <img src=\"./assets/logo.png\" width=\"120\" height=\"120\" alt=\"LightRAG Logo\" style=\"border-radius: 20px; box-shadow: 0 8px 32px rgba(0, 217, 255, 0.3);\">\n</div>\n\n# 🚀 LightRAG: Simple and Fast Retrieval-Augmented Generation\n\n<div align=\"center\">\n    <a href=\"https://trendshift.io/repositories/13043\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/13043\" alt=\"HKUDS%2FLightRAG | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n</div>\n\n<div align=\"center\">\n  <div style=\"width: 100%; height: 2px; margin: 20px 0; background: linear-gradient(90deg, transparent, #00d9ff, transparent);\"></div>\n</div>\n\n<div align=\"center\">\n  <div style=\"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 15px; padding: 25px; text-align: center;\">\n    <p>\n      <a href='https://github.com/HKUDS/LightRAG'><img src='https://img.shields.io/badge/🔥Project-Page-00d9ff?style=for-the-badge&logo=github&logoColor=white&labelColor=1a1a2e'></a>\n      <a href='https://arxiv.org/abs/2410.05779'><img src='https://img.shields.io/badge/📄arXiv-2410.05779-ff6b6b?style=for-the-badge&logo=arxiv&logoColor=white&labelColor=1a1a2e'></a>\n      <a href=\"https://github.com/HKUDS/LightRAG/stargazers\"><img src='https://img.shields.io/github/stars/HKUDS/LightRAG?color=00d9ff&style=for-the-badge&logo=star&logoColor=white&labelColor=1a1a2e' /></a>\n    </p>\n    <p>\n      <img src=\"https://img.shields.io/badge/🐍Python-3.10-4ecdc4?style=for-the-badge&logo=python&logoColor=white&labelColor=1a1a2e\">\n      <a href=\"https://pypi.org/project/lightrag-hku/\"><img src=\"https://img.shields.io/pypi/v/lightrag-hku.svg?style=for-the-badge&logo=pypi&logoColor=white&labelColor=1a1a2e&color=ff6b6b\"></a>\n    </p>\n    <p>\n      <a href=\"https://discord.gg/yF2MmDJyGJ\"><img src=\"https://img.shields.io/badge/💬Discord-Community-7289da?style=for-the-badge&logo=discord&logoColor=white&labelColor=1a1a2e\"></a>\n      <a href=\"https://github.com/HKUDS/LightRAG/issues/285\"><img src=\"https://img.shields.io/badge/💬WeChat-Group-07c160?style=for-the-badge&logo=wechat&logoColor=white&labelColor=1a1a2e\"></a>\n    </p>\n    <p>\n      <a href=\"README-zh.md\"><img src=\"https://img.shields.io/badge/🇨🇳中文版-1a1a2e?style=for-the-badge\"></a>\n      <a href=\"README.md\"><img src=\"https://img.shields.io/badge/🇺🇸English-1a1a2e?style=for-the-badge\"></a>\n    </p>\n    <p>\n      <a href=\"https://pepy.tech/projects/lightrag-hku\"><img src=\"https://static.pepy.tech/personalized-badge/lightrag-hku?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads\"></a>\n    </p>\n  </div>\n</div>\n\n</div>\n\n<div align=\"center\" style=\"margin: 30px 0;\">\n  <img src=\"https://user-images.githubusercontent.com/74038190/212284100-561aa473-3905-4a80-b561-0d28506553ee.gif\" width=\"800\">\n</div>\n\n<div align=\"center\" style=\"margin: 30px 0;\">\n    <img src=\"./README.assets/b2aaf634151b4706892693ffb43d9093.png\" width=\"800\" alt=\"LightRAG Diagram\">\n</div>\n\n---\n\n<div align=\"center\">\n  <table>\n    <tr>\n      <td style=\"vertical-align: middle;\">\n        <img src=\"./assets/LiteWrite.png\"\n             width=\"56\"\n             height=\"56\"\n             alt=\"LiteWrite\"\n             style=\"border-radius: 12px;\" />\n      </td>\n      <td style=\"vertical-align: middle; padding-left: 12px;\">\n        <a href=\"https://litewrite.ai\">\n          <img src=\"https://img.shields.io/badge/🚀%20LiteWrite-AI%20Native%20LaTeX%20Editor-ff6b6b?style=for-the-badge&logoColor=white&labelColor=1a1a2e\">\n        </a>\n      </td>\n    </tr>\n  </table>\n</div>\n\n---\n\n## 🎉 News\n- [2025.11]🎯[New Feature]: Integrated **RAGAS for Evaluation** and **Langfuse for Tracing**. Updated the API to return retrieved contexts alongside query results to support context precision metrics.\n- [2025.10]🎯[Scalability Enhancement]: Eliminated processing bottlenecks to support **Large-Scale Datasets Efficiently**.\n- [2025.09]🎯[New Feature] Enhances knowledge graph extraction accuracy for **Open-Sourced LLMs** such as Qwen3-30B-A3B.\n- [2025.08]🎯[New Feature] **Reranker** is now supported, significantly boosting performance for mixed queries (set as default query mode).\n- [2025.08]🎯[New Feature] Added **Document Deletion** with automatic KG regeneration to ensure optimal query performance.\n- [2025.06]🎯[New Release] Our team has released [RAG-Anything](https://github.com/HKUDS/RAG-Anything) — an **All-in-One Multimodal RAG** system for seamless processing of text, images, tables, and equations.\n- [2025.06]🎯[New Feature] LightRAG now supports comprehensive multimodal data handling through [RAG-Anything](https://github.com/HKUDS/RAG-Anything) integration, enabling seamless document parsing and RAG capabilities across diverse formats including PDFs, images, Office documents, tables, and formulas. Please refer to the new [multimodal section](https://github.com/HKUDS/LightRAG/?tab=readme-ov-file#multimodal-document-processing-rag-anything-integration) for details.\n- [2025.03]🎯[New Feature] LightRAG now supports citation functionality, enabling proper source attribution and enhanced document traceability.\n- [2025.02]🎯[New Feature] You can now use MongoDB as an all-in-one storage solution for unified data management.\n- [2025.02]🎯[New Release] Our team has released [VideoRAG](https://github.com/HKUDS/VideoRAG)-a RAG system for understanding extremely long-context videos\n- [2025.01]🎯[New Release] Our team has released [MiniRAG](https://github.com/HKUDS/MiniRAG) making RAG simpler with small models.\n- [2025.01]🎯You can now use PostgreSQL as an all-in-one storage solution for data management.\n- [2024.11]🎯[New Resource] A comprehensive guide to LightRAG is now available on [LearnOpenCV](https://learnopencv.com/lightrag). — explore in-depth tutorials and best practices. Many thanks to the blog author for this excellent contribution!\n- [2024.11]🎯[New Feature] Introducing the LightRAG WebUI — an interface that allows you to insert, query, and visualize LightRAG knowledge through an intuitive web-based dashboard.\n- [2024.11]🎯[New Feature] You can now [use Neo4J for Storage](https://github.com/HKUDS/LightRAG?tab=readme-ov-file#using-neo4j-for-storage)-enabling graph database support.\n- [2024.10]🎯[New Feature] We've added a link to a [LightRAG Introduction Video](https://youtu.be/oageL-1I0GE). — a walkthrough of LightRAG's capabilities. Thanks to the author for this excellent contribution!\n- [2024.10]🎯[New Channel] We have created a [Discord channel](https://discord.gg/yF2MmDJyGJ)!💬 Welcome to join our community for sharing, discussions, and collaboration! 🎉🎉\n- [2024.10]🎯[New Feature] LightRAG now supports [Ollama models](https://github.com/HKUDS/LightRAG?tab=readme-ov-file#quick-start)!\n\n<details>\n  <summary style=\"font-size: 1.4em; font-weight: bold; cursor: pointer; display: list-item;\">\n    Algorithm Flowchart\n  </summary>\n\n![LightRAG Indexing Flowchart](https://learnopencv.com/wp-content/uploads/2024/11/LightRAG-VectorDB-Json-KV-Store-Indexing-Flowchart-scaled.jpg)\n*Figure 1: LightRAG Indexing Flowchart - Img Caption : [Source](https://learnopencv.com/lightrag/)*\n![LightRAG Retrieval and Querying Flowchart](https://learnopencv.com/wp-content/uploads/2024/11/LightRAG-Querying-Flowchart-Dual-Level-Retrieval-Generation-Knowledge-Graphs-scaled.jpg)\n*Figure 2: LightRAG Retrieval and Querying Flowchart - Img Caption : [Source](https://learnopencv.com/lightrag/)*\n\n</details>\n\n## Installation\n\n> **💡 Using uv for Package Management**: This project uses [uv](https://docs.astral.sh/uv/) for fast and reliable Python package management.\n> Install uv first: `curl -LsSf https://astral.sh/uv/install.sh | sh` (Unix/macOS) or `powershell -c \"irm https://astral.sh/uv/install.ps1 | iex\"` (Windows)\n>\n> **Note**: You can also use pip if you prefer, but uv is recommended for better performance and more reliable dependency management.\n>\n> **📦 Offline Deployment**: For offline or air-gapped environments, see the [Offline Deployment Guide](./docs/OfflineDeployment.md) for instructions on pre-installing all dependencies and cache files.\n\n### Install LightRAG Server\n\nThe LightRAG Server is designed to provide Web UI and API support. The Web UI facilitates document indexing, knowledge graph exploration, and a simple RAG query interface. LightRAG Server also provide an Ollama compatible interfaces, aiming to emulate LightRAG as an Ollama chat model. This allows AI chat bot, such as Open WebUI, to access LightRAG easily.\n\n* Install from PyPI\n\n```bash\n### Install LightRAG Server as tool using uv (recommended)\nuv tool install \"lightrag-hku[api]\"\n\n### Or using pip\n# python -m venv .venv\n# source .venv/bin/activate  # Windows: .venv\\Scripts\\activate\n# pip install \"lightrag-hku[api]\"\n\n### Build front-end artifacts\ncd lightrag_webui\nbun install --frozen-lockfile\nbun run build\ncd ..\n\n# Setup env file\n# Obtain the env.example file by downloading it from the GitHub repository root\n# or by copying it from a local source checkout.\ncp env.example .env  # Update the .env with your LLM and embedding configurations\n# Launch the server\nlightrag-server\n```\n\n* Installation from Source\n\n```bash\ngit clone https://github.com/HKUDS/LightRAG.git\ncd LightRAG\n\n# Using uv (recommended)\n# Note: uv sync automatically creates a virtual environment in .venv/\nuv sync --extra api\nsource .venv/bin/activate  # Activate the virtual environment (Linux/macOS)\n# Or on Windows: .venv\\Scripts\\activate\n\n### Or using pip with virtual environment\n# python -m venv .venv\n# source .venv/bin/activate  # Windows: .venv\\Scripts\\activate\n# pip install -e \".[api]\"\n\n# Build front-end artifacts\ncd lightrag_webui\nbun install --frozen-lockfile\nbun run build\ncd ..\n\n# setup env file\ncp env.example .env  # Update the .env with your LLM and embedding configurations\n# Launch API-WebUI server\nlightrag-server\n```\n\n* Launching the LightRAG Server with Docker Compose\n\n```bash\ngit clone https://github.com/HKUDS/LightRAG.git\ncd LightRAG\ncp env.example .env  # Update the .env with your LLM and embedding configurations\n# modify LLM and Embedding settings in .env\ndocker compose up\n```\n\n> Historical versions of LightRAG docker images can be found here: [LightRAG Docker Images]( https://github.com/HKUDS/LightRAG/pkgs/container/lightrag)\n\n### Create .env File With Setup Tool\n\nInstead of editing `env.example` by hand, use the interactive setup wizard to generate a configured `.env` and, when needed, `docker-compose.final.yml`:\n\n```bash\nmake env-base           # Required first step: LLM, embedding, reranker\nmake env-storage        # Optional: storage backends and database services\nmake env-server         # Optional: server port, auth, and SSL\nmake env-base-rewrite   # Optional: force-regenerate wizard-managed compose services\nmake env-storage-rewrite # Optional: force-regenerate wizard-managed compose services\nmake env-security-check # Optional: audit the current .env for security risks\n```\n\nFor full description of every target see [docs/InteractiveSetup.md](./docs/InteractiveSetup.md).\nThe setup wizards update configuration only; run `make env-security-check` separately to audit the\ncurrent `.env` for security risks before deployment.\nBy default, rerunning the setup preserves unchanged wizard-managed compose service blocks; use a\n`*-rewrite` target only when you need to rebuild those managed blocks from the bundled templates.\n\n### Install  LightRAG Core\n\n* Install from source (Recommended)\n\n```bash\ncd LightRAG\n# Note: uv sync automatically creates a virtual environment in .venv/\nuv sync\nsource .venv/bin/activate  # Activate the virtual environment (Linux/macOS)\n# Or on Windows: .venv\\Scripts\\activate\n\n# Or: pip install -e .\n```\n\n* Install from PyPI\n\n```bash\nuv pip install lightrag-hku\n# Or: pip install lightrag-hku\n```\n\n## Quick Start\n\n### LLM and Technology Stack Requirements for LightRAG\n\nLightRAG's demands on the capabilities of Large Language Models (LLMs) are significantly higher than those of traditional RAG, as it requires the LLM to perform entity-relationship extraction tasks from documents. Configuring appropriate Embedding and Reranker models is also crucial for improving query performance.\n\n- **LLM Selection**:\n  - It is recommended to use an LLM with at least 32 billion parameters.\n  - The context length should be at least 32KB, with 64KB being recommended.\n  - It is not recommended to choose reasoning models during the document indexing stage.\n  - During the query stage, it is recommended to choose models with stronger capabilities than those used in the indexing stage to achieve better query results.\n- **Embedding Model**:\n  - A high-performance Embedding model is essential for RAG.\n  - We recommend using mainstream multilingual Embedding models, such as: `BAAI/bge-m3` and `text-embedding-3-large`.\n  - **Important Note**: The Embedding model must be determined before document indexing, and the same model must be used during the document query phase. For certain storage solutions (e.g., PostgreSQL), the vector dimension must be defined upon initial table creation. Therefore, when changing embedding models, it is necessary to delete the existing vector-related tables and allow LightRAG to recreate them with the new dimensions.\n- **Reranker Model Configuration**:\n  - Configuring a Reranker model can significantly enhance LightRAG's retrieval performance.\n  - When a Reranker model is enabled, it is recommended to set the \"mix mode\" as the default query mode.\n  - We recommend using mainstream Reranker models, such as: `BAAI/bge-reranker-v2-m3` or models provided by services like Jina.\n\n### Quick Start for LightRAG Server\n\n* For more information about LightRAG Server, please refer to [LightRAG Server](./lightrag/api/README.md).\n\n### Quick Start for LightRAG core\n\nTo get started with LightRAG core, refer to the sample codes available in the `examples` folder. Additionally, a [video demo](https://www.youtube.com/watch?v=g21royNJ4fw) demonstration is provided to guide you through the local setup process. If you already possess an OpenAI API key, you can run the demo right away:\n\n```bash\n### you should run the demo code with project folder\ncd LightRAG\n### provide your API-KEY for OpenAI\nexport OPENAI_API_KEY=\"sk-...your_opeai_key...\"\n### download the demo document of \"A Christmas Carol\" by Charles Dickens\ncurl https://raw.githubusercontent.com/gusye1234/nano-graphrag/main/tests/mock_data.txt > ./book.txt\n### run the demo code\npython examples/lightrag_openai_demo.py\n```\n\nFor a streaming response implementation example, please see `examples/lightrag_openai_compatible_demo.py`. Prior to execution, ensure you modify the sample code's LLM and embedding configurations accordingly.\n\n**Note 1**: When running the demo program, please be aware that different test scripts may use different embedding models. If you switch to a different embedding model, you must clear the data directory (`./dickens`); otherwise, the program may encounter errors. If you wish to retain the LLM cache, you can preserve the `kv_store_llm_response_cache.json` file while clearing the data directory.\n\n**Note 2**: Only `lightrag_openai_demo.py` and `lightrag_openai_compatible_demo.py` are officially supported sample codes. Other sample files are community contributions that haven't undergone full testing and optimization.\n\n## Programming with LightRAG Core\n\n> ⚠️ **If you would like to integrate LightRAG into your project, we recommend utilizing the REST API provided by the LightRAG Server**. LightRAG Core is typically intended for embedded applications or for researchers who wish to conduct studies and evaluations.\n\n### ⚠️ Important: Initialization Requirements\n\n**LightRAG requires explicit initialization before use.** You must call `await rag.initialize_storages()` after creating a LightRAG instance, otherwise you will encounter errors.\n\n### A Simple Program\n\nUse the below Python snippet to initialize LightRAG, insert text to it, and perform queries:\n\n```python\nimport os\nimport asyncio\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.openai import gpt_4o_mini_complete, gpt_4o_complete, openai_embed\nfrom lightrag.utils import setup_logger\n\nsetup_logger(\"lightrag\", level=\"INFO\")\n\nWORKING_DIR = \"./rag_storage\"\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        embedding_func=openai_embed,\n        llm_model_func=gpt_4o_mini_complete,\n    )\n    # IMPORTANT: Both initialization calls are required!\n    await rag.initialize_storages()  # Initialize storage backends\n    return rag\n\nasync def main():\n    try:\n        # Initialize RAG instance\n        rag = await initialize_rag()\n        await rag.ainsert(\"Your text\")\n\n        # Perform hybrid search\n        mode = \"hybrid\"\n        print(\n          await rag.aquery(\n              \"What are the top themes in this story?\",\n              param=QueryParam(mode=mode)\n          )\n        )\n\n    except Exception as e:\n        print(f\"An error occurred: {e}\")\n    finally:\n        if rag:\n            await rag.finalize_storages()\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\nImportant notes for the above snippet:\n\n- Export your OPENAI_API_KEY environment variable before running the script.\n- This program uses the default storage settings for LightRAG, so all data will be persisted to WORKING_DIR/rag_storage.\n- This program demonstrates only the simplest way to initialize a LightRAG object: Injecting the embedding and LLM functions, and initializing storage and pipeline status after creating the LightRAG object.\n\n### LightRAG init parameters\n\nA full list of LightRAG init parameters:\n\n<details>\n<summary> Parameters </summary>\n\n| **Parameter** | **Type** | **Explanation** | **Default** |\n| -------------- | ---------- | ----------------- | ------------- |\n| **working_dir** | `str` | Directory where the cache will be stored | `lightrag_cache+timestamp` |\n| **workspace** | str | Workspace name for data isolation between different LightRAG Instances | |\n| **kv_storage** | `str` | Storage type for documents and text chunks. Supported types: `JsonKVStorage`,`PGKVStorage`,`RedisKVStorage`,`MongoKVStorage`,`OpenSearchKVStorage` | `JsonKVStorage` |\n| **vector_storage** | `str` | Storage type for embedding vectors. Supported types: `NanoVectorDBStorage`,`PGVectorStorage`,`MilvusVectorDBStorage`,`ChromaVectorDBStorage`,`FaissVectorDBStorage`,`MongoVectorDBStorage`,`QdrantVectorDBStorage`,`OpenSearchVectorDBStorage` | `NanoVectorDBStorage` |\n| **graph_storage** | `str` | Storage type for graph edges and nodes. Supported types: `NetworkXStorage`,`Neo4JStorage`,`PGGraphStorage`,`AGEStorage`,`OpenSearchGraphStorage` | `NetworkXStorage` |\n| **doc_status_storage** | `str` | Storage type for documents process status. Supported types: `JsonDocStatusStorage`,`PGDocStatusStorage`,`MongoDocStatusStorage`,`OpenSearchDocStatusStorage` | `JsonDocStatusStorage` |\n| **chunk_token_size** | `int` | Maximum token size per chunk when splitting documents | `1200` |\n| **chunk_overlap_token_size** | `int` | Overlap token size between two chunks when splitting documents | `100` |\n| **tokenizer** | `Tokenizer` | The function used to convert text into tokens (numbers) and back using .encode() and .decode() functions following `TokenizerInterface` protocol. If you don't specify one, it will use the default Tiktoken tokenizer. | `TiktokenTokenizer` |\n| **tiktoken_model_name** | `str` | If you're using the default Tiktoken tokenizer, this is the name of the specific Tiktoken model to use. This setting is ignored if you provide your own tokenizer. | `gpt-4o-mini` |\n| **entity_extract_max_gleaning** | `int` | Number of loops in the entity extraction process, appending history messages | `1` |\n| **node_embedding_algorithm** | `str` | Algorithm for node embedding (currently not used) | `node2vec` |\n| **node2vec_params** | `dict` | Parameters for node embedding | `{\"dimensions\": 1536,\"num_walks\": 10,\"walk_length\": 40,\"window_size\": 2,\"iterations\": 3,\"random_seed\": 3,}` |\n| **embedding_func** | `EmbeddingFunc` | Function to generate embedding vectors from text | `openai_embed` |\n| **embedding_batch_num** | `int` | Maximum batch size for embedding processes (multiple texts sent per batch) | `32` |\n| **embedding_func_max_async** | `int` | Maximum number of concurrent asynchronous embedding processes | `16` |\n| **llm_model_func** | `callable` | Function for LLM generation | `gpt_4o_mini_complete` |\n| **llm_model_name** | `str` | LLM model name for generation | `meta-llama/Llama-3.2-1B-Instruct` |\n| **summary_context_size** | `int` | Maximum tokens send to LLM to generate summaries for entity relation merging | `10000`（configured by env var SUMMARY_CONTEXT_SIZE) |\n| **summary_max_tokens** | `int` | Maximum token size for entity/relation description | `500`（configured by env var SUMMARY_MAX_TOKENS) |\n| **llm_model_max_async** | `int` | Maximum number of concurrent asynchronous LLM processes | `4`（default value changed by env var MAX_ASYNC) |\n| **llm_model_kwargs** | `dict` | Additional parameters for LLM generation | |\n| **vector_db_storage_cls_kwargs** | `dict` | Additional parameters for vector database, like setting the threshold for nodes and relations retrieval | cosine_better_than_threshold: 0.2（default value changed by env var COSINE_THRESHOLD) |\n| **enable_llm_cache** | `bool` | If `TRUE`, stores LLM results in cache; repeated prompts return cached responses | `TRUE` |\n| **enable_llm_cache_for_entity_extract** | `bool` | If `TRUE`, stores LLM results in cache for entity extraction; Good for beginners to debug your application | `TRUE` |\n| **addon_params** | `dict` | Additional parameters, e.g., `{\"language\": \"Simplified Chinese\", \"entity_types\": [\"organization\", \"person\", \"location\", \"event\"]}`: sets example limit, entity/relation extraction output language | language: English` |\n| **embedding_cache_config** | `dict` | Configuration for question-answer caching. Contains three parameters: `enabled`: Boolean value to enable/disable cache lookup functionality. When enabled, the system will check cached responses before generating new answers. `similarity_threshold`: Float value (0-1), similarity threshold. When a new question's similarity with a cached question exceeds this threshold, the cached answer will be returned directly without calling the LLM. `use_llm_check`: Boolean value to enable/disable LLM similarity verification. When enabled, LLM will be used as a secondary check to verify the similarity between questions before returning cached answers. | Default: `{\"enabled\": False, \"similarity_threshold\": 0.95, \"use_llm_check\": False}` |\n\n</details>\n\n### Query Param\n\nUse QueryParam to control the behavior your query:\n\n```python\nclass QueryParam:\n    \"\"\"Configuration parameters for query execution in LightRAG.\"\"\"\n\n    mode: Literal[\"local\", \"global\", \"hybrid\", \"naive\", \"mix\", \"bypass\"] = \"global\"\n    \"\"\"Specifies the retrieval mode:\n    - \"local\": Focuses on context-dependent information.\n    - \"global\": Utilizes global knowledge.\n    - \"hybrid\": Combines local and global retrieval methods.\n    - \"naive\": Performs a basic search without advanced techniques.\n    - \"mix\": Integrates knowledge graph and vector retrieval.\n    \"\"\"\n\n    only_need_context: bool = False\n    \"\"\"If True, only returns the retrieved context without generating a response.\"\"\"\n\n    only_need_prompt: bool = False\n    \"\"\"If True, only returns the generated prompt without producing a response.\"\"\"\n\n    response_type: str = \"Multiple Paragraphs\"\n    \"\"\"Defines the response format. Examples: 'Multiple Paragraphs', 'Single Paragraph', 'Bullet Points'.\"\"\"\n\n    stream: bool = False\n    \"\"\"If True, enables streaming output for real-time responses.\"\"\"\n\n    top_k: int = int(os.getenv(\"TOP_K\", \"60\"))\n    \"\"\"Number of top items to retrieve. Represents entities in 'local' mode and relationships in 'global' mode.\"\"\"\n\n    chunk_top_k: int = int(os.getenv(\"CHUNK_TOP_K\", \"20\"))\n    \"\"\"Number of text chunks to retrieve initially from vector search and keep after reranking.\n    If None, defaults to top_k value.\n    \"\"\"\n\n    max_entity_tokens: int = int(os.getenv(\"MAX_ENTITY_TOKENS\", \"6000\"))\n    \"\"\"Maximum number of tokens allocated for entity context in unified token control system.\"\"\"\n\n    max_relation_tokens: int = int(os.getenv(\"MAX_RELATION_TOKENS\", \"8000\"))\n    \"\"\"Maximum number of tokens allocated for relationship context in unified token control system.\"\"\"\n\n    max_total_tokens: int = int(os.getenv(\"MAX_TOTAL_TOKENS\", \"30000\"))\n    \"\"\"Maximum total tokens budget for the entire query context (entities + relations + chunks + system prompt).\"\"\"\n\n    # History messages are only sent to LLM for context, not used for retrieval\n    conversation_history: list[dict[str, str]] = field(default_factory=list)\n    \"\"\"Stores past conversation history to maintain context.\n    Format: [{\"role\": \"user/assistant\", \"content\": \"message\"}].\n    \"\"\"\n\n    # Deprecated (ids filter lead to potential hallucination effects)\n    ids: list[str] | None = None\n    \"\"\"List of ids to filter the results.\"\"\"\n\n    model_func: Callable[..., object] | None = None\n    \"\"\"Optional override for the LLM model function to use for this specific query.\n    If provided, this will be used instead of the global model function.\n    This allows using different models for different query modes.\n    \"\"\"\n\n    user_prompt: str | None = None\n    \"\"\"User-provided prompt for the query.\n    Addition instructions for LLM. If provided, this will be inject into the prompt template.\n    It's purpose is the let user customize the way LLM generate the response.\n    \"\"\"\n\n    enable_rerank: bool = True\n    \"\"\"Enable reranking for retrieved text chunks. If True but no rerank model is configured, a warning will be issued.\n    Default is True to enable reranking when rerank model is available.\n    \"\"\"\n```\n\n> default value of Top_k can be change by environment  variables  TOP_K.\n\n### LLM and Embedding Injection\n\nLightRAG requires the utilization of LLM and Embedding models to accomplish document indexing and querying tasks. During the initialization phase, it is necessary to inject the invocation methods of the relevant models into LightRAG：\n\n<details>\n<summary> <b>Using Open AI-like APIs</b> </summary>\n\n* LightRAG also supports Open AI-like chat/embeddings APIs:\n\n```python\nimport os\nimport numpy as np\nfrom lightrag.utils import wrap_embedding_func_with_attrs\nfrom lightrag.llm.openai import openai_complete_if_cache, openai_embed\n\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], keyword_extraction=False, **kwargs\n) -> str:\n    return await openai_complete_if_cache(\n        \"solar-mini\",\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=os.getenv(\"UPSTAGE_API_KEY\"),\n        base_url=\"https://api.upstage.ai/v1/solar\",\n        **kwargs\n    )\n\n@wrap_embedding_func_with_attrs(embedding_dim=4096, max_token_size=8192, model_name=\"solar-embedding-1-large-query\")\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await openai_embed.func(\n        texts,\n        model=\"solar-embedding-1-large-query\",\n        api_key=os.getenv(\"UPSTAGE_API_KEY\"),\n        base_url=\"https://api.upstage.ai/v1/solar\"\n    )\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=llm_model_func,\n        embedding_func=embedding_func  # Pass the decorated function directly\n    )\n\n    await rag.initialize_storages()\n    return rag\n```\n\n> **Important Note on Embedding Function Wrapping:**\n>\n> `EmbeddingFunc` cannot be nested. Functions that have been decorated with `@wrap_embedding_func_with_attrs` (such as `openai_embed`, `ollama_embed`, etc.) cannot be wrapped again using `EmbeddingFunc()`. This is why we call `xxx_embed.func` (the underlying unwrapped function) instead of `xxx_embed` directly when creating custom embedding functions.\n\n</details>\n\n<details>\n<summary> <b>Using Hugging Face Models</b> </summary>\n\n* If you want to use Hugging Face models, you only need to set LightRAG as follows:\n\nSee `lightrag_hf_demo.py`\n\n```python\nfrom functools import partial\nfrom transformers import AutoTokenizer, AutoModel\n\n# Pre-load tokenizer and model\ntokenizer = AutoTokenizer.from_pretrained(\"sentence-transformers/all-MiniLM-L6-v2\")\nembed_model = AutoModel.from_pretrained(\"sentence-transformers/all-MiniLM-L6-v2\")\n\n# Initialize LightRAG with Hugging Face model\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=hf_model_complete,  # Use Hugging Face model for text generation\n    llm_model_name='meta-llama/Llama-3.1-8B-Instruct',  # Model name from Hugging Face\n    # Use Hugging Face embedding function\n    embedding_func=EmbeddingFunc(\n        embedding_dim=384,\n        max_token_size=2048,\n        model_name=\"sentence-transformers/all-MiniLM-L6-v2\",\n        func=partial(\n            hf_embed.func,  # Use .func to access the unwrapped function\n            tokenizer=tokenizer,\n            embed_model=embed_model\n        )\n    ),\n)\n```\n\n</details>\n\n<details>\n<summary> <b>Using Ollama Models</b> </summary>\n\n**Overview**\n\nIf you want to use Ollama models, you need to pull model you plan to use and embedding model, for example `nomic-embed-text`.\n\nThen you only need to set LightRAG as follows:\n\n```python\nimport numpy as np\nfrom lightrag.utils import wrap_embedding_func_with_attrs\nfrom lightrag.llm.ollama import ollama_model_complete, ollama_embed\n\n@wrap_embedding_func_with_attrs(embedding_dim=768, max_token_size=8192, model_name=\"nomic-embed-text\")\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await ollama_embed.func(texts, embed_model=\"nomic-embed-text\")\n\n# Initialize LightRAG with Ollama model\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=ollama_model_complete,  # Use Ollama model for text generation\n    llm_model_name='your_model_name', # Your model name\n    embedding_func=embedding_func,  # Pass the decorated function directly\n)\n```\n\n* **Increasing context size**\n\nIn order for LightRAG to work context should be at least 32k tokens. By default Ollama models have context size of 8k. You can achieve this using one of two ways:\n\n* **Increasing the `num_ctx` parameter in Modelfile**\n\n1. Pull the model:\n\n```bash\nollama pull qwen2\n```\n\n2. Display the model file:\n\n```bash\nollama show --modelfile qwen2 > Modelfile\n```\n\n3. Edit the Modelfile by adding the following line:\n\n```bash\nPARAMETER num_ctx 32768\n```\n\n4. Create the modified model:\n\n```bash\nollama create -f Modelfile qwen2m\n```\n\n* **Setup `num_ctx` via Ollama API**\n\nTiy can use `llm_model_kwargs` param to configure ollama:\n\n```python\nimport numpy as np\nfrom lightrag.utils import wrap_embedding_func_with_attrs\nfrom lightrag.llm.ollama import ollama_model_complete, ollama_embed\n\n@wrap_embedding_func_with_attrs(embedding_dim=768, max_token_size=8192, model_name=\"nomic-embed-text\")\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await ollama_embed.func(texts, embed_model=\"nomic-embed-text\")\n\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=ollama_model_complete,  # Use Ollama model for text generation\n    llm_model_name='your_model_name', # Your model name\n    llm_model_kwargs={\"options\": {\"num_ctx\": 32768}},\n    embedding_func=embedding_func,  # Pass the decorated function directly\n)\n```\n\n> **Important Note on Embedding Function Wrapping:**\n>\n> `EmbeddingFunc` cannot be nested. Functions that have been decorated with `@wrap_embedding_func_with_attrs` (such as `openai_embed`, `ollama_embed`, etc.) cannot be wrapped again using `EmbeddingFunc()`. This is why we call `xxx_embed.func` (the underlying unwrapped function) instead of `xxx_embed` directly when creating custom embedding functions.\n\n* **Low RAM GPUs**\n\nIn order to run this experiment on low RAM GPU you should select small model and tune context window (increasing context increase memory consumption). For example, running this ollama example on repurposed mining GPU with 6Gb of RAM required to set context size to 26k while using `gemma2:2b`. It was able to find 197 entities and 19 relations on `book.txt`.\n\n</details>\n\n<details>\n<summary> <b>LlamaIndex</b> </summary>\n\nLightRAG supports integration with LlamaIndex (`llm/llama_index_impl.py`):\n\n- Integrates with OpenAI and other providers through LlamaIndex\n- See [LlamaIndex Documentation](https://developers.llamaindex.ai/python/framework/) for detailed setup or the [examples](examples/unofficial-sample/)\n\n**Example Usage**\n\n```python\n# Using LlamaIndex with direct OpenAI access\nimport asyncio\nfrom lightrag import LightRAG\nfrom lightrag.llm.llama_index_impl import llama_index_complete_if_cache, llama_index_embed\nfrom llama_index.embeddings.openai import OpenAIEmbedding\nfrom llama_index.llms.openai import OpenAI\nfrom lightrag.utils import setup_logger\n\n# Setup log handler for LightRAG\nsetup_logger(\"lightrag\", level=\"INFO\")\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=\"your/path\",\n        llm_model_func=llama_index_complete_if_cache,  # LlamaIndex-compatible completion function\n        embedding_func=EmbeddingFunc(    # LlamaIndex-compatible embedding function\n            embedding_dim=1536,\n            max_token_size=2048,\n            model_name=embed_model,\n            func=partial(llama_index_embed.func, embed_model=embed_model)  # Use .func to access the unwrapped function\n        ),\n    )\n\n    await rag.initialize_storages()\n    return rag\n\ndef main():\n    # Initialize RAG instance\n    rag = asyncio.run(initialize_rag())\n\n    with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n        rag.insert(f.read())\n\n    # Perform naive search\n    print(\n        rag.query(\"What are the top themes in this story?\", param=QueryParam(mode=\"naive\"))\n    )\n\n    # Perform local search\n    print(\n        rag.query(\"What are the top themes in this story?\", param=QueryParam(mode=\"local\"))\n    )\n\n    # Perform global search\n    print(\n        rag.query(\"What are the top themes in this story?\", param=QueryParam(mode=\"global\"))\n    )\n\n    # Perform hybrid search\n    print(\n        rag.query(\"What are the top themes in this story?\", param=QueryParam(mode=\"hybrid\"))\n    )\n\nif __name__ == \"__main__\":\n    main()\n```\n\n**For detailed documentation and examples, see:**\n\n- [LlamaIndex Documentation](https://developers.llamaindex.ai/python/framework/)\n- [Direct OpenAI Example](examples/unofficial-sample/lightrag_llamaindex_direct_demo.py)\n- [LiteLLM Proxy Example](examples/unofficial-sample/lightrag_llamaindex_litellm_demo.py)\n- [LiteLLM Proxy with Opik Example](examples/unofficial-sample/lightrag_llamaindex_litellm_opik_demo.py)\n\n</details>\n\n<details>\n<summary> <b>Using Azure OpenAI Models</b> </summary>\n\nIf you want to use Azure OpenAI models, you only need to set up LightRAG as follows:\n\n```python\nimport os\nimport numpy as np\nfrom lightrag.utils import wrap_embedding_func_with_attrs\nfrom lightrag.llm.azure_openai import azure_openai_complete_if_cache, azure_openai_embed\n\n# Configure the generation model\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], keyword_extraction=False, **kwargs\n) -> str:\n    return await azure_openai_complete_if_cache(\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=os.getenv(\"AZURE_OPENAI_API_KEY\"),\n        azure_endpoint=os.getenv(\"AZURE_OPENAI_ENDPOINT\"),\n        api_version=os.getenv(\"AZURE_OPENAI_API_VERSION\"),\n        deployment_name=os.getenv(\"AZURE_OPENAI_DEPLOYMENT_NAME\"),\n        **kwargs\n    )\n\n# Configure the embedding model\n@wrap_embedding_func_with_attrs(\n    embedding_dim=1536,\n    max_token_size=8192,\n    model_name=os.getenv(\"AZURE_OPENAI_EMBEDDING_MODEL\")\n)\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await azure_openai_embed.func(\n        texts,\n        api_key=os.getenv(\"AZURE_OPENAI_API_KEY\"),\n        azure_endpoint=os.getenv(\"AZURE_OPENAI_ENDPOINT\"),\n        api_version=os.getenv(\"AZURE_OPENAI_API_VERSION\"),\n        deployment_name=os.getenv(\"AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME\")\n    )\n\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=llm_model_func,\n    embedding_func=embedding_func\n)\n```\n\n</details>\n\n<details>\n<summary> <b>Using Google Gemini Models</b> </summary>\n\nIf you want to use Google Gemini models, you only need to set up LightRAG as follows:\n\n```python\nimport os\nimport numpy as np\nfrom lightrag.utils import wrap_embedding_func_with_attrs\nfrom lightrag.llm.gemini import gemini_model_complete, gemini_embed\n\n# Configure the generation model\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], keyword_extraction=False, **kwargs\n) -> str:\n    return await gemini_model_complete(\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=os.getenv(\"GEMINI_API_KEY\"),\n        model_name=\"gemini-2.0-flash\",\n        **kwargs\n    )\n\n# Configure the embedding model\n@wrap_embedding_func_with_attrs(\n    embedding_dim=768,\n    max_token_size=2048,\n    model_name=\"models/text-embedding-004\"\n)\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await gemini_embed.func(\n        texts,\n        api_key=os.getenv(\"GEMINI_API_KEY\"),\n        model=\"models/text-embedding-004\"\n    )\n\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=llm_model_func,\n    llm_model_name=\"gemini-2.0-flash\",\n    embedding_func=embedding_func\n)\n```\n\n</details>\n\n### Rerank Function Injection\n\nTo enhance retrieval quality, documents can be re-ranked based on a more effective relevance scoring model. The `rerank.py` file provides three Reranker provider driver functions:\n\n* **Cohere / vLLM**: `cohere_rerank`\n* **Jina AI**: `jina_rerank`\n* **Aliyun**: `ali_rerank`\n\nYou can inject one of these functions into the `rerank_model_func` attribute of the LightRAG object. This will enable LightRAG's query function to re-order retrieved text blocks using the injected function. For detailed usage, please refer to the `examples/rerank_example.py` file.\n\n### User Prompt vs. Query\n\nWhen using LightRAG for content queries, avoid combining the search process with unrelated output processing, as this significantly impacts query effectiveness. The `user_prompt` parameter in Query Param is specifically designed to address this issue — it does not participate in the RAG retrieval phase, but rather guides the LLM on how to process the retrieved results after the query is completed. Here's how to use it:\n\n```python\n# Create query parameters\nquery_param = QueryParam(\n    mode = \"hybrid\",  # Other modes：local, global, hybrid, mix, naive\n    user_prompt = \"For diagrams, use mermaid format with English/Pinyin node names and Chinese display labels\",\n)\n\n# Query and process\nresponse_default = rag.query(\n    \"Please draw a character relationship diagram for Scrooge\",\n    param=query_param\n)\nprint(response_default)\n```\n\n### Insert\n\n<details>\n  <summary> <b> Basic Insert </b></summary>\n\n```python\n# Basic Insert\nrag.insert(\"Text\")\n```\n\n</details>\n\n<details>\n  <summary> <b> Batch Insert </b></summary>\n\n```python\n# Basic Batch Insert: Insert multiple texts at once\nrag.insert([\"TEXT1\", \"TEXT2\",...])\n\n# Batch Insert with custom batch size configuration\nrag = LightRAG(\n    ...\n    working_dir=WORKING_DIR,\n    max_parallel_insert = 4\n)\n\nrag.insert([\"TEXT1\", \"TEXT2\", \"TEXT3\", ...])  # Documents will be processed in batches of 4\n```\n\nThe `max_parallel_insert` parameter determines the number of documents processed concurrently in the document indexing pipeline. If unspecified, the default value is **2**. We recommend keeping this setting **below 10**, as the performance bottleneck typically lies with the LLM (Large Language Model) processing.\n\n</details>\n\n<details>\n  <summary> <b> Insert with ID </b></summary>\n\nIf you want to provide your own IDs for your documents, number of documents and number of IDs must be the same.\n\n```python\n# Insert single text, and provide ID for it\nrag.insert(\"TEXT1\", ids=[\"ID_FOR_TEXT1\"])\n\n# Insert multiple texts, and provide IDs for them\nrag.insert([\"TEXT1\", \"TEXT2\",...], ids=[\"ID_FOR_TEXT1\", \"ID_FOR_TEXT2\"])\n```\n\n</details>\n\n<details>\n  <summary><b>Insert using Pipeline</b></summary>\n\nThe `apipeline_enqueue_documents` and `apipeline_process_enqueue_documents` functions allow you to perform incremental insertion of documents into the graph.This is useful for scenarios where you want to process documents in the background while still allowing the main thread to continue executing.\n\n```python\nrag = LightRAG(..)\n\nawait rag.apipeline_enqueue_documents(input)\n# Your routine in loop\nawait rag.apipeline_process_enqueue_documents(input)\n```\n\n</details>\n\n<details>\n  <summary><b>Insert Multi-file Type Support</b></summary>\n\nThe `textract` supports reading file types such as TXT, DOCX, PPTX, CSV, and PDF.\n\n```python\nimport textract\n\nfile_path = 'TEXT.pdf'\ntext_content = textract.process(file_path)\n\nrag.insert(text_content.decode('utf-8'))\n```\n\n</details>\n\n<details>\n  <summary><b>Citation Functionality</b></summary>\n\nBy providing file paths, the system ensures that sources can be traced back to their original documents.\n\n```python\n# Define documents and their file paths\ndocuments = [\"Document content 1\", \"Document content 2\"]\nfile_paths = [\"path/to/doc1.txt\", \"path/to/doc2.txt\"]\n\n# Insert documents with file paths\nrag.insert(documents, file_paths=file_paths)\n```\n\n</details>\n\n### Storage\n\nLightRAG uses 4 types of storage for different purposes:\n\n* KV_STORAGE: llm response cache, text chunks, document information\n* VECTOR_STORAGE: entities vectors, relation vectors, chunks vectors\n* GRAPH_STORAGE: entity relation graph\n* DOC_STATUS_STORAGE: document indexing status\n\nEach storage type has several implementations:\n\n* KV_STORAGE supported implementations:\n\n```\nJsonKVStorage        JsonFile (default)\nPGKVStorage          Postgres\nRedisKVStorage       Redis\nMongoKVStorage       MongoDB\nOpenSearchKVStorage  OpenSearch\n```\n\n* GRAPH_STORAGE supported implementations:\n\n```\nNetworkXStorage          NetworkX (default)\nNeo4JStorage             Neo4J\nPGGraphStorage           PostgreSQL with AGE plugin\nMemgraphStorage          Memgraph\nOpenSearchGraphStorage   OpenSearch\n```\n\n> Testing has shown that Neo4J delivers superior performance in production environments compared to PostgreSQL with AGE plugin.\n\n* VECTOR_STORAGE supported implementations:\n\n```\nNanoVectorDBStorage         NanoVector (default)\nPGVectorStorage             Postgres\nMilvusVectorDBStorage       Milvus\nFaissVectorDBStorage        Faiss\nQdrantVectorDBStorage       Qdrant\nMongoVectorDBStorage        MongoDB\nOpenSearchVectorDBStorage   OpenSearch\n```\n\n* DOC_STATUS_STORAGE: supported implementations:\n\n```\nJsonDocStatusStorage        JsonFile (default)\nPGDocStatusStorage          Postgres\nMongoDocStatusStorage       MongoDB\nOpenSearchDocStatusStorage  OpenSearch\n```\n\nExample connection configurations for each storage type can be found in the repository's `env.example` file. The database instance in the connection string needs to be created by you on the database server beforehand. LightRAG is only responsible for creating tables within the database instance, not for creating the database instance itself. If using Redis as storage, remember to configure automatic data persistence rules for Redis, otherwise data will be lost after the Redis service restarts. If using PostgreSQL, it is recommended to use version 16.6 or above.\n\n<details>\n<summary> <b>Using Neo4J Storage</b> </summary>\n\n* For production level scenarios you will most likely want to leverage an enterprise solution\n* for KG storage. Running Neo4J in Docker is recommended for seamless local testing.\n* See: https://hub.docker.com/_/neo4j\n\n```python\nexport NEO4J_URI=\"neo4j://localhost:7687\"\nexport NEO4J_USERNAME=\"neo4j\"\nexport NEO4J_PASSWORD=\"password\"\nexport NEO4J_DATABASE=\"neo4j\" #<----------- If you are using community edition neo4j docker image.\n\n# Setup logger for LightRAG\nsetup_logger(\"lightrag\", level=\"INFO\")\n\n# When you launch the project be sure to override the default KG by specifying graph_storage=\"Neo4JStorage\".\n# Initialize LightRAG with Neo4J implementation.\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=gpt_4o_mini_complete,  # Use gpt_4o_mini_complete LLM model\n        graph_storage=\"Neo4JStorage\", #<-----------override KG default\n    )\n\n    # Initialize database connections\n    await rag.initialize_storages()\n    # Initialize pipeline status for document processing\n    return rag\n```\n\nsee test_neo4j.py for a working example.\n\n</details>\n\n<details>\n<summary> <b>Using PostgreSQL Storage</b> </summary>\n\nFor production level scenarios you will most likely want to leverage an enterprise solution. PostgreSQL can provide a one-stop solution for you as KV store, VectorDB (pgvector) and GraphDB (apache AGE). PostgreSQL version 16.6 or higher is supported.\n\n* PostgreSQL is lightweight,the whole binary distribution including all necessary plugins can be zipped to 40MB: Ref to [Windows Release](https://github.com/ShanGor/apache-age-windows/releases/tag/PG17%2Fv1.5.0-rc0) as it is easy to install for Linux/Mac.\n* If you prefer docker, please start with this image if you are a beginner to avoid hiccups (Default user password:rag/rag): https://hub.docker.com/r/gzdaniel/postgres-for-rag\n* How to start? Ref to: [examples/lightrag_gemini_postgres_demo.py](https://github.com/HKUDS/LightRAG/blob/main/examples/lightrag_gemini_postgres_demo.py)\n* For high-performance graph database requirements, Neo4j is recommended as Apache AGE's performance is not as competitive.\n\n</details>\n\n<details>\n<summary> <b>Using Faiss Storage</b> </summary>\nBefore using Faiss vector database, you must manually install `faiss-cpu` or `faiss-gpu`.\n\n- Install the required dependencies:\n\n```\npip install faiss-cpu\n```\n\nYou can also install `faiss-gpu` if you have GPU support.\n\n- Here we are using `sentence-transformers` but you can also use `OpenAIEmbedding` model with `3072` dimensions.\n\n```python\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    model = SentenceTransformer('all-MiniLM-L6-v2')\n    embeddings = model.encode(texts, convert_to_numpy=True)\n    return embeddings\n\n# Initialize LightRAG with the LLM model function and embedding function\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=llm_model_func,\n    embedding_func=EmbeddingFunc(\n        embedding_dim=384,\n        max_token_size=2048,\n        model_name=\"all-MiniLM-L6-v2\",\n        func=embedding_func,\n    ),\n    vector_storage=\"FaissVectorDBStorage\",\n    vector_db_storage_cls_kwargs={\n        \"cosine_better_than_threshold\": 0.3  # Your desired threshold\n    }\n)\n```\n\n</details>\n\n<details>\n<summary> <b>Using Memgraph for Storage</b> </summary>\n\n* Memgraph is a high-performance, in-memory graph database compatible with the Neo4j Bolt protocol.\n* You can run Memgraph locally using Docker for easy testing:\n* See: https://memgraph.com/download\n\n```python\nexport MEMGRAPH_URI=\"bolt://localhost:7687\"\n\n# Setup logger for LightRAG\nsetup_logger(\"lightrag\", level=\"INFO\")\n\n# When you launch the project, override the default KG: NetworkX\n# by specifying kg=\"MemgraphStorage\".\n\n# Note: Default settings use NetworkX\n# Initialize LightRAG with Memgraph implementation.\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=gpt_4o_mini_complete,  # Use gpt_4o_mini_complete LLM model\n        graph_storage=\"MemgraphStorage\", #<-----------override KG default\n    )\n\n    # Initialize database connections\n    await rag.initialize_storages()\n    # Initialize pipeline status for document processing\n    return rag\n```\n\n</details>\n\n<details>\n<summary> <b>Using Milvus for Vector Storage</b> </summary>\n\nMilvus is a high-performance, scalable vector database for production-level vector storage. LightRAG provides three ways to configure Milvus, plus support for configurable index types to optimize performance and memory usage.\n\n### Supported Index Types\n\n- `AUTOINDEX` (default): Milvus automatically selects the best index\n- `HNSW`: Hierarchical Navigable Small World graph for high recall\n- `HNSW_SQ`: HNSW with scalar quantization for memory savings (requires Milvus 2.6.8+)\n- `HNSW_PQ`, `HNSW_PRQ`: HNSW with product / product-residual quantization\n- `IVF_FLAT`, `IVF_SQ8`, `IVF_PQ`: Inverted-file family indexes\n- `DISKANN`: Disk-based approximate nearest neighbor\n- `SCANN`: Scalable nearest neighbor\n\n### Supported Metric Types\n\n`COSINE` (default), `L2`, `IP`\n\n---\n\n### Config Approach 1 — Environment Variables (`.env` file)\n\nBest for: **LightRAG Server deployments and Docker/k8s setups**.\n\n```bash\n# Connection\nMILVUS_URI=http://localhost:19530\nMILVUS_DB_NAME=lightrag\n# MILVUS_USER=root\n# MILVUS_PASSWORD=your_password\n# MILVUS_TOKEN=your_token\n\n# Storage selection\nLIGHTRAG_VECTOR_STORAGE=MilvusVectorDBStorage\n\n# Index configuration (all optional — sensible defaults apply)\nMILVUS_INDEX_TYPE=HNSW              # Default: AUTOINDEX\nMILVUS_METRIC_TYPE=COSINE           # Default: COSINE\nMILVUS_HNSW_M=16                    # Default: 16, range [2-2048]\nMILVUS_HNSW_EF_CONSTRUCTION=360     # Default: 360\nMILVUS_HNSW_EF=200                  # Default: 200\n\n# HNSW_SQ options (requires Milvus 2.6.8+)\n# MILVUS_INDEX_TYPE=HNSW_SQ\n# MILVUS_HNSW_SQ_TYPE=SQ8           # SQ4U, SQ6, SQ8, BF16, FP16\n# MILVUS_HNSW_SQ_REFINE=false       # Enable refinement\n# MILVUS_HNSW_SQ_REFINE_TYPE=FP32   # Refinement precision\n# MILVUS_HNSW_SQ_REFINE_K=10        # Refinement expansion factor\n\n# IVF options\n# MILVUS_IVF_NLIST=1024\n# MILVUS_IVF_NPROBE=16\n```\n\nThen in Python code:\n\n```python\nfrom lightrag import LightRAG\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=\"./rag_storage\",\n        llm_model_func=...,\n        embedding_func=...,\n        vector_storage=\"MilvusVectorDBStorage\",\n    )\n    await rag.initialize_storages()\n    return rag\n```\n\n### Config Approach 2 — `vector_db_storage_cls_kwargs` (Python SDK)\n\nBest for: **Python SDK / framework integration** where you want all config in code.\n\n```python\nfrom lightrag import LightRAG\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=\"./rag_storage\",\n        llm_model_func=...,\n        embedding_func=...,\n        vector_storage=\"MilvusVectorDBStorage\",\n        vector_db_storage_cls_kwargs={\n            \"milvus_uri\": \"http://localhost:19530\",\n            \"milvus_db_name\": \"lightrag\",\n            \"index_type\": \"HNSW\",\n            \"metric_type\": \"COSINE\",\n            \"hnsw_m\": 16,\n            \"hnsw_ef_construction\": 360,\n            \"hnsw_ef\": 200,\n            \"cosine_better_than_threshold\": 0.2,\n        },\n    )\n    await rag.initialize_storages()\n    return rag\n```\n\n### Config Approach 3 — `config.ini` (legacy)\n\nConnection parameters only; index settings use env vars or kwargs.\n\n```ini\n[milvus]\nuri = http://localhost:19530\ndb_name = lightrag\n# user = root\n# password = your_password\n# token = your_token\n```\n\n### Configuration Priority\n\n| Setting | 1st (highest) | 2nd | 3rd (lowest) |\n|---|---|---|---|\n| Connection (`uri`, …) | `vector_db_storage_cls_kwargs` | Environment variables | `config.ini` |\n| Index (`index_type`, …) | `vector_db_storage_cls_kwargs` | Environment variables | defaults |\n\n### HNSW_SQ Compression Trade-offs\n\n| SQ Type | Compression | Precision | Notes |\n|---|---|---|---|\n| `SQ4U` | ~8× | Lower | Best memory savings |\n| `SQ6` | ~5.3× | Balanced | Good trade-off |\n| `SQ8` | ~4× | Good | **Recommended** |\n| `BF16` / `FP16` | ~2× | High | Near-lossless |\n\n**Version Requirements:**\n- HNSW_SQ index type requires **Milvus 2.6.8 or higher**\n- LightRAG will automatically validate the server version and raise an error if requirements are not met\n- Other index types work with Milvus 2.0+\n\n**Backward Compatibility:**\n- If no index configuration is provided, LightRAG uses AUTOINDEX (Milvus default behavior)\n- Existing collections are not affected; index configuration only applies to newly created collections\n\nFor complete configuration options, see `env.example` and `docs/MilvusConfigurationGuide.md`.\n\n</details>\n\n<details>\n<summary> <b>Using MongoDB Storage</b> </summary>\n\nMongoDB provides a one-stop storage solution for LightRAG. MongoDB offers native KV storage and vector storage. LightRAG uses MongoDB collections to implement a simple graph storage. `MongoVectorDBStorage` requires a MongoDB deployment with Atlas Search / Vector Search support, such as MongoDB Atlas or Atlas local. The setup wizard's bundled local Docker MongoDB service is MongoDB Community Edition, so it can be used for KV/graph/doc-status storage but not for `MongoVectorDBStorage`.\n\n</details>\n\n<details>\n<summary> <b>Using Redis Storage</b> </summary>\n\nLightRAG supports using Redis as KV storage. When using Redis storage, attention should be paid to persistence configuration and memory usage configuration. The following is the recommended Redis configuration:\n\n```\nsave 900 1\nsave 300 10\nsave 60 1000\nstop-writes-on-bgsave-error yes\nmaxmemory 4gb\nmaxmemory-policy noeviction\nmaxclients 500\n```\n\nWhen the interactive setup manages a local Redis container, it stages a user-editable config at `./data/config/redis.conf` and mounts it into the container. Setup preserves that file on reruns so local Redis tuning can be adjusted without losing manual edits.\n\n</details>\n\n<details>\n<summary> <b>Using OpenSearch Storage</b> </summary>\n\nOpenSearch provides a unified storage solution for all four LightRAG storage types (KV, Vector, Graph, DocStatus). It offers native k-NN vector search, full-text search, and horizontal scalability — all without cloud-only restrictions.\n\n* **Requirements**: OpenSearch 3.x or higher with k-NN plugin enabled.\n\nInstall with Docker (without plugins):\n```bash\ndocker run -d -p 9200:9200 -e \"discovery.type=single-node\" \\\n  -e \"OPENSEARCH_INITIAL_ADMIN_PASSWORD=<custom-admin-password>\" \\\n  opensearchproject/opensearch:latest\n```\n\nInstall with Docker Compose (Recommended, with plugins):\n```bash\ncurl -O https://raw.githubusercontent.com/opensearch-project/opensearch-build/main/docker/release/dockercomposefiles/docker-compose-3.x.yml\n# Launch OpenSearch cluster\nOPENSEARCH_INITIAL_ADMIN_PASSWORD=<custom-admin-password> docker-compose -f docker-compose-3.x.yml up -d\n```\n\n* **Configuration**: Set environment variables (see `env.example` for full list):\n\n```bash\nexport OPENSEARCH_HOSTS=localhost:9200\nexport OPENSEARCH_USER=admin\nexport OPENSEARCH_PASSWORD=<custom-admin-password>\nexport OPENSEARCH_USE_SSL=true\nexport OPENSEARCH_VERIFY_CERTS=false\n```\n\n* **Usage**:\n\n```python\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=your_llm_func,\n    embedding_func=your_embed_func,\n    kv_storage=\"OpenSearchKVStorage\",\n    doc_status_storage=\"OpenSearchDocStatusStorage\",\n    graph_storage=\"OpenSearchGraphStorage\",\n    vector_storage=\"OpenSearchVectorDBStorage\",\n)\n```\n\n* **Graph Traversal**: When the OpenSearch SQL plugin with PPL support is available, graph queries use server-side BFS via the `graphlookup` command for optimal performance. Otherwise, it falls back to client-side batched BFS. This is auto-detected at startup, or can be forced via `OPENSEARCH_USE_PPL_GRAPHLOOKUP=true|false`.\n\n* **Integration Testing**: To run integration tests against a live OpenSearch cluster:\n\n1. Start OpenSearch using Docker Compose (download [`docker-compose-3.x.yml`](https://raw.githubusercontent.com/opensearch-project/opensearch-build/main/docker/release/dockercomposefiles/docker-compose-3.x.yml)):\n\n```bash\nOPENSEARCH_INITIAL_ADMIN_PASSWORD=<custom-admin-password> docker-compose -f docker-compose-3.x.yml up -d\n```\n\n2. Verify the cluster is running:\n\n```bash\ncurl -sk -u admin:<custom-admin-password> https://localhost:9200\ncurl -sk -u admin:<custom-admin-password> https://localhost:9200/_cat/plugins?v\n```\n\n3. Run the unit tests (no OpenSearch required — uses mocks):\n\n```bash\npython -m pytest tests/test_opensearch_storage.py -v\n```\n\n4. Run the OpenSearch storage demo against the live cluster:\n\n```bash\nexport OPENSEARCH_HOSTS=localhost:9200\nexport OPENSEARCH_USER=admin\nexport OPENSEARCH_PASSWORD=<custom-admin-password>\nexport OPENSEARCH_USE_SSL=true\nexport OPENSEARCH_VERIFY_CERTS=false\npython examples/opensearch_storage_demo.py\n```\n\n5. Run the full OpenAI + OpenSearch demo (requires `OPENAI_API_KEY`):\n\n```bash\nexport OPENAI_API_KEY=your-api-key\npython examples/lightrag_openai_opensearch_graph_demo.py\n```\n\n6. Visualize the knowledge graph via LightRAG WebUI or standalone HTML:\n\nRequires [building front-end artifacts](https://github.com/HKUDS/LightRAG/blob/main/lightrag/api/README.md) before starting LightRAG Server.\n```bash\n# Starting lightrag-server with OpenSearch Storage\nLIGHTRAG_KV_STORAGE=OpenSearchKVStorage \\\nLIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage \\\nLIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage \\\nLIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage \\\nLLM_BINDING=openai \\\nEMBEDDING_BINDING=openai \\\nEMBEDDING_MODEL=text-embedding-3-large \\\nEMBEDDING_DIM=3072 \\\nOPENAI_API_KEY=your-api-key \\\nlightrag-server\n\n# Display the knowledge graph via LightRAG WebUI\npython examples/graph_visual_with_opensearch.py\n\n# Open http://localhost:9621/webui/ -> Knowledge Graph\n# Or generate standalone HTML file\npython examples/graph_visual_with_opensearch.py --html\n```\n\n\n</details>\n\n### Data Isolation Between LightRAG Instances\n\nThe `workspace` parameter ensures data isolation between different LightRAG instances. Once initialized, the `workspace` is immutable and cannot be changed. Here is how workspaces are implemented for different types of storage:\n\n- **For local file-based databases, data isolation is achieved through workspace subdirectories:** `JsonKVStorage`, `JsonDocStatusStorage`, `NetworkXStorage`, `NanoVectorDBStorage`, `FaissVectorDBStorage`.\n- **For databases that store data in collections, it's done by adding a workspace prefix to the collection name:** `RedisKVStorage`, `RedisDocStatusStorage`, `MilvusVectorDBStorage`, `MongoKVStorage`, `MongoDocStatusStorage`, `MongoVectorDBStorage`, `MongoGraphStorage`, `PGGraphStorage`.\n- **For Qdrant vector database, data isolation is achieved through payload-based partitioning (Qdrant's recommended multitenancy approach):** `QdrantVectorDBStorage` uses shared collections with payload filtering for unlimited workspace scalability.\n- **For relational databases, data isolation is achieved by adding a `workspace` field to the tables for logical data separation:** `PGKVStorage`, `PGVectorStorage`, `PGDocStatusStorage`.\n- **For the Neo4j graph database, logical data isolation is achieved through labels:** `Neo4JStorage`\n- **For OpenSearch, data isolation is achieved through index name prefixes:** `OpenSearchKVStorage`, `OpenSearchDocStatusStorage`, `OpenSearchGraphStorage`, `OpenSearchVectorDBStorage`\n\nTo maintain compatibility with legacy data, the default workspace for PostgreSQL non-graph storage is `default` and, for PostgreSQL AGE graph storage is null, for Neo4j graph storage is `base` when no workspace is configured. For all external storages, the system provides dedicated workspace environment variables to override the common `WORKSPACE` environment variable configuration. These storage-specific workspace environment variables are: `REDIS_WORKSPACE`, `MILVUS_WORKSPACE`, `QDRANT_WORKSPACE`, `MONGODB_WORKSPACE`, `POSTGRES_WORKSPACE`, `NEO4J_WORKSPACE`, `OPENSEARCH_WORKSPACE`.\n\n**Usage Example:**\nFor a practical demonstration of managing multiple isolated knowledge bases (e.g., separating \"Book\" content from \"HR Policies\") within a single application, refer to the [Workspace Demo](examples/lightrag_gemini_workspace_demo.py).\n\n### AGENTS.md -- Guiding Coding Agents\n\nAGENTS.md is a simple, open format for guiding coding agents (https://agents.md/). It is a dedicated, predictable place to provide the context and instructions to help AI coding agents work on LightRAG project. Different AI coders should not maintain separate guidance files individually. If any AI coder cannot automatically recognize AGENTS.md, symbolic links can be used as a solution. After establishing symbolic links, you can prevent them from being committed to the Git repository by configuring your local `.gitignore_global`.\n\n## Edit Entities and Relations\n\nLightRAG now supports comprehensive knowledge graph management capabilities, allowing you to create, edit, and delete entities and relationships within your knowledge graph.\n\n<details>\n  <summary> <b> Create Entities and Relations </b></summary>\n\n```python\n# Create new entity\nentity = rag.create_entity(\"Google\", {\n    \"description\": \"Google is a multinational technology company specializing in internet-related services and products.\",\n    \"entity_type\": \"company\"\n})\n\n# Create another entity\nproduct = rag.create_entity(\"Gmail\", {\n    \"description\": \"Gmail is an email service developed by Google.\",\n    \"entity_type\": \"product\"\n})\n\n# Create relation between entities\nrelation = rag.create_relation(\"Google\", \"Gmail\", {\n    \"description\": \"Google develops and operates Gmail.\",\n    \"keywords\": \"develops operates service\",\n    \"weight\": 2.0\n})\n```\n\n</details>\n\n<details>\n  <summary> <b> Edit Entities and Relations </b></summary>\n\n```python\n# Edit an existing entity\nupdated_entity = rag.edit_entity(\"Google\", {\n    \"description\": \"Google is a subsidiary of Alphabet Inc., founded in 1998.\",\n    \"entity_type\": \"tech_company\"\n})\n\n# Rename an entity (with all its relationships properly migrated)\nrenamed_entity = rag.edit_entity(\"Gmail\", {\n    \"entity_name\": \"Google Mail\",\n    \"description\": \"Google Mail (formerly Gmail) is an email service.\"\n})\n\n# Edit a relation between entities\nupdated_relation = rag.edit_relation(\"Google\", \"Google Mail\", {\n    \"description\": \"Google created and maintains Google Mail service.\",\n    \"keywords\": \"creates maintains email service\",\n    \"weight\": 3.0\n})\n```\n\nAll operations are available in both synchronous and asynchronous versions. The asynchronous versions have the prefix \"a\" (e.g., `acreate_entity`, `aedit_relation`).\n\n</details>\n\n<details>\n  <summary> <b> Insert Custom KG </b></summary>\n\n```python\ncustom_kg = {\n        \"chunks\": [\n            {\n                \"content\": \"Alice and Bob are collaborating on quantum computing research.\",\n                \"source_id\": \"doc-1\",\n                \"file_path\": \"test_file\",\n            }\n        ],\n        \"entities\": [\n            {\n                \"entity_name\": \"Alice\",\n                \"entity_type\": \"person\",\n                \"description\": \"Alice is a researcher specializing in quantum physics.\",\n                \"source_id\": \"doc-1\",\n                \"file_path\": \"test_file\"\n            },\n            {\n                \"entity_name\": \"Bob\",\n                \"entity_type\": \"person\",\n                \"description\": \"Bob is a mathematician.\",\n                \"source_id\": \"doc-1\",\n                \"file_path\": \"test_file\"\n            },\n            {\n                \"entity_name\": \"Quantum Computing\",\n                \"entity_type\": \"technology\",\n                \"description\": \"Quantum computing utilizes quantum mechanical phenomena for computation.\",\n                \"source_id\": \"doc-1\",\n                \"file_path\": \"test_file\"\n            }\n        ],\n        \"relationships\": [\n            {\n                \"src_id\": \"Alice\",\n                \"tgt_id\": \"Bob\",\n                \"description\": \"Alice and Bob are research partners.\",\n                \"keywords\": \"collaboration research\",\n                \"weight\": 1.0,\n                \"source_id\": \"doc-1\",\n                \"file_path\": \"test_file\"\n            },\n            {\n                \"src_id\": \"Alice\",\n                \"tgt_id\": \"Quantum Computing\",\n                \"description\": \"Alice conducts research on quantum computing.\",\n                \"keywords\": \"research expertise\",\n                \"weight\": 1.0,\n                \"source_id\": \"doc-1\",\n                \"file_path\": \"test_file\"\n            },\n            {\n                \"src_id\": \"Bob\",\n                \"tgt_id\": \"Quantum Computing\",\n                \"description\": \"Bob researches quantum computing.\",\n                \"keywords\": \"research application\",\n                \"weight\": 1.0,\n                \"source_id\": \"doc-1\",\n                \"file_path\": \"test_file\"\n            }\n        ]\n    }\n\nrag.insert_custom_kg(custom_kg)\n```\n\n</details>\n\n<details>\n  <summary> <b>Other Entity and Relation Operations</b></summary>\n\n- **create_entity**: Creates a new entity with specified attributes\n- **edit_entity**: Updates an existing entity's attributes or renames it\n- **create_relation**: Creates a new relation between existing entities\n- **edit_relation**: Updates an existing relation's attributes\n\nThese operations maintain data consistency across both the graph database and vector database components, ensuring your knowledge graph remains coherent.\n\n</details>\n\n## Delete Functions\n\nLightRAG provides comprehensive deletion capabilities, allowing you to delete documents, entities, and relationships.\n\n<details>\n<summary> <b>Delete Entities</b> </summary>\n\nYou can delete entities by their name along with all associated relationships:\n\n```python\n# Delete entity and all its relationships (synchronous version)\nrag.delete_by_entity(\"Google\")\n\n# Asynchronous version\nawait rag.adelete_by_entity(\"Google\")\n```\n\nWhen deleting an entity:\n- Removes the entity node from the knowledge graph\n- Deletes all associated relationships\n- Removes related embedding vectors from the vector database\n- Maintains knowledge graph integrity\n\n</details>\n\n<details>\n<summary> <b>Delete Relations</b> </summary>\n\nYou can delete relationships between two specific entities:\n\n```python\n# Delete relationship between two entities (synchronous version)\nrag.delete_by_relation(\"Google\", \"Gmail\")\n\n# Asynchronous version\nawait rag.adelete_by_relation(\"Google\", \"Gmail\")\n```\n\nWhen deleting a relationship:\n- Removes the specified relationship edge\n- Deletes the relationship's embedding vector from the vector database\n- Preserves both entity nodes and their other relationships\n\n</details>\n\n<details>\n<summary> <b>Delete by Document ID</b> </summary>\n\nYou can delete an entire document and all its related knowledge through document ID:\n\n```python\n# Delete by document ID (asynchronous version)\nawait rag.adelete_by_doc_id(\"doc-12345\")\n```\n\nOptimized processing when deleting by document ID:\n- **Smart Cleanup**: Automatically identifies and removes entities and relationships that belong only to this document\n- **Preserve Shared Knowledge**: If entities or relationships exist in other documents, they are preserved and their descriptions are rebuilt\n- **Cache Optimization**: Clears related LLM cache to reduce storage overhead\n- **Incremental Rebuilding**: Reconstructs affected entity and relationship descriptions from remaining documents\n\nThe deletion process includes:\n1. Delete all text chunks related to the document\n2. Identify and delete entities and relationships that belong only to this document\n3. Rebuild entities and relationships that still exist in other documents\n4. Update all related vector indexes\n5. Clean up document status records\n\nNote: Deletion by document ID is an asynchronous operation as it involves complex knowledge graph reconstruction processes.\n\n</details>\n\n**Important Reminders:**\n\n1. **Irreversible Operations**: All deletion operations are irreversible, please use with caution\n2. **Performance Considerations**: Deleting large amounts of data may take some time, especially deletion by document ID\n3. **Data Consistency**: Deletion operations automatically maintain consistency between the knowledge graph and vector database\n4. **Backup Recommendations**: Consider backing up data before performing important deletion operations\n\n**Batch Deletion Recommendations:**\n- For batch deletion operations, consider using asynchronous methods for better performance\n- For large-scale deletions, consider processing in batches to avoid excessive system load\n\n## Entity Merging\n\n<details>\n<summary> <b>Merge Entities and Their Relationships</b> </summary>\n\nLightRAG now supports merging multiple entities into a single entity, automatically handling all relationships:\n\n```python\n# Basic entity merging\nrag.merge_entities(\n    source_entities=[\"Artificial Intelligence\", \"AI\", \"Machine Intelligence\"],\n    target_entity=\"AI Technology\"\n)\n```\n\nWith custom merge strategy:\n\n```python\n# Define custom merge strategy for different fields\nrag.merge_entities(\n    source_entities=[\"John Smith\", \"Dr. Smith\", \"J. Smith\"],\n    target_entity=\"John Smith\",\n    merge_strategy={\n        \"description\": \"concatenate\",  # Combine all descriptions\n        \"entity_type\": \"keep_first\",   # Keep the entity type from the first entity\n        \"source_id\": \"join_unique\"     # Combine all unique source IDs\n    }\n)\n```\n\nWith custom target entity data:\n\n```python\n# Specify exact values for the merged entity\nrag.merge_entities(\n    source_entities=[\"New York\", \"NYC\", \"Big Apple\"],\n    target_entity=\"New York City\",\n    target_entity_data={\n        \"entity_type\": \"LOCATION\",\n        \"description\": \"New York City is the most populous city in the United States.\",\n    }\n)\n```\n\nAdvanced usage combining both approaches:\n\n```python\n# Merge company entities with both strategy and custom data\nrag.merge_entities(\n    source_entities=[\"Microsoft Corp\", \"Microsoft Corporation\", \"MSFT\"],\n    target_entity=\"Microsoft\",\n    merge_strategy={\n        \"description\": \"concatenate\",  # Combine all descriptions\n        \"source_id\": \"join_unique\"     # Combine source IDs\n    },\n    target_entity_data={\n        \"entity_type\": \"ORGANIZATION\",\n    }\n)\n```\n\nWhen merging entities:\n\n* All relationships from source entities are redirected to the target entity\n* Duplicate relationships are intelligently merged\n* Self-relationships (loops) are prevented\n* Source entities are removed after merging\n* Relationship weights and attributes are preserved\n\n</details>\n\n## Multimodal Document Processing (RAG-Anything Integration)\n\nLightRAG now seamlessly integrates with [RAG-Anything](https://github.com/HKUDS/RAG-Anything), a comprehensive **All-in-One Multimodal Document Processing RAG system** built specifically for LightRAG. RAG-Anything enables advanced parsing and retrieval-augmented generation (RAG) capabilities, allowing you to handle multimodal documents seamlessly and extract structured content—including text, images, tables, and formulas—from various document formats for integration into your RAG pipeline.\n\n**Key Features:**\n- **End-to-End Multimodal Pipeline**: Complete workflow from document ingestion and parsing to intelligent multimodal query answering\n- **Universal Document Support**: Seamless processing of PDFs, Office documents (DOC/DOCX/PPT/PPTX/XLS/XLSX), images, and diverse file formats\n- **Specialized Content Analysis**: Dedicated processors for images, tables, mathematical equations, and heterogeneous content types\n- **Multimodal Knowledge Graph**: Automatic entity extraction and cross-modal relationship discovery for enhanced understanding\n- **Hybrid Intelligent Retrieval**: Advanced search capabilities spanning textual and multimodal content with contextual understanding\n\n**Quick Start:**\n1. Install RAG-Anything:\n   ```bash\n   pip install raganything\n   ```\n2. Process multimodal documents:\n    <details>\n    <summary> <b> RAGAnything Usage Example </b></summary>\n\n    ```python\n        import asyncio\n        from raganything import RAGAnything\n        from lightrag import LightRAG\n        from lightrag.llm.openai import openai_complete_if_cache, openai_embed\n        from lightrag.utils import EmbeddingFunc\n        import os\n\n        async def load_existing_lightrag():\n            # First, create or load an existing LightRAG instance\n            lightrag_working_dir = \"./existing_lightrag_storage\"\n\n            # Check if previous LightRAG instance exists\n            if os.path.exists(lightrag_working_dir) and os.listdir(lightrag_working_dir):\n                print(\"✅ Found existing LightRAG instance, loading...\")\n            else:\n                print(\"❌ No existing LightRAG instance found, will create new one\")\n\n            from functools import partial\n\n            # Create/Load LightRAG instance with your configurations\n            lightrag_instance = LightRAG(\n                working_dir=lightrag_working_dir,\n                llm_model_func=lambda prompt, system_prompt=None, history_messages=[], **kwargs: openai_complete_if_cache(\n                    \"gpt-4o-mini\",\n                    prompt,\n                    system_prompt=system_prompt,\n                    history_messages=history_messages,\n                    api_key=\"your-api-key\",\n                    **kwargs,\n                ),\n                embedding_func=EmbeddingFunc(\n                    embedding_dim=3072,\n                    max_token_size=8192,\n                    model=\"text-embedding-3-large\",\n                    func=partial(\n                        openai_embed.func,  # Use .func to access the unwrapped function\n                        model=\"text-embedding-3-large\",\n                        api_key=api_key,\n                        base_url=base_url,\n                    ),\n                )\n            )\n\n            # Initialize storage (this will load existing data if available)\n            await lightrag_instance.initialize_storages()\n\n            # Now initialize RAGAnything with the existing LightRAG instance\n            rag = RAGAnything(\n                lightrag=lightrag_instance,  # Pass the existing LightRAG instance\n                # Only need vision model for multimodal processing\n                vision_model_func=lambda prompt, system_prompt=None, history_messages=[], image_data=None, **kwargs: openai_complete_if_cache(\n                    \"gpt-4o\",\n                    \"\",\n                    system_prompt=None,\n                    history_messages=[],\n                    messages=[\n                        {\"role\": \"system\", \"content\": system_prompt} if system_prompt else None,\n                        {\"role\": \"user\", \"content\": [\n                            {\"type\": \"text\", \"text\": prompt},\n                            {\"type\": \"image_url\", \"image_url\": {\"url\": f\"data:image/jpeg;base64,{image_data}\"}}\n                        ]} if image_data else {\"role\": \"user\", \"content\": prompt}\n                    ],\n                    api_key=\"your-api-key\",\n                    **kwargs,\n                ) if image_data else openai_complete_if_cache(\n                    \"gpt-4o-mini\",\n                    prompt,\n                    system_prompt=system_prompt,\n                    history_messages=history_messages,\n                    api_key=\"your-api-key\",\n                    **kwargs,\n                )\n                # Note: working_dir, llm_model_func, embedding_func, etc. are inherited from lightrag_instance\n            )\n\n            # Query the existing knowledge base\n            result = await rag.query_with_multimodal(\n                \"What data has been processed in this LightRAG instance?\",\n                mode=\"hybrid\"\n            )\n            print(\"Query result:\", result)\n\n            # Add new multimodal documents to the existing LightRAG instance\n            await rag.process_document_complete(\n                file_path=\"path/to/new/multimodal_document.pdf\",\n                output_dir=\"./output\"\n            )\n\n        if __name__ == \"__main__\":\n            asyncio.run(load_existing_lightrag())\n    ```\n    </details>\n\nFor detailed documentation and advanced usage, please refer to the [RAG-Anything repository](https://github.com/HKUDS/RAG-Anything).\n\n## Token Usage Tracking\n\n<details>\n<summary> <b>Overview and Usage</b> </summary>\n\nLightRAG provides a TokenTracker tool to monitor and manage token consumption by large language models. This feature is particularly useful for controlling API costs and optimizing performance.\n\n### Usage\n\n```python\nfrom lightrag.utils import TokenTracker\n\n# Create TokenTracker instance\ntoken_tracker = TokenTracker()\n\n# Method 1: Using context manager (Recommended)\n# Suitable for scenarios requiring automatic token usage tracking\nwith token_tracker:\n    result1 = await llm_model_func(\"your question 1\")\n    result2 = await llm_model_func(\"your question 2\")\n\n# Method 2: Manually adding token usage records\n# Suitable for scenarios requiring more granular control over token statistics\ntoken_tracker.reset()\n\nrag.insert()\n\nrag.query(\"your question 1\", param=QueryParam(mode=\"naive\"))\nrag.query(\"your question 2\", param=QueryParam(mode=\"mix\"))\n\n# Display total token usage (including insert and query operations)\nprint(\"Token usage:\", token_tracker.get_usage())\n```\n\n### Usage Tips\n- Use context managers for long sessions or batch operations to automatically track all token consumption\n- For scenarios requiring segmented statistics, use manual mode and call reset() when appropriate\n- Regular checking of token usage helps detect abnormal consumption early\n- Actively use this feature during development and testing to optimize production costs\n\n### Practical Examples\nYou can refer to these examples for implementing token tracking:\n- `examples/lightrag_gemini_track_token_demo.py`: Token tracking example using Google Gemini model\n- `examples/lightrag_siliconcloud_track_token_demo.py`: Token tracking example using SiliconCloud model\n\nThese examples demonstrate how to effectively use the TokenTracker feature with different models and scenarios.\n\n</details>\n\n## Data Export Functions\n\n### Overview\n\nLightRAG allows you to export your knowledge graph data in various formats for analysis, sharing, and backup purposes. The system supports exporting entities, relations, and relationship data.\n\n### Export Functions\n\n<details>\n  <summary> <b> Basic Usage </b></summary>\n\n```python\n# Basic CSV export (default format)\nrag.export_data(\"knowledge_graph.csv\")\n\n# Specify any format\nrag.export_data(\"output.xlsx\", file_format=\"excel\")\n```\n\n</details>\n\n<details>\n  <summary> <b> Different File Formats supported </b></summary>\n\n```python\n#Export data in CSV format\nrag.export_data(\"graph_data.csv\", file_format=\"csv\")\n\n# Export data in Excel sheet\nrag.export_data(\"graph_data.xlsx\", file_format=\"excel\")\n\n# Export data in markdown format\nrag.export_data(\"graph_data.md\", file_format=\"md\")\n\n# Export data in Text\nrag.export_data(\"graph_data.txt\", file_format=\"txt\")\n```\n</details>\n\n<details>\n  <summary> <b> Additional Options </b></summary>\n\nInclude vector embeddings in the export (optional):\n\n```python\nrag.export_data(\"complete_data.csv\", include_vector_data=True)\n```\n</details>\n\n### Data Included in Export\n\nAll exports include:\n\n* Entity information (names, IDs, metadata)\n* Relation data (connections between entities)\n* Relationship information from vector database\n\n## Cache\n\n<details>\n  <summary> <b>Clear Cache</b> </summary>\n\nYou can clear the configured LLM response cache storage with `aclear_cache()`. This API clears all cached entries in `llm_response_cache` and does not support selective cleanup by mode or cache type.\n\n```python\n# Clear all cache\nawait rag.aclear_cache()\n\n# Synchronous version\nrag.clear_cache()\n```\n\nFor selective cleanup of query-related caches, use the `lightrag.tools.clean_llm_query_cache` tool and see the guide in [lightrag/tools/README_CLEAN_LLM_QUERY_CACHE.md](./lightrag/tools/README_CLEAN_LLM_QUERY_CACHE.md). It manages query caches and keywords caches for `mix`, `hybrid`, `local`, and `global` modes. It does not clean extraction caches such as `default:extract:*` and `default:summary:*`.\n\n</details>\n\n## Troubleshooting\n\n### Common Initialization Errors\n\nIf you encounter these errors when using LightRAG:\n\n1. **`AttributeError: __aenter__`**\n   - **Cause**: Storage backends not initialized\n   - **Solution**: Call `await rag.initialize_storages()` after creating the LightRAG instance\n\n2. **`KeyError: 'history_messages'`**\n   - **Cause**: Pipeline status not initialized\n   - **Solution**: Call `await rag.initialize_storages()` after creating the LightRAG instance\n\n3. **Both errors in sequence**\n   - **Cause**: Neither initialization method was called\n   - **Solution**: Always follow this pattern:\n   ```python\n   rag = LightRAG(...)\n   await rag.initialize_storages()\n   ```\n\n### Model Switching Issues\n\nWhen switching between different embedding models, you must clear the data directory to avoid errors. The only file you may want to preserve is `kv_store_llm_response_cache.json` if you wish to retain the LLM cache.\n\n## LightRAG API\n\nThe LightRAG Server is designed to provide Web UI and API support.  **For more information about LightRAG Server, please refer to [LightRAG Server](./lightrag/api/README.md).**\n\n## Graph Visualization\n\nThe LightRAG Server offers a comprehensive knowledge graph visualization feature. It supports various gravity layouts, node queries, subgraph filtering, and more. **For more information about LightRAG Server, please refer to [LightRAG Server](./lightrag/api/README.md).**\n\n![iShot_2025-03-23_12.40.08](./README.assets/iShot_2025-03-23_12.40.08.png)\n\n## Langfuse observability integration\n\nLangfuse provides a drop-in replacement for the OpenAI client that automatically tracks all LLM interactions, enabling developers to monitor, debug, and optimize their RAG systems without code changes.\n\n### Installation with Langfuse option\n\n```\npip install lightrag-hku\npip install lightrag-hku[observability]\n\n# Or install from source code with debug mode enabled\npip install -e .\npip install -e \".[observability]\"\n```\n\n### Config Langfuse env vars\n\nmodify .env file:\n\n```\n## Langfuse Observability (Optional)\n# LLM observability and tracing platform\n# Install with: pip install lightrag-hku[observability]\n# Sign up at: https://cloud.langfuse.com or self-host\nLANGFUSE_SECRET_KEY=\"\"\nLANGFUSE_PUBLIC_KEY=\"\"\nLANGFUSE_HOST=\"https://cloud.langfuse.com\"  # or your self-hosted instance\nLANGFUSE_ENABLE_TRACE=true\n```\n\n### Langfuse Usage\n\nOnce installed and configured, Langfuse automatically traces all OpenAI LLM calls. Langfuse dashboard features include:\n\n- **Tracing**: View complete LLM call chains\n- **Analytics**: Token usage, latency, cost metrics\n- **Debugging**: Inspect prompts and responses\n- **Evaluation**: Compare model outputs\n- **Monitoring**: Real-time alerting\n\n### Important Notice\n\n**Note**: LightRAG currently only integrates OpenAI-compatible API calls with Langfuse. APIs such as Ollama, Azure, and AWS Bedrock are not yet supported for Langfuse observability.\n\n## RAGAS-based Evaluation\n\n**RAGAS** (Retrieval Augmented Generation Assessment) is a framework for reference-free evaluation of RAG systems using LLMs. There is an evaluation script based on RAGAS. For detailed information, please refer to [RAGAS-based Evaluation Framework](lightrag/evaluation/README_EVALUASTION_RAGAS.md).\n\n## Evaluation\n\n### Dataset\n\nThe dataset used in LightRAG can be downloaded from [TommyChien/UltraDomain](https://huggingface.co/datasets/TommyChien/UltraDomain).\n\n### Generate Query\n\nLightRAG uses the following prompt to generate high-level queries, with the corresponding code in `examples/generate_query.py`.\n\n<details>\n<summary> Prompt </summary>\n\n```python\nGiven the following description of a dataset:\n\n{description}\n\nPlease identify 5 potential users who would engage with this dataset. For each user, list 5 tasks they would perform with this dataset. Then, for each (user, task) combination, generate 5 questions that require a high-level understanding of the entire dataset.\n\nOutput the results in the following structure:\n- User 1: [user description]\n    - Task 1: [task description]\n        - Question 1:\n        - Question 2:\n        - Question 3:\n        - Question 4:\n        - Question 5:\n    - Task 2: [task description]\n        ...\n    - Task 5: [task description]\n- User 2: [user description]\n    ...\n- User 5: [user description]\n    ...\n```\n\n</details>\n\n### Batch Eval\n\nTo evaluate the performance of two RAG systems on high-level queries, LightRAG uses the following prompt, with the specific code available in `reproduce/batch_eval.py`.\n\n<details>\n<summary> Prompt </summary>\n\n```python\n---Role---\nYou are an expert tasked with evaluating two answers to the same question based on three criteria: **Comprehensiveness**, **Diversity**, and **Empowerment**.\n---Goal---\nYou will evaluate two answers to the same question based on three criteria: **Comprehensiveness**, **Diversity**, and **Empowerment**.\n\n- **Comprehensiveness**: How much detail does the answer provide to cover all aspects and details of the question?\n- **Diversity**: How varied and rich is the answer in providing different perspectives and insights on the question?\n- **Empowerment**: How well does the answer help the reader understand and make informed judgments about the topic?\n\nFor each criterion, choose the better answer (either Answer 1 or Answer 2) and explain why. Then, select an overall winner based on these three categories.\n\nHere is the question:\n{query}\n\nHere are the two answers:\n\n**Answer 1:**\n{answer1}\n\n**Answer 2:**\n{answer2}\n\nEvaluate both answers using the three criteria listed above and provide detailed explanations for each criterion.\n\nOutput your evaluation in the following JSON format:\n\n{{\n    \"Comprehensiveness\": {{\n        \"Winner\": \"[Answer 1 or Answer 2]\",\n        \"Explanation\": \"[Provide explanation here]\"\n    }},\n    \"Empowerment\": {{\n        \"Winner\": \"[Answer 1 or Answer 2]\",\n        \"Explanation\": \"[Provide explanation here]\"\n    }},\n    \"Overall Winner\": {{\n        \"Winner\": \"[Answer 1 or Answer 2]\",\n        \"Explanation\": \"[Summarize why this answer is the overall winner based on the three criteria]\"\n    }}\n}}\n```\n\n</details>\n\n### Overall Performance Table\n\n||**Agriculture**||**CS**||**Legal**||**Mix**||\n|----------------------|---------------|------------|------|------------|---------|------------|-------|------------|\n||NaiveRAG|**LightRAG**|NaiveRAG|**LightRAG**|NaiveRAG|**LightRAG**|NaiveRAG|**LightRAG**|\n|**Comprehensiveness**|32.4%|**67.6%**|38.4%|**61.6%**|16.4%|**83.6%**|38.8%|**61.2%**|\n|**Diversity**|23.6%|**76.4%**|38.0%|**62.0%**|13.6%|**86.4%**|32.4%|**67.6%**|\n|**Empowerment**|32.4%|**67.6%**|38.8%|**61.2%**|16.4%|**83.6%**|42.8%|**57.2%**|\n|**Overall**|32.4%|**67.6%**|38.8%|**61.2%**|15.2%|**84.8%**|40.0%|**60.0%**|\n||RQ-RAG|**LightRAG**|RQ-RAG|**LightRAG**|RQ-RAG|**LightRAG**|RQ-RAG|**LightRAG**|\n|**Comprehensiveness**|31.6%|**68.4%**|38.8%|**61.2%**|15.2%|**84.8%**|39.2%|**60.8%**|\n|**Diversity**|29.2%|**70.8%**|39.2%|**60.8%**|11.6%|**88.4%**|30.8%|**69.2%**|\n|**Empowerment**|31.6%|**68.4%**|36.4%|**63.6%**|15.2%|**84.8%**|42.4%|**57.6%**|\n|**Overall**|32.4%|**67.6%**|38.0%|**62.0%**|14.4%|**85.6%**|40.0%|**60.0%**|\n||HyDE|**LightRAG**|HyDE|**LightRAG**|HyDE|**LightRAG**|HyDE|**LightRAG**|\n|**Comprehensiveness**|26.0%|**74.0%**|41.6%|**58.4%**|26.8%|**73.2%**|40.4%|**59.6%**|\n|**Diversity**|24.0%|**76.0%**|38.8%|**61.2%**|20.0%|**80.0%**|32.4%|**67.6%**|\n|**Empowerment**|25.2%|**74.8%**|40.8%|**59.2%**|26.0%|**74.0%**|46.0%|**54.0%**|\n|**Overall**|24.8%|**75.2%**|41.6%|**58.4%**|26.4%|**73.6%**|42.4%|**57.6%**|\n||GraphRAG|**LightRAG**|GraphRAG|**LightRAG**|GraphRAG|**LightRAG**|GraphRAG|**LightRAG**|\n|**Comprehensiveness**|45.6%|**54.4%**|48.4%|**51.6%**|48.4%|**51.6%**|**50.4%**|49.6%|\n|**Diversity**|22.8%|**77.2%**|40.8%|**59.2%**|26.4%|**73.6%**|36.0%|**64.0%**|\n|**Empowerment**|41.2%|**58.8%**|45.2%|**54.8%**|43.6%|**56.4%**|**50.8%**|49.2%|\n|**Overall**|45.2%|**54.8%**|48.0%|**52.0%**|47.2%|**52.8%**|**50.4%**|49.6%|\n\n## Reproduce\n\nAll the code can be found in the `./reproduce` directory.\n\n### Step-0 Extract Unique Contexts\n\nFirst, we need to extract unique contexts in the datasets.\n\n<details>\n<summary> Code </summary>\n\n```python\ndef extract_unique_contexts(input_directory, output_directory):\n\n    os.makedirs(output_directory, exist_ok=True)\n\n    jsonl_files = glob.glob(os.path.join(input_directory, '*.jsonl'))\n    print(f\"Found {len(jsonl_files)} JSONL files.\")\n\n    for file_path in jsonl_files:\n        filename = os.path.basename(file_path)\n        name, ext = os.path.splitext(filename)\n        output_filename = f\"{name}_unique_contexts.json\"\n        output_path = os.path.join(output_directory, output_filename)\n\n        unique_contexts_dict = {}\n\n        print(f\"Processing file: {filename}\")\n\n        try:\n            with open(file_path, 'r', encoding='utf-8') as infile:\n                for line_number, line in enumerate(infile, start=1):\n                    line = line.strip()\n                    if not line:\n                        continue\n                    try:\n                        json_obj = json.loads(line)\n                        context = json_obj.get('context')\n                        if context and context not in unique_contexts_dict:\n                            unique_contexts_dict[context] = None\n                    except json.JSONDecodeError as e:\n                        print(f\"JSON decoding error in file {filename} at line {line_number}: {e}\")\n        except FileNotFoundError:\n            print(f\"File not found: {filename}\")\n            continue\n        except Exception as e:\n            print(f\"An error occurred while processing file {filename}: {e}\")\n            continue\n\n        unique_contexts_list = list(unique_contexts_dict.keys())\n        print(f\"There are {len(unique_contexts_list)} unique `context` entries in the file {filename}.\")\n\n        try:\n            with open(output_path, 'w', encoding='utf-8') as outfile:\n                json.dump(unique_contexts_list, outfile, ensure_ascii=False, indent=4)\n            print(f\"Unique `context` entries have been saved to: {output_filename}\")\n        except Exception as e:\n            print(f\"An error occurred while saving to the file {output_filename}: {e}\")\n\n    print(\"All files have been processed.\")\n\n```\n\n</details>\n\n### Step-1 Insert Contexts\n\nFor the extracted contexts, we insert them into the LightRAG system.\n\n<details>\n<summary> Code </summary>\n\n```python\ndef insert_text(rag, file_path):\n    with open(file_path, mode='r') as f:\n        unique_contexts = json.load(f)\n\n    retries = 0\n    max_retries = 3\n    while retries < max_retries:\n        try:\n            rag.insert(unique_contexts)\n            break\n        except Exception as e:\n            retries += 1\n            print(f\"Insertion failed, retrying ({retries}/{max_retries}), error: {e}\")\n            time.sleep(10)\n    if retries == max_retries:\n        print(\"Insertion failed after exceeding the maximum number of retries\")\n```\n\n</details>\n\n### Step-2 Generate Queries\n\nWe extract tokens from the first and the second half of each context in the dataset, then combine them as dataset descriptions to generate queries.\n\n<details>\n<summary> Code </summary>\n\n```python\ntokenizer = GPT2Tokenizer.from_pretrained('gpt2')\n\ndef get_summary(context, tot_tokens=2000):\n    tokens = tokenizer.tokenize(context)\n    half_tokens = tot_tokens // 2\n\n    start_tokens = tokens[1000:1000 + half_tokens]\n    end_tokens = tokens[-(1000 + half_tokens):1000]\n\n    summary_tokens = start_tokens + end_tokens\n    summary = tokenizer.convert_tokens_to_string(summary_tokens)\n\n    return summary\n```\n\n</details>\n\n### Step-3 Query\n\nFor the queries generated in Step-2, we will extract them and query LightRAG.\n\n<details>\n<summary> Code </summary>\n\n```python\ndef extract_queries(file_path):\n    with open(file_path, 'r') as f:\n        data = f.read()\n\n    data = data.replace('**', '')\n\n    queries = re.findall(r'- Question \\d+: (.+)', data)\n\n    return queries\n```\n\n</details>\n\n## 🔗 Related Projects\n\n*Ecosystem & Extensions*\n\n<div align=\"center\">\n  <table>\n    <tr>\n      <td align=\"center\">\n        <a href=\"https://github.com/HKUDS/RAG-Anything\">\n          <div style=\"width: 100px; height: 100px; background: linear-gradient(135deg, rgba(0, 217, 255, 0.1) 0%, rgba(0, 217, 255, 0.05) 100%); border-radius: 15px; border: 1px solid rgba(0, 217, 255, 0.2); display: flex; align-items: center; justify-content: center; margin-bottom: 10px;\">\n            <span style=\"font-size: 32px;\">📸</span>\n          </div>\n          <b>RAG-Anything</b><br>\n          <sub>Multimodal RAG</sub>\n        </a>\n      </td>\n      <td align=\"center\">\n        <a href=\"https://github.com/HKUDS/VideoRAG\">\n          <div style=\"width: 100px; height: 100px; background: linear-gradient(135deg, rgba(0, 217, 255, 0.1) 0%, rgba(0, 217, 255, 0.05) 100%); border-radius: 15px; border: 1px solid rgba(0, 217, 255, 0.2); display: flex; align-items: center; justify-content: center; margin-bottom: 10px;\">\n            <span style=\"font-size: 32px;\">🎥</span>\n          </div>\n          <b>VideoRAG</b><br>\n          <sub>Extreme Long-Context Video RAG</sub>\n        </a>\n      </td>\n      <td align=\"center\">\n        <a href=\"https://github.com/HKUDS/MiniRAG\">\n          <div style=\"width: 100px; height: 100px; background: linear-gradient(135deg, rgba(0, 217, 255, 0.1) 0%, rgba(0, 217, 255, 0.05) 100%); border-radius: 15px; border: 1px solid rgba(0, 217, 255, 0.2); display: flex; align-items: center; justify-content: center; margin-bottom: 10px;\">\n            <span style=\"font-size: 32px;\">✨</span>\n          </div>\n          <b>MiniRAG</b><br>\n          <sub>Extremely Simple RAG</sub>\n        </a>\n      </td>\n    </tr>\n  </table>\n</div>\n\n---\n\n## ⭐ Star History\n\n<a href=\"https://star-history.com/#HKUDS/LightRAG&Date\">\n <picture>\n   <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=HKUDS/LightRAG&type=Date&theme=dark\" />\n   <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=HKUDS/LightRAG&type=Date\" />\n   <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=HKUDS/LightRAG&type=Date\" />\n </picture>\n</a>\n\n## 🤝 Contribution\n\n<div align=\"center\">\n  We thank all our contributors for their valuable contributions.\n</div>\n\n<div align=\"center\">\n  <a href=\"https://github.com/HKUDS/LightRAG/graphs/contributors\">\n    <img src=\"https://contrib.rocks/image?repo=HKUDS/LightRAG\" style=\"border-radius: 15px; box-shadow: 0 0 20px rgba(0, 217, 255, 0.3);\" />\n  </a>\n</div>\n\n---\n\n## 📖 Citation\n\n```python\n@article{guo2024lightrag,\ntitle={LightRAG: Simple and Fast Retrieval-Augmented Generation},\nauthor={Zirui Guo and Lianghao Xia and Yanhua Yu and Tu Ao and Chao Huang},\nyear={2024},\neprint={2410.05779},\narchivePrefix={arXiv},\nprimaryClass={cs.IR}\n}\n```\n\n---\n\n<div align=\"center\" style=\"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 15px; padding: 30px; margin: 30px 0;\">\n  <div>\n    <img src=\"https://user-images.githubusercontent.com/74038190/212284100-561aa473-3905-4a80-b561-0d28506553ee.gif\" width=\"500\">\n  </div>\n  <div style=\"margin-top: 20px;\">\n    <a href=\"https://github.com/HKUDS/LightRAG\" style=\"text-decoration: none;\">\n      <img src=\"https://img.shields.io/badge/⭐%20Star%20us%20on%20GitHub-1a1a2e?style=for-the-badge&logo=github&logoColor=white\">\n    </a>\n    <a href=\"https://github.com/HKUDS/LightRAG/issues\" style=\"text-decoration: none;\">\n      <img src=\"https://img.shields.io/badge/🐛%20Report%20Issues-ff6b6b?style=for-the-badge&logo=github&logoColor=white\">\n    </a>\n    <a href=\"https://github.com/HKUDS/LightRAG/discussions\" style=\"text-decoration: none;\">\n      <img src=\"https://img.shields.io/badge/💬%20Discussions-4ecdc4?style=for-the-badge&logo=github&logoColor=white\">\n    </a>\n  </div>\n</div>\n\n<div align=\"center\">\n  <div style=\"width: 100%; max-width: 600px; margin: 20px auto; padding: 20px; background: linear-gradient(135deg, rgba(0, 217, 255, 0.1) 0%, rgba(0, 217, 255, 0.05) 100%); border-radius: 15px; border: 1px solid rgba(0, 217, 255, 0.2);\">\n    <div style=\"display: flex; justify-content: center; align-items: center; gap: 15px;\">\n      <span style=\"font-size: 24px;\">⭐</span>\n      <span style=\"color: #00d9ff; font-size: 18px;\">Thank you for visiting LightRAG!</span>\n      <span style=\"font-size: 24px;\">⭐</span>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Reporting Security Issues\n\nThe LightRAG team and community take security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.\n\nTo report a security issue, please use the GitHub Security Advisory:  [Report a Vulnerability](https://github.com/HKUDS/LightRAG/security/advisories/new)\n\nThe LightRAG team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.\n\nReport security bugs in third-party modules to the person or team maintaining the module.\n\n### Supported Versions\n\nThe following versions currently being supported with security updates.\n\n| Version | Supported          |\n| ------- | ------------------ |\n| 1.2.x   | :x:                |\n| 1.3.x   | :white_check_mark: |\n"
  },
  {
    "path": "config.ini.example",
    "content": "[neo4j]\nuri = neo4j+s://xxxxxxxx.databases.neo4j.io\nusername = neo4j\npassword = your-password\nconnection_pool_size = 100\nconnection_timeout = 30.0\nconnection_acquisition_timeout = 30.0\nmax_transaction_retry_time = 30.0\nmax_connection_lifetime = 300.0\nliveness_check_timeout = 30.0\nkeep_alive = true\n\n[mongodb]\nuri = mongodb+srv://name:password@your-cluster-address\ndatabase = lightrag\n\n[redis]\nuri=redis://localhost:6379/1\n\n[qdrant]\nuri = http://localhost:16333\n\n[postgres]\nhost = localhost\nport = 5432\nuser = your_username\npassword = your_password\ndatabase = your_database\n# workspace = default\nmax_connections = 12\nvector_index_type = HNSW        # HNSW, IVFFLAT or VCHORDRQ\nhnsw_m = 16\nhnsw_ef = 64\nivfflat_lists = 100\nvchordrq_build_options =\nvchordrq_probes =\nvchordrq_epsilon = 1.9\n\n[memgraph]\nuri = bolt://localhost:7687\n\n[milvus]\nuri = http://localhost:19530\ndb_name = lightrag\n# user = root\n# password = your_password\n# token = your_token\n"
  },
  {
    "path": "docker-build-push.sh",
    "content": "#!/bin/bash\nset -e\n\n# Configuration\nIMAGE_NAME=\"ghcr.io/hkuds/lightrag\"\nDOCKERFILE=\"Dockerfile\"\nTAG=\"latest\"\n\n# Get version from git tags\nVERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo \"dev\")\n\necho \"==================================\"\necho \"  Multi-Architecture Docker Build\"\necho \"==================================\"\necho \"Image: ${IMAGE_NAME}:${TAG}\"\necho \"Version: ${VERSION}\"\necho \"Platforms: linux/amd64, linux/arm64\"\necho \"==================================\"\necho \"\"\n\n# Check Docker login status (skip if CR_PAT is set for CI/CD)\nif [ -z \"$CR_PAT\" ]; then\n    if ! docker info 2>/dev/null | grep -q \"Username\"; then\n        echo \"⚠️  Warning: Not logged in to Docker registry\"\n        echo \"Please login first: docker login ghcr.io\"\n        echo \"Or set CR_PAT environment variable for automated login\"\n        echo \"\"\n        read -p \"Continue anyway? (y/n) \" -n 1 -r\n        echo\n        if [[ ! $REPLY =~ ^[Yy]$ ]]; then\n            exit 1\n        fi\n    fi\nelse\n    echo \"Using CR_PAT environment variable for authentication\"\nfi\n\n# Check if buildx builder exists, create if not\nif ! docker buildx ls | grep -q \"desktop-linux\"; then\n    echo \"Creating buildx builder...\"\n    docker buildx create --name desktop-linux --use\n    docker buildx inspect --bootstrap\nelse\n    echo \"Using existing buildx builder: desktop-linux\"\n    docker buildx use desktop-linux\nfi\n\necho \"\"\necho \"Building and pushing multi-architecture image...\"\necho \"\"\n\n# Build and push\ndocker buildx build \\\n  --platform linux/amd64,linux/arm64 \\\n  --file ${DOCKERFILE} \\\n  --tag ${IMAGE_NAME}:${TAG} \\\n  --tag ${IMAGE_NAME}:${VERSION} \\\n  --push \\\n  .\n\necho \"\"\necho \"✓ Build and push complete!\"\necho \"\"\necho \"Images pushed:\"\necho \"  - ${IMAGE_NAME}:${TAG}\"\necho \"  - ${IMAGE_NAME}:${VERSION}\"\necho \"\"\necho \"Verifying multi-architecture manifest...\"\necho \"\"\n\n# Verify\ndocker buildx imagetools inspect ${IMAGE_NAME}:${TAG}\n\necho \"\"\necho \"✓ Verification complete!\"\necho \"\"\necho \"Pull with: docker pull ${IMAGE_NAME}:${TAG}\"\n"
  },
  {
    "path": "docker-compose-full.yml",
    "content": "# Full Docker Compose Deployment Sample Generated by Setup Wizard: `make base` and `make storage`\n# This Sample File requires NVIDIA GPU for Milvus and VLLM services.\n# You can customize your setup using the Setup Wizard; for detailed instructions, please refer to docs/InteractiveSetup.md\nservices:\n  lightrag:\n    image: ghcr.io/hkuds/lightrag:latest\n    build:\n      context: .\n      dockerfile: Dockerfile\n      tags:\n        - ghcr.io/hkuds/lightrag:latest\n    ports:\n      - \"${HOST:-0.0.0.0}:${PORT:-9621}:9621\"\n    volumes:\n      - ./data/rag_storage:/app/data/rag_storage\n      - ./data/inputs:/app/data/inputs\n      - ./config.ini:/app/config.ini\n      - ./.env:/app/.env\n    deploy:\n      restart_policy:\n        condition: on-failure\n        max_attempts: 10\n    extra_hosts:\n      - \"host.docker.internal:host-gateway\"\n    environment:\n      MILVUS_URI: \"http://milvus:19530\"\n      NEO4J_URI: \"neo4j://neo4j:7687\"\n      POSTGRES_HOST: \"postgres\"\n      POSTGRES_PORT: \"5432\"\n      EMBEDDING_BINDING_HOST: \"http://vllm-embed:8001/v1\"\n      RERANK_BINDING_HOST: \"http://vllm-rerank:8000/rerank\"\n      WORKING_DIR: \"/app/data/rag_storage\"\n      INPUT_DIR: \"/app/data/inputs\"\n      MEMGRAPH_URI: \"bolt://host.docker.internal:7687\"\n      HOST: \"0.0.0.0\"\n      PORT: \"9621\"\n    depends_on:\n      vllm-embed:\n        condition: service_healthy\n      vllm-rerank:\n        condition: service_healthy\n      postgres:\n        condition: service_healthy\n      neo4j:\n        condition: service_healthy\n      milvus:\n        condition: service_healthy\n\n  milvus:\n    image: milvusdb/milvus:v2.6.11-gpu\n    command: [\"milvus\", \"run\", \"standalone\"]\n    security_opt:\n      - seccomp:unconfined\n    environment:\n      ETCD_ENDPOINTS: milvus-etcd:2379\n      MINIO_ADDRESS: milvus-minio:9000\n      MINIO_ACCESS_KEY_ID: \"${MINIO_ACCESS_KEY_ID:?missing}\"\n      MINIO_SECRET_ACCESS_KEY: \"${MINIO_SECRET_ACCESS_KEY:?missing}\"\n    # ports:\n    #   - \"19530:19530\"\n    #   - \"9091:9091\"\n    volumes:\n      - milvus_data:/var/lib/milvus\n    deploy:\n      resources:\n        reservations:\n          devices:\n            - driver: nvidia\n              capabilities: [\"gpu\"]\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' 19530)\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 10s\n      timeout: 3s\n      retries: 120\n      start_period: 10s\n    depends_on:\n      milvus-etcd:\n        condition: service_healthy\n      milvus-minio:\n        condition: service_healthy\n    restart: unless-stopped\n\n  milvus-etcd:\n    image: quay.io/coreos/etcd:v3.5.25\n    environment:\n      ETCD_AUTO_COMPACTION_MODE: revision\n      ETCD_AUTO_COMPACTION_RETENTION: \"1000\"\n      ETCD_QUOTA_BACKEND_BYTES: \"4294967296\"\n      ETCD_SNAPSHOT_COUNT: \"50000\"\n    volumes:\n      - milvus-etcd_data:/etcd\n    command: >\n      etcd\n      -advertise-client-urls=http://0.0.0.0:2379\n      -listen-client-urls=http://0.0.0.0:2379\n      -data-dir /etcd\n    healthcheck:\n      test: [\"CMD\", \"etcdctl\", \"endpoint\", \"health\"]\n      interval: 20s\n      timeout: 20s\n      retries: 3\n    restart: unless-stopped\n\n  milvus-minio:\n    image: minio/minio:RELEASE.2025-09-07T16-13-09Z\n    environment:\n      MINIO_ROOT_USER: \"${MINIO_ACCESS_KEY_ID:?missing}\"\n      MINIO_ROOT_PASSWORD: \"${MINIO_SECRET_ACCESS_KEY:?missing}\"\n    volumes:\n      - milvus-minio_data:/minio_data\n    command: minio server /minio_data --console-address \":9001\"\n    healthcheck:\n      test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:9000/minio/health/live\"]\n      interval: 30s\n      timeout: 20s\n      retries: 3\n    restart: unless-stopped\n\n  neo4j:\n    image: neo4j:5-community\n    # ports:\n    #   - \"7474:7474\"\n    #   - \"${NEO4J_BOLT_PORT:-7687}:7687\"\n    volumes:\n      - neo4j_data:/data\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' 7687)\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 10s\n      timeout: 3s\n      retries: 120\n      start_period: 10s\n    restart: unless-stopped\n    environment:\n      NEO4J_AUTH: ${NEO4J_USERNAME:?missing}/${NEO4J_PASSWORD:?missing}\n      NEO4J_dbms_default__database: \"neo4j\"\n\n  postgres:\n    image: gzdaniel/postgres-for-rag:16.6\n    command: [\"sh\", \"-c\", \"service postgresql start && sleep infinity\"]\n    # ports:\n    #   - \"5432:5432\"\n    volumes:\n      - postgres_data:/var/lib/postgresql\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' 5432)\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 120\n      start_period: 10s\n    restart: unless-stopped\n    environment:\n      # The custom image featuring pre-installed AGE and pgvector extensions, including a pre-configured administrator account\n      POSTGRES_USER: \"rag\"\n      POSTGRES_PASSWORD: \"rag\"\n      POSTGRES_DB: \"rag\"\n\n  vllm-embed:\n    image: vllm/vllm-openai:latest\n    runtime: nvidia\n    command: >\n      --model ${VLLM_EMBED_MODEL:-BAAI/bge-m3}\n      --port ${VLLM_EMBED_PORT:-8001}\n      --dtype float16\n      --api-key ${VLLM_EMBED_API_KEY}\n      ${VLLM_EMBED_EXTRA_ARGS:-}\n    environment:\n      NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}\n      NVIDIA_DRIVER_CAPABILITIES: ${NVIDIA_DRIVER_CAPABILITIES:-compute,utility}\n    ports:\n      - \"${VLLM_EMBED_PORT:-8001}:${VLLM_EMBED_PORT:-8001}\"\n    volumes:\n      - vllm_embed_cache:/root/.cache/huggingface\n    ipc: host\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' ${VLLM_EMBED_PORT:-8001})\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 120\n      start_period: 10s\n    deploy:\n      resources:\n        reservations:\n          devices:\n            - driver: nvidia\n              count: all\n              capabilities: [gpu]\n    restart: unless-stopped\n\n  vllm-rerank:\n    image: vllm/vllm-openai:latest\n    runtime: nvidia\n    command: >\n      --model ${VLLM_RERANK_MODEL:-BAAI/bge-reranker-v2-m3}\n      --port ${VLLM_RERANK_PORT:-8000}\n      --dtype float16\n      --api-key ${VLLM_RERANK_API_KEY}\n      ${VLLM_RERANK_EXTRA_ARGS:-}\n    environment:\n      NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}\n      NVIDIA_DRIVER_CAPABILITIES: ${NVIDIA_DRIVER_CAPABILITIES:-compute,utility}\n    ports:\n      - \"${VLLM_RERANK_PORT:-8000}:${VLLM_RERANK_PORT:-8000}\"\n    volumes:\n      - vllm_rerank_cache:/root/.cache/huggingface\n    ipc: host\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' ${VLLM_RERANK_PORT:-8000})\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 120\n      start_period: 10s\n    deploy:\n      resources:\n        reservations:\n          devices:\n            - driver: nvidia\n              count: all\n              capabilities: [gpu]\n    restart: unless-stopped\n\nvolumes:\n  milvus_data:\n  milvus-etcd_data:\n  milvus-minio_data:\n  neo4j_data:\n  postgres_data:\n  vllm_embed_cache:\n  vllm_rerank_cache:\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  lightrag:\n    image: ghcr.io/hkuds/lightrag:latest\n    build:\n      context: .\n      dockerfile: Dockerfile\n      tags:\n        - ghcr.io/hkuds/lightrag:latest\n    ports:\n      - \"${HOST:-0.0.0.0}:${PORT:-9621}:9621\"\n    volumes:\n      - ./data/rag_storage:/app/data/rag_storage\n      - ./data/inputs:/app/data/inputs\n      - ./config.ini:/app/config.ini\n      - ./.env:/app/.env\n    deploy:\n      restart_policy:\n        condition: on-failure\n        max_attempts: 10\n    extra_hosts:\n      - \"host.docker.internal:host-gateway\"\n    environment:\n      WORKING_DIR: \"/app/data/rag_storage\"\n      INPUT_DIR: \"/app/data/inputs\"\n      HOST: \"0.0.0.0\"\n      PORT: \"9621\"\n"
  },
  {
    "path": "docs/Algorithm.md",
    "content": "![LightRAG Indexing Flowchart](https://learnopencv.com/wp-content/uploads/2024/11/LightRAG-VectorDB-Json-KV-Store-Indexing-Flowchart-scaled.jpg)\n*Figure 1: LightRAG Indexing Flowchart - Img Caption : [Source](https://learnopencv.com/lightrag/)*\n![LightRAG Retrieval and Querying Flowchart](https://learnopencv.com/wp-content/uploads/2024/11/LightRAG-Querying-Flowchart-Dual-Level-Retrieval-Generation-Knowledge-Graphs-scaled.jpg)\n*Figure 2: LightRAG Retrieval and Querying Flowchart - Img Caption : [Source](https://learnopencv.com/lightrag/)*\n"
  },
  {
    "path": "docs/DockerDeployment.md",
    "content": "# LightRAG Docker Deployment\n\nA lightweight Knowledge Graph Retrieval-Augmented Generation system with multiple LLM backend support.\n\n## 🚀 Preparation\n\n### Clone the repository:\n\n```bash\n# Linux/MacOS\ngit clone https://github.com/HKUDS/LightRAG.git\ncd LightRAG\n```\n```powershell\n# Windows PowerShell\ngit clone https://github.com/HKUDS/LightRAG.git\ncd LightRAG\n```\n\n### Configure your environment:\n\n```bash\n# Linux/MacOS\ncp .env.example .env\n# Edit .env with your preferred configuration\n```\n```powershell\n# Windows PowerShell\nCopy-Item .env.example .env\n# Edit .env with your preferred configuration\n```\n\nLightRAG can be configured using environment variables in the `.env` file:\n\n**Server Configuration**\n\n- `HOST`: Server host (default: 0.0.0.0)\n- `PORT`: Server port (default: 9621)\n\n**LLM Configuration**\n\n- `LLM_BINDING`: LLM backend to use (lollms/ollama/openai)\n- `LLM_BINDING_HOST`: LLM server host URL\n- `LLM_MODEL`: Model name to use\n\n**Embedding Configuration**\n\n- `EMBEDDING_BINDING`: Embedding backend (lollms/ollama/openai)\n- `EMBEDDING_BINDING_HOST`: Embedding server host URL\n- `EMBEDDING_MODEL`: Embedding model name\n\n**RAG Configuration**\n\n- `MAX_ASYNC`: Maximum async operations\n- `MAX_TOKENS`: Maximum token size\n- `EMBEDDING_DIM`: Embedding dimensions\n\n## 🐳 Docker Deployment\n\nDocker instructions work the same on all platforms with Docker Desktop installed.\n\n### Build Optimization\n\nThe Dockerfile uses BuildKit cache mounts to significantly improve build performance:\n\n- **Automatic cache management**: BuildKit is automatically enabled via `# syntax=docker/dockerfile:1` directive\n- **Faster rebuilds**: Only downloads changed dependencies when `uv.lock` or `bun.lock` files are modified\n- **Efficient package caching**: UV and Bun package downloads are cached across builds\n- **No manual configuration needed**: Works out of the box in Docker Compose and GitHub Actions\n\n### Start LightRAG  server:\n\n```bash\ndocker compose up -d\n```\n\nIf you used the interactive setup, start the generated stack with:\n\n```bash\ndocker compose -f docker-compose.final.yml up -d\n```\n\nThe interactive setup keeps `.env` host-usable. Container-only hostnames such as `postgres` or `host.docker.internal`, along with staged SSL paths under `/app/data/certs/`, are injected into the generated `docker-compose.final.yml` for the `lightrag` service instead of being persisted back into `.env`.\nOn reruns, unchanged wizard-managed service blocks in `docker-compose.final.yml` are preserved by\ndefault. To repair or fully regenerate those managed blocks from the bundled templates, rerun the\nmatching setup target with `make env-base-rewrite` or `make env-storage-rewrite`.\n\nIf the generated stack includes local Milvus, compose resolves `MINIO_ACCESS_KEY_ID` and\n`MINIO_SECRET_ACCESS_KEY` at startup from the repo `.env` or exported shell environment. The\ngenerated compose file does not snapshot those values, and `docker compose` exits immediately if\neither variable is missing.\n\nBefore exposing the generated stack beyond localhost, run:\n\n```bash\nmake env-security-check\n```\n\nThat command audits the current `.env` for missing authentication, unsafe whitelist settings, weak\nJWT secrets, and other setup-level security risks without rewriting any files.\n\nLightRAG Server uses the following paths for data storage:\n\n```\ndata/\n├── rag_storage/    # RAG data persistence\n└── inputs/         # Input documents\n```\n\n### Optional: local vLLM embedding and reranker\n\nTo run embedding and/or reranking locally with vLLM, run `make env-base` and answer `yes` when prompted to run the embedding model and rerank service locally via Docker.\nThat configures the embedding service to use `BAAI/bge-m3` on port 8001 with a local vLLM server, and can also add a `vllm-rerank` service on port 8000.\n\nAlternatively, rerun `make env-base` later and enable only the rerank Docker prompt to add the `vllm-rerank` service automatically.\nvLLM provides a `v1/rerank` endpoint that works with the `cohere` binding.\n\nExample `docker-compose.override.yml` for GPU hosts (embedding + reranker):\n\n```yaml\nservices:\n  vllm-embed:\n    image: vllm/vllm-openai:latest\n    runtime: nvidia\n    command: >\n      --model BAAI/bge-m3\n      --port 8001\n      --dtype float16\n    ports:\n      - \"8001:8001\"\n    volumes:\n      - ./data/hf-cache:/root/.cache/huggingface\n    ipc: host\n    deploy:\n      resources:\n        reservations:\n          devices:\n            - driver: nvidia\n              count: all\n              capabilities: [gpu]\n\n  vllm-rerank:\n    image: vllm/vllm-openai:latest\n    runtime: nvidia\n    command: >\n      --model BAAI/bge-reranker-v2-m3\n      --port 8000\n      --dtype float16\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - ./data/hf-cache:/root/.cache/huggingface\n    ipc: host\n    deploy:\n      resources:\n        reservations:\n          devices:\n            - driver: nvidia\n              count: all\n              capabilities: [gpu]\n```\n\nFor CPU-only hosts, use the official CPU image instead:\n\n```yaml\nservices:\n  vllm-embed:\n    image: vllm/vllm-openai-cpu:latest\n    command: >\n      --model BAAI/bge-m3\n      --port 8001\n      --dtype float32\n    ports:\n      - \"8001:8001\"\n    volumes:\n      - ./data/hf-cache:/root/.cache/huggingface\n\n  vllm-rerank:\n    image: vllm/vllm-openai-cpu:latest\n    command: >\n      --model BAAI/bge-reranker-v2-m3\n      --port 8000\n      --dtype float32\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - ./data/hf-cache:/root/.cache/huggingface\n```\n\nAdd the embedding and rerank config to `.env`:\n\n```bash\nEMBEDDING_BINDING=openai\nEMBEDDING_MODEL=BAAI/bge-m3\nEMBEDDING_DIM=1024\nEMBEDDING_BINDING_HOST=http://localhost:8001/v1\nEMBEDDING_BINDING_API_KEY=local-key\nVLLM_EMBED_DEVICE=cpu\n\nRERANK_BINDING=cohere\nRERANK_MODEL=BAAI/bge-reranker-v2-m3\nRERANK_BINDING_HOST=http://localhost:8000/rerank\nRERANK_BINDING_API_KEY=local-key\nVLLM_RERANK_DEVICE=cpu\n```\n\nIf LightRAG runs in Docker while vLLM runs on the host, the generated compose file rewrites those endpoints to:\n\n```bash\nEMBEDDING_BINDING_HOST=http://host.docker.internal:8001/v1\nRERANK_BINDING_HOST=http://host.docker.internal:8000/rerank\n```\n\nFor GPU, set:\n\n```bash\nVLLM_EMBED_DEVICE=cuda\nVLLM_RERANK_DEVICE=cuda\n```\n\nEnsure the NVIDIA Container Toolkit is installed and the host has CUDA drivers available.\nThe setup wizard uses the CPU image by default for `cpu` device and the GPU image for `cuda` device.\nWhen rerunning `make env-base`, an existing `VLLM_EMBED_DEVICE` / `VLLM_RERANK_DEVICE` value is\npreserved instead of being overwritten by a fresh GPU auto-detection result.\nThose templates already pin the matching vLLM `--dtype` (`float32` on CPU, `float16` on CUDA), so no separate `VLLM_*_DTYPE` environment variables are needed.\n\n### SSL certificates\n\nThe setup wizard stages TLS certificate files under `./data/certs/` before generating the compose file.\nThis keeps generated host mounts under the same `./data` root used by the default Docker deployment.\n\n### PostgreSQL image\n\nThe interactive setup defaults PostgreSQL to `gzdaniel/postgres-for-rag:16.6`.\nThat image bundles both Apache AGE and pgvector so the generated stack works with `PGGraphStorage` and `PGVectorStorage` without extra extension setup.\n\n### Updates\n\nTo update the Docker container:\n```bash\ndocker compose pull\ndocker compose down\ndocker compose up\n```\n\n### Offline deployment\n\nSoftware packages requiring `transformers`, `torch`, or `cuda` will is not preinstalled in the dokcer images. Consequently, document extraction tools such as Docling, as well as local LLM models like Hugging Face and LMDeploy, can not be used in an off line enviroment. These high-compute-resource-demanding services should not be integrated into LightRAG. Docling will be decoupled and deployed as a standalone service.\n\n## 📦 Build Docker Images\n\n### For local development and testing\n\n```bash\n# Build and run with Docker Compose (BuildKit automatically enabled)\ndocker compose up --build\n\n# Or explicitly enable BuildKit if needed\nDOCKER_BUILDKIT=1 docker compose up --build\n```\n\n**Note**: BuildKit is automatically enabled by the `# syntax=docker/dockerfile:1` directive in the Dockerfile, ensuring optimal caching performance.\n\n### For production release\n\n **multi-architecture build and push**:\n\n```bash\n# Use the provided build script\n./docker-build-push.sh\n```\n\n**The build script will**:\n\n- Check Docker registry login status\n- Create/use buildx builder automatically\n- Build for both AMD64 and ARM64 architectures\n- Push to GitHub Container Registry (ghcr.io)\n- Verify the multi-architecture manifest\n\n**Prerequisites**:\n\nBefore building multi-architecture images, ensure you have:\n\n- Docker 20.10+ with Buildx support\n- Sufficient disk space (20GB+ recommended for offline image)\n- Registry access credentials (if pushing images)\n"
  },
  {
    "path": "docs/FrontendBuildGuide.md",
    "content": "# Frontend Build Guide\n\n## Overview\n\nThe LightRAG project includes a React-based WebUI frontend. This guide explains how frontend building works in different scenarios.\n\n## Key Principle\n\n- **Git Repository**: Frontend build results are **NOT** included (kept clean)\n- **PyPI Package**: Frontend build results **ARE** included (ready to use)\n- **Build Tool**: **Bun** is recommended, but **Node.js/npm** is fully supported as a fallback\n\n## Installation Scenarios\n\n### 1. End Users (From PyPI) ✨\n\n**Command:**\n```bash\npip install lightrag-hku[api]\n```\n\n**What happens:**\n- Frontend is already built and included in the package\n- No additional steps needed\n- Web interface works immediately\n\n---\n\n### 2. Development Mode (Recommended for Contributors) 🔧\n\n**Command:**\n```bash\n# Clone the repository\ngit clone https://github.com/HKUDS/LightRAG.git\ncd LightRAG\n\n# Install in editable mode (no frontend build required yet)\npip install -e \".[api]\"\n\n# Build frontend when needed (can be done anytime)\ncd lightrag_webui\nbun install --frozen-lockfile\nbun run build\ncd ..\n```\n\n**Advantages:**\n- Install first, build later (flexible workflow)\n- Changes take effect immediately (symlink mode)\n- Frontend can be rebuilt anytime without reinstalling\n\n**How it works:**\n- Creates symlinks to source directory\n- Frontend build output goes to `lightrag/api/webui/`\n- Changes are immediately visible in installed package\n\n---\n\n### 3. Normal Installation (Testing Package Build) 📦\n\n**Command:**\n```bash\n# Clone the repository\ngit clone https://github.com/HKUDS/LightRAG.git\ncd LightRAG\n\n# ⚠️ MUST build frontend FIRST\ncd lightrag_webui\nbun install --frozen-lockfile\nbun run build\ncd ..\n\n# Now install\npip install \".[api]\"\n```\n\n**What happens:**\n- Frontend files are **copied** to site-packages\n- Post-build modifications won't affect installed package\n- Requires rebuild + reinstall to update\n\n**When to use:**\n- Testing complete installation process\n- Verifying package configuration\n- Simulating PyPI user experience\n\n---\n\n### 4. Creating Distribution Package 🚀\n\n**Command:**\n```bash\n# Build frontend first\ncd lightrag_webui\nbun install --frozen-lockfile --production\nbun run build\ncd ..\n\n# Create distribution packages\npython -m build\n\n# Output: dist/lightrag_hku-*.whl and dist/lightrag_hku-*.tar.gz\n```\n\n**What happens:**\n- `setup.py` checks if frontend is built\n- If missing, installation fails with helpful error message\n- Generated package includes all frontend files\n\n---\n\n## GitHub Actions (Automated Release)\n\nWhen creating a release on GitHub:\n\n1. **Automatically builds frontend** using Bun\n2. **Verifies** build completed successfully\n3. **Creates Python package** with frontend included\n4. **Publishes to PyPI** using existing trusted publisher setup\n\n**No manual intervention required!**\n\n---\n\n## Quick Reference\n\n| Scenario | Command | Frontend Required | Can Build After |\n|----------|---------|-------------------|-----------------|\n| From PyPI | `pip install lightrag-hku[api]` | Included | No (already installed) |\n| Development | `pip install -e \".[api]\"` | No | ✅ Yes (anytime) |\n| Normal Install | `pip install \".[api]\"` | ✅ Yes (before) | No (must reinstall) |\n| Create Package | `python -m build` | ✅ Yes (before) | N/A |\n\n---\n\n## Bun Installation\n\nIf you don't have Bun installed:\n\n```bash\n# macOS/Linux\ncurl -fsSL https://bun.sh/install | bash\n\n# Windows\npowershell -c \"irm bun.sh/install.ps1 | iex\"\n```\n\nOfficial documentation: https://bun.sh\n\n---\n\n## File Structure\n\n```\nLightRAG/\n├── lightrag_webui/          # Frontend source code\n│   ├── src/                 # React components\n│   ├── package.json         # Dependencies\n│   └── vite.config.ts       # Build configuration\n│       └── outDir: ../lightrag/api/webui  # Build output\n│\n├── lightrag/\n│   └── api/\n│       └── webui/           # Frontend build output (gitignored)\n│           ├── index.html   # Built files (after running bun run build)\n│           └── assets/      # Built assets\n│\n├── setup.py                 # Build checks\n├── pyproject.toml           # Package configuration\n└── .gitignore               # Excludes lightrag/api/webui/* (except .gitkeep)\n```\n\n---\n\n## Troubleshooting\n\n### Q: I installed in development mode but the web interface doesn't work\n\n**A:** Build the frontend:\n```bash\ncd lightrag_webui && bun run build\n```\n\n### Q: I built the frontend but it's not in my installed package\n\n**A:** You probably used `pip install .` after building. Either:\n- Use `pip install -e \".[api]\"` for development\n- Or reinstall: `pip uninstall lightrag-hku && pip install \".[api]\"`\n\n### Q: Where are the built frontend files?\n\n**A:** In `lightrag/api/webui/` after running `bun run build`\n\n### Q: Can I use npm or yarn instead of Bun?\n\n**A:** Yes. The build scripts (`dev`, `build`, `preview`, `lint`) are runtime-agnostic and work with both Bun and Node.js/npm:\n```bash\nnpm install\nnpm run build\n```\nBun is recommended for speed, but npm is fully supported. Tests (`bun test`) still require Bun.\n\n### Q: Build fails with `Cannot find package '@/lib'`\n\n**A:** This was caused by `vite.config.ts` using a TypeScript path alias (`@/`) that only Bun could resolve at config load time. Update to the latest version where this is fixed with a relative import.\n\n---\n\n## Summary\n\n✅ **PyPI users**: No action needed, frontend included\n✅ **Developers**: Use `pip install -e \".[api]\"`, build frontend when needed\n✅ **CI/CD**: Automatic build in GitHub Actions\n✅ **Git**: Frontend build output never committed\n\nFor questions or issues, please open a GitHub issue.\n"
  },
  {
    "path": "docs/InteractiveSetup.md",
    "content": "# Interactive Setup Guide\n\nUse the interactive setup wizard when you want LightRAG to guide you through the configuration instead of editing `.env` by hand.\n\nThe wizard is exposed through `make` targets:\n\n- `make env-base`\n- `make env-storage`\n- `make env-server`\n- `make env-validate`\n- `make env-security-check`\n- `make env-backup`\n- `make env-base-rewrite`\n- `make env-storage-rewrite`\n\nYou do not need to call the underlying shell script directly.\n\n## What This Wizard Is For\n\nThe setup wizard helps you configure LightRAG in three parts:\n\n- `env-base` sets up the LLM, embedding model, and optional reranker.\n- `env-storage` adds or changes storage backends such as PostgreSQL, Neo4j, Redis, Milvus, Qdrant, MongoDB, or Memgraph.\n- `env-server` sets server host and port, WebUI labels, authentication, API keys, and SSL.\n\nYou can rerun each step later. The wizard loads your existing `.env` and shows current values as defaults, so you only need to change what is different.\n\n## Before You Start\n\n- Run commands from the repository root.\n- The `make env-*` targets automatically choose a compatible Bash 4+ interpreter.\n- Use the documented `make env-*` targets rather than invoking the setup script yourself.\n- `make env-base` is the normal starting point because it creates the initial `.env`.\n- `make env-storage` and `make env-server` require an existing `.env`.\n- If you choose any wizard-managed Docker service, the wizard also prepares LightRAG for the Docker startup path.\n\n## Choose Your Setup Path\n\nUse this quick guide to decide what to run:\n\n- I want the fastest first run with remote model providers: `make env-base`\n- I want embedding or reranking to run locally in Docker: `make env-base`\n- I already configured models and now want databases: `make env-storage`\n- I already configured models and now want auth, API keys, or SSL: `make env-server`\n- I want to check whether my current setup is valid: `make env-validate`\n- I want to audit my current setup before exposing it: `make env-security-check`\n- I want a standalone backup without changing configuration: `make env-backup`\n- I need to repair the generated compose services from the bundled templates: `make env-base-rewrite` or `make env-storage-rewrite`\n\n## Scenario 1: First-Time Local Setup\n\nUse this when you want LightRAG running with the least amount of setup and you already have remote model endpoints or API keys.\n\n**Command**\n\n```bash\nmake env-base\n```\n\n**What the wizard asks**\n\n- LLM provider, model, endpoint, and API key\n- Whether the embedding model should run locally via Docker\n- If embedding stays remote: embedding provider, model, dimension, endpoint, and API key\n- Whether reranking should be enabled\n- If reranking is enabled: whether the rerank service should run locally via Docker\n- If reranking stays remote: rerank provider, model, endpoint, and API key\n\n**What gets written**\n\n- `.env`\n- `docker-compose.final.yml` only if you enabled wizard-managed Docker services\n\n**What to do next**\n\n- If you did not enable wizard-managed Docker services:\n\n```bash\nlightrag-server\n```\n\n- If you enabled wizard-managed Docker services:\n\n```bash\ndocker compose -f docker-compose.final.yml up -d\n```\n\n## Scenario 2: Local Setup With Docker-Hosted Embedding or Rerank\n\nUse this when you want LightRAG to run local inference services for embedding and/or reranking through Docker.\n\n**Command**\n\n```bash\nmake env-base\n```\n\n**Recommended answers**\n\n- Answer `yes` to `Run embedding model locally via Docker (vLLM)?` if you want local embeddings\n- Answer `yes` to `Enable reranking?` and then `yes` to `Run rerank service locally via Docker?` if you want local reranking\n\n**What the wizard asks after you enable local services**\n\n- Embedding model name for local vLLM\n- Rerank model name for local vLLM\n- Remote LLM details if your main LLM is still external\n\n**What gets written**\n\n- `.env`\n- `docker-compose.final.yml` with the selected local services\n\n**What to do next**\n\n```bash\ndocker compose -f docker-compose.final.yml up -d\n```\n\nThis starts the generated Docker-based LightRAG stack together with the selected local services.\n\n## Scenario 3: Add Storage After The Base Setup\n\nUse this when you already have `.env` from `make env-base` and now want to switch from default local-file storage to database-backed storage.\n\n**Command**\n\n```bash\nmake env-storage\n```\n\n**Prerequisite**\n\n- `.env` must already exist\n\n**What the wizard asks**\n\n- KV storage backend\n- Vector storage backend\n- Graph storage backend\n- Doc-status storage backend\n- For each required database, whether it should run locally via Docker\n- For each required database, the needed connection details such as host, URI, port, user, password, database name, or device type\n\n**Important rule**\n\n- If you choose `MongoVectorDBStorage` for vector storage, the wizard does not offer the bundled local Docker MongoDB service. You must provide a MongoDB deployment that supports Atlas Search / Vector Search.\n\n**What gets written**\n\n- `.env`\n- `docker-compose.final.yml` if you selected wizard-managed storage services\n\n**What to do next**\n\n- If you selected Docker-managed storage services:\n\n```bash\ndocker compose -f docker-compose.final.yml up -d\n```\n\n- If you pointed LightRAG at external databases, make sure those services are reachable before starting LightRAG.\n\n## Scenario 4: Harden A Deployment With Auth And SSL\n\nUse this when you already have `.env` and need to prepare the server for shared or external use.\n\n**Commands**\n\n```bash\nmake env-server\nmake env-security-check\n```\n\n**Prerequisite**\n\n- `.env` must already exist\n\n**What `env-server` asks**\n\n- Server host and port\n- WebUI title and description\n- Summary language\n- Whether to configure authentication and API key settings\n- Auth accounts, JWT secret, token lifetime, API key, and whitelist paths\n- Whether to enable SSL/TLS\n- SSL certificate file path and SSL key file path\n\n**What gets written**\n\n- `.env`\n- `docker-compose.final.yml` may be updated if your current setup already uses wizard-managed Docker services\n\n**What to do next**\n\n- Run `make env-security-check`\n- If the stack uses Docker, recreate the LightRAG service with your compose file\n- If the stack runs on the host, restart `lightrag-server`\n\nFor broader deployment guidance, see [DockerDeployment.md](/Users/ydh/mycode/ai/paper-RAG/docs/DockerDeployment.md).\n\n## Validate, Audit, And Backup\n\nThese commands do not walk you through a full setup flow, but they are part of normal operations.\n\n### Validate The Current Configuration\n\n```bash\nmake env-validate\n```\n\nUse this when you want to confirm that the current `.env` is internally consistent. It reports problems such as missing required values, malformed auth settings, invalid URIs, invalid ports, or missing SSL files.\n\n### Audit Security Before Exposure\n\n```bash\nmake env-security-check\n```\n\nUse this before exposing LightRAG beyond localhost. It reports risky setups such as missing authentication, weak or missing JWT secrets, unsafe whitelist settings, or unresolved sensitive placeholders.\n\n### Create A Standalone Backup\n\n```bash\nmake env-backup\n```\n\nUse this when you want a manual backup without running any setup flow.\n\n## Outputs And What They Mean\n\n### `.env`\n\nThe wizard writes `.env` in the repository root. This file becomes the current runtime configuration produced by the latest wizard run.\n\nIn practice, this means:\n\n- rerunning the wizard updates `.env`\n- existing values are reused as defaults on later runs\n- you should treat `.env` as the active configuration for the workflow you most recently configured\n- before `env-base`, `env-storage`, or `env-server` writes `.env`, the wizard automatically creates a timestamped backup of the existing file when one is present\n\n### `docker-compose.final.yml`\n\nThe wizard creates or updates `docker-compose.final.yml` only when you choose wizard-managed Docker services or when an existing wizard-generated compose setup needs to stay aligned with new server settings.\n\nWhen one of the setup flows is about to replace or remove an existing generated compose file, it automatically creates a timestamped backup first.\n\nUse this file when starting the generated Docker stack:\n\n```bash\ndocker compose -f docker-compose.final.yml up -d\n```\n\nThe base `docker-compose.yml` remains the general project compose file. The generated `docker-compose.final.yml` is the wizard-managed output.\n\n## Troubleshooting And Advanced Notes\n\n- If `make env-storage` or `make env-server` says `.env` is missing, run `make env-base` first.\n- You do not need to run `make env-backup` before rerunning `env-base`, `env-storage`, or `env-server`; those flows already back up the existing `.env`, and they also back up the generated compose file before changing it.\n- If you need to fully rebuild wizard-managed compose services from the current bundled templates, use `make env-base-rewrite` or `make env-storage-rewrite`.\n- If you switch between host-oriented and Docker-oriented workflows, rerun the relevant setup step instead of trying to manually merge old settings.\n- If the generated stack includes local Milvus, make sure `MINIO_ACCESS_KEY_ID` and `MINIO_SECRET_ACCESS_KEY` are available before running `docker compose -f docker-compose.final.yml up -d`.\n- For Docker deployment details beyond the interactive wizard, see [DockerDeployment.md](/Users/ydh/mycode/ai/paper-RAG/docs/DockerDeployment.md).\n\n## Typical Command Sequences\n\n### Remote models, local server\n\n```bash\nmake env-base\nlightrag-server\n```\n\n### Remote LLM, local embedding and rerank in Docker\n\n```bash\nmake env-base\ndocker compose -f docker-compose.final.yml up -d\n```\n\n### Add storage after the base setup\n\n```bash\nmake env-base\nmake env-storage\ndocker compose -f docker-compose.final.yml up -d\n```\n\n### Add security and SSL before exposure\n\n```bash\nmake env-base\nmake env-storage\nmake env-server\nmake env-security-check\ndocker compose -f docker-compose.final.yml up -d\n```\n"
  },
  {
    "path": "docs/LightRAG_concurrent_explain.md",
    "content": "## LightRAG Multi-Document Processing: Concurrent Control Strategy\n\nLightRAG employs a multi-layered concurrent control strategy when processing multiple documents. This article provides an in-depth analysis of the concurrent control mechanisms at document level, chunk level, and LLM request level, helping you understand why specific concurrent behaviors occur.\n\n### 1. Document-Level Concurrent Control\n\n**Control Parameter**: `max_parallel_insert`\n\nThis parameter controls the number of documents processed simultaneously. The purpose is to prevent excessive parallelism from overwhelming system resources, which could lead to extended processing times for individual files. Document-level concurrency is governed by the `max_parallel_insert` attribute within LightRAG, which defaults to 2 and is configurable via the `MAX_PARALLEL_INSERT` environment variable.  `max_parallel_insert` is recommended to be set between 2 and 10, typically `llm_model_max_async/3`. Setting this value too high can increase the likelihood of naming conflicts among entities and relationships across different documents during the merge phase, thereby reducing its overall efficiency.\n\n### 2. Chunk-Level Concurrent Control\n\n**Control Parameter**: `llm_model_max_async`\n\nThis parameter controls the number of chunks processed simultaneously in the extraction stage within a document. The purpose is to prevent a high volume of concurrent requests from monopolizing LLM processing resources, which would impede the efficient parallel processing of multiple files. Chunk-Level Concurrent Control is governed by the `llm_model_max_async` attribute within LightRAG, which defaults to 4 and is configurable via the `MAX_ASYNC` environment variable. The purpose of this parameter is to fully leverage the LLM's concurrency capabilities when processing individual documents.\n\nIn the `extract_entities` function, **each document independently creates** its own chunk semaphore. Since each document independently creates chunk semaphores, the theoretical chunk concurrency of the system is:\n$$\nChunkConcurrency = Max Parallel Insert × LLM Model Max Async\n$$\nFor example:\n- `max_parallel_insert = 2` (process 2 documents simultaneously)\n- `llm_model_max_async = 4` (maximum 4 chunk concurrency per document)\n- Theoretical chunk-level concurrent: 2 × 4 = 8\n\n### 3. Graph-Level Concurrent Control\n\n**Control Parameter**: `llm_model_max_async * 2`\n\nThis parameter controls the number of entities and relations processed simultaneously in the merging stage within a document. The purpose is to prevent a high volume of concurrent requests from monopolizing LLM processing resources, which would impede the efficient parallel processing of multiple files. Graph-level concurrency is governed by the `llm_model_max_async` attribute within LightRAG, which defaults to 4 and is configurable via the `MAX_ASYNC` environment variable. Graph-level parallelism control parameters are equally applicable to managing parallelism during the entity relationship reconstruction phase after document deletion.\n\nGiven that the entity relationship merging phase doesn't necessitate LLM interaction for every operation, its parallelism is set at double the LLM's parallelism. This optimizes machine utilization while concurrently preventing excessive queuing resource contention for the LLM.\n\n### 4. LLM-Level Concurrent Control\n\n**Control Parameter**: `llm_model_max_async`\n\nThis parameter governs the **concurrent volume** of LLM requests dispatched by the entire LightRAG system, encompassing the document extraction stage, merging stage, and user query handling.\n\nLLM request prioritization is managed via a global priority queue, which **systematically prioritizes user queries** over merging-related requests, and merging-related requests over extraction-related requests. This strategic prioritization **minimizes user query latency**.\n\nLLM-level concurrency is governed by the `llm_model_max_async` attribute within LightRAG, which defaults to 4 and is configurable via the `MAX_ASYNC` environment variable.\n\n### 5. Complete Concurrent Hierarchy Diagram\n\n```mermaid\ngraph TD\nclassDef doc fill:#e6f3ff,stroke:#5b9bd5,stroke-width:2px;\nclassDef chunk fill:#fbe5d6,stroke:#ed7d31,stroke-width:1px;\nclassDef merge fill:#e2f0d9,stroke:#70ad47,stroke-width:2px;\n\nA[\"Multiple Documents<br>max_parallel_insert = 2\"] --> A1\nA --> B1\n\nA1[DocA: split to n chunks] --> A_chunk;\nB1[DocB: split to m chunks] --> B_chunk;\n\nsubgraph A_chunk[Extraction Stage]\n    A_chunk_title[Entity Relation Extraction<br>llm_model_max_async = 4];\n    A_chunk_title --> A_chunk1[Chunk A1]:::chunk;\n    A_chunk_title --> A_chunk2[Chunk A2]:::chunk;\n    A_chunk_title --> A_chunk3[Chunk A3]:::chunk;\n    A_chunk_title --> A_chunk4[Chunk A4]:::chunk;\n    A_chunk1 & A_chunk2 & A_chunk3 & A_chunk4  --> A_chunk_done([Extraction Complete]);\nend\n\nsubgraph B_chunk[Extraction Stage]\n    B_chunk_title[Entity Relation Extraction<br>llm_model_max_async = 4];\n    B_chunk_title --> B_chunk1[Chunk B1]:::chunk;\n    B_chunk_title --> B_chunk2[Chunk B2]:::chunk;\n    B_chunk_title --> B_chunk3[Chunk B3]:::chunk;\n    B_chunk_title --> B_chunk4[Chunk B4]:::chunk;\n    B_chunk1 & B_chunk2 & B_chunk3 & B_chunk4  --> B_chunk_done([Extraction Complete]);\nend\nA_chunk -.->|LLM Request| LLM_Queue;\n\nA_chunk --> A_merge;\nB_chunk --> B_merge;\n\nsubgraph A_merge[Merge Stage]\n    A_merge_title[Entity Relation Merging<br>llm_model_max_async * 2 = 8];\n    A_merge_title --> A1_entity[Ent a1]:::merge;\n    A_merge_title --> A2_entity[Ent a2]:::merge;\n    A_merge_title --> A3_entity[Rel a3]:::merge;\n    A_merge_title --> A4_entity[Rel a4]:::merge;\n    A1_entity & A2_entity & A3_entity & A4_entity --> A_done([Merge Complete])\nend\n\nsubgraph B_merge[Merge Stage]\n    B_merge_title[Entity Relation Merging<br>llm_model_max_async * 2 = 8];\n    B_merge_title --> B1_entity[Ent b1]:::merge;\n    B_merge_title --> B2_entity[Ent b2]:::merge;\n    B_merge_title --> B3_entity[Rel b3]:::merge;\n    B_merge_title --> B4_entity[Rel b4]:::merge;\n    B1_entity & B2_entity & B3_entity & B4_entity --> B_done([Merge Complete])\nend\n\nA_merge -.->|LLM Request| LLM_Queue[\"LLM Request Prioritized Queue<br>llm_model_max_async = 4\"];\nB_merge -.->|LLM Request| LLM_Queue;\nB_chunk -.->|LLM Request| LLM_Queue;\n\n```\n\n> The extraction and merge stages share a global prioritized LLM queue, regulated by `llm_model_max_async`. While numerous entity and relation extraction and merging operations may be \"actively processing\", **only a limited number will concurrently execute LLM requests** the remainder will be queued and awaiting their turn.\n\n### 6. Performance Optimization Recommendations\n\n* **Increase LLM Concurrent Setting based on the capabilities of your LLM server or API provider**\n\nDuring the file processing phase, the performance and concurrency capabilities of the LLM are critical bottlenecks. When deploying LLMs locally, the service's concurrency capacity must adequately account for the context length requirements of LightRAG. LightRAG recommends that LLMs support a minimum context length of 32KB; therefore, server concurrency should be calculated based on this benchmark. For API providers, LightRAG will retry requests up to three times if the client's request is rejected due to concurrent request limits. Backend logs can be used to determine if LLM retries are occurring, thereby indicating whether `MAX_ASYNC` has exceeded the API provider's limits.\n\n* **Align Parallel Document Insertion Settings with LLM Concurrency Configurations**\n\nThe recommended number of parallel document processing tasks is 1/4 of the LLM's concurrency, with a minimum of 2 and a maximum of 10. Setting a higher number of parallel document processing tasks typically does not accelerate overall document processing speed, as even a small number of concurrently processed documents can fully utilize the LLM's parallel processing capabilities. Excessive parallel document processing can significantly increase the processing time for each individual document. Since LightRAG commits processing results on a file-by-file basis, a large number of concurrent files would necessitate caching a substantial amount of data. In the event of a system error, all documents in the middle stage would require reprocessing, thereby increasing error handling costs. For instance, setting `MAX_PARALLEL_INSERT` to 3 is appropriate when `MAX_ASYNC` is configured to 12.\n"
  },
  {
    "path": "docs/MilvusConfigurationGuide.md",
    "content": "# Milvus Configuration via vector_db_storage_cls_kwargs\n\n## Overview\n\nMilvus index parameters can be configured through `vector_db_storage_cls_kwargs`, which is the **recommended approach** for framework integration scenarios (e.g., when using RAGAnything or other frameworks built on top of LightRAG).\n\n## Why Use vector_db_storage_cls_kwargs?\n\n✅ **Framework Integration**: Allows configuration to be passed through framework layers without environment variable changes\n✅ **Programmatic Configuration**: Set parameters in code rather than relying on environment variables\n✅ **Dynamic Configuration**: Different configurations for different RAG instances\n✅ **Clean API**: All parameters passed in one place during initialization\n\n## Supported Parameters\n\nAll 11 MilvusIndexConfig parameters can be configured via `vector_db_storage_cls_kwargs`:\n\n### Base Configuration\n- `index_type`: Index type (AUTOINDEX, HNSW, HNSW_SQ, IVF_FLAT, etc.)\n- `metric_type`: Distance metric (COSINE, L2, IP)\n\n### HNSW Parameters\n- `hnsw_m`: Number of connections per layer (2-2048, default: 16)\n- `hnsw_ef_construction`: Size of dynamic candidate list during construction (default: 360)\n- `hnsw_ef`: Size of dynamic candidate list during search (default: 200)\n\n### HNSW_SQ Parameters (requires Milvus 2.6.8+)\n- `sq_type`: Quantization type (SQ4U, SQ6, SQ8, BF16, FP16, default: SQ8)\n- `sq_refine`: Enable refinement (default: False)\n- `sq_refine_type`: Refinement type (SQ6, SQ8, BF16, FP16, FP32, default: FP32)\n- `sq_refine_k`: Number of candidates to refine (default: 10)\n\n### IVF Parameters\n- `ivf_nlist`: Number of cluster units (1-65536, default: 1024)\n- `ivf_nprobe`: Number of units to query (default: 16)\n\n## Configuration Priority\n\nConfiguration is resolved in the following order:\n1. **Parameters passed via vector_db_storage_cls_kwargs** (highest priority)\n2. Environment variables (MILVUS_INDEX_TYPE, etc.)\n3. Default values\n\n## Usage Examples\n\n### Basic Configuration\n\n```python\nfrom lightrag import LightRAG\n\nrag = LightRAG(\n    working_dir=\"./demo\",\n    vector_storage=\"MilvusVectorDBStorage\",\n    vector_db_storage_cls_kwargs={\n        \"cosine_better_than_threshold\": 0.2,\n        \"index_type\": \"HNSW\",\n        \"metric_type\": \"COSINE\",\n        \"hnsw_m\": 32,\n        \"hnsw_ef_construction\": 256,\n        \"hnsw_ef\": 150,\n    }\n)\n```\n\n### RAGAnything Framework Integration\n\n```python\n# In RAGAnything framework code:\ndef create_lightrag_instance(user_config):\n    \"\"\"Create LightRAG instance with user-provided Milvus configuration\"\"\"\n\n    # User configuration from RAGAnything\n    milvus_config = {\n        \"cosine_better_than_threshold\": user_config.get(\"threshold\", 0.2),\n        \"index_type\": user_config.get(\"index_type\", \"HNSW\"),\n        \"hnsw_m\": user_config.get(\"hnsw_m\", 32),\n        # ... other parameters\n    }\n\n    # Pass configuration to LightRAG\n    rag = LightRAG(\n        working_dir=user_config[\"working_dir\"],\n        vector_storage=\"MilvusVectorDBStorage\",\n        vector_db_storage_cls_kwargs=milvus_config,\n    )\n\n    return rag\n```\n\n### Advanced Configuration with HNSW_SQ\n\n```python\nrag = LightRAG(\n    working_dir=\"./demo\",\n    vector_storage=\"MilvusVectorDBStorage\",\n    vector_db_storage_cls_kwargs={\n        \"cosine_better_than_threshold\": 0.2,\n        \"index_type\": \"HNSW_SQ\",  # Requires Milvus 2.6.8+\n        \"metric_type\": \"COSINE\",\n        \"hnsw_m\": 48,\n        \"hnsw_ef_construction\": 400,\n        \"hnsw_ef\": 200,\n        \"sq_type\": \"SQ8\",\n        \"sq_refine\": True,\n        \"sq_refine_type\": \"FP32\",\n        \"sq_refine_k\": 20,\n    }\n)\n```\n\n### IVF Configuration\n\n```python\nrag = LightRAG(\n    working_dir=\"./demo\",\n    vector_storage=\"MilvusVectorDBStorage\",\n    vector_db_storage_cls_kwargs={\n        \"cosine_better_than_threshold\": 0.2,\n        \"index_type\": \"IVF_FLAT\",\n        \"metric_type\": \"L2\",\n        \"ivf_nlist\": 2048,\n        \"ivf_nprobe\": 32,\n    }\n)\n```\n\n## Implementation Details\n\n### How It Works\n\n1. When `MilvusVectorDBStorage.__post_init__()` is called:\n   ```python\n   kwargs = self.global_config.get(\"vector_db_storage_cls_kwargs\", {})\n   index_config_keys = MilvusIndexConfig.get_config_field_names()\n   index_config_params = {\n       k: v for k, v in kwargs.items() if k in index_config_keys\n   }\n   self.index_config = MilvusIndexConfig(**index_config_params)\n   ```\n\n2. `MilvusIndexConfig.get_config_field_names()` dynamically extracts all valid parameter names from the dataclass\n3. Only valid Milvus index parameters are extracted from kwargs\n4. Parameters are passed to `MilvusIndexConfig` which applies defaults and validates them\n5. Environment variables are used as fallback for any parameters not provided in kwargs\n\n### Automatic Synchronization\n\nThe implementation uses `MilvusIndexConfig.get_config_field_names()` to dynamically extract valid parameters. This means:\n- ✅ New parameters added to `MilvusIndexConfig` are **automatically recognized**\n- ✅ No need to maintain duplicate parameter lists\n- ✅ Single source of truth for configuration parameters\n\n## Testing\n\nThe configuration via `vector_db_storage_cls_kwargs` is thoroughly tested:\n\n```bash\n# Run all kwargs bridge tests\npython -m pytest tests/test_milvus_kwargs_bridge.py -v\n\n# Test RAGAnything integration scenario specifically\npython -m pytest tests/test_milvus_kwargs_bridge.py::TestMilvusKwargsParameterBridge::test_raganything_framework_integration_scenario -v\n\n# Test all parameters support\npython -m pytest tests/test_milvus_kwargs_bridge.py::TestMilvusKwargsParameterBridge::test_all_milvus_parameters_supported_via_kwargs -v\n```\n\n## Examples\n\nSee `examples/milvus_kwargs_configuration_demo.py` for a complete working example.\n\n## Backward Compatibility\n\n✅ **100% backward compatible** with existing code\n✅ Environment variable configuration still works\n✅ All existing tests pass\n\n## FAQ\n\n### Q: Can I mix kwargs and environment variables?\n**A:** Yes! Parameters in `vector_db_storage_cls_kwargs` take priority over environment variables.\n\n### Q: What happens to non-Milvus parameters in kwargs?\n**A:** They are ignored. Only valid MilvusIndexConfig parameters are extracted. This allows frameworks to pass their own parameters alongside Milvus configuration.\n\n### Q: Do I need to set environment variables?\n**A:** No! When using `vector_db_storage_cls_kwargs`, environment variables are optional. They serve as fallback values.\n\n### Q: Is this approach recommended for RAGAnything?\n**A:** Yes! This is the **recommended approach** for any framework that builds on top of LightRAG, as it allows clean configuration passing through framework layers.\n\n## References\n\n- Test Suite: `tests/test_milvus_kwargs_bridge.py`\n- Implementation: `lightrag/kg/milvus_impl.py` (lines 1237-1272)\n- Example: `examples/milvus_kwargs_configuration_demo.py`\n- MilvusIndexConfig: `lightrag/kg/milvus_impl.py` (lines 75-303)\n"
  },
  {
    "path": "docs/OfflineDeployment.md",
    "content": "# LightRAG Offline Deployment Guide\n\nThis guide provides comprehensive instructions for deploying LightRAG in offline environments where internet access is limited or unavailable.\n\nIf you deploy LightRAG using Docker, there is no need to refer to this document, as the LightRAG Docker image is pre-configured for offline operation.\n\n> Software packages requiring `transformers`, `torch`, or `cuda` will not be included in the offline dependency group. Consequently, document extraction tools such as Docling, as well as local LLM models like Hugging Face and LMDeploy, are outside the scope of offline installation support. These high-compute-resource-demanding services should not be integrated into LightRAG. Docling will be decoupled and deployed as a standalone service.\n\n## Table of Contents\n\n- [Overview](#overview)\n- [Quick Start](#quick-start)\n- [Layered Dependencies](#layered-dependencies)\n- [Tiktoken Cache Management](#tiktoken-cache-management)\n- [Complete Offline Deployment Workflow](#complete-offline-deployment-workflow)\n- [Troubleshooting](#troubleshooting)\n\n## Overview\n\nLightRAG uses dynamic package installation (`pipmaster`) for optional features based on file types and configurations. In offline environments, these dynamic installations will fail. This guide shows you how to pre-install all necessary dependencies and cache files.\n\n### What Gets Dynamically Installed?\n\nLightRAG dynamically installs packages for:\n\n- **Storage Backends**: `redis`, `neo4j`, `pymilvus`, `pymongo`, `asyncpg`, `qdrant-client`\n- **LLM Providers**: `openai`, `anthropic`, `ollama`, `zhipuai`, `aioboto3`, `voyageai`, `llama-index`, `lmdeploy`, `transformers`, `torch`\n- **Tiktoken Models**: BPE encoding models downloaded from OpenAI CDN\n\n**Note**: Document processing dependencies (`pypdf`, `python-docx`, `python-pptx`, `openpyxl`) are now pre-installed with the `api` extras group and no longer require dynamic installation.\n\n## Quick Start\n\n### Option 1: Using pip with Offline Extras\n\n```bash\n# Online environment: Install all offline dependencies\npip install lightrag-hku[offline]\n\n# Download tiktoken cache\nlightrag-download-cache\n\n# Create offline package\npip download lightrag-hku[offline] -d ./offline-packages\ntar -czf lightrag-offline.tar.gz ./offline-packages ~/.tiktoken_cache\n\n# Transfer to offline server\nscp lightrag-offline.tar.gz user@offline-server:/path/to/\n\n# Offline environment: Install\ntar -xzf lightrag-offline.tar.gz\npip install --no-index --find-links=./offline-packages lightrag-hku[offline]\nexport TIKTOKEN_CACHE_DIR=~/.tiktoken_cache\n```\n\n### Option 2: Using Requirements Files\n\n```bash\n# Online environment: Download packages\npip download -r requirements-offline.txt -d ./packages\n\n# Transfer to offline server\ntar -czf packages.tar.gz ./packages\nscp packages.tar.gz user@offline-server:/path/to/\n\n# Offline environment: Install\ntar -xzf packages.tar.gz\npip install --no-index --find-links=./packages -r requirements-offline.txt\n```\n\n## Layered Dependencies\n\nLightRAG provides flexible dependency groups for different use cases:\n\n### Available Dependency Groups\n\n| Group | Description | Use Case |\n| ----- | ----------- | -------- |\n| `api` | API server + document processing | FastAPI server with PDF, DOCX, PPTX, XLSX support |\n| `offline-storage` | Storage backends | Redis, Neo4j, MongoDB, PostgreSQL, etc. |\n| `offline-llm` | LLM providers | OpenAI, Anthropic, Ollama, etc. |\n| `offline` | Complete offline package | API + Storage + LLM (all features) |\n\n**Note**: Document processing (PDF, DOCX, PPTX, XLSX) is included in the `api` extras group. The previous `offline-docs` group has been merged into `api` for better integration.\n\n> Software packages requiring `transformers`, `torch`, or `cuda` will not be included in the offline dependency group.\n\n### Installation Examples\n\n```bash\n# Install API with document processing\npip install lightrag-hku[api]\n\n# Install API and storage backends\npip install lightrag-hku[api,offline-storage]\n\n# Install all offline dependencies (recommended for offline deployment)\npip install lightrag-hku[offline]\n```\n\n### Using Individual Requirements Files\n\n```bash\n# Storage backends only\npip install -r requirements-offline-storage.txt\n\n# LLM providers only\npip install -r requirements-offline-llm.txt\n\n# All offline dependencies\npip install -r requirements-offline.txt\n```\n\n## Tiktoken Cache Management\n\nTiktoken downloads BPE encoding models on first use. In offline environments, you must pre-download these models.\n\n### Using the CLI Command\n\nAfter installing LightRAG, use the built-in command:\n\n```bash\n# Download to default location (see output for exact path)\nlightrag-download-cache\n\n# Download to specific directory\nlightrag-download-cache --cache-dir ./tiktoken_cache\n\n# Download specific models only\nlightrag-download-cache --models gpt-4o-mini gpt-4\n```\n\n### Default Models Downloaded\n\n- `gpt-4o-mini` (LightRAG default)\n- `gpt-4o`\n- `gpt-4`\n- `gpt-3.5-turbo`\n- `text-embedding-ada-002`\n- `text-embedding-3-small`\n- `text-embedding-3-large`\n\n### Setting Cache Location in Offline Environment\n\n```bash\n# Option 1: Environment variable (temporary)\nexport TIKTOKEN_CACHE_DIR=/path/to/tiktoken_cache\n\n# Option 2: Add to ~/.bashrc or ~/.zshrc (persistent)\necho 'export TIKTOKEN_CACHE_DIR=~/.tiktoken_cache' >> ~/.bashrc\nsource ~/.bashrc\n\n# Option 3: Copy to default location\ncp -r /path/to/tiktoken_cache ~/.tiktoken_cache/\n```\n\n## Complete Offline Deployment Workflow\n\n### Step 1: Prepare in Online Environment\n\n```bash\n# 1. Install LightRAG with offline dependencies\npip install lightrag-hku[offline]\n\n# 2. Download tiktoken cache\nlightrag-download-cache --cache-dir ./offline_cache/tiktoken\n\n# 3. Download all Python packages\npip download lightrag-hku[offline] -d ./offline_cache/packages\n\n# 4. Create archive for transfer\ntar -czf lightrag-offline-complete.tar.gz ./offline_cache\n\n# 5. Verify contents\ntar -tzf lightrag-offline-complete.tar.gz | head -20\n```\n\n### Step 2: Transfer to Offline Environment\n\n```bash\n# Using scp\nscp lightrag-offline-complete.tar.gz user@offline-server:/tmp/\n\n# Or using USB/physical media\n# Copy lightrag-offline-complete.tar.gz to USB drive\n```\n\n### Step 3: Install in Offline Environment\n\n```bash\n# 1. Extract archive\ncd /tmp\ntar -xzf lightrag-offline-complete.tar.gz\n\n# 2. Install Python packages\npip install --no-index \\\n    --find-links=/tmp/offline_cache/packages \\\n    lightrag-hku[offline]\n\n# 3. Set up tiktoken cache\nmkdir -p ~/.tiktoken_cache\ncp -r /tmp/offline_cache/tiktoken/* ~/.tiktoken_cache/\nexport TIKTOKEN_CACHE_DIR=~/.tiktoken_cache\n\n# 4. Add to shell profile for persistence\necho 'export TIKTOKEN_CACHE_DIR=~/.tiktoken_cache' >> ~/.bashrc\n```\n\n### Step 4: Verify Installation\n\n```bash\n# Test Python import\npython -c \"from lightrag import LightRAG; print('✓ LightRAG imported')\"\n\n# Test tiktoken\npython -c \"from lightrag.utils import TiktokenTokenizer; t = TiktokenTokenizer(); print('✓ Tiktoken working')\"\n\n# Test optional dependencies (if installed)\npython -c \"import docling; print('✓ Docling available')\"\npython -c \"import redis; print('✓ Redis available')\"\n```\n\n## Troubleshooting\n\n### Issue: Tiktoken fails with network error\n\n**Problem**: `Unable to load tokenizer for model gpt-4o-mini`\n\n**Solution**:\n```bash\n# Ensure TIKTOKEN_CACHE_DIR is set\necho $TIKTOKEN_CACHE_DIR\n\n# Verify cache files exist\nls -la ~/.tiktoken_cache/\n\n# If empty, you need to download cache in online environment first\n```\n\n### Issue: Dynamic package installation fails\n\n**Problem**: `Error installing package xxx`\n\n**Solution**:\n```bash\n# Pre-install the specific package you need\n# For API with document processing:\npip install lightrag-hku[api]\n\n# For storage backends:\npip install lightrag-hku[offline-storage]\n\n# For LLM providers:\npip install lightrag-hku[offline-llm]\n```\n\n### Issue: Missing dependencies at runtime\n\n**Problem**: `ModuleNotFoundError: No module named 'xxx'`\n\n**Solution**:\n```bash\n# Check what you have installed\npip list | grep -i xxx\n\n# Install missing component\npip install lightrag-hku[offline]  # Install all offline deps\n```\n\n### Issue: Permission denied on tiktoken cache\n\n**Problem**: `PermissionError: [Errno 13] Permission denied`\n\n**Solution**:\n```bash\n# Ensure cache directory has correct permissions\nchmod 755 ~/.tiktoken_cache\nchmod 644 ~/.tiktoken_cache/*\n\n# Or use a user-writable directory\nexport TIKTOKEN_CACHE_DIR=~/my_tiktoken_cache\nmkdir -p ~/my_tiktoken_cache\n```\n\n## Best Practices\n\n1. **Test in Online Environment First**: Always test your complete setup in an online environment before going offline.\n\n2. **Keep Cache Updated**: Periodically update your offline cache when new models are released.\n\n3. **Document Your Setup**: Keep notes on which optional dependencies you actually need.\n\n4. **Version Pinning**: Consider pinning specific versions in production:\n   ```bash\n   pip freeze > requirements-production.txt\n   ```\n\n5. **Minimal Installation**: Only install what you need:\n   ```bash\n   # If you only need API with document processing\n   pip install lightrag-hku[api]\n   # Then manually add specific LLM: pip install openai\n   ```\n\n## Additional Resources\n\n- [LightRAG GitHub Repository](https://github.com/HKUDS/LightRAG)\n- [Docker Deployment Guide](./DockerDeployment.md)\n- [API Documentation](../lightrag/api/README.md)\n\n## Support\n\nIf you encounter issues not covered in this guide:\n\n1. Check the [GitHub Issues](https://github.com/HKUDS/LightRAG/issues)\n2. Review the [project documentation](../README.md)\n3. Create a new issue with your offline deployment details\n"
  },
  {
    "path": "docs/UV_LOCK_GUIDE.md",
    "content": "# uv.lock Update Guide\n\n## What is uv.lock?\n\n`uv.lock` is uv's lock file. It captures the exact version of every dependency, including transitive ones, much like:\n- Node.js `package-lock.json`\n- Rust `Cargo.lock`\n- Python Poetry `poetry.lock`\n\nKeeping `uv.lock` in version control guarantees that everyone installs the same dependency set.\n\n## When does uv.lock change?\n\n### Situations where it does *not* change automatically\n\n- Running `uv sync --frozen`\n- Building Docker images that call `uv sync --frozen`\n- Editing source code without touching dependency metadata\n\n### Situations where it will change\n\n1. **`uv lock` or `uv lock --upgrade`**\n\n   ```bash\n   uv lock                # Resolve according to current constraints\n   uv lock --upgrade      # Re-resolve and upgrade to the newest compatible releases\n   ```\n\n   Use these commands after modifying `pyproject.toml`, when you want fresh dependency versions, or if the lock file was deleted or corrupted.\n\n2. **`uv add`**\n\n   ```bash\n    uv add requests           # Adds the dependency and updates both files\n    uv add --dev pytest       # Adds a dev dependency\n   ```\n\n   `uv add` edits `pyproject.toml` and refreshes `uv.lock` in one step.\n\n3. **`uv remove`**\n\n   ```bash\n   uv remove requests\n   ```\n\n   This removes the dependency from `pyproject.toml` and rewrites `uv.lock`.\n\n4. **`uv sync` without `--frozen`**\n\n   ```bash\n   uv sync\n   ```\n\n   Normally this only installs what is already locked. However, if `pyproject.toml` and `uv.lock` disagree or the lock file is missing, uv will regenerate and update `uv.lock`. In CI and production builds you should prefer `uv sync --frozen` to prevent unintended updates.\n\n## Example workflows\n\n### Scenario 1: Add a new dependency\n\n```bash\n# Recommended: let uv handle both files\nuv add fastapi\ngit add pyproject.toml uv.lock\ngit commit -m \"Add fastapi dependency\"\n\n# Manual alternative\n# 1. Edit pyproject.toml\n# 2. Regenerate the lock file\nuv lock\ngit add pyproject.toml uv.lock\ngit commit -m \"Add fastapi dependency\"\n```\n\n### Scenario 2: Relax or tighten a version constraint\n\n```bash\n# 1. Edit the requirement in pyproject.toml,\n#    e.g. openai>=1.0.0,<2.0.0 -> openai>=1.5.0,<2.0.0\n\n# 2. Re-resolve the lock file\nuv lock\n\n# 3. Commit both files\ngit add pyproject.toml uv.lock\ngit commit -m \"Update openai to >=1.5.0\"\n```\n\n### Scenario 3: Upgrade everything to the newest compatible versions\n\n```bash\nuv lock --upgrade\ngit diff uv.lock\ngit add uv.lock\ngit commit -m \"Upgrade dependencies to latest compatible versions\"\n```\n\n### Scenario 4: Teammate syncing the project\n\n```bash\ngit pull               # Fetch latest code and lock file\nuv sync --frozen       # Install exactly what uv.lock specifies\n```\n\n## Using uv.lock in Docker\n\n```dockerfile\nRUN uv sync --frozen --no-dev --extra api\n```\n\n`--frozen` guarantees reproducible builds because uv will refuse to deviate from the locked versions.\n`--extra api` install API server\n\n## Generating a lock file that includes offline dependencies\n\nIf you need `uv.lock` to capture the optional offline stacks, regenerate it with the relevant extras enabled:\n\n```bash\nuv lock --extra api --extra offline\n```\n\nThis command resolves the base project requirements plus both the `api` and `offline` optional dependency sets, ensuring downstream `uv sync --frozen --extra api --extra offline` installs work without further resolution.\n\n## Frequently asked questions\n\n- **`uv.lock` is almost 1 MB. Does that matter?**\n  No. The file is read only during dependency resolution.\n\n- **Should we commit `uv.lock`?**\n  Yes. Commit it so collaborators and CI jobs share the same dependency graph.\n\n- **Deleted the lock file by accident?**\n  Run `uv lock` to regenerate it from `pyproject.toml`.\n\n- **Can `uv.lock` and `requirements.txt` coexist?**\n  They can, but maintaining both is redundant. Prefer relying on `uv.lock` alone whenever possible.\n\n- **How do I inspect locked versions?**\n  ```bash\n  uv tree\n  grep -A5 'name = \"openai\"' uv.lock\n  ```\n\n## Best practices\n\n### Recommended\n\n1. Commit `uv.lock` alongside `pyproject.toml`.\n2. Use `uv sync --frozen` in CI, Docker, and other reproducible environments.\n3. Use plain `uv sync` during local development if you want uv to reconcile the lock for you.\n4. Run `uv lock --upgrade` periodically to pick up the latest compatible releases.\n5. Regenerate the lock file immediately after changing dependency constraints.\n\n### Avoid\n\n1. Running `uv sync` without `--frozen` in CI or production pipelines.\n2. Editing `uv.lock` by hand—uv will overwrite manual edits.\n3. Ignoring lock file diffs in code reviews—unexpected dependency changes can break builds.\n\n## Summary\n\n| Command               | Updates `uv.lock` | Typical use                               |\n|-----------------------|-------------------|-------------------------------------------|\n| `uv lock`             | ✅ Yes            | After editing constraints                 |\n| `uv lock --upgrade`   | ✅ Yes            | Upgrade to the newest compatible versions |\n| `uv add <pkg>`        | ✅ Yes            | Add a dependency                          |\n| `uv remove <pkg>`     | ✅ Yes            | Remove a dependency                       |\n| `uv sync`             | ⚠️ Maybe          | Local development; can regenerate the lock |\n| `uv sync --frozen`    | ❌ No             | CI/CD, Docker, reproducible builds        |\n\nRemember: `uv.lock` only changes when you run a command that tells it to. Keep it in sync with your project and commit it whenever it changes.\n"
  },
  {
    "path": "env.example",
    "content": "### All configurable environment variable must show up in this sample file in active or comment out status\n### Setup tool `make env-*` uses this file to generate final .env file\n### Lines starting with `# #` represent repeated environment variables;\n### These are placeholders and setup tool should not be substituted with actual values in this lines.\n### Wizard metadata: describes which runtime this generated .env currently targets.\n\n### Target environment of this env file: host/compose (compose is for Dokcer or Kubernetes)\n# LIGHTRAG_RUNTIME_TARGET=host\n\n###########################\n### Server Configuration\n###########################\nHOST=0.0.0.0\nPORT=9621\nWEBUI_TITLE='My Graph KB'\nWEBUI_DESCRIPTION=\"Simple and Fast Graph Based RAG System\"\n# WORKERS=2\n### gunicorn worker timeout(as default LLM request timeout if LLM_TIMEOUT is not set)\n# TIMEOUT=150\n# CORS_ORIGINS=http://localhost:3000,http://localhost:8080\n\n### Optional SSL Configuration\n### Docker note: generated compose files mount staged certs at /app/data/certs/ inside the container\n# SSL=true\n# SSL_CERTFILE=/path/to/cert.pem\n# SSL_KEYFILE=/path/to/key.pem\n\n### Directory Configuration (defaults to current working directory)\n### Default value is ./inputs and ./rag_storage\n# INPUT_DIR=<absolute_path_for_doc_input_dir>\n# WORKING_DIR=<absolute_path_for_working_dir>\n\n### Tiktoken cache directory (Store cached files in this folder for offline deployment)\n# TIKTOKEN_CACHE_DIR=/app/data/tiktoken\n\n### Ollama Emulating Model and Tag\n# OLLAMA_EMULATING_MODEL_NAME=lightrag\nOLLAMA_EMULATING_MODEL_TAG=latest\n\n### Max nodes for graph retrieval (Ensure WebUI local settings are also updated, which is limited to this value)\n# MAX_GRAPH_NODES=1000\n\n### Logging level\n# LOG_LEVEL=INFO\n# VERBOSE=False\n# LOG_MAX_BYTES=10485760\n# LOG_BACKUP_COUNT=5\n### Logfile location (defaults to current working directory)\n# LOG_DIR=/path/to/log/directory\n\n#####################################\n### Login and API-Key Configuration\n#####################################\n# AUTH_ACCOUNTS='admin:admin123,user1:pass456'\n# TOKEN_SECRET=Your-Key-For-LightRAG-API-Server\n# JWT_ALGORITHM=HS256\n# TOKEN_EXPIRE_HOURS=48\n# GUEST_TOKEN_EXPIRE_HOURS=24\n\n### Token Auto-Renewal Configuration (Sliding Window Expiration)\n### Enable automatic token renewal to prevent active users from being logged out\n### When enabled, tokens will be automatically renewed when remaining time < threshold\n# TOKEN_AUTO_RENEW=true\n### Token renewal threshold (0.0 - 1.0)\n### Renew token when remaining time < (total time * threshold)\n### Default: 0.5 (renew when 50% time remaining)\n### Examples:\n###   0.5 = renew when 24h token has 12h left\n###   0.25 = renew when 24h token has 6h left\n# TOKEN_RENEW_THRESHOLD=0.5\n### Note: Token renewal is automatically skipped for certain endpoints:\n###   - /health: Health check endpoint (no authentication required)\n###   - /documents/paginated: Frequently polled by client (5-30s interval)\n###   - /documents/pipeline_status: Very frequently polled by client (2s interval)\n###   - Rate limit: Minimum 60 seconds between renewals for same user\n\n### API-Key to access LightRAG Server API\n### Use this key in HTTP requests with the 'X-API-Key' header\n### Example: curl -H \"X-API-Key: your-secure-api-key-here\" http://localhost:9621/query\n# LIGHTRAG_API_KEY=your-secure-api-key-here\n# WHITELIST_PATHS=/health,/api/*\n\n######################################################################################\n### Query Configuration\n###\n### How to control the context length sent to LLM:\n###    MAX_ENTITY_TOKENS + MAX_RELATION_TOKENS < MAX_TOTAL_TOKENS\n###    Chunk_Tokens = MAX_TOTAL_TOKENS - Actual_Entity_Tokens - Actual_Relation_Tokens\n######################################################################################\n# LLM response cache for query (Not valid for streaming response)\n# ENABLE_LLM_CACHE=true\n# COSINE_THRESHOLD=0.2\n### Number of entities or relations retrieved from KG\n# TOP_K=40\n### Maximum number or chunks for naive vector search\n# CHUNK_TOP_K=20\n### control the actual entities send to LLM\n# MAX_ENTITY_TOKENS=6000\n### control the actual relations send to LLM\n# MAX_RELATION_TOKENS=8000\n### control the maximum tokens send to LLM (include entities, relations and chunks)\n# MAX_TOTAL_TOKENS=30000\n\n### chunk selection strategies\n###     VECTOR: Pick KG chunks by vector similarity, delivered chunks to the LLM aligning more closely with naive retrieval\n###     WEIGHT: Pick KG chunks by entity and chunk weight, delivered more solely KG related chunks to the LLM\n###     If reranking is enabled, the impact of chunk selection strategies will be diminished.\n# KG_CHUNK_PICK_METHOD=VECTOR\n\n### maximum number of related chunks per source entity or relation\n###     The chunk picker uses this value to determine the total number of chunks selected from KG(knowledge graph)\n###     Higher values increase re-ranking time\n# RELATED_CHUNK_NUMBER=5\n\n#########################################################\n### Reranking configuration\n### RERANK_BINDING type: null, cohere, jina, aliyun\n### For rerank model deployed by vLLM use cohere binding\n### If LightRAG deployed in Docker:\n###    uses host.docker.internal instead of localhost in RERANK_BINDING_HOST\n#########################################################\nRERANK_BINDING=null\n# RERANK_MODEL=BAAI/bge-reranker-v2-m3\n# RERANK_BINDING_HOST=http://localhost:8000/rerank\n# RERANK_BINDING_API_KEY=your_rerank_api_key_here\n\n### rerank score chunk filter(set to 0.0 to keep all chunks, 0.6 or above if LLM is not strong enough)\n# MIN_RERANK_SCORE=0.0\n### Enable rerank by default in query params when RERANK_BINDING is not null\n# RERANK_BY_DEFAULT=True\n\n### Cohere AI\n# # RERANK_MODEL=rerank-v3.5\n# # RERANK_BINDING_HOST=https://api.cohere.com/v2/rerank\n# # RERANK_BINDING_API_KEY=your_rerank_api_key_here\n### Cohere rerank chunking configuration (useful for models with token limits like ColBERT)\n# RERANK_ENABLE_CHUNKING=true\n# RERANK_MAX_TOKENS_PER_DOC=480\n\n### Aliyun Dashscope\n# # RERANK_MODEL=gte-rerank-v2\n# # RERANK_BINDING_HOST=https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank\n# # RERANK_BINDING_API_KEY=your_rerank_api_key_here\n\n### Jina AI\n# # RERANK_MODEL=jina-reranker-v2-base-multilingual\n# # RERANK_BINDING_HOST=https://api.jina.ai/v1/rerank\n# # RERANK_BINDING_API_KEY=your_rerank_api_key_here\n\n### For local deployment Embedding and Reranker with vLLM (OpenAI-compatible API)\n### Wizard metadata used to preserve the chosen deployment provider across setup reruns\n# LIGHTRAG_SETUP_EMBEDDING_PROVIDER=vllm\n# LIGHTRAG_SETUP_RERANK_PROVIDER=vllm\n# VLLM_EMBED_MODEL=BAAI/bge-m3\n# VLLM_EMBED_PORT=8001\n# VLLM_EMBED_DEVICE=cpu\n### VLLM_EMBED_API_KEY is passed as --api-key to vLLM; synced to EMBEDDING_BINDING_API_KEY; auto-generated if blank\n# VLLM_EMBED_API_KEY=\n# VLLM_EMBED_EXTRA_ARGS=\n# VLLM_RERANK_MODEL=BAAI/bge-reranker-v2-m3\n# VLLM_RERANK_PORT=8000\n# VLLM_RERANK_DEVICE=cuda\n### VLLM_RERANK_API_KEY is passed as --api-key to vLLM; synced to RERANK_BINDING_API_KEY; auto-generated if blank\n# VLLM_RERANK_API_KEY=\n### Use float16 for GPU mode. CPU mode uses the official vLLM CPU image.\n# VLLM_USE_CPU=1\n### Set to 1 for CPU mode, unset for GPU mode\n# CUDA_VISIBLE_DEVICES=-1\n### Set to -1 to disable CUDA (CPU mode), or specific GPU IDs for GPU mode\n# NVIDIA_VISIBLE_DEVICES=0\n### Optional Docker runtime equivalent; generated GPU compose honors either variable.\n# VLLM_RERANK_EXTRA_ARGS=\n\n########################################\n### Document processing configuration\n########################################\nENABLE_LLM_CACHE_FOR_EXTRACT=true\n\n### Document processing output language: English, Chinese, French, German ...\nSUMMARY_LANGUAGE=English\n\n### File upload size limit (in bytes)\n### Default: 104857600 (100MB)\n### Set to 0 or None for unlimited upload size\n### Examples:\n###   52428800  = 50MB\n###   104857600 = 100MB (default)\n###   209715200 = 200MB\n### Note: If using Nginx as reverse proxy, also configure client_max_body_size\n# MAX_UPLOAD_SIZE=104857600\n\n### Entity types that the LLM will attempt to recognize\n# ENTITY_TYPES='[\"Person\", \"Creature\", \"Organization\", \"Location\", \"Event\", \"Concept\", \"Method\", \"Content\", \"Data\", \"Artifact\", \"NaturalObject\"]'\n\n### Chunk size for document splitting, 500~1500 is recommended\n# CHUNK_SIZE=1200\n# CHUNK_OVERLAP_SIZE=100\n\n### Number of summary segments or tokens to trigger LLM summary on entity/relation merge (at least 3 is recommended)\n# FORCE_LLM_SUMMARY_ON_MERGE=8\n### Max description token size to trigger LLM summary\n# SUMMARY_MAX_TOKENS = 1200\n### Recommended LLM summary output length in tokens\n# SUMMARY_LENGTH_RECOMMENDED=600\n### Maximum context size sent to LLM for description summary\n# SUMMARY_CONTEXT_SIZE=12000\n### Maximum token size allowed for entity extraction input context\n# MAX_EXTRACT_INPUT_TOKENS=20480\n\n### control the maximum chunk_ids stored in vector and graph db\n# MAX_SOURCE_IDS_PER_ENTITY=300\n# MAX_SOURCE_IDS_PER_RELATION=300\n### control chunk_ids limitation method: FIFO, KEEP\n###    FIFO: First in first out\n###    KEEP: Keep oldest (less merge action and faster)\n# SOURCE_IDS_LIMIT_METHOD=FIFO\n\n# Maximum number of file paths stored in entity/relation file_path field (For displayed only, does not affect query performance)\n# MAX_FILE_PATHS=100\n\n### PDF decryption password for protected PDF files\n# PDF_DECRYPT_PASSWORD=your_pdf_password_here\n\n###############################\n### Concurrency Configuration\n###############################\n### Max concurrency requests of LLM (for both query and document processing)\nMAX_ASYNC=4\n### Number of parallel processing documents(between 2~10, MAX_ASYNC/3 is recommended)\nMAX_PARALLEL_INSERT=2\n### Max concurrency requests for Embedding\n# EMBEDDING_FUNC_MAX_ASYNC=8\n### Num of chunks send to Embedding in single request\n# EMBEDDING_BATCH_NUM=10\n\n###########################################################################\n### LLM Configuration\n### LLM_BINDING type: openai, ollama, lollms, azure_openai, aws_bedrock, gemini\n### LLM_BINDING_HOST: Service endpoint (left empty if using default endpoint provided by openai or gemini SDK)\n### LLM_BINDING_API_KEY: api key\n### If LightRAG deployed in Docker:\n###    uses host.docker.internal instead of localhost in LLM_BINDING_HOST\n###########################################################################\n### LLM request timeout setting for all llm (0 means no timeout for Ollma)\n# LLM_TIMEOUT=180\n\nLLM_BINDING=openai\nLLM_BINDING_HOST=https://api.openai.com/v1\nLLM_BINDING_API_KEY=your_api_key\nLLM_MODEL=gpt-5-mini\n\n### use the following command to see all support options for OpenAI, azure_openai or OpenRouter\n### lightrag-server --llm-binding openai --help\n### OpenAI Specific Parameters\n# OPENAI_LLM_REASONING_EFFORT=minimal\n### OpenRouter Specific Parameters\n# OPENAI_LLM_EXTRA_BODY='{\"reasoning\": {\"enabled\": false}}'\n### Qwen3 Specific Parameters deploy by vLLM\n# OPENAI_LLM_EXTRA_BODY='{\"chat_template_kwargs\": {\"enable_thinking\": false}}'\n\n### OpenAI Compatible API Specific Parameters\n### Increased temperature values may mitigate infinite inference loops in certain LLM, such as Qwen3-30B.\n# OPENAI_LLM_TEMPERATURE=0.9\n### Set the max_tokens to mitigate endless output of some LLM (less than LLM_TIMEOUT * llm_output_tokens/second, i.e. 9000 = 180s * 50 tokens/s)\n### Typically, max_tokens does not include prompt content\n### For vLLM/SGLang deployed models, or most of OpenAI compatible API provider\n# OPENAI_LLM_MAX_TOKENS=9000\n### For OpenAI o1-mini or newer modles utilizes max_completion_tokens instead of max_tokens\n# OPENAI_LLM_MAX_COMPLETION_TOKENS=9000\n\n### Azure OpenAI example\n### Use deployment name as model name or set AZURE_OPENAI_DEPLOYMENT instead\n# AZURE_OPENAI_API_VERSION=2024-08-01-preview\n# # LLM_BINDING=azure_openai\n# # LLM_BINDING_HOST=https://xxxx.openai.azure.com/\n# # LLM_BINDING_API_KEY=your_api_key\n# # LLM_MODEL=my-gpt-mini-deployment\n\n### Openrouter example\n# # LLM_BINDING=openai\n# # LLM_BINDING_HOST=https://openrouter.ai/api/v1\n# # LLM_BINDING_API_KEY=your_api_key\n# # LLM_MODEL=google/gemini-2.5-flash\n\n### Google Gemini example (AI Studio)\n# # LLM_BINDING=gemini\n# # LLM_BINDING_API_KEY=your_gemini_api_key\n# # LLM_BINDING_HOST=https://generativelanguage.googleapis.com\n# # LLM_MODEL=gemini-flash-latest\n\n### use the following command to see all support options for OpenAI, azure_openai or OpenRouter\n### lightrag-server --llm-binding gemini --help\n### Gemini Specific Parameters\n# GEMINI_LLM_MAX_OUTPUT_TOKENS=9000\n# GEMINI_LLM_TEMPERATURE=0.7\n### Enable or disable thinking\n# GEMINI_LLM_THINKING_CONFIG='{\"thinking_budget\": -1, \"include_thoughts\": true}'\n# # GEMINI_LLM_THINKING_CONFIG='{\"thinking_budget\": 0, \"include_thoughts\": false}'\n\n### Google Vertex AI example\n### Vertex AI use GOOGLE_APPLICATION_CREDENTIALS instead of API-KEY for authentication\n### LLM_BINDING_HOST=DEFAULT_GEMINI_ENDPOINT means select endpoit based on project and location automatically\n# # LLM_BINDING=gemini\n# # LM_BINDING_HOST=https://aiplatform.googleapis.com\n### or use DEFAULT_GEMINI_ENDPOINT to select endpoint based on project and location automatically\n# # LLM_BINDING_HOST=DEFAULT_GEMINI_ENDPOINT\n# # LLM_MODEL=gemini-2.5-flash\n# GOOGLE_GENAI_USE_VERTEXAI=true\n# GOOGLE_CLOUD_PROJECT='your-project-id'\n# GOOGLE_CLOUD_LOCATION='us-central1'\n# GOOGLE_APPLICATION_CREDENTIALS='/Users/xxxxx/your-service-account-credentials-file.json'\n\n### Ollama example\n# # LLM_BINDING=ollama\n# # LLM_BINDING_HOST=http://localhost:11434\n# # LLM_MODEL=qwen3.5:9b\n\n### use the following command to see all support options for Ollama LLM\n### lightrag-server --llm-binding ollama --help\n### Ollama Server Specific Parameters\n### OLLAMA_LLM_NUM_CTX must be provided, and should at least larger than MAX_TOTAL_TOKENS + 2000\nOLLAMA_LLM_NUM_CTX=32768\n### Set the max_output_tokens to mitigate endless output of some LLM (less than LLM_TIMEOUT * llm_output_tokens/second, i.e. 9000 = 180s * 50 tokens/s)\n# OLLAMA_LLM_NUM_PREDICT=9000\n# OLLAMA_LLM_TEMPERATURE=0.85\n### Stop sequences for Ollama LLM\n# OLLAMA_LLM_STOP='[\"</s>\", \"<|EOT|>\"]'\n\n### Bedrock Specific Parameters\n### Bedrock uses AWS credentials from the environment / AWS credential chain.\n### It does not use LLM_BINDING_API_KEY.\n# # LLM_BINDING=aws_bedrock\n# # LLM_MODEL=anthropic.claude-3-5-sonnet-20241022-v2:0\n# AWS_ACCESS_KEY_ID=your_aws_access_key_id\n# AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key\n# AWS_SESSION_TOKEN=your_optional_aws_session_token\n# AWS_REGION=us-east-1\n# BEDROCK_LLM_TEMPERATURE=1.0\n\n#######################################################################################\n### Embedding Configuration (Should not be changed after the first file processed)\n### EMBEDDING_BINDING: ollama, openai, azure_openai, jina, lollms, aws_bedrock\n### EMBEDDING_BINDING_HOST: Service endpoint (left empty if using default endpoint provided by openai or gemini SDK)\n### EMBEDDING_BINDING_API_KEY: api key\n### If LightRAG deployed in Docker:\n###    uses host.docker.internal instead of localhost in EMBEDDING_BINDING_HOST\n### Control whether to send embedding_dim parameter to embedding API\n###    For OpenAI: Set EMBEDDING_SEND_DIM=true to enable dynamic dimension adjustment\n###    For OpenAI: Set EMBEDDING_SEND_DIM=false (default) to disable sending dimension parameter\n###    For Gemini: Allways set EMBEDDING_SEND_DIM=true\n#######################################################################################\n# EMBEDDING_TIMEOUT=30\n\n### OpenAI compatible embedding\nEMBEDDING_BINDING=openai\nEMBEDDING_BINDING_HOST=https://api.openai.com/v1\nEMBEDDING_BINDING_API_KEY=your_api_key\nEMBEDDING_MODEL=text-embedding-3-large\nEMBEDDING_DIM=3072\nEMBEDDING_TOKEN_LIMIT=8192\nEMBEDDING_SEND_DIM=false\n\n### Optional for Azure Embedding\n### Use deployment name as model name or set AZURE_EMBEDDING_DEPLOYMENT instead\n# # EMBEDDING_BINDING=azure_openai\n# # EMBEDDING_BINDING_HOST=https://xxxx.openai.azure.com/\n# # EMBEDDING_API_KEY=your_api_key\n# # EMBEDDING_MODEL==my-text-embedding-3-large-deployment\n# # EMBEDDING_DIM=3072\n# AZURE_EMBEDDING_API_VERSION=2024-08-01-preview\n\n### Gemini embedding\n# # EMBEDDING_BINDING=gemini\n# # EMBEDDING_MODEL=gemini-embedding-001\n# # EMBEDDING_DIM=1536\n# # EMBEDDING_TOKEN_LIMIT=2048\n# # EMBEDDING_BINDING_HOST=https://generativelanguage.googleapis.com\n# # EMBEDDING_BINDING_API_KEY=your_api_key\n### Gemini embedding requires sending dimension to server\n# # EMBEDDING_SEND_DIM=true\n\n### Ollama embedding\n# # EMBEDDING_BINDING=ollama\n# # EMBEDDING_BINDING_HOST=http://localhost:11434\n# # EMBEDDING_BINDING_API_KEY=your_api_key\n# # EMBEDDING_MODEL=qwen3-embedding:4b\n# # EMBEDDING_DIM=2560\n### Optional for Ollama embedding\nOLLAMA_EMBEDDING_NUM_CTX=8192\n### use the following command to see all support options for Ollama embedding\n### lightrag-server --embedding-binding ollama --help\n\n### Bedrock embedding\n### Bedrock uses AWS credentials from the environment / AWS credential chain.\n### It does not use EMBEDDING_BINDING_API_KEY.\n# # EMBEDDING_BINDING=aws_bedrock\n# # EMBEDDING_MODEL=amazon.titan-embed-text-v2:0\n# # EMBEDDING_DIM=1024\n# AWS_ACCESS_KEY_ID=your_aws_access_key_id\n# AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key\n# AWS_SESSION_TOKEN=your_optional_aws_session_token\n# AWS_REGION=us-east-1\n\n### Jina AI Embedding\n# # EMBEDDING_BINDING=jina\n# # EMBEDDING_BINDING_HOST=https://api.jina.ai/v1/embeddings\n# # EMBEDDING_MODEL=jina-embeddings-v4\n# # EMBEDDING_DIM=2048\n# # EMBEDDING_BINDING_API_KEY=your_api_key\n\n####################################################################\n### WORKSPACE sets workspace name for all storage types\n### for the purpose of isolating data from LightRAG instances.\n### Valid workspace name constraints: a-z, A-Z, 0-9, and _\n####################################################################\n# WORKSPACE=\n\n############################\n### Data storage selection\n############################\n### Default storage (Recommended for small scale deployment)\n# LIGHTRAG_KV_STORAGE=JsonKVStorage\n# LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\n# LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\n# LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\n\n### Wizard metadata used to preserve env-storage Docker deployment defaults across setup reruns\n# LIGHTRAG_SETUP_POSTGRES_DEPLOYMENT=docker\n# LIGHTRAG_SETUP_NEO4J_DEPLOYMENT=docker\n# LIGHTRAG_SETUP_MONGODB_DEPLOYMENT=docker\n# LIGHTRAG_SETUP_MONGODB_DEPLOYMENT=atlas-capable\n# LIGHTRAG_SETUP_REDIS_DEPLOYMENT=docker\n# LIGHTRAG_SETUP_MILVUS_DEPLOYMENT=docker\n# LIGHTRAG_SETUP_QDRANT_DEPLOYMENT=docker\n# LIGHTRAG_SETUP_MEMGRAPH_DEPLOYMENT=docker\n# LIGHTRAG_SETUP_OPENSEARCH_DEPLOYMENT=docker\n\n### Redis Storage (Recommended for production deployment)\n# # LIGHTRAG_KV_STORAGE=RedisKVStorage\n# # LIGHTRAG_DOC_STATUS_STORAGE=RedisDocStatusStorage\n\n### Vector Storage (Recommended for production deployment)\n# # LIGHTRAG_VECTOR_STORAGE=MilvusVectorDBStorage\n# # LIGHTRAG_VECTOR_STORAGE=QdrantVectorDBStorage\n# # LIGHTRAG_VECTOR_STORAGE=FaissVectorDBStorage\n\n### Graph Storage (Recommended for production deployment)\n# # LIGHTRAG_GRAPH_STORAGE=Neo4JStorage\n# # LIGHTRAG_GRAPH_STORAGE=MemgraphStorage\n\n### Select OpenSearch for all storages\n# # LIGHTRAG_KV_STORAGE=OpenSearchKVStorage\n# # LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage\n# # LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage\n# # LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage\n\n### Select PostgreSQL for all storages\n# # LIGHTRAG_KV_STORAGE=PGKVStorage\n# # LIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage\n# # LIGHTRAG_GRAPH_STORAGE=PGGraphStorage\n# # LIGHTRAG_VECTOR_STORAGE=PGVectorStorage\n\n### Select MongoDB for all storage (Vector storage requires an Atlas-capable deployment)\n# # LIGHTRAG_KV_STORAGE=MongoKVStorage\n# # LIGHTRAG_DOC_STATUS_STORAGE=MongoDocStatusStorage\n# # LIGHTRAG_GRAPH_STORAGE=MongoGraphStorage\n# # LIGHTRAG_VECTOR_STORAGE=MongoVectorDBStorage\n\n### PostgreSQL Configuration\nPOSTGRES_HOST=localhost\nPOSTGRES_PORT=5432\nPOSTGRES_USER=your_username\nPOSTGRES_PASSWORD='your_password'\nPOSTGRES_DATABASE=rag\nPOSTGRES_MAX_CONNECTIONS=12\n### DB specific workspace should not be set, keep for compatible only\n# POSTGRES_WORKSPACE=forced_workspace_name\n\n### PostgreSQL Vector Storage Configuration\n### Enable/disable vector features (default: true for backward compatibility)\n### Set to false to disable pgvector extension and vector operations when using PostgreSQL\n### only for KV/Graph/DocStatus storage with a different vector backend (e.g., Milvus, Qdrant)\nPOSTGRES_ENABLE_VECTOR=true\n### Vector storage type: HNSW, IVFFlat, VCHORDRQ\nPOSTGRES_VECTOR_INDEX_TYPE=HNSW\nPOSTGRES_HNSW_M=16\nPOSTGRES_HNSW_EF=200\nPOSTGRES_IVFFLAT_LISTS=100\nPOSTGRES_VCHORDRQ_BUILD_OPTIONS=\nPOSTGRES_VCHORDRQ_PROBES=\nPOSTGRES_VCHORDRQ_EPSILON=1.9\n\n### PostgreSQL Connection Retry Configuration (Network Robustness)\n### NEW DEFAULTS (v1.4.10+): Optimized for HA deployments with ~30s switchover time\n### These defaults provide out-of-the-box support for PostgreSQL High Availability setups\n###\n### Number of retry attempts (1-100, default: 10)\n###   - Default 10 attempts allows ~225s total retry time (sufficient for most HA scenarios)\n###   - For extreme cases: increase up to 20-50\n### Initial retry backoff in seconds (0.1-300.0, default: 3.0)\n###   - Default 3.0s provides reasonable initial delay for switchover detection\n###   - For faster recovery: decrease to 1.0-2.0\n### Maximum retry backoff in seconds (must be >= backoff, max: 600.0, default: 30.0)\n###   - Default 30.0s matches typical switchover completion time\n###   - For longer switchovers: increase to 60-90\n### Connection pool close timeout in seconds (1.0-30.0, default: 5.0)\n# POSTGRES_CONNECTION_RETRIES=10\n# POSTGRES_CONNECTION_RETRY_BACKOFF=3.0\n# POSTGRES_CONNECTION_RETRY_BACKOFF_MAX=30.0\n# POSTGRES_POOL_CLOSE_TIMEOUT=5.0\n\n### PostgreSQL SSL Configuration (Optional)\n# POSTGRES_SSL_MODE=require\n# POSTGRES_SSL_CERT=/path/to/client-cert.pem\n# POSTGRES_SSL_KEY=/path/to/client-key.pem\n# POSTGRES_SSL_ROOT_CERT=/path/to/ca-cert.pem\n# POSTGRES_SSL_CRL=/path/to/crl.pem\n\n### PostgreSQL Server Settings (for Supabase Supavisor)\n# Use this to pass extra options to the PostgreSQL connection string.\n# For Supabase, you might need to set it like this:\n# POSTGRES_SERVER_SETTINGS=\"options=reference%3D[project-ref]\"\n\n# Default is 100 set to 0 to disable\n# POSTGRES_STATEMENT_CACHE_SIZE=100\n\n### Neo4j Configuration\nNEO4J_URI=neo4j+s://xxxxxxxx.databases.neo4j.io\nNEO4J_USERNAME=neo4j\nNEO4J_PASSWORD='your_password'\nNEO4J_DATABASE=neo4j\nNEO4J_MAX_CONNECTION_POOL_SIZE=100\nNEO4J_CONNECTION_TIMEOUT=30\nNEO4J_CONNECTION_ACQUISITION_TIMEOUT=30\nNEO4J_MAX_TRANSACTION_RETRY_TIME=30\nNEO4J_MAX_CONNECTION_LIFETIME=300\nNEO4J_LIVENESS_CHECK_TIMEOUT=30\nNEO4J_KEEP_ALIVE=true\n### DB specific workspace should not be set, keep for compatible only\n# NEO4J_WORKSPACE=forced_workspace_name\n\n### MongoDB Configuration\n# For MongoVectorDBStorage, MONGO_URI must point to a MongoDB endpoint with\n# Atlas Search / Vector Search support, such as MongoDB Atlas or Atlas local.\nMONGO_URI=mongodb://localhost:27017/\nMONGO_DATABASE=LightRAG\n### DB specific workspace should not be set, keep for compatible only\n# MONGODB_WORKSPACE=forced_workspace_name\n\n# Community/local Docker MongoDB example for KV, graph, or doc-status storage only:\n# MONGO_URI=mongodb://localhost:27017/\n\n### OpenSearch Configuration\n### OpenSearch can be used for all storage types: KV, Vector, Graph, DocStatus\n# # LIGHTRAG_KV_STORAGE=OpenSearchKVStorage\n# # LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage\n# # LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage\n# # LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage\n### Connection settings (comma-separated host:port entries; do not include http:// or https://)\n### This setup wizard supports authenticated OpenSearch clusters only.\n### OPENSEARCH_USE_SSL controls whether those hosts are reached over TLS.\nOPENSEARCH_HOSTS=localhost:9200\nOPENSEARCH_USER=admin\nOPENSEARCH_PASSWORD=LightRAG2026_!@\nOPENSEARCH_USE_SSL=true\nOPENSEARCH_VERIFY_CERTS=false\n# OPENSEARCH_TIMEOUT=30\n# OPENSEARCH_MAX_RETRIES=3\n### k-NN Settings for Vector Storage (HNSW algorithm)\n# OPENSEARCH_KNN_EF_CONSTRUCTION=200\n# OPENSEARCH_KNN_M=16\n# OPENSEARCH_KNN_EF_SEARCH=100\n### PPL graphlookup for server-side graph traversal (auto-detected if not set)\n# OPENSEARCH_USE_PPL_GRAPHLOOKUP=true\n### DB specific workspace should not be set, keep for compatible only\n# OPENSEARCH_WORKSPACE=forced_workspace_name\n\n### Milvus Configuration\nMILVUS_URI=http://localhost:19530\nMILVUS_DB_NAME=lightrag\n# MILVUS_DEVICE=cpu\n# MILVUS_USER=root\n# MILVUS_PASSWORD=your_password\n# MILVUS_TOKEN=your_token\n# Required for the bundled Docker Milvus stack; may come from .env or exported shell variables.\n# MINIO_ACCESS_KEY_ID=minioadmin\n# MINIO_SECRET_ACCESS_KEY=minioadmin\n### DB specific workspace should not be set, keep for compatible only\n# MILVUS_WORKSPACE=forced_workspace_name\n\n### Milvus Vector Index Configuration\n### Index type: AUTOINDEX (default), HNSW, HNSW_SQ, HNSW_PQ, IVF_FLAT, IVF_SQ8, DISKANN\n# MILVUS_INDEX_TYPE=AUTOINDEX\n\n### Metric type: COSINE (default), L2, IP\n# MILVUS_METRIC_TYPE=COSINE\n\n### HNSW / HNSW_SQ / HNSW_PQ Parameters (aligned with Milvus 2.4+ defaults)\n### M: Maximum number of connections per node [2-2048], default 16\n# MILVUS_HNSW_M=16\n### efConstruction: Size of dynamic candidate list during build [8-512], default 360\n# MILVUS_HNSW_EF_CONSTRUCTION=360\n### ef: Size of dynamic candidate list during search, default 200\n# MILVUS_HNSW_EF=200\n\n### HNSW_SQ Specific Parameters (requires Milvus 2.6.8+)\n### sq_type: Scalar quantization type - SQ4U, SQ6, SQ8 (default), BF16, FP16\n# MILVUS_HNSW_SQ_TYPE=SQ8\n### refine: Enable refinement step for higher precision, default false\n# MILVUS_HNSW_SQ_REFINE=false\n### refine_type: Refinement precision (must be higher than sq_type) - SQ6, SQ8, BF16, FP16, FP32\n# MILVUS_HNSW_SQ_REFINE_TYPE=FP32\n### refine_k: Refinement expansion factor, default 10\n# MILVUS_HNSW_SQ_REFINE_K=10\n\n### IVF_FLAT / IVF_SQ8 Parameters\n### nlist: Number of cluster units [1-65536], recommended sqrt(n) for n>1M, default 1024\n# MILVUS_IVF_NLIST=1024\n### nprobe: Number of units to query [1-nlist], default 16\n# MILVUS_IVF_NPROBE=16\n\n### Qdrant\nQDRANT_URL=http://localhost:6333\n# QDRANT_DEVICE=cpu\n# QDRANT_API_KEY=your-api-key\n### Qdrant upsert batching (enabled by default)\n### Split large upserts by estimated JSON payload size and point count\n### Default 16MB keeps safe headroom below common 32MB gateway/request limits\n# QDRANT_UPSERT_MAX_PAYLOAD_BYTES=16777216\n# QDRANT_UPSERT_MAX_POINTS_PER_BATCH=128\n### DB specific workspace should not be set, keep for compatible only\n# QDRANT_WORKSPACE=forced_workspace_name\n\n### Redis\nREDIS_URI=redis://localhost:6379\nREDIS_SOCKET_TIMEOUT=30\nREDIS_CONNECT_TIMEOUT=10\nREDIS_MAX_CONNECTIONS=100\nREDIS_RETRY_ATTEMPTS=3\n### DB specific workspace should not be set, keep for compatible only\n# REDIS_WORKSPACE=forced_workspace_name\n\n### Memgraph Configuration\nMEMGRAPH_URI=bolt://localhost:7687\nMEMGRAPH_USERNAME=\nMEMGRAPH_PASSWORD=\nMEMGRAPH_DATABASE=memgraph\n### DB specific workspace should not be set, keep for compatible only\n# MEMGRAPH_WORKSPACE=forced_workspace_name\n\n###########################################################\n### Langfuse Observability Configuration\n### Only works with LLM provided by OpenAI compatible API\n### Install with: pip install lightrag-hku[observability]\n### Sign up at: https://cloud.langfuse.com or self-host\n###########################################################\n# LANGFUSE_SECRET_KEY=\"\"\n# LANGFUSE_PUBLIC_KEY=\"\"\n# LANGFUSE_HOST=\"https://cloud.langfuse.com\"\n# LANGFUSE_ENABLE_TRACE=true\n\n############################\n### Evaluation Configuration\n############################\n### RAGAS evaluation models (used for RAG quality assessment)\n### ⚠️ IMPORTANT: Both LLM and Embedding endpoints MUST be OpenAI-compatible\n### Default uses OpenAI models for evaluation\n\n### LLM Configuration for Evaluation\n# EVAL_LLM_MODEL=gpt-4o-mini\n### API key for LLM evaluation (fallback to OPENAI_API_KEY if not set)\n# EVAL_LLM_BINDING_API_KEY=your_api_key\n### Custom OpenAI-compatible endpoint for LLM evaluation (optional)\n# EVAL_LLM_BINDING_HOST=https://api.openai.com/v1\n\n### Embedding Configuration for Evaluation\n# EVAL_EMBEDDING_MODEL=text-embedding-3-large\n### API key for embeddings (fallback: EVAL_LLM_BINDING_API_KEY -> OPENAI_API_KEY)\n# EVAL_EMBEDDING_BINDING_API_KEY=your_embedding_api_key\n### Custom OpenAI-compatible endpoint for embeddings (fallback: EVAL_LLM_BINDING_HOST)\n# EVAL_EMBEDDING_BINDING_HOST=https://api.openai.com/v1\n\n### Performance Tuning\n### Number of concurrent test case evaluations\n### Lower values reduce API rate limit issues but increase evaluation time\n# EVAL_MAX_CONCURRENT=2\n### TOP_K query parameter of LightRAG (default: 10)\n### Number of entities or relations retrieved from KG\n# EVAL_QUERY_TOP_K=10\n### LLM request retry and timeout settings for evaluation\n# EVAL_LLM_MAX_RETRIES=5\n# EVAL_LLM_TIMEOUT=180\n"
  },
  {
    "path": "examples/generate_query.py",
    "content": "from openai import OpenAI\n\n# os.environ[\"OPENAI_API_KEY\"] = \"\"\n\n\ndef openai_complete_if_cache(\n    model=\"gpt-4o-mini\", prompt=None, system_prompt=None, history_messages=[], **kwargs\n) -> str:\n    openai_client = OpenAI()\n\n    messages = []\n    if system_prompt:\n        messages.append({\"role\": \"system\", \"content\": system_prompt})\n    messages.extend(history_messages)\n    messages.append({\"role\": \"user\", \"content\": prompt})\n\n    response = openai_client.chat.completions.create(\n        model=model, messages=messages, **kwargs\n    )\n    return response.choices[0].message.content\n\n\nif __name__ == \"__main__\":\n    description = \"\"\n    prompt = f\"\"\"\n    Given the following description of a dataset:\n\n    {description}\n\n    Please identify 5 potential users who would engage with this dataset. For each user, list 5 tasks they would perform with this dataset. Then, for each (user, task) combination, generate 5 questions that require a high-level understanding of the entire dataset.\n\n    Output the results in the following structure:\n    - User 1: [user description]\n        - Task 1: [task description]\n            - Question 1:\n            - Question 2:\n            - Question 3:\n            - Question 4:\n            - Question 5:\n        - Task 2: [task description]\n            ...\n        - Task 5: [task description]\n    - User 2: [user description]\n        ...\n    - User 5: [user description]\n        ...\n    \"\"\"\n\n    result = openai_complete_if_cache(model=\"gpt-4o-mini\", prompt=prompt)\n\n    file_path = \"./queries.txt\"\n    with open(file_path, \"w\") as file:\n        file.write(result)\n\n    print(f\"Queries written to {file_path}\")\n"
  },
  {
    "path": "examples/graph_visual_with_html.py",
    "content": "import pipmaster as pm\n\nif not pm.is_installed(\"pyvis\"):\n    pm.install(\"pyvis\")\nif not pm.is_installed(\"networkx\"):\n    pm.install(\"networkx\")\n\nimport networkx as nx\nfrom pyvis.network import Network\nimport random\n\n# Load the GraphML file\nG = nx.read_graphml(\"./dickens/graph_chunk_entity_relation.graphml\")\n\n# Create a Pyvis network\nnet = Network(height=\"100vh\", notebook=True)\n\n# Convert NetworkX graph to Pyvis network\nnet.from_nx(G)\n\n\n# Add colors and title to nodes\nfor node in net.nodes:\n    node[\"color\"] = \"#{:06x}\".format(random.randint(0, 0xFFFFFF))\n    if \"description\" in node:\n        node[\"title\"] = node[\"description\"]\n\n# Add title to edges\nfor edge in net.edges:\n    if \"description\" in edge:\n        edge[\"title\"] = edge[\"description\"]\n\n# Save and display the network\nnet.show(\"knowledge_graph.html\")\n"
  },
  {
    "path": "examples/graph_visual_with_neo4j.py",
    "content": "import os\nimport json\nimport xml.etree.ElementTree as ET\nfrom neo4j import GraphDatabase\n\n# Constants\nWORKING_DIR = \"./dickens\"\nBATCH_SIZE_NODES = 500\nBATCH_SIZE_EDGES = 100\n\n# Neo4j connection credentials\nNEO4J_URI = \"bolt://localhost:7687\"\nNEO4J_USERNAME = \"neo4j\"\nNEO4J_PASSWORD = \"your_password\"\n\n\ndef xml_to_json(xml_file):\n    try:\n        tree = ET.parse(xml_file)\n        root = tree.getroot()\n\n        # Print the root element's tag and attributes to confirm the file has been correctly loaded\n        print(f\"Root element: {root.tag}\")\n        print(f\"Root attributes: {root.attrib}\")\n\n        data = {\"nodes\": [], \"edges\": []}\n\n        # Use namespace\n        namespace = {\"\": \"http://graphml.graphdrawing.org/xmlns\"}\n\n        for node in root.findall(\".//node\", namespace):\n            node_data = {\n                \"id\": node.get(\"id\").strip('\"'),\n                \"entity_type\": node.find(\"./data[@key='d1']\", namespace).text.strip('\"')\n                if node.find(\"./data[@key='d1']\", namespace) is not None\n                else \"\",\n                \"description\": node.find(\"./data[@key='d2']\", namespace).text\n                if node.find(\"./data[@key='d2']\", namespace) is not None\n                else \"\",\n                \"source_id\": node.find(\"./data[@key='d3']\", namespace).text\n                if node.find(\"./data[@key='d3']\", namespace) is not None\n                else \"\",\n            }\n            data[\"nodes\"].append(node_data)\n\n        for edge in root.findall(\".//edge\", namespace):\n            edge_data = {\n                \"source\": edge.get(\"source\").strip('\"'),\n                \"target\": edge.get(\"target\").strip('\"'),\n                \"weight\": float(edge.find(\"./data[@key='d5']\", namespace).text)\n                if edge.find(\"./data[@key='d5']\", namespace) is not None\n                else 0.0,\n                \"description\": edge.find(\"./data[@key='d6']\", namespace).text\n                if edge.find(\"./data[@key='d6']\", namespace) is not None\n                else \"\",\n                \"keywords\": edge.find(\"./data[@key='d9']\", namespace).text\n                if edge.find(\"./data[@key='d9']\", namespace) is not None\n                else \"\",\n                \"source_id\": edge.find(\"./data[@key='d8']\", namespace).text\n                if edge.find(\"./data[@key='d8']\", namespace) is not None\n                else \"\",\n            }\n            data[\"edges\"].append(edge_data)\n\n        # Print the number of nodes and edges found\n        print(f\"Found {len(data['nodes'])} nodes and {len(data['edges'])} edges\")\n\n        return data\n    except ET.ParseError as e:\n        print(f\"Error parsing XML file: {e}\")\n        return None\n    except Exception as e:\n        print(f\"An error occurred: {e}\")\n        return None\n\n\ndef convert_xml_to_json(xml_path, output_path):\n    \"\"\"Converts XML file to JSON and saves the output.\"\"\"\n    if not os.path.exists(xml_path):\n        print(f\"Error: File not found - {xml_path}\")\n        return None\n\n    json_data = xml_to_json(xml_path)\n    if json_data:\n        with open(output_path, \"w\", encoding=\"utf-8\") as f:\n            json.dump(json_data, f, ensure_ascii=False, indent=2)\n        print(f\"JSON file created: {output_path}\")\n        return json_data\n    else:\n        print(\"Failed to create JSON data\")\n        return None\n\n\ndef process_in_batches(tx, query, data, batch_size):\n    \"\"\"Process data in batches and execute the given query.\"\"\"\n    for i in range(0, len(data), batch_size):\n        batch = data[i : i + batch_size]\n        tx.run(query, {\"nodes\": batch} if \"nodes\" in query else {\"edges\": batch})\n\n\ndef main():\n    # Paths\n    xml_file = os.path.join(WORKING_DIR, \"graph_chunk_entity_relation.graphml\")\n    json_file = os.path.join(WORKING_DIR, \"graph_data.json\")\n\n    # Convert XML to JSON\n    json_data = convert_xml_to_json(xml_file, json_file)\n    if json_data is None:\n        return\n\n    # Load nodes and edges\n    nodes = json_data.get(\"nodes\", [])\n    edges = json_data.get(\"edges\", [])\n\n    # Neo4j queries\n    create_nodes_query = \"\"\"\n    UNWIND $nodes AS node\n    MERGE (e:Entity {id: node.id})\n    SET e.entity_type = node.entity_type,\n        e.description = node.description,\n        e.source_id = node.source_id,\n        e.displayName = node.id\n    REMOVE e:Entity\n    WITH e, node\n    CALL apoc.create.addLabels(e, [node.id]) YIELD node AS labeledNode\n    RETURN count(*)\n    \"\"\"\n\n    create_edges_query = \"\"\"\n    UNWIND $edges AS edge\n    MATCH (source {id: edge.source})\n    MATCH (target {id: edge.target})\n    WITH source, target, edge,\n         CASE\n            WHEN edge.keywords CONTAINS 'lead' THEN 'lead'\n            WHEN edge.keywords CONTAINS 'participate' THEN 'participate'\n            WHEN edge.keywords CONTAINS 'uses' THEN 'uses'\n            WHEN edge.keywords CONTAINS 'located' THEN 'located'\n            WHEN edge.keywords CONTAINS 'occurs' THEN 'occurs'\n           ELSE REPLACE(SPLIT(edge.keywords, ',')[0], '\\\"', '')\n         END AS relType\n    CALL apoc.create.relationship(source, relType, {\n      weight: edge.weight,\n      description: edge.description,\n      keywords: edge.keywords,\n      source_id: edge.source_id\n    }, target) YIELD rel\n    RETURN count(*)\n    \"\"\"\n\n    set_displayname_and_labels_query = \"\"\"\n    MATCH (n)\n    SET n.displayName = n.id\n    WITH n\n    CALL apoc.create.setLabels(n, [n.entity_type]) YIELD node\n    RETURN count(*)\n    \"\"\"\n\n    # Create a Neo4j driver\n    driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USERNAME, NEO4J_PASSWORD))\n\n    try:\n        # Execute queries in batches\n        with driver.session() as session:\n            # Insert nodes in batches\n            session.execute_write(\n                process_in_batches, create_nodes_query, nodes, BATCH_SIZE_NODES\n            )\n\n            # Insert edges in batches\n            session.execute_write(\n                process_in_batches, create_edges_query, edges, BATCH_SIZE_EDGES\n            )\n\n            # Set displayName and labels\n            session.run(set_displayname_and_labels_query)\n\n    except Exception as e:\n        print(f\"Error occurred: {e}\")\n\n    finally:\n        driver.close()\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/graph_visual_with_opensearch.py",
    "content": "\"\"\"\nKnowledge Graph Visualization with OpenSearch + LightRAG WebUI\n\nThis script demonstrates two ways to visualize the knowledge graph\nstored in OpenSearch:\n\n1. **WebUI (recommended)**: Opens the LightRAG WebUI in your browser\n   for interactive graph exploration with search, filtering, and\n   force-directed layout.\n\n2. **Standalone HTML**: Fetches graph data from the LightRAG Server API\n   and generates an interactive HTML file using Pyvis, similar to\n   graph_visual_with_html.py but reading from OpenSearch instead of\n   a local .graphml file.\n\nPrerequisites:\n    1. LightRAG Server running with OpenSearch storage:\n       lightrag-server --host 0.0.0.0 --port 9621\n\n    2. Documents already indexed (e.g., via the WebUI or API)\n\nUsage:\n    # Open WebUI for interactive exploration\n    python examples/graph_visual_with_opensearch.py\n\n    # Generate standalone HTML file\n    python examples/graph_visual_with_opensearch.py --html\n\n    # Custom server URL and output file\n    python examples/graph_visual_with_opensearch.py --html --server http://localhost:9621 --output my_graph.html\n\"\"\"\n\nimport argparse\nimport os\nimport sys\nimport webbrowser\n\nimport pipmaster as pm\n\nif not pm.is_installed(\"requests\"):\n    pm.install(\"requests\")\nif not pm.is_installed(\"pyvis\"):\n    pm.install(\"pyvis\")\n\nimport requests\nfrom pyvis.network import Network\n\n\ndef fetch_graph(server_url: str, label: str = \"*\", max_nodes: int = 300) -> dict:\n    \"\"\"Fetch knowledge graph data from LightRAG Server API.\"\"\"\n    url = f\"{server_url}/graphs\"\n    params = {\"label\": label, \"max_nodes\": max_nodes}\n    resp = requests.get(url, params=params, timeout=30)\n    resp.raise_for_status()\n    return resp.json()\n\n\ndef generate_html(graph_data: dict, output_file: str) -> str:\n    \"\"\"Generate an interactive HTML visualization from graph data.\"\"\"\n    nodes = graph_data.get(\"nodes\", [])\n    edges = graph_data.get(\"edges\", [])\n\n    if not nodes:\n        print(\"No nodes found in the graph. Index some documents first.\")\n        sys.exit(1)\n\n    print(f\"Building visualization: {len(nodes)} nodes, {len(edges)} edges\")\n\n    net = Network(height=\"100vh\", notebook=False, cdn_resources=\"in_line\")\n\n    # Add nodes with colors based on entity type\n    import hashlib\n\n    for node in nodes:\n        node_id = node.get(\"id\", \"\")\n        props = node.get(\"properties\", {})\n        entity_type = props.get(\"entity_type\", \"unknown\")\n        description = props.get(\"description\", \"\")\n\n        # Deterministic color from entity type\n        color_hash = int(hashlib.md5(entity_type.encode()).hexdigest()[:6], 16)\n        color = f\"#{color_hash:06x}\"\n\n        net.add_node(\n            node_id,\n            label=node_id,\n            title=f\"[{entity_type}] {description[:200]}\"\n            if description\n            else entity_type,\n            color=color,\n        )\n\n    # Add edges\n    for edge in edges:\n        source = edge.get(\"source\", \"\")\n        target = edge.get(\"target\", \"\")\n        props = edge.get(\"properties\", {})\n        rel_type = edge.get(\"type\", \"\")\n        description = props.get(\"description\", \"\")\n\n        net.add_edge(\n            source,\n            target,\n            title=f\"[{rel_type}] {description[:200]}\" if description else rel_type,\n            label=rel_type,\n        )\n\n    net.save_graph(output_file)\n    print(f\"Graph saved to {output_file}\")\n    return output_file\n\n\ndef main():\n    parser = argparse.ArgumentParser(\n        description=\"Visualize LightRAG knowledge graph from OpenSearch\"\n    )\n    parser.add_argument(\n        \"--html\",\n        action=\"store_true\",\n        help=\"Generate standalone HTML file instead of opening WebUI\",\n    )\n    parser.add_argument(\n        \"--server\",\n        default=\"http://localhost:9621\",\n        help=\"LightRAG Server URL (default: http://localhost:9621)\",\n    )\n    parser.add_argument(\n        \"--output\",\n        default=\"knowledge_graph_opensearch.html\",\n        help=\"Output HTML file (default: knowledge_graph_opensearch.html)\",\n    )\n    parser.add_argument(\n        \"--label\",\n        default=\"*\",\n        help=\"Starting node label, or '*' for all nodes (default: *)\",\n    )\n    parser.add_argument(\n        \"--max-nodes\",\n        type=int,\n        default=300,\n        help=\"Maximum nodes to fetch (default: 300)\",\n    )\n    args = parser.parse_args()\n\n    # Verify server is running\n    try:\n        requests.get(f\"{args.server}/health\", timeout=5)\n    except requests.ConnectionError:\n        print(f\"Error: Cannot connect to LightRAG Server at {args.server}\")\n        print(\"Start the server first: lightrag-server --host 0.0.0.0 --port 9621\")\n        sys.exit(1)\n\n    if args.html:\n        # Generate standalone HTML\n        graph_data = fetch_graph(args.server, args.label, args.max_nodes)\n        output = generate_html(graph_data, args.output)\n        webbrowser.open(f\"file://{os.path.abspath(output)}\")\n    else:\n        # Open WebUI graph explorer\n        url = f\"{args.server}/#/graph\"\n        print(f\"Opening LightRAG WebUI graph explorer: {url}\")\n        webbrowser.open(url)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/insert_custom_kg.py",
    "content": "import os\nfrom lightrag import LightRAG\nfrom lightrag.llm.openai import gpt_4o_mini_complete\n#########\n# Uncomment the below two lines if running in a jupyter notebook to handle the async nature of rag.insert()\n# import nest_asyncio\n# nest_asyncio.apply()\n#########\n\nWORKING_DIR = \"./custom_kg\"\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\nrag = LightRAG(\n    working_dir=WORKING_DIR,\n    llm_model_func=gpt_4o_mini_complete,  # Use gpt_4o_mini_complete LLM model\n    # llm_model_func=gpt_4o_complete  # Optionally, use a stronger model\n)\n\ncustom_kg = {\n    \"entities\": [\n        {\n            \"entity_name\": \"CompanyA\",\n            \"entity_type\": \"Organization\",\n            \"description\": \"A major technology company\",\n            \"source_id\": \"Source1\",\n        },\n        {\n            \"entity_name\": \"ProductX\",\n            \"entity_type\": \"Product\",\n            \"description\": \"A popular product developed by CompanyA\",\n            \"source_id\": \"Source1\",\n        },\n        {\n            \"entity_name\": \"PersonA\",\n            \"entity_type\": \"Person\",\n            \"description\": \"A renowned researcher in AI\",\n            \"source_id\": \"Source2\",\n        },\n        {\n            \"entity_name\": \"UniversityB\",\n            \"entity_type\": \"Organization\",\n            \"description\": \"A leading university specializing in technology and sciences\",\n            \"source_id\": \"Source2\",\n        },\n        {\n            \"entity_name\": \"CityC\",\n            \"entity_type\": \"Location\",\n            \"description\": \"A large metropolitan city known for its culture and economy\",\n            \"source_id\": \"Source3\",\n        },\n        {\n            \"entity_name\": \"EventY\",\n            \"entity_type\": \"Event\",\n            \"description\": \"An annual technology conference held in CityC\",\n            \"source_id\": \"Source3\",\n        },\n    ],\n    \"relationships\": [\n        {\n            \"src_id\": \"CompanyA\",\n            \"tgt_id\": \"ProductX\",\n            \"description\": \"CompanyA develops ProductX\",\n            \"keywords\": \"develop, produce\",\n            \"weight\": 1.0,\n            \"source_id\": \"Source1\",\n        },\n        {\n            \"src_id\": \"PersonA\",\n            \"tgt_id\": \"UniversityB\",\n            \"description\": \"PersonA works at UniversityB\",\n            \"keywords\": \"employment, affiliation\",\n            \"weight\": 0.9,\n            \"source_id\": \"Source2\",\n        },\n        {\n            \"src_id\": \"CityC\",\n            \"tgt_id\": \"EventY\",\n            \"description\": \"EventY is hosted in CityC\",\n            \"keywords\": \"host, location\",\n            \"weight\": 0.8,\n            \"source_id\": \"Source3\",\n        },\n    ],\n    \"chunks\": [\n        {\n            \"content\": \"ProductX, developed by CompanyA, has revolutionized the market with its cutting-edge features.\",\n            \"source_id\": \"Source1\",\n            \"source_chunk_index\": 0,\n        },\n        {\n            \"content\": \"One outstanding feature of ProductX is its advanced AI capabilities.\",\n            \"source_id\": \"Source1\",\n            \"chunk_order_index\": 1,\n        },\n        {\n            \"content\": \"PersonA is a prominent researcher at UniversityB, focusing on artificial intelligence and machine learning.\",\n            \"source_id\": \"Source2\",\n            \"source_chunk_index\": 0,\n        },\n        {\n            \"content\": \"EventY, held in CityC, attracts technology enthusiasts and companies from around the globe.\",\n            \"source_id\": \"Source3\",\n            \"source_chunk_index\": 0,\n        },\n        {\n            \"content\": \"None\",\n            \"source_id\": \"UNKNOWN\",\n            \"source_chunk_index\": 0,\n        },\n    ],\n}\n\nrag.insert_custom_kg(custom_kg)\n"
  },
  {
    "path": "examples/lightrag_azure_openai_demo.py",
    "content": "import os\nimport asyncio\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.utils import EmbeddingFunc\nimport numpy as np\nfrom dotenv import load_dotenv\nimport logging\nfrom openai import AzureOpenAI\n\nlogging.basicConfig(level=logging.INFO)\n\nload_dotenv()\n\nAZURE_OPENAI_API_VERSION = os.getenv(\"AZURE_OPENAI_API_VERSION\")\nAZURE_OPENAI_DEPLOYMENT = os.getenv(\"AZURE_OPENAI_DEPLOYMENT\")\nAZURE_OPENAI_API_KEY = os.getenv(\"AZURE_OPENAI_API_KEY\")\nAZURE_OPENAI_ENDPOINT = os.getenv(\"AZURE_OPENAI_ENDPOINT\")\n\nAZURE_EMBEDDING_DEPLOYMENT = os.getenv(\"AZURE_EMBEDDING_DEPLOYMENT\")\nAZURE_EMBEDDING_API_VERSION = os.getenv(\"AZURE_EMBEDDING_API_VERSION\")\n\nWORKING_DIR = \"./dickens\"\n\nif os.path.exists(WORKING_DIR):\n    import shutil\n\n    shutil.rmtree(WORKING_DIR)\n\nos.mkdir(WORKING_DIR)\n\n\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], keyword_extraction=False, **kwargs\n) -> str:\n    client = AzureOpenAI(\n        api_key=AZURE_OPENAI_API_KEY,\n        api_version=AZURE_OPENAI_API_VERSION,\n        azure_endpoint=AZURE_OPENAI_ENDPOINT,\n    )\n\n    messages = []\n    if system_prompt:\n        messages.append({\"role\": \"system\", \"content\": system_prompt})\n    if history_messages:\n        messages.extend(history_messages)\n    messages.append({\"role\": \"user\", \"content\": prompt})\n\n    chat_completion = client.chat.completions.create(\n        model=AZURE_OPENAI_DEPLOYMENT,  # model = \"deployment_name\".\n        messages=messages,\n        temperature=kwargs.get(\"temperature\", 0),\n        top_p=kwargs.get(\"top_p\", 1),\n        n=kwargs.get(\"n\", 1),\n    )\n    return chat_completion.choices[0].message.content\n\n\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    client = AzureOpenAI(\n        api_key=AZURE_OPENAI_API_KEY,\n        api_version=AZURE_EMBEDDING_API_VERSION,\n        azure_endpoint=AZURE_OPENAI_ENDPOINT,\n    )\n    embedding = client.embeddings.create(model=AZURE_EMBEDDING_DEPLOYMENT, input=texts)\n\n    embeddings = [item.embedding for item in embedding.data]\n    return np.array(embeddings)\n\n\nasync def test_funcs():\n    result = await llm_model_func(\"How are you?\")\n    print(\"Resposta do llm_model_func: \", result)\n\n    result = await embedding_func([\"How are you?\"])\n    print(\"Resultado do embedding_func: \", result.shape)\n    print(\"Dimensão da embedding: \", result.shape[1])\n\n\nasyncio.run(test_funcs())\n\nembedding_dimension = 3072\n\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=llm_model_func,\n        embedding_func=EmbeddingFunc(\n            embedding_dim=embedding_dimension,\n            max_token_size=8192,\n            func=embedding_func,\n        ),\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\ndef main():\n    rag = asyncio.run(initialize_rag())\n\n    book1 = open(\"./book_1.txt\", encoding=\"utf-8\")\n    book2 = open(\"./book_2.txt\", encoding=\"utf-8\")\n\n    rag.insert([book1.read(), book2.read()])\n\n    query_text = \"What are the main themes?\"\n\n    print(\"Result (Naive):\")\n    print(rag.query(query_text, param=QueryParam(mode=\"naive\")))\n\n    print(\"\\nResult (Local):\")\n    print(rag.query(query_text, param=QueryParam(mode=\"local\")))\n\n    print(\"\\nResult (Global):\")\n    print(rag.query(query_text, param=QueryParam(mode=\"global\")))\n\n    print(\"\\nResult (Hybrid):\")\n    print(rag.query(query_text, param=QueryParam(mode=\"hybrid\")))\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/lightrag_gemini_demo.py",
    "content": "\"\"\"\nLightRAG Demo with Google Gemini Models\n\nThis example demonstrates how to use LightRAG with Google's Gemini 2.0 Flash model\nfor text generation and the text-embedding-004 model for embeddings.\n\nPrerequisites:\n    1. Set GEMINI_API_KEY environment variable:\n       export GEMINI_API_KEY='your-actual-api-key'\n\n    2. Prepare a text file named 'book.txt' in the current directory\n       (or modify BOOK_FILE constant to point to your text file)\n\nUsage:\n    python examples/lightrag_gemini_demo.py\n\"\"\"\n\nimport os\nimport asyncio\nimport nest_asyncio\nimport numpy as np\n\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.gemini import gemini_model_complete, gemini_embed\nfrom lightrag.utils import wrap_embedding_func_with_attrs\n\nnest_asyncio.apply()\n\nWORKING_DIR = \"./rag_storage\"\nBOOK_FILE = \"./book.txt\"\n\n# Validate API key\nGEMINI_API_KEY = os.environ.get(\"GEMINI_API_KEY\")\nif not GEMINI_API_KEY:\n    raise ValueError(\n        \"GEMINI_API_KEY environment variable is not set. \"\n        \"Please set it with: export GEMINI_API_KEY='your-api-key'\"\n    )\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n\n# --------------------------------------------------\n# LLM function\n# --------------------------------------------------\nasync def llm_model_func(prompt, system_prompt=None, history_messages=[], **kwargs):\n    return await gemini_model_complete(\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=GEMINI_API_KEY,\n        model_name=\"gemini-2.0-flash\",\n        **kwargs,\n    )\n\n\n# --------------------------------------------------\n# Embedding function\n# --------------------------------------------------\n@wrap_embedding_func_with_attrs(\n    embedding_dim=768,\n    send_dimensions=True,\n    max_token_size=2048,\n    model_name=\"models/text-embedding-004\",\n)\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await gemini_embed.func(\n        texts, api_key=GEMINI_API_KEY, model=\"models/text-embedding-004\"\n    )\n\n\n# --------------------------------------------------\n# Initialize RAG\n# --------------------------------------------------\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=llm_model_func,\n        embedding_func=embedding_func,\n        llm_model_name=\"gemini-2.0-flash\",\n    )\n\n    # 🔑 REQUIRED\n    await rag.initialize_storages()\n    return rag\n\n\n# --------------------------------------------------\n# Main\n# --------------------------------------------------\ndef main():\n    # Validate book file exists\n    if not os.path.exists(BOOK_FILE):\n        raise FileNotFoundError(\n            f\"'{BOOK_FILE}' not found. \"\n            \"Please provide a text file to index in the current directory.\"\n        )\n\n    rag = asyncio.run(initialize_rag())\n\n    # Insert text\n    with open(BOOK_FILE, \"r\", encoding=\"utf-8\") as f:\n        rag.insert(f.read())\n\n    query = \"What are the top themes?\"\n\n    print(\"\\nNaive Search:\")\n    print(rag.query(query, param=QueryParam(mode=\"naive\")))\n\n    print(\"\\nLocal Search:\")\n    print(rag.query(query, param=QueryParam(mode=\"local\")))\n\n    print(\"\\nGlobal Search:\")\n    print(rag.query(query, param=QueryParam(mode=\"global\")))\n\n    print(\"\\nHybrid Search:\")\n    print(rag.query(query, param=QueryParam(mode=\"hybrid\")))\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/lightrag_gemini_postgres_demo.py",
    "content": "\"\"\"\nLightRAG Demo with PostgreSQL + Google Gemini\n\nThis example demonstrates how to use LightRAG with:\n- Google Gemini (LLM + Embeddings)\n- PostgreSQL-backed storages for:\n  - Vector storage\n  - Graph storage\n  - KV storage\n  - Document status storage\n\nPrerequisites:\n1. PostgreSQL database running and accessible\n2. Required tables will be auto-created by LightRAG\n3. Set environment variables (example .env):\n\n   POSTGRES_HOST=localhost\n   POSTGRES_PORT=5432\n   POSTGRES_USER=admin\n   POSTGRES_PASSWORD=admin\n   POSTGRES_DATABASE=ai\n\n   LIGHTRAG_KV_STORAGE=PGKVStorage\n   LIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage\n   LIGHTRAG_GRAPH_STORAGE=PGGraphStorage\n   LIGHTRAG_VECTOR_STORAGE=PGVectorStorage\n\n   GEMINI_API_KEY=your-api-key\n\n4. Prepare a text file to index (default: Data/book-small.txt)\n\nUsage:\n    python examples/lightrag_postgres_demo.py\n\"\"\"\n\nimport os\nimport asyncio\nimport numpy as np\n\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.gemini import gemini_model_complete, gemini_embed\nfrom lightrag.utils import setup_logger, wrap_embedding_func_with_attrs\n\n\n# --------------------------------------------------\n# Logger\n# --------------------------------------------------\nsetup_logger(\"lightrag\", level=\"INFO\")\n\n\n# --------------------------------------------------\n# Config\n# --------------------------------------------------\nWORKING_DIR = \"./rag_storage\"\nBOOK_FILE = \"Data/book.txt\"\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\nGEMINI_API_KEY = os.getenv(\"GEMINI_API_KEY\")\nif not GEMINI_API_KEY:\n    raise ValueError(\"GEMINI_API_KEY environment variable is not set\")\n\n\n# --------------------------------------------------\n# LLM function (Gemini)\n# --------------------------------------------------\nasync def llm_model_func(\n    prompt,\n    system_prompt=None,\n    history_messages=[],\n    keyword_extraction=False,\n    **kwargs,\n) -> str:\n    return await gemini_model_complete(\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=GEMINI_API_KEY,\n        model_name=\"gemini-2.0-flash\",\n        **kwargs,\n    )\n\n\n# --------------------------------------------------\n# Embedding function (Gemini)\n# --------------------------------------------------\n@wrap_embedding_func_with_attrs(\n    embedding_dim=768,\n    max_token_size=2048,\n    model_name=\"models/text-embedding-004\",\n)\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await gemini_embed.func(\n        texts,\n        api_key=GEMINI_API_KEY,\n        model=\"models/text-embedding-004\",\n    )\n\n\n# --------------------------------------------------\n# Initialize RAG with PostgreSQL storages\n# --------------------------------------------------\nasync def initialize_rag() -> LightRAG:\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_name=\"gemini-2.0-flash\",\n        llm_model_func=llm_model_func,\n        embedding_func=embedding_func,\n        # Performance tuning\n        embedding_func_max_async=4,\n        embedding_batch_num=8,\n        llm_model_max_async=2,\n        # Chunking\n        chunk_token_size=1200,\n        chunk_overlap_token_size=100,\n        # PostgreSQL-backed storages\n        graph_storage=\"PGGraphStorage\",\n        vector_storage=\"PGVectorStorage\",\n        doc_status_storage=\"PGDocStatusStorage\",\n        kv_storage=\"PGKVStorage\",\n    )\n\n    # REQUIRED: initialize all storage backends\n    await rag.initialize_storages()\n    return rag\n\n\n# --------------------------------------------------\n# Main\n# --------------------------------------------------\nasync def main():\n    rag = None\n    try:\n        print(\"Initializing LightRAG with PostgreSQL + Gemini...\")\n        rag = await initialize_rag()\n\n        if not os.path.exists(BOOK_FILE):\n            raise FileNotFoundError(\n                f\"'{BOOK_FILE}' not found. Please provide a text file to index.\"\n            )\n\n        print(f\"\\nReading document: {BOOK_FILE}\")\n        with open(BOOK_FILE, \"r\", encoding=\"utf-8\") as f:\n            content = f.read()\n\n        print(f\"Loaded document ({len(content)} characters)\")\n\n        print(\"\\nInserting document into LightRAG (this may take some time)...\")\n        await rag.ainsert(content)\n        print(\"Document indexed successfully!\")\n\n        print(\"\\n\" + \"=\" * 60)\n        print(\"Running sample queries\")\n        print(\"=\" * 60)\n\n        query = \"What are the top themes in this document?\"\n\n        for mode in [\"naive\", \"local\", \"global\", \"hybrid\"]:\n            print(f\"\\n[{mode.upper()} MODE]\")\n            result = await rag.aquery(query, param=QueryParam(mode=mode))\n            print(result[:400] + \"...\" if len(result) > 400 else result)\n\n        print(\"\\nRAG system is ready for use!\")\n\n    except Exception as e:\n        print(\"An error occurred:\", e)\n        import traceback\n\n        traceback.print_exc()\n\n    finally:\n        if rag is not None:\n            await rag.finalize_storages()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/lightrag_gemini_workspace_demo.py",
    "content": "\"\"\"\nLightRAG Data Isolation Demo: Workspace Management\n\nThis example demonstrates how to maintain multiple isolated knowledge bases\nwithin a single application using LightRAG's 'workspace' feature.\n\nKey Concepts:\n- Workspace Isolation: Each RAG instance is assigned a unique workspace name,\n  which ensures that Knowledge Graphs, Vector Databases, and Chunks are\n  stored in separate, non-conflicting directories.\n- Independent Configuration: Different workspaces can utilize different\n  ENTITY_TYPES and document sets simultaneously.\n\nPrerequisites:\n1. Set the following environment variables:\n   - GEMINI_API_KEY: Your Google Gemini API key.\n   - ENTITY_TYPES: A JSON string of entity categories (e.g., '[\"Person\", \"Organization\"]').\n2. Ensure your data directory contains:\n   - Data/book-small.txt\n   - Data/HR_policies.txt\n\nUsage:\n    python lightrag_workspace_demo.py\n\"\"\"\n\nimport os\nimport asyncio\nimport json\nimport numpy as np\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.gemini import gemini_model_complete, gemini_embed\nfrom lightrag.utils import wrap_embedding_func_with_attrs\nfrom lightrag.constants import DEFAULT_ENTITY_TYPES\n\n\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], keyword_extraction=False, **kwargs\n) -> str:\n    \"\"\"Wrapper for Gemini LLM completion.\"\"\"\n    return await gemini_model_complete(\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=os.getenv(\"GEMINI_API_KEY\"),\n        model_name=\"gemini-2.0-flash-exp\",\n        **kwargs,\n    )\n\n\n@wrap_embedding_func_with_attrs(\n    embedding_dim=768, max_token_size=2048, model_name=\"models/text-embedding-004\"\n)\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    \"\"\"Wrapper for Gemini embedding model.\"\"\"\n    return await gemini_embed.func(\n        texts, api_key=os.getenv(\"GEMINI_API_KEY\"), model=\"models/text-embedding-004\"\n    )\n\n\nasync def initialize_rag(\n    workspace: str = \"default_workspace\",\n    entities=None,\n) -> LightRAG:\n    \"\"\"\n    Initializes a LightRAG instance with data isolation.\n\n    - entities (if provided) overrides everything\n    - else ENTITY_TYPES env var is used\n    - else DEFAULT_ENTITY_TYPES is used\n    \"\"\"\n\n    if entities is not None:\n        entity_types = entities\n    else:\n        env_entities = os.getenv(\"ENTITY_TYPES\")\n        if env_entities:\n            entity_types = json.loads(env_entities)\n        else:\n            entity_types = DEFAULT_ENTITY_TYPES\n\n    rag = LightRAG(\n        workspace=workspace,\n        llm_model_name=\"gemini-2.0-flash\",\n        llm_model_func=llm_model_func,\n        embedding_func=embedding_func,\n        embedding_func_max_async=4,\n        embedding_batch_num=8,\n        llm_model_max_async=2,\n        addon_params={\"entity_types\": entity_types},\n    )\n\n    await rag.initialize_storages()\n    return rag\n\n\nasync def main():\n    rag_1 = None\n    rag_2 = None\n    try:\n        # 1. Initialize Isolated Workspaces\n        # Instance 1: Dedicated to literary analysis\n        # Instance 2: Dedicated to corporate HR documentation\n        print(\"Initializing isolated LightRAG workspaces...\")\n        rag_1 = await initialize_rag(\"rag_workspace_book\")\n        rag_2 = await initialize_rag(\"rag_workspace_hr\")\n\n        # 2. Populate Workspace 1 (Literature)\n        book_path = \"Data/book-small.txt\"\n        if os.path.exists(book_path):\n            with open(book_path, \"r\", encoding=\"utf-8\") as f:\n                print(f\"Indexing {book_path} into Literature Workspace...\")\n                await rag_1.ainsert(f.read())\n\n        # 3. Populate Workspace 2 (Corporate)\n        hr_path = \"Data/HR_policies.txt\"\n        if os.path.exists(hr_path):\n            with open(hr_path, \"r\", encoding=\"utf-8\") as f:\n                print(f\"Indexing {hr_path} into HR Workspace...\")\n                await rag_2.ainsert(f.read())\n\n        # 4. Context-Specific Querying\n        print(\"\\n--- Querying Literature Workspace ---\")\n        res1 = await rag_1.aquery(\n            \"What is the main theme?\",\n            param=QueryParam(mode=\"hybrid\", stream=False),\n        )\n        print(f\"Book Analysis: {res1[:200]}...\")\n\n        print(\"\\n--- Querying HR Workspace ---\")\n        res2 = await rag_2.aquery(\n            \"What is the leave policy?\", param=QueryParam(mode=\"hybrid\")\n        )\n        print(f\"HR Response: {res2[:200]}...\")\n\n    except Exception as e:\n        print(f\"An error occurred: {e}\")\n    finally:\n        # Finalize storage to safely close DB connections and write buffers\n        if rag_1:\n            await rag_1.finalize_storages()\n        if rag_2:\n            await rag_2.finalize_storages()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/lightrag_ollama_demo.py",
    "content": "import asyncio\nimport os\nimport inspect\nimport logging\nimport logging.config\nfrom functools import partial\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.ollama import ollama_model_complete, ollama_embed\nfrom lightrag.utils import EmbeddingFunc, logger, set_verbose_debug\n\nfrom dotenv import load_dotenv\n\nload_dotenv(dotenv_path=\".env\", override=False)\n\nWORKING_DIR = \"./dickens\"\n\n\ndef configure_logging():\n    \"\"\"Configure logging for the application\"\"\"\n\n    # Reset any existing handlers to ensure clean configuration\n    for logger_name in [\"uvicorn\", \"uvicorn.access\", \"uvicorn.error\", \"lightrag\"]:\n        logger_instance = logging.getLogger(logger_name)\n        logger_instance.handlers = []\n        logger_instance.filters = []\n\n    # Get log directory path from environment variable or use current directory\n    log_dir = os.getenv(\"LOG_DIR\", os.getcwd())\n    log_file_path = os.path.abspath(os.path.join(log_dir, \"lightrag_ollama_demo.log\"))\n\n    print(f\"\\nLightRAG compatible demo log file: {log_file_path}\\n\")\n    os.makedirs(os.path.dirname(log_file_path), exist_ok=True)\n\n    # Get log file max size and backup count from environment variables\n    log_max_bytes = int(os.getenv(\"LOG_MAX_BYTES\", 10485760))  # Default 10MB\n    log_backup_count = int(os.getenv(\"LOG_BACKUP_COUNT\", 5))  # Default 5 backups\n\n    logging.config.dictConfig(\n        {\n            \"version\": 1,\n            \"disable_existing_loggers\": False,\n            \"formatters\": {\n                \"default\": {\n                    \"format\": \"%(levelname)s: %(message)s\",\n                },\n                \"detailed\": {\n                    \"format\": \"%(asctime)s - %(name)s - %(levelname)s - %(message)s\",\n                },\n            },\n            \"handlers\": {\n                \"console\": {\n                    \"formatter\": \"default\",\n                    \"class\": \"logging.StreamHandler\",\n                    \"stream\": \"ext://sys.stderr\",\n                },\n                \"file\": {\n                    \"formatter\": \"detailed\",\n                    \"class\": \"logging.handlers.RotatingFileHandler\",\n                    \"filename\": log_file_path,\n                    \"maxBytes\": log_max_bytes,\n                    \"backupCount\": log_backup_count,\n                    \"encoding\": \"utf-8\",\n                },\n            },\n            \"loggers\": {\n                \"lightrag\": {\n                    \"handlers\": [\"console\", \"file\"],\n                    \"level\": \"INFO\",\n                    \"propagate\": False,\n                },\n            },\n        }\n    )\n\n    # Set the logger level to INFO\n    logger.setLevel(logging.INFO)\n    # Enable verbose debug if needed\n    set_verbose_debug(os.getenv(\"VERBOSE_DEBUG\", \"false\").lower() == \"true\")\n\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=ollama_model_complete,\n        llm_model_name=os.getenv(\"LLM_MODEL\", \"qwen2.5-coder:7b\"),\n        summary_max_tokens=8192,\n        llm_model_kwargs={\n            \"host\": os.getenv(\"LLM_BINDING_HOST\", \"http://localhost:11434\"),\n            \"options\": {\"num_ctx\": 8192},\n            \"timeout\": int(os.getenv(\"TIMEOUT\", \"300\")),\n        },\n        # Note: ollama_embed is decorated with @wrap_embedding_func_with_attrs,\n        # which wraps it in an EmbeddingFunc. Using .func accesses the original\n        # unwrapped function to avoid double wrapping when we create our own\n        # EmbeddingFunc with custom configuration (embedding_dim, max_token_size).\n        embedding_func=EmbeddingFunc(\n            embedding_dim=int(os.getenv(\"EMBEDDING_DIM\", \"1024\")),\n            max_token_size=int(os.getenv(\"MAX_EMBED_TOKENS\", \"8192\")),\n            func=partial(\n                ollama_embed.func,  # Access the unwrapped function to avoid double EmbeddingFunc wrapping\n                embed_model=os.getenv(\"EMBEDDING_MODEL\", \"bge-m3:latest\"),\n                host=os.getenv(\"EMBEDDING_BINDING_HOST\", \"http://localhost:11434\"),\n            ),\n        ),\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\nasync def print_stream(stream):\n    async for chunk in stream:\n        print(chunk, end=\"\", flush=True)\n\n\nasync def main():\n    try:\n        # Clear old data files\n        files_to_delete = [\n            \"graph_chunk_entity_relation.graphml\",\n            \"kv_store_doc_status.json\",\n            \"kv_store_full_docs.json\",\n            \"kv_store_text_chunks.json\",\n            \"vdb_chunks.json\",\n            \"vdb_entities.json\",\n            \"vdb_relationships.json\",\n        ]\n\n        for file in files_to_delete:\n            file_path = os.path.join(WORKING_DIR, file)\n            if os.path.exists(file_path):\n                os.remove(file_path)\n                print(f\"Deleting old file:: {file_path}\")\n\n        # Initialize RAG instance\n        rag = await initialize_rag()\n\n        # Test embedding function\n        test_text = [\"This is a test string for embedding.\"]\n        embedding = await rag.embedding_func(test_text)\n        embedding_dim = embedding.shape[1]\n        print(\"\\n=======================\")\n        print(\"Test embedding function\")\n        print(\"========================\")\n        print(f\"Test dict: {test_text}\")\n        print(f\"Detected embedding dimension: {embedding_dim}\\n\\n\")\n\n        with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n            await rag.ainsert(f.read())\n\n        # Perform naive search\n        print(\"\\n=====================\")\n        print(\"Query mode: naive\")\n        print(\"=====================\")\n        resp = await rag.aquery(\n            \"What are the top themes in this story?\",\n            param=QueryParam(mode=\"naive\", stream=True),\n        )\n        if inspect.isasyncgen(resp):\n            await print_stream(resp)\n        else:\n            print(resp)\n\n        # Perform local search\n        print(\"\\n=====================\")\n        print(\"Query mode: local\")\n        print(\"=====================\")\n        resp = await rag.aquery(\n            \"What are the top themes in this story?\",\n            param=QueryParam(mode=\"local\", stream=True),\n        )\n        if inspect.isasyncgen(resp):\n            await print_stream(resp)\n        else:\n            print(resp)\n\n        # Perform global search\n        print(\"\\n=====================\")\n        print(\"Query mode: global\")\n        print(\"=====================\")\n        resp = await rag.aquery(\n            \"What are the top themes in this story?\",\n            param=QueryParam(mode=\"global\", stream=True),\n        )\n        if inspect.isasyncgen(resp):\n            await print_stream(resp)\n        else:\n            print(resp)\n\n        # Perform hybrid search\n        print(\"\\n=====================\")\n        print(\"Query mode: hybrid\")\n        print(\"=====================\")\n        resp = await rag.aquery(\n            \"What are the top themes in this story?\",\n            param=QueryParam(mode=\"hybrid\", stream=True),\n        )\n        if inspect.isasyncgen(resp):\n            await print_stream(resp)\n        else:\n            print(resp)\n\n    except Exception as e:\n        print(f\"An error occurred: {e}\")\n    finally:\n        if rag:\n            await rag.llm_response_cache.index_done_callback()\n            await rag.finalize_storages()\n\n\nif __name__ == \"__main__\":\n    # Configure logging before running the main function\n    configure_logging()\n    asyncio.run(main())\n    print(\"\\nDone!\")\n"
  },
  {
    "path": "examples/lightrag_openai_compatible_demo.py",
    "content": "import os\nimport asyncio\nimport inspect\nimport logging\nimport logging.config\nfrom functools import partial\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.openai import openai_complete_if_cache\nfrom lightrag.llm.ollama import ollama_embed\nfrom lightrag.utils import EmbeddingFunc, logger, set_verbose_debug\n\nfrom dotenv import load_dotenv\n\nload_dotenv(dotenv_path=\".env\", override=False)\n\nWORKING_DIR = \"./dickens\"\n\n\ndef configure_logging():\n    \"\"\"Configure logging for the application\"\"\"\n\n    # Reset any existing handlers to ensure clean configuration\n    for logger_name in [\"uvicorn\", \"uvicorn.access\", \"uvicorn.error\", \"lightrag\"]:\n        logger_instance = logging.getLogger(logger_name)\n        logger_instance.handlers = []\n        logger_instance.filters = []\n\n    # Get log directory path from environment variable or use current directory\n    log_dir = os.getenv(\"LOG_DIR\", os.getcwd())\n    log_file_path = os.path.abspath(\n        os.path.join(log_dir, \"lightrag_compatible_demo.log\")\n    )\n\n    print(f\"\\nLightRAG compatible demo log file: {log_file_path}\\n\")\n    os.makedirs(os.path.dirname(log_dir), exist_ok=True)\n\n    # Get log file max size and backup count from environment variables\n    log_max_bytes = int(os.getenv(\"LOG_MAX_BYTES\", 10485760))  # Default 10MB\n    log_backup_count = int(os.getenv(\"LOG_BACKUP_COUNT\", 5))  # Default 5 backups\n\n    logging.config.dictConfig(\n        {\n            \"version\": 1,\n            \"disable_existing_loggers\": False,\n            \"formatters\": {\n                \"default\": {\n                    \"format\": \"%(levelname)s: %(message)s\",\n                },\n                \"detailed\": {\n                    \"format\": \"%(asctime)s - %(name)s - %(levelname)s - %(message)s\",\n                },\n            },\n            \"handlers\": {\n                \"console\": {\n                    \"formatter\": \"default\",\n                    \"class\": \"logging.StreamHandler\",\n                    \"stream\": \"ext://sys.stderr\",\n                },\n                \"file\": {\n                    \"formatter\": \"detailed\",\n                    \"class\": \"logging.handlers.RotatingFileHandler\",\n                    \"filename\": log_file_path,\n                    \"maxBytes\": log_max_bytes,\n                    \"backupCount\": log_backup_count,\n                    \"encoding\": \"utf-8\",\n                },\n            },\n            \"loggers\": {\n                \"lightrag\": {\n                    \"handlers\": [\"console\", \"file\"],\n                    \"level\": \"INFO\",\n                    \"propagate\": False,\n                },\n            },\n        }\n    )\n\n    # Set the logger level to INFO\n    logger.setLevel(logging.INFO)\n    # Enable verbose debug if needed\n    set_verbose_debug(os.getenv(\"VERBOSE_DEBUG\", \"false\").lower() == \"true\")\n\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], keyword_extraction=False, **kwargs\n) -> str:\n    return await openai_complete_if_cache(\n        os.getenv(\"LLM_MODEL\", \"deepseek-chat\"),\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=os.getenv(\"LLM_BINDING_API_KEY\") or os.getenv(\"OPENAI_API_KEY\"),\n        base_url=os.getenv(\"LLM_BINDING_HOST\", \"https://api.deepseek.com\"),\n        **kwargs,\n    )\n\n\nasync def print_stream(stream):\n    async for chunk in stream:\n        if chunk:\n            print(chunk, end=\"\", flush=True)\n\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=llm_model_func,\n        # Note: ollama_embed is decorated with @wrap_embedding_func_with_attrs,\n        # which wraps it in an EmbeddingFunc. Using .func accesses the original\n        # unwrapped function to avoid double wrapping when we create our own\n        # EmbeddingFunc with custom configuration (embedding_dim, max_token_size).\n        embedding_func=EmbeddingFunc(\n            embedding_dim=int(os.getenv(\"EMBEDDING_DIM\", \"1024\")),\n            max_token_size=int(os.getenv(\"MAX_EMBED_TOKENS\", \"8192\")),\n            func=partial(\n                ollama_embed.func,  # Access the unwrapped function to avoid double EmbeddingFunc wrapping\n                embed_model=os.getenv(\"EMBEDDING_MODEL\", \"bge-m3:latest\"),\n                host=os.getenv(\"EMBEDDING_BINDING_HOST\", \"http://localhost:11434\"),\n            ),\n        ),\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\nasync def main():\n    try:\n        # Clear old data files\n        files_to_delete = [\n            \"graph_chunk_entity_relation.graphml\",\n            \"kv_store_doc_status.json\",\n            \"kv_store_full_docs.json\",\n            \"kv_store_text_chunks.json\",\n            \"vdb_chunks.json\",\n            \"vdb_entities.json\",\n            \"vdb_relationships.json\",\n        ]\n\n        for file in files_to_delete:\n            file_path = os.path.join(WORKING_DIR, file)\n            if os.path.exists(file_path):\n                os.remove(file_path)\n                print(f\"Deleting old file:: {file_path}\")\n\n        # Initialize RAG instance\n        rag = await initialize_rag()\n\n        # Test embedding function\n        test_text = [\"This is a test string for embedding.\"]\n        embedding = await rag.embedding_func(test_text)\n        embedding_dim = embedding.shape[1]\n        print(\"\\n=======================\")\n        print(\"Test embedding function\")\n        print(\"========================\")\n        print(f\"Test dict: {test_text}\")\n        print(f\"Detected embedding dimension: {embedding_dim}\\n\\n\")\n\n        with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n            await rag.ainsert(f.read())\n\n        # Perform naive search\n        print(\"\\n=====================\")\n        print(\"Query mode: naive\")\n        print(\"=====================\")\n        resp = await rag.aquery(\n            \"What are the top themes in this story?\",\n            param=QueryParam(mode=\"naive\", stream=True),\n        )\n        if inspect.isasyncgen(resp):\n            await print_stream(resp)\n        else:\n            print(resp)\n\n        # Perform local search\n        print(\"\\n=====================\")\n        print(\"Query mode: local\")\n        print(\"=====================\")\n        resp = await rag.aquery(\n            \"What are the top themes in this story?\",\n            param=QueryParam(mode=\"local\", stream=True),\n        )\n        if inspect.isasyncgen(resp):\n            await print_stream(resp)\n        else:\n            print(resp)\n\n        # Perform global search\n        print(\"\\n=====================\")\n        print(\"Query mode: global\")\n        print(\"=====================\")\n        resp = await rag.aquery(\n            \"What are the top themes in this story?\",\n            param=QueryParam(mode=\"global\", stream=True),\n        )\n        if inspect.isasyncgen(resp):\n            await print_stream(resp)\n        else:\n            print(resp)\n\n        # Perform hybrid search\n        print(\"\\n=====================\")\n        print(\"Query mode: hybrid\")\n        print(\"=====================\")\n        resp = await rag.aquery(\n            \"What are the top themes in this story?\",\n            param=QueryParam(mode=\"hybrid\", stream=True),\n        )\n        if inspect.isasyncgen(resp):\n            await print_stream(resp)\n        else:\n            print(resp)\n\n    except Exception as e:\n        print(f\"An error occurred: {e}\")\n    finally:\n        if rag:\n            await rag.finalize_storages()\n\n\nif __name__ == \"__main__\":\n    # Configure logging before running the main function\n    configure_logging()\n    asyncio.run(main())\n    print(\"\\nDone!\")\n"
  },
  {
    "path": "examples/lightrag_openai_demo.py",
    "content": "import os\nimport asyncio\nimport logging\nimport logging.config\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.openai import gpt_4o_mini_complete, openai_embed\nfrom lightrag.utils import logger, set_verbose_debug\n\nWORKING_DIR = \"./dickens\"\n\n\ndef configure_logging():\n    \"\"\"Configure logging for the application\"\"\"\n\n    # Reset any existing handlers to ensure clean configuration\n    for logger_name in [\"uvicorn\", \"uvicorn.access\", \"uvicorn.error\", \"lightrag\"]:\n        logger_instance = logging.getLogger(logger_name)\n        logger_instance.handlers = []\n        logger_instance.filters = []\n\n    # Get log directory path from environment variable or use current directory\n    log_dir = os.getenv(\"LOG_DIR\", os.getcwd())\n    log_file_path = os.path.abspath(os.path.join(log_dir, \"lightrag_demo.log\"))\n\n    print(f\"\\nLightRAG demo log file: {log_file_path}\\n\")\n    os.makedirs(os.path.dirname(log_dir), exist_ok=True)\n\n    # Get log file max size and backup count from environment variables\n    log_max_bytes = int(os.getenv(\"LOG_MAX_BYTES\", 10485760))  # Default 10MB\n    log_backup_count = int(os.getenv(\"LOG_BACKUP_COUNT\", 5))  # Default 5 backups\n\n    logging.config.dictConfig(\n        {\n            \"version\": 1,\n            \"disable_existing_loggers\": False,\n            \"formatters\": {\n                \"default\": {\n                    \"format\": \"%(levelname)s: %(message)s\",\n                },\n                \"detailed\": {\n                    \"format\": \"%(asctime)s - %(name)s - %(levelname)s - %(message)s\",\n                },\n            },\n            \"handlers\": {\n                \"console\": {\n                    \"formatter\": \"default\",\n                    \"class\": \"logging.StreamHandler\",\n                    \"stream\": \"ext://sys.stderr\",\n                },\n                \"file\": {\n                    \"formatter\": \"detailed\",\n                    \"class\": \"logging.handlers.RotatingFileHandler\",\n                    \"filename\": log_file_path,\n                    \"maxBytes\": log_max_bytes,\n                    \"backupCount\": log_backup_count,\n                    \"encoding\": \"utf-8\",\n                },\n            },\n            \"loggers\": {\n                \"lightrag\": {\n                    \"handlers\": [\"console\", \"file\"],\n                    \"level\": \"INFO\",\n                    \"propagate\": False,\n                },\n            },\n        }\n    )\n\n    # Set the logger level to INFO\n    logger.setLevel(logging.INFO)\n    # Enable verbose debug if needed\n    set_verbose_debug(os.getenv(\"VERBOSE_DEBUG\", \"false\").lower() == \"true\")\n\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        embedding_func=openai_embed,\n        llm_model_func=gpt_4o_mini_complete,\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n\n    return rag\n\n\nasync def main():\n    # Check if OPENAI_API_KEY environment variable exists\n    if not os.getenv(\"OPENAI_API_KEY\"):\n        print(\n            \"Error: OPENAI_API_KEY environment variable is not set. Please set this variable before running the program.\"\n        )\n        print(\"You can set the environment variable by running:\")\n        print(\"  export OPENAI_API_KEY='your-openai-api-key'\")\n        return  # Exit the async function\n\n    try:\n        # Clear old data files\n        files_to_delete = [\n            \"graph_chunk_entity_relation.graphml\",\n            \"kv_store_doc_status.json\",\n            \"kv_store_full_docs.json\",\n            \"kv_store_text_chunks.json\",\n            \"vdb_chunks.json\",\n            \"vdb_entities.json\",\n            \"vdb_relationships.json\",\n        ]\n\n        for file in files_to_delete:\n            file_path = os.path.join(WORKING_DIR, file)\n            if os.path.exists(file_path):\n                os.remove(file_path)\n                print(f\"Deleting old file:: {file_path}\")\n\n        # Initialize RAG instance\n        rag = await initialize_rag()\n\n        # Test embedding function\n        test_text = [\"This is a test string for embedding.\"]\n        embedding = await rag.embedding_func(test_text)\n        embedding_dim = embedding.shape[1]\n        print(\"\\n=======================\")\n        print(\"Test embedding function\")\n        print(\"========================\")\n        print(f\"Test dict: {test_text}\")\n        print(f\"Detected embedding dimension: {embedding_dim}\\n\\n\")\n\n        with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n            await rag.ainsert(f.read())\n\n        # Perform naive search\n        print(\"\\n=====================\")\n        print(\"Query mode: naive\")\n        print(\"=====================\")\n        print(\n            await rag.aquery(\n                \"What are the top themes in this story?\", param=QueryParam(mode=\"naive\")\n            )\n        )\n\n        # Perform local search\n        print(\"\\n=====================\")\n        print(\"Query mode: local\")\n        print(\"=====================\")\n        print(\n            await rag.aquery(\n                \"What are the top themes in this story?\", param=QueryParam(mode=\"local\")\n            )\n        )\n\n        # Perform global search\n        print(\"\\n=====================\")\n        print(\"Query mode: global\")\n        print(\"=====================\")\n        print(\n            await rag.aquery(\n                \"What are the top themes in this story?\",\n                param=QueryParam(mode=\"global\"),\n            )\n        )\n\n        # Perform hybrid search\n        print(\"\\n=====================\")\n        print(\"Query mode: hybrid\")\n        print(\"=====================\")\n        print(\n            await rag.aquery(\n                \"What are the top themes in this story?\",\n                param=QueryParam(mode=\"hybrid\"),\n            )\n        )\n    except Exception as e:\n        print(f\"An error occurred: {e}\")\n    finally:\n        if rag:\n            await rag.finalize_storages()\n\n\nif __name__ == \"__main__\":\n    # Configure logging before running the main function\n    configure_logging()\n    asyncio.run(main())\n    print(\"\\nDone!\")\n"
  },
  {
    "path": "examples/lightrag_openai_mongodb_graph_demo.py",
    "content": "import os\nimport asyncio\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.openai import gpt_4o_mini_complete, openai_embed\nfrom lightrag.utils import EmbeddingFunc\nimport numpy as np\n\n#########\n# Uncomment the below two lines if running in a jupyter notebook to handle the async nature of rag.insert()\n# import nest_asyncio\n# nest_asyncio.apply()\n#########\nWORKING_DIR = \"./mongodb_test_dir\"\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n\nos.environ[\"OPENAI_API_KEY\"] = \"sk-\"\nos.environ[\"MONGO_URI\"] = \"mongodb://0.0.0.0:27017/?directConnection=true\"\nos.environ[\"MONGO_DATABASE\"] = \"LightRAG\"\nos.environ[\"MONGO_KG_COLLECTION\"] = \"MDB_KG\"\n\n# Embedding Configuration and Functions\nEMBEDDING_MODEL = os.environ.get(\"EMBEDDING_MODEL\", \"text-embedding-3-large\")\nEMBEDDING_MAX_TOKEN_SIZE = int(os.environ.get(\"EMBEDDING_MAX_TOKEN_SIZE\", 8192))\n\n\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    # Note: openai_embed is decorated with @wrap_embedding_func_with_attrs,\n    # which wraps it in an EmbeddingFunc. Using .func accesses the original\n    # unwrapped function to avoid double wrapping when we create our own\n    # EmbeddingFunc with custom configuration in create_embedding_function_instance().\n    return await openai_embed.func(\n        texts,\n        model=EMBEDDING_MODEL,\n    )\n\n\nasync def get_embedding_dimension():\n    test_text = [\"This is a test sentence.\"]\n    embedding = await embedding_func(test_text)\n    return embedding.shape[1]\n\n\nasync def create_embedding_function_instance():\n    # Get embedding dimension\n    embedding_dimension = await get_embedding_dimension()\n    # Create embedding function instance\n    return EmbeddingFunc(\n        embedding_dim=embedding_dimension,\n        max_token_size=EMBEDDING_MAX_TOKEN_SIZE,\n        func=embedding_func,\n    )\n\n\nasync def initialize_rag():\n    embedding_func_instance = await create_embedding_function_instance()\n\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=gpt_4o_mini_complete,\n        embedding_func=embedding_func_instance,\n        graph_storage=\"MongoGraphStorage\",\n        log_level=\"DEBUG\",\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\ndef main():\n    # Initialize RAG instance\n    rag = asyncio.run(initialize_rag())\n\n    with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n        rag.insert(f.read())\n\n    # Perform naive search\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"naive\")\n        )\n    )\n\n    # Perform local search\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"local\")\n        )\n    )\n\n    # Perform global search\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"global\")\n        )\n    )\n\n    # Perform hybrid search\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"hybrid\")\n        )\n    )\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/lightrag_openai_opensearch_graph_demo.py",
    "content": "\"\"\"\nLightRAG Demo with OpenSearch + OpenAI\n\nThis example demonstrates how to use LightRAG with:\n- OpenAI (LLM + Embeddings)\n- OpenSearch-backed storages for:\n  - KV storage\n  - Vector storage (k-NN)\n  - Graph storage (dual-index nodes + edges)\n  - Document status storage\n\nPrerequisites:\n1. OpenSearch cluster running and accessible (3.x or higher with k-NN plugin)\n2. Required indices will be auto-created by LightRAG\n3. Set environment variables (example .env):\n\n   OPENSEARCH_HOSTS=localhost:9200\n   OPENSEARCH_USER=admin\n   OPENSEARCH_PASSWORD=your-password\n   OPENSEARCH_USE_SSL=false\n   OPENSEARCH_VERIFY_CERTS=false\n\n   OPENAI_API_KEY=your-api-key\n\n4. Prepare a text file to index (default: ./book.txt)\n\nUsage:\n    python examples/lightrag_openai_opensearch_graph_demo.py\n\"\"\"\n\nimport os\nimport asyncio\nimport numpy as np\n\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.openai import gpt_4o_mini_complete, openai_embed\nfrom lightrag.utils import setup_logger, EmbeddingFunc\n\n\n# --------------------------------------------------\n# Logger\n# --------------------------------------------------\nsetup_logger(\"lightrag\", level=\"INFO\")\n\n\n# --------------------------------------------------\n# Config\n# --------------------------------------------------\nWORKING_DIR = \"./opensearch_rag_storage\"\nBOOK_FILE = \"./book.txt\"\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n# Replace with your API key, or set via environment variable\nif not os.getenv(\"OPENAI_API_KEY\"):\n    os.environ[\"OPENAI_API_KEY\"] = \"sk-\"\n\nEMBEDDING_MODEL = os.environ.get(\"EMBEDDING_MODEL\", \"text-embedding-3-large\")\nEMBEDDING_MAX_TOKEN_SIZE = int(os.environ.get(\"EMBEDDING_MAX_TOKEN_SIZE\", 8192))\n\n\n# --------------------------------------------------\n# Embedding function (OpenAI)\n# --------------------------------------------------\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await openai_embed.func(\n        texts,\n        model=EMBEDDING_MODEL,\n    )\n\n\nasync def get_embedding_dimension():\n    test_text = [\"This is a test sentence.\"]\n    embedding = await embedding_func(test_text)\n    return embedding.shape[1]\n\n\nasync def create_embedding_function_instance():\n    embedding_dimension = await get_embedding_dimension()\n    return EmbeddingFunc(\n        embedding_dim=embedding_dimension,\n        max_token_size=EMBEDDING_MAX_TOKEN_SIZE,\n        func=embedding_func,\n    )\n\n\n# --------------------------------------------------\n# Initialize RAG with OpenSearch storages\n# --------------------------------------------------\nasync def initialize_rag() -> LightRAG:\n    embedding_func_instance = await create_embedding_function_instance()\n\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=gpt_4o_mini_complete,\n        embedding_func=embedding_func_instance,\n        # OpenSearch-backed storages\n        kv_storage=\"OpenSearchKVStorage\",\n        doc_status_storage=\"OpenSearchDocStatusStorage\",\n        graph_storage=\"OpenSearchGraphStorage\",\n        vector_storage=\"OpenSearchVectorDBStorage\",\n    )\n\n    # REQUIRED: initialize all storage backends\n    await rag.initialize_storages()\n\n    # Clean previous data so the example is re-runnable\n    # (LLM response cache is preserved for faster reruns)\n    for storage in [\n        rag.full_docs,\n        rag.text_chunks,\n        rag.full_entities,\n        rag.full_relations,\n        rag.entity_chunks,\n        rag.relation_chunks,\n        rag.entities_vdb,\n        rag.relationships_vdb,\n        rag.chunks_vdb,\n        rag.chunk_entity_relation_graph,\n        rag.doc_status,\n    ]:\n        await storage.drop()\n    print(\"Cleared previous data.\")\n\n    return rag\n\n\n# --------------------------------------------------\n# Main\n# --------------------------------------------------\nasync def main():\n    rag = None\n    try:\n        print(\"Initializing LightRAG with OpenSearch + OpenAI...\")\n        rag = await initialize_rag()\n\n        if not os.path.exists(BOOK_FILE):\n            raise FileNotFoundError(\n                f\"'{BOOK_FILE}' not found. Please provide a text file to index.\"\n            )\n\n        print(f\"\\nReading document: {BOOK_FILE}\")\n        with open(BOOK_FILE, \"r\", encoding=\"utf-8\") as f:\n            content = f.read()\n\n        print(f\"Loaded document ({len(content)} characters)\")\n\n        print(\"\\nInserting document into LightRAG (this may take some time)...\")\n        await rag.ainsert(content)\n        print(\"Document indexed successfully!\")\n\n        print(\"\\n\" + \"=\" * 60)\n        print(\"Running sample queries\")\n        print(\"=\" * 60)\n\n        query = \"What are the top themes in this document?\"\n\n        for mode in [\"naive\", \"local\", \"global\", \"hybrid\"]:\n            print(f\"\\n[{mode.upper()} MODE]\")\n            result = await rag.aquery(query, param=QueryParam(mode=mode))\n            print(result)\n\n        print(\"\\nRAG system is ready for use!\")\n\n    except Exception as e:\n        print(\"An error occurred:\", e)\n        import traceback\n\n        traceback.print_exc()\n\n    finally:\n        if rag is not None:\n            await rag.finalize_storages()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/lightrag_vllm_demo.py",
    "content": "\"\"\"\nLightRAG Demo with vLLM (LLM, Embeddings, and Reranker)\n\nThis example demonstrates how to use LightRAG with:\n- vLLM-served LLM (OpenAI-compatible API)\n- vLLM-served embedding model\n- Jina-compatible reranker (also vLLM-served)\n\nPrerequisites:\n    1. Create a .env file or export environment variables:\n       - LLM_MODEL\n       - LLM_BINDING_HOST\n       - LLM_BINDING_API_KEY\n       - EMBEDDING_MODEL\n       - EMBEDDING_BINDING_HOST\n       - EMBEDDING_BINDING_API_KEY\n       - EMBEDDING_DIM\n       - EMBEDDING_TOKEN_LIMIT\n       - RERANK_MODEL\n       - RERANK_BINDING_HOST\n       - RERANK_BINDING_API_KEY\n\n    2. Prepare a text file to index (default: Data/book-small.txt)\n\n    3. Configure storage backends via environment variables or modify\n       the storage parameters in initialize_rag() below.\n\nUsage:\n    python examples/lightrag_vllm_demo.py\n\"\"\"\n\nimport os\nimport asyncio\nfrom functools import partial\nfrom dotenv import load_dotenv\n\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.openai import openai_complete_if_cache, openai_embed\nfrom lightrag.utils import EmbeddingFunc\nfrom lightrag.rerank import jina_rerank\n\nload_dotenv()\n\n# --------------------------------------------------\n# Constants\n# --------------------------------------------------\n\nWORKING_DIR = \"./LightRAG_Data\"\nBOOK_FILE = \"Data/book-small.txt\"\n\n# --------------------------------------------------\n# LLM function (vLLM, OpenAI-compatible)\n# --------------------------------------------------\n\n\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], **kwargs\n) -> str:\n    return await openai_complete_if_cache(\n        model=os.getenv(\"LLM_MODEL\", \"Qwen/Qwen3-14B-AWQ\"),\n        prompt=prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        base_url=os.getenv(\"LLM_BINDING_HOST\", \"http://0.0.0.0:4646/v1\"),\n        api_key=os.getenv(\"LLM_BINDING_API_KEY\", \"not_needed\"),\n        timeout=600,\n        **kwargs,\n    )\n\n\n# --------------------------------------------------\n# Embedding function (vLLM)\n# --------------------------------------------------\n\nvLLM_emb_func = EmbeddingFunc(\n    model_name=os.getenv(\"EMBEDDING_MODEL\", \"Qwen/Qwen3-Embedding-0.6B\"),\n    send_dimensions=False,\n    embedding_dim=int(os.getenv(\"EMBEDDING_DIM\", 1024)),\n    max_token_size=int(os.getenv(\"EMBEDDING_TOKEN_LIMIT\", 4096)),\n    func=partial(\n        openai_embed.func,\n        model=os.getenv(\"EMBEDDING_MODEL\", \"Qwen/Qwen3-Embedding-0.6B\"),\n        base_url=os.getenv(\n            \"EMBEDDING_BINDING_HOST\",\n            \"http://0.0.0.0:1234/v1\",\n        ),\n        api_key=os.getenv(\"EMBEDDING_BINDING_API_KEY\", \"not_needed\"),\n    ),\n)\n\n# --------------------------------------------------\n# Reranker (Jina-compatible, vLLM-served)\n# --------------------------------------------------\n\njina_rerank_model_func = partial(\n    jina_rerank,\n    model=os.getenv(\"RERANK_MODEL\", \"Qwen/Qwen3-Reranker-0.6B\"),\n    api_key=os.getenv(\"RERANK_BINDING_API_KEY\"),\n    base_url=os.getenv(\n        \"RERANK_BINDING_HOST\",\n        \"http://0.0.0.0:3535/v1/rerank\",\n    ),\n)\n\n# --------------------------------------------------\n# Initialize RAG\n# --------------------------------------------------\n\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=llm_model_func,\n        embedding_func=vLLM_emb_func,\n        rerank_model_func=jina_rerank_model_func,\n        # Storage backends (configurable via environment or modify here)\n        kv_storage=os.getenv(\"KV_STORAGE\", \"PGKVStorage\"),\n        doc_status_storage=os.getenv(\"DOC_STATUS_STORAGE\", \"PGDocStatusStorage\"),\n        vector_storage=os.getenv(\"VECTOR_STORAGE\", \"PGVectorStorage\"),\n        graph_storage=os.getenv(\"GRAPH_STORAGE\", \"Neo4JStorage\"),\n    )\n\n    await rag.initialize_storages()\n    return rag\n\n\n# --------------------------------------------------\n# Main\n# --------------------------------------------------\n\n\nasync def main():\n    rag = None\n    try:\n        # Validate book file exists\n        if not os.path.exists(BOOK_FILE):\n            raise FileNotFoundError(\n                f\"'{BOOK_FILE}' not found. Please provide a text file to index.\"\n            )\n\n        rag = await initialize_rag()\n\n        # --------------------------------------------------\n        # Data Ingestion\n        # --------------------------------------------------\n        print(f\"Indexing {BOOK_FILE}...\")\n        with open(BOOK_FILE, \"r\", encoding=\"utf-8\") as f:\n            await rag.ainsert(f.read())\n        print(\"Indexing complete.\")\n\n        # --------------------------------------------------\n        # Query\n        # --------------------------------------------------\n        query = (\n            \"What are the main themes of the book, and how do the key characters \"\n            \"evolve throughout the story?\"\n        )\n\n        print(\"\\nHybrid Search with Reranking:\")\n        result = await rag.aquery(\n            query,\n            param=QueryParam(\n                mode=\"hybrid\",\n                stream=False,\n                enable_rerank=True,\n            ),\n        )\n\n        print(\"\\nResult:\\n\", result)\n\n    except Exception as e:\n        print(f\"An error occurred: {e}\")\n    finally:\n        if rag:\n            await rag.finalize_storages()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n    print(\"\\nDone!\")\n"
  },
  {
    "path": "examples/milvus_kwargs_configuration_demo.py",
    "content": "\"\"\"\nExample: Configuring Milvus Index Parameters via vector_db_storage_cls_kwargs\n\nThis example demonstrates how to configure Milvus indexing parameters through\nvector_db_storage_cls_kwargs, which is the recommended approach when using\nframeworks that build on top of LightRAG (like RAGAnything).\n\nThis approach allows configuration to be passed through framework layers without\nrequiring environment variable changes or direct code modifications.\n\"\"\"\n\nimport os\nimport asyncio\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.openai import openai_complete_if_cache, openai_embed\n\n\nasync def main():\n    # Configure Milvus connection\n    os.environ[\"MILVUS_URI\"] = \"http://localhost:19530\"\n    # os.environ[\"MILVUS_USER\"] = \"root\"\n    # os.environ[\"MILVUS_PASSWORD\"] = \"your_password\"\n    # os.environ[\"MILVUS_DB_NAME\"] = \"lightrag\"\n\n    # Initialize LightRAG with Milvus index configuration via vector_db_storage_cls_kwargs\n    # This is the recommended approach for framework integration (e.g., RAGAnything)\n    rag = LightRAG(\n        working_dir=\"./demo_index\",\n        llm_model_func=openai_complete_if_cache,\n        embedding_func=openai_embed,\n        # Specify Milvus as the vector storage backend\n        vector_storage=\"MilvusVectorDBStorage\",\n        # Configure Milvus indexing parameters via vector_db_storage_cls_kwargs\n        # These parameters are extracted and passed to MilvusIndexConfig\n        vector_db_storage_cls_kwargs={\n            # Required parameter for all vector storage backends\n            \"cosine_better_than_threshold\": 0.2,\n            # Milvus index configuration parameters\n            # All of these can be configured via vector_db_storage_cls_kwargs\n            # Index type (AUTOINDEX, HNSW, HNSW_SQ, IVF_FLAT, etc.)\n            \"index_type\": \"HNSW\",\n            # Distance metric (COSINE, L2, IP)\n            \"metric_type\": \"COSINE\",\n            # HNSW parameters\n            \"hnsw_m\": 32,  # Number of connections per layer (2-2048)\n            \"hnsw_ef_construction\": 256,  # Size of dynamic candidate list during construction\n            \"hnsw_ef\": 150,  # Size of dynamic candidate list during search\n            # IVF parameters (used when index_type is IVF_FLAT, IVF_SQ8, IVF_PQ)\n            # \"ivf_nlist\": 2048,              # Number of cluster units\n            # \"ivf_nprobe\": 32,               # Number of units to query\n            # HNSW_SQ parameters (requires Milvus 2.6.8+)\n            # \"sq_type\": \"SQ8\",               # Quantization type (SQ4U, SQ6, SQ8, BF16, FP16)\n            # \"sq_refine\": True,              # Enable refinement\n            # \"sq_refine_type\": \"FP32\",       # Refinement type\n            # \"sq_refine_k\": 20,              # Number of candidates to refine\n        },\n    )\n\n    # Initialize storage backends\n    await rag.initialize_storages()\n\n    print(\n        \"✅ LightRAG initialized with Milvus index configuration via vector_db_storage_cls_kwargs\"\n    )\n    print(\n        f\"   Index Type: {rag.vector_db_storages['entities'].index_config.index_type}\"\n    )\n    print(\n        f\"   Metric Type: {rag.vector_db_storages['entities'].index_config.metric_type}\"\n    )\n    print(f\"   HNSW M: {rag.vector_db_storages['entities'].index_config.hnsw_m}\")\n    print(\n        f\"   HNSW EF Construction: {rag.vector_db_storages['entities'].index_config.hnsw_ef_construction}\"\n    )\n    print(f\"   HNSW EF: {rag.vector_db_storages['entities'].index_config.hnsw_ef}\")\n\n    # Example: Insert some text\n    sample_text = \"\"\"\n    LightRAG is a Retrieval-Augmented Generation framework that uses graph-based\n    knowledge representation for enhanced information retrieval. It supports multiple\n    vector storage backends including Milvus, which offers advanced indexing options\n    for optimal performance.\n    \"\"\"\n\n    await rag.ainsert(sample_text)\n    print(\"\\n✅ Sample text inserted\")\n\n    # Example: Query with different modes\n    result = await rag.aquery(\"What is LightRAG?\", param=QueryParam(mode=\"hybrid\"))\n    print(f\"\\n✅ Query result: {result[:200]}...\")\n\n    # Cleanup\n    await rag.finalize_storages()\n\n\nif __name__ == \"__main__\":\n    print(\"=\" * 80)\n    print(\"Milvus Configuration via vector_db_storage_cls_kwargs Example\")\n    print(\"=\" * 80)\n    print()\n    print(\"This example shows how to configure Milvus indexing parameters through\")\n    print(\"vector_db_storage_cls_kwargs, which is ideal for framework integration.\")\n    print()\n    print(\"Key Benefits:\")\n    print(\"  • No environment variable changes required\")\n    print(\"  • Configuration can be passed through framework layers\")\n    print(\"  • Perfect for RAGAnything and similar frameworks\")\n    print(\"  • All 11 index parameters are supported\")\n    print()\n    print(\"=\" * 80)\n    print()\n\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/modalprocessors_example.py",
    "content": "\"\"\"\nExample of directly using modal processors\n\nThis example demonstrates how to use LightRAG's modal processors directly without going through MinerU.\n\"\"\"\n\nimport asyncio\nimport argparse\nfrom lightrag.llm.openai import openai_complete_if_cache, openai_embed\nfrom lightrag import LightRAG\nfrom lightrag.utils import EmbeddingFunc\nfrom raganything.modalprocessors import (\n    ImageModalProcessor,\n    TableModalProcessor,\n    EquationModalProcessor,\n)\n\nWORKING_DIR = \"./rag_storage\"\n\n\ndef get_llm_model_func(api_key: str, base_url: str = None):\n    return lambda prompt, system_prompt=None, history_messages=[], **kwargs: (\n        openai_complete_if_cache(\n            \"gpt-4o-mini\",\n            prompt,\n            system_prompt=system_prompt,\n            history_messages=history_messages,\n            api_key=api_key,\n            base_url=base_url,\n            **kwargs,\n        )\n    )\n\n\ndef get_vision_model_func(api_key: str, base_url: str = None):\n    return (\n        lambda prompt,\n        system_prompt=None,\n        history_messages=[],\n        image_data=None,\n        **kwargs: (\n            openai_complete_if_cache(\n                \"gpt-4o\",\n                \"\",\n                system_prompt=None,\n                history_messages=[],\n                messages=[\n                    {\"role\": \"system\", \"content\": system_prompt}\n                    if system_prompt\n                    else None,\n                    {\n                        \"role\": \"user\",\n                        \"content\": [\n                            {\"type\": \"text\", \"text\": prompt},\n                            {\n                                \"type\": \"image_url\",\n                                \"image_url\": {\n                                    \"url\": f\"data:image/jpeg;base64,{image_data}\"\n                                },\n                            },\n                        ],\n                    }\n                    if image_data\n                    else {\"role\": \"user\", \"content\": prompt},\n                ],\n                api_key=api_key,\n                base_url=base_url,\n                **kwargs,\n            )\n            if image_data\n            else openai_complete_if_cache(\n                \"gpt-4o-mini\",\n                prompt,\n                system_prompt=system_prompt,\n                history_messages=history_messages,\n                api_key=api_key,\n                base_url=base_url,\n                **kwargs,\n            )\n        )\n    )\n\n\nasync def process_image_example(lightrag: LightRAG, vision_model_func):\n    \"\"\"Example of processing an image\"\"\"\n    # Create image processor\n    image_processor = ImageModalProcessor(\n        lightrag=lightrag, modal_caption_func=vision_model_func\n    )\n\n    # Prepare image content\n    image_content = {\n        \"img_path\": \"image.jpg\",\n        \"img_caption\": [\"Example image caption\"],\n        \"img_footnote\": [\"Example image footnote\"],\n    }\n\n    # Process image\n    description, entity_info = await image_processor.process_multimodal_content(\n        modal_content=image_content,\n        content_type=\"image\",\n        file_path=\"image_example.jpg\",\n        entity_name=\"Example Image\",\n    )\n\n    print(\"Image Processing Results:\")\n    print(f\"Description: {description}\")\n    print(f\"Entity Info: {entity_info}\")\n\n\nasync def process_table_example(lightrag: LightRAG, llm_model_func):\n    \"\"\"Example of processing a table\"\"\"\n    # Create table processor\n    table_processor = TableModalProcessor(\n        lightrag=lightrag, modal_caption_func=llm_model_func\n    )\n\n    # Prepare table content\n    table_content = {\n        \"table_body\": \"\"\"\n        | Name | Age | Occupation |\n        |------|-----|------------|\n        | John | 25  | Engineer   |\n        | Mary | 30  | Designer   |\n        \"\"\",\n        \"table_caption\": [\"Employee Information Table\"],\n        \"table_footnote\": [\"Data updated as of 2024\"],\n    }\n\n    # Process table\n    description, entity_info = await table_processor.process_multimodal_content(\n        modal_content=table_content,\n        content_type=\"table\",\n        file_path=\"table_example.md\",\n        entity_name=\"Employee Table\",\n    )\n\n    print(\"\\nTable Processing Results:\")\n    print(f\"Description: {description}\")\n    print(f\"Entity Info: {entity_info}\")\n\n\nasync def process_equation_example(lightrag: LightRAG, llm_model_func):\n    \"\"\"Example of processing a mathematical equation\"\"\"\n    # Create equation processor\n    equation_processor = EquationModalProcessor(\n        lightrag=lightrag, modal_caption_func=llm_model_func\n    )\n\n    # Prepare equation content\n    equation_content = {\"text\": \"E = mc^2\", \"text_format\": \"LaTeX\"}\n\n    # Process equation\n    description, entity_info = await equation_processor.process_multimodal_content(\n        modal_content=equation_content,\n        content_type=\"equation\",\n        file_path=\"equation_example.txt\",\n        entity_name=\"Mass-Energy Equivalence\",\n    )\n\n    print(\"\\nEquation Processing Results:\")\n    print(f\"Description: {description}\")\n    print(f\"Entity Info: {entity_info}\")\n\n\nasync def initialize_rag(api_key: str, base_url: str = None):\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        embedding_func=EmbeddingFunc(\n            embedding_dim=3072,\n            max_token_size=8192,\n            func=lambda texts: openai_embed(\n                texts,\n                model=\"text-embedding-3-large\",\n                api_key=api_key,\n                base_url=base_url,\n            ),\n        ),\n        llm_model_func=lambda prompt,\n        system_prompt=None,\n        history_messages=[],\n        **kwargs: (\n            openai_complete_if_cache(\n                \"gpt-4o-mini\",\n                prompt,\n                system_prompt=system_prompt,\n                history_messages=history_messages,\n                api_key=api_key,\n                base_url=base_url,\n                **kwargs,\n            )\n        ),\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\ndef main():\n    \"\"\"Main function to run the example\"\"\"\n    parser = argparse.ArgumentParser(description=\"Modal Processors Example\")\n    parser.add_argument(\"--api-key\", required=True, help=\"OpenAI API key\")\n    parser.add_argument(\"--base-url\", help=\"Optional base URL for API\")\n    parser.add_argument(\n        \"--working-dir\", \"-w\", default=WORKING_DIR, help=\"Working directory path\"\n    )\n\n    args = parser.parse_args()\n\n    # Run examples\n    asyncio.run(main_async(args.api_key, args.base_url))\n\n\nasync def main_async(api_key: str, base_url: str = None):\n    # Initialize LightRAG\n    lightrag = await initialize_rag(api_key, base_url)\n\n    # Get model functions\n    llm_model_func = get_llm_model_func(api_key, base_url)\n    vision_model_func = get_vision_model_func(api_key, base_url)\n\n    # Run examples\n    await process_image_example(lightrag, vision_model_func)\n    await process_table_example(lightrag, llm_model_func)\n    await process_equation_example(lightrag, llm_model_func)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/opensearch_storage_demo.py",
    "content": "\"\"\"\nIntegration test for OpenSearch Storage in LightRAG.\n\nTests all 4 storage types against a live OpenSearch cluster:\n- KV Storage: CRUD, filter_keys\n- DocStatus Storage: CRUD, pagination (PIT + search_after), status counts\n- Graph Storage: nodes, edges, BFS traversal, search_labels\n- Vector Storage: k-NN upsert, query, get/delete\n\nPrerequisites:\n    OpenSearch cluster running with k-NN plugin enabled.\n    Set env vars: OPENSEARCH_HOSTS, OPENSEARCH_USER, OPENSEARCH_PASSWORD,\n                  OPENSEARCH_USE_SSL, OPENSEARCH_VERIFY_CERTS\n\nUsage:\n    OPENSEARCH_HOSTS=localhost:9200 OPENSEARCH_USER=admin \\\n    OPENSEARCH_PASSWORD=<password> OPENSEARCH_USE_SSL=true \\\n    OPENSEARCH_VERIFY_CERTS=false python examples/opensearch_storage_demo.py\n\"\"\"\n\nimport asyncio\nimport numpy as np\nfrom lightrag.kg.opensearch_impl import (\n    OpenSearchKVStorage,\n    OpenSearchDocStatusStorage,\n    OpenSearchGraphStorage,\n    OpenSearchVectorDBStorage,\n    ClientManager,\n)\nfrom lightrag.kg.shared_storage import initialize_share_data\nfrom lightrag.base import DocStatus\n\n\nclass MockEmbeddingFunc:\n    \"\"\"Mock embedding function for testing.\"\"\"\n\n    def __init__(self, dim=128):\n        self.embedding_dim = dim\n        self.max_token_size = 512\n        self.model_name = \"mock-embedding\"\n\n    async def __call__(self, texts, **kwargs):\n        return np.random.rand(len(texts), self.embedding_dim).astype(np.float32)\n\n\nCONFIG = {\n    \"embedding_batch_num\": 10,\n    \"max_graph_nodes\": 1000,\n    \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.2},\n}\nEMBED = MockEmbeddingFunc()\nPASSED = 0\nFAILED = 0\n\n\ndef check(condition, msg):\n    global PASSED, FAILED\n    if condition:\n        print(f\"  ✓ {msg}\")\n        PASSED += 1\n    else:\n        print(f\"  ✗ {msg}\")\n        FAILED += 1\n\n\nasync def test_connection_manager():\n    print(\"\\n=== Connection Manager ===\")\n    client1 = await ClientManager.get_client()\n    client2 = await ClientManager.get_client()\n    check(client1 is client2, \"Singleton pattern (same instance)\")\n    await ClientManager.release_client(client1)\n    await ClientManager.release_client(client2)\n    check(True, \"Released clients\")\n\n\nasync def test_kv_storage():\n    print(\"\\n=== KV Storage ===\")\n    s = OpenSearchKVStorage(\n        namespace=\"integ_kv\",\n        global_config=CONFIG,\n        embedding_func=EMBED,\n        workspace=\"integ\",\n    )\n    await s.initialize()\n    try:\n        await s.upsert({\"k1\": {\"content\": \"hello\"}, \"k2\": {\"content\": \"world\"}})\n        await s.index_done_callback()\n\n        doc = await s.get_by_id(\"k1\")\n        check(doc is not None and doc.get(\"content\") == \"hello\", \"get_by_id\")\n\n        docs = await s.get_by_ids([\"k1\", \"k2\", \"missing\"])\n        check(docs[0] is not None and docs[2] is None, \"get_by_ids preserves order\")\n\n        missing = await s.filter_keys({\"k1\", \"k99\"})\n        check(missing == {\"k99\"}, f\"filter_keys: {missing}\")\n\n        check(not await s.is_empty(), \"is_empty=False\")\n\n        await s.delete([\"k2\"])\n        await s.index_done_callback()\n        check(await s.get_by_id(\"k2\") is None, \"delete + verify\")\n    finally:\n        await s.drop()\n        await s.finalize()\n\n\nasync def test_doc_status_storage():\n    print(\"\\n=== DocStatus Storage ===\")\n    s = OpenSearchDocStatusStorage(\n        namespace=\"integ_ds\",\n        global_config=CONFIG,\n        embedding_func=EMBED,\n        workspace=\"integ\",\n    )\n    await s.initialize()\n    try:\n        # Insert docs\n        await s.upsert(\n            {\n                f\"d{i}\": {\n                    \"status\": \"processed\" if i % 2 == 0 else \"pending\",\n                    \"file_path\": f\"/file{i}.txt\",\n                    \"content_summary\": f\"summary {i}\",\n                    \"content_length\": i * 10,\n                    \"chunks_count\": i,\n                    \"created_at\": 1000 + i,\n                    \"updated_at\": 2000 + i,\n                }\n                for i in range(20)\n            }\n        )\n        await s.index_done_callback()\n\n        # Status counts\n        counts = await s.get_all_status_counts()\n        check(counts.get(\"all\") == 20, f\"all_status_counts: {counts}\")\n        check(\n            counts.get(\"processed\") == 10, f\"processed count: {counts.get('processed')}\"\n        )\n\n        # get_docs_by_status (uses PIT + search_after)\n        processed = await s.get_docs_by_status(DocStatus.PROCESSED)\n        check(len(processed) == 10, f\"get_docs_by_status(processed): {len(processed)}\")\n\n        # get_docs_by_track_id (uses PIT + search_after)\n        await s.upsert(\n            {\n                \"tracked1\": {\n                    \"status\": \"processed\",\n                    \"file_path\": \"/t.txt\",\n                    \"content_summary\": \"s\",\n                    \"content_length\": 1,\n                    \"chunks_count\": 1,\n                    \"created_at\": 100,\n                    \"updated_at\": 200,\n                    \"track_id\": \"batch-42\",\n                }\n            }\n        )\n        await s.index_done_callback()\n        tracked = await s.get_docs_by_track_id(\"batch-42\")\n        check(len(tracked) == 1, f\"get_docs_by_track_id: {len(tracked)}\")\n\n        # Paginated (uses PIT + search_after)\n        page1, total = await s.get_docs_paginated(page=1, page_size=10)\n        check(total == 21, f\"paginated total: {total}\")\n        check(len(page1) == 10, f\"page1 size: {len(page1)}\")\n\n        page2, _ = await s.get_docs_paginated(page=2, page_size=10)\n        check(len(page2) == 10, f\"page2 size: {len(page2)}\")\n\n        page3, _ = await s.get_docs_paginated(page=3, page_size=10)\n        check(len(page3) == 1, f\"page3 size: {len(page3)}\")\n\n        # With status filter\n        filtered, ftotal = await s.get_docs_paginated(\n            status_filter=DocStatus.PENDING, page=1, page_size=50\n        )\n        check(ftotal == 10, f\"filtered total: {ftotal}\")\n\n        # get_doc_by_file_path\n        doc = await s.get_doc_by_file_path(\"/file0.txt\")\n        check(doc is not None and doc[\"_id\"] == \"d0\", \"get_doc_by_file_path\")\n    finally:\n        await s.drop()\n        await s.finalize()\n\n\nasync def test_graph_storage():\n    print(\"\\n=== Graph Storage ===\")\n    s = OpenSearchGraphStorage(\n        namespace=\"integ_graph\",\n        global_config=CONFIG,\n        embedding_func=EMBED,\n        workspace=\"integ\",\n    )\n    await s.initialize()\n    try:\n        # Upsert nodes and edges\n        await s.upsert_node(\n            \"Alice\", {\"entity_type\": \"person\", \"description\": \"A researcher\"}\n        )\n        await s.upsert_node(\n            \"Bob\", {\"entity_type\": \"person\", \"description\": \"A developer\"}\n        )\n        await s.upsert_node(\n            \"Quantum\", {\"entity_type\": \"topic\", \"description\": \"Quantum computing\"}\n        )\n        await s.upsert_edge(\n            \"Alice\",\n            \"Bob\",\n            {\"relationship\": \"knows\", \"weight\": \"1.0\", \"keywords\": \"collab\"},\n        )\n        await s.upsert_edge(\n            \"Alice\",\n            \"Quantum\",\n            {\"relationship\": \"researches\", \"weight\": \"2.0\", \"keywords\": \"research\"},\n        )\n        await s.upsert_edge(\n            \"Bob\",\n            \"Quantum\",\n            {\"relationship\": \"studies\", \"weight\": \"0.5\", \"keywords\": \"learning\"},\n        )\n        await s.index_done_callback()\n\n        check(await s.has_node(\"Alice\"), \"has_node(Alice)\")\n        check(not await s.has_node(\"Nobody\"), \"has_node(Nobody)=False\")\n        check(await s.has_edge(\"Alice\", \"Bob\"), \"has_edge(Alice,Bob)\")\n\n        node = await s.get_node(\"Alice\")\n        check(node is not None and node.get(\"entity_type\") == \"person\", \"get_node\")\n        check(node.get(\"entity_id\") == \"Alice\", \"entity_id field present\")\n\n        check(\n            await s.node_degree(\"Alice\") == 2,\n            f\"node_degree(Alice)={await s.node_degree('Alice')}\",\n        )\n\n        edges = await s.get_node_edges(\"Alice\")\n        check(len(edges) == 2, f\"get_node_edges: {len(edges)}\")\n\n        # Batch ops\n        batch = await s.get_nodes_batch([\"Alice\", \"Bob\", \"Missing\"])\n        check(\"Alice\" in batch and \"Missing\" not in batch, \"get_nodes_batch\")\n\n        degrees = await s.node_degrees_batch([\"Alice\", \"Bob\", \"Quantum\"])\n        check(degrees.get(\"Alice\") == 2, f\"node_degrees_batch: {degrees}\")\n\n        # Knowledge graph (BFS)\n        kg = await s.get_knowledge_graph(\"Alice\", max_depth=2)\n        check(len(kg.nodes) == 3, f\"BFS nodes: {len(kg.nodes)}\")\n        check(len(kg.edges) == 3, f\"BFS edges: {len(kg.edges)}\")\n\n        # get_all_labels (uses PIT)\n        labels = await s.get_all_labels()\n        check(\"Alice\" in labels and \"Bob\" in labels, f\"get_all_labels: {labels}\")\n\n        # get_all_nodes (uses PIT)\n        all_nodes = await s.get_all_nodes()\n        check(len(all_nodes) == 3, f\"get_all_nodes: {len(all_nodes)}\")\n\n        # get_all_edges (uses PIT)\n        all_edges = await s.get_all_edges()\n        check(len(all_edges) == 3, f\"get_all_edges: {len(all_edges)}\")\n\n        # search_labels\n        found = await s.search_labels(\"ali\", limit=10)\n        check(\"Alice\" in found, f\"search_labels('ali'): {found}\")\n\n        # popular_labels\n        popular = await s.get_popular_labels(limit=10)\n        check(len(popular) > 0, f\"get_popular_labels: {popular}\")\n\n        # Delete node (cascading)\n        await s.delete_node(\"Bob\")\n        await s.index_done_callback()\n        check(not await s.has_node(\"Bob\"), \"delete_node cascade\")\n        check(not await s.has_edge(\"Alice\", \"Bob\"), \"edges removed after delete_node\")\n\n        print(f\"  (PPL graphlookup: {s._ppl_graphlookup_available})\")\n    finally:\n        await s.drop()\n        await s.finalize()\n\n\nasync def test_vector_storage():\n    print(\"\\n=== Vector Storage ===\")\n    s = OpenSearchVectorDBStorage(\n        namespace=\"integ_vec\",\n        global_config=CONFIG,\n        embedding_func=EMBED,\n        workspace=\"integ\",\n        meta_fields={\"content\", \"entity_name\"},\n    )\n    await s.initialize()\n    try:\n        await s.upsert(\n            {\n                \"v1\": {\"content\": \"apple fruit\"},\n                \"v2\": {\"content\": \"banana fruit\"},\n                \"v3\": {\"content\": \"quantum physics\"},\n            }\n        )\n        await s.index_done_callback()\n\n        results = await s.query(\"apple\", top_k=3)\n        check(len(results) > 0, f\"query returned {len(results)} results\")\n        check(all(\"distance\" in r for r in results), \"results have distance\")\n\n        doc = await s.get_by_id(\"v1\")\n        check(doc is not None and doc[\"id\"] == \"v1\", \"get_by_id\")\n\n        docs = await s.get_by_ids([\"v1\", \"v2\", \"missing\"])\n        check(docs[0] is not None and docs[2] is None, \"get_by_ids\")\n\n        vecs = await s.get_vectors_by_ids([\"v1\"])\n        check(\"v1\" in vecs and len(vecs[\"v1\"]) == 128, \"get_vectors_by_ids\")\n\n        await s.delete([\"v3\"])\n        await s.index_done_callback()\n        check(await s.get_by_id(\"v3\") is None, \"delete + verify\")\n    finally:\n        await s.drop()\n        await s.finalize()\n\n\nasync def main():\n    print(\"=\" * 60)\n    print(\"OpenSearch Storage Integration Tests\")\n    print(\"=\" * 60)\n\n    initialize_share_data(workers=1)\n\n    try:\n        await test_connection_manager()\n        await test_kv_storage()\n        await test_doc_status_storage()\n        await test_graph_storage()\n        await test_vector_storage()\n    except Exception as e:\n        print(f\"\\n✗ Fatal error: {e}\")\n        import traceback\n\n        traceback.print_exc()\n\n    print(f\"\\n{'=' * 60}\")\n    print(f\"Results: {PASSED} passed, {FAILED} failed\")\n    print(f\"{'=' * 60}\")\n    if FAILED > 0:\n        exit(1)\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/raganything_example.py",
    "content": "#!/usr/bin/env python\n\"\"\"\nExample script demonstrating the integration of MinerU parser with RAGAnything\n\nThis example shows how to:\n1. Process parsed documents with RAGAnything\n2. Perform multimodal queries on the processed documents\n3. Handle different types of content (text, images, tables)\n\"\"\"\n\nimport os\nimport argparse\nimport asyncio\nimport logging\nimport logging.config\nfrom pathlib import Path\n\n# Add project root directory to Python path\nimport sys\n\nsys.path.append(str(Path(__file__).parent.parent))\n\nfrom lightrag.llm.openai import openai_complete_if_cache, openai_embed\nfrom lightrag.utils import EmbeddingFunc, logger, set_verbose_debug\nfrom raganything import RAGAnything, RAGAnythingConfig\n\n\ndef configure_logging():\n    \"\"\"Configure logging for the application\"\"\"\n    # Get log directory path from environment variable or use current directory\n    log_dir = os.getenv(\"LOG_DIR\", os.getcwd())\n    log_file_path = os.path.abspath(os.path.join(log_dir, \"raganything_example.log\"))\n\n    print(f\"\\nRAGAnything example log file: {log_file_path}\\n\")\n    os.makedirs(os.path.dirname(log_dir), exist_ok=True)\n\n    # Get log file max size and backup count from environment variables\n    log_max_bytes = int(os.getenv(\"LOG_MAX_BYTES\", 10485760))  # Default 10MB\n    log_backup_count = int(os.getenv(\"LOG_BACKUP_COUNT\", 5))  # Default 5 backups\n\n    logging.config.dictConfig(\n        {\n            \"version\": 1,\n            \"disable_existing_loggers\": False,\n            \"formatters\": {\n                \"default\": {\n                    \"format\": \"%(levelname)s: %(message)s\",\n                },\n                \"detailed\": {\n                    \"format\": \"%(asctime)s - %(name)s - %(levelname)s - %(message)s\",\n                },\n            },\n            \"handlers\": {\n                \"console\": {\n                    \"formatter\": \"default\",\n                    \"class\": \"logging.StreamHandler\",\n                    \"stream\": \"ext://sys.stderr\",\n                },\n                \"file\": {\n                    \"formatter\": \"detailed\",\n                    \"class\": \"logging.handlers.RotatingFileHandler\",\n                    \"filename\": log_file_path,\n                    \"maxBytes\": log_max_bytes,\n                    \"backupCount\": log_backup_count,\n                    \"encoding\": \"utf-8\",\n                },\n            },\n            \"loggers\": {\n                \"lightrag\": {\n                    \"handlers\": [\"console\", \"file\"],\n                    \"level\": \"INFO\",\n                    \"propagate\": False,\n                },\n            },\n        }\n    )\n\n    # Set the logger level to INFO\n    logger.setLevel(logging.INFO)\n    # Enable verbose debug if needed\n    set_verbose_debug(os.getenv(\"VERBOSE\", \"false\").lower() == \"true\")\n\n\nasync def process_with_rag(\n    file_path: str,\n    output_dir: str,\n    api_key: str,\n    base_url: str = None,\n    working_dir: str = None,\n):\n    \"\"\"\n    Process document with RAGAnything\n\n    Args:\n        file_path: Path to the document\n        output_dir: Output directory for RAG results\n        api_key: OpenAI API key\n        base_url: Optional base URL for API\n        working_dir: Working directory for RAG storage\n    \"\"\"\n    try:\n        # Create RAGAnything configuration\n        config = RAGAnythingConfig(\n            working_dir=working_dir or \"./rag_storage\",\n            mineru_parse_method=\"auto\",\n            enable_image_processing=True,\n            enable_table_processing=True,\n            enable_equation_processing=True,\n        )\n\n        # Define LLM model function\n        def llm_model_func(prompt, system_prompt=None, history_messages=[], **kwargs):\n            return openai_complete_if_cache(\n                \"gpt-4o-mini\",\n                prompt,\n                system_prompt=system_prompt,\n                history_messages=history_messages,\n                api_key=api_key,\n                base_url=base_url,\n                **kwargs,\n            )\n\n        # Define vision model function for image processing\n        def vision_model_func(\n            prompt, system_prompt=None, history_messages=[], image_data=None, **kwargs\n        ):\n            if image_data:\n                return openai_complete_if_cache(\n                    \"gpt-4o\",\n                    \"\",\n                    system_prompt=None,\n                    history_messages=[],\n                    messages=[\n                        {\"role\": \"system\", \"content\": system_prompt}\n                        if system_prompt\n                        else None,\n                        {\n                            \"role\": \"user\",\n                            \"content\": [\n                                {\"type\": \"text\", \"text\": prompt},\n                                {\n                                    \"type\": \"image_url\",\n                                    \"image_url\": {\n                                        \"url\": f\"data:image/jpeg;base64,{image_data}\"\n                                    },\n                                },\n                            ],\n                        }\n                        if image_data\n                        else {\"role\": \"user\", \"content\": prompt},\n                    ],\n                    api_key=api_key,\n                    base_url=base_url,\n                    **kwargs,\n                )\n            else:\n                return llm_model_func(prompt, system_prompt, history_messages, **kwargs)\n\n        # Define embedding function\n        embedding_func = EmbeddingFunc(\n            embedding_dim=3072,\n            max_token_size=8192,\n            func=lambda texts: openai_embed(\n                texts,\n                model=\"text-embedding-3-large\",\n                api_key=api_key,\n                base_url=base_url,\n            ),\n        )\n\n        # Initialize RAGAnything with new dataclass structure\n        rag = RAGAnything(\n            config=config,\n            llm_model_func=llm_model_func,\n            vision_model_func=vision_model_func,\n            embedding_func=embedding_func,\n        )\n\n        # Process document\n        await rag.process_document_complete(\n            file_path=file_path, output_dir=output_dir, parse_method=\"auto\"\n        )\n\n        # Example queries - demonstrating different query approaches\n        logger.info(\"\\nQuerying processed document:\")\n\n        # 1. Pure text queries using aquery()\n        text_queries = [\n            \"What is the main content of the document?\",\n            \"What are the key topics discussed?\",\n        ]\n\n        for query in text_queries:\n            logger.info(f\"\\n[Text Query]: {query}\")\n            result = await rag.aquery(query, mode=\"hybrid\")\n            logger.info(f\"Answer: {result}\")\n\n        # 2. Multimodal query with specific multimodal content using aquery_with_multimodal()\n        logger.info(\n            \"\\n[Multimodal Query]: Analyzing performance data in context of document\"\n        )\n        multimodal_result = await rag.aquery_with_multimodal(\n            \"Compare this performance data with any similar results mentioned in the document\",\n            multimodal_content=[\n                {\n                    \"type\": \"table\",\n                    \"table_data\": \"\"\"Method,Accuracy,Processing_Time\n                                RAGAnything,95.2%,120ms\n                                Traditional_RAG,87.3%,180ms\n                                Baseline,82.1%,200ms\"\"\",\n                    \"table_caption\": \"Performance comparison results\",\n                }\n            ],\n            mode=\"hybrid\",\n        )\n        logger.info(f\"Answer: {multimodal_result}\")\n\n        # 3. Another multimodal query with equation content\n        logger.info(\"\\n[Multimodal Query]: Mathematical formula analysis\")\n        equation_result = await rag.aquery_with_multimodal(\n            \"Explain this formula and relate it to any mathematical concepts in the document\",\n            multimodal_content=[\n                {\n                    \"type\": \"equation\",\n                    \"latex\": \"F1 = 2 \\\\cdot \\\\frac{precision \\\\cdot recall}{precision + recall}\",\n                    \"equation_caption\": \"F1-score calculation formula\",\n                }\n            ],\n            mode=\"hybrid\",\n        )\n        logger.info(f\"Answer: {equation_result}\")\n\n    except Exception as e:\n        logger.error(f\"Error processing with RAG: {str(e)}\")\n        import traceback\n\n        logger.error(traceback.format_exc())\n\n\ndef main():\n    \"\"\"Main function to run the example\"\"\"\n    parser = argparse.ArgumentParser(description=\"MinerU RAG Example\")\n    parser.add_argument(\"file_path\", help=\"Path to the document to process\")\n    parser.add_argument(\n        \"--working_dir\", \"-w\", default=\"./rag_storage\", help=\"Working directory path\"\n    )\n    parser.add_argument(\n        \"--output\", \"-o\", default=\"./output\", help=\"Output directory path\"\n    )\n    parser.add_argument(\n        \"--api-key\",\n        default=os.getenv(\"OPENAI_API_KEY\"),\n        help=\"OpenAI API key (defaults to OPENAI_API_KEY env var)\",\n    )\n    parser.add_argument(\"--base-url\", help=\"Optional base URL for API\")\n\n    args = parser.parse_args()\n\n    # Check if API key is provided\n    if not args.api_key:\n        logger.error(\"Error: OpenAI API key is required\")\n        logger.error(\"Set OPENAI_API_KEY environment variable or use --api-key option\")\n        return\n\n    # Create output directory if specified\n    if args.output:\n        os.makedirs(args.output, exist_ok=True)\n\n    # Process with RAG\n    asyncio.run(\n        process_with_rag(\n            args.file_path, args.output, args.api_key, args.base_url, args.working_dir\n        )\n    )\n\n\nif __name__ == \"__main__\":\n    # Configure logging first\n    configure_logging()\n\n    print(\"RAGAnything Example\")\n    print(\"=\" * 30)\n    print(\"Processing document with multimodal RAG pipeline\")\n    print(\"=\" * 30)\n\n    main()\n"
  },
  {
    "path": "examples/rerank_example.py",
    "content": "\"\"\"\nLightRAG Rerank Integration Example\n\nThis example demonstrates how to use rerank functionality with LightRAG\nto improve retrieval quality across different query modes.\n\nConfiguration Required:\n1. Set your OpenAI LLM API key and base URL with env vars\n    LLM_MODEL\n    LLM_BINDING_HOST\n    LLM_BINDING_API_KEY\n2. Set your OpenAI embedding API key and base URL with env vars:\n    EMBEDDING_MODEL\n    EMBEDDING_DIM\n    EMBEDDING_BINDING_HOST\n    EMBEDDING_BINDING_API_KEY\n3. Set your vLLM deployed AI rerank model setting with env vars:\n    RERANK_BINDING=cohere\n    RERANK_MODEL (e.g., answerai-colbert-small-v1 or rerank-v3.5)\n    RERANK_BINDING_HOST (e.g., https://api.cohere.com/v2/rerank or LiteLLM proxy)\n    RERANK_BINDING_API_KEY\n    RERANK_ENABLE_CHUNKING=true (optional, for models with token limits)\n    RERANK_MAX_TOKENS_PER_DOC=480 (optional, default 4096)\n\nNote: Rerank is controlled per query via the 'enable_rerank' parameter (default: True)\n\"\"\"\n\nimport asyncio\nimport os\nimport numpy as np\n\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.openai import openai_complete_if_cache, openai_embed\nfrom lightrag.utils import EmbeddingFunc, setup_logger\n\nfrom functools import partial\nfrom lightrag.rerank import cohere_rerank\n\n# Set up your working directory\nWORKING_DIR = \"./test_rerank\"\nsetup_logger(\"test_rerank\")\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], **kwargs\n) -> str:\n    return await openai_complete_if_cache(\n        os.getenv(\"LLM_MODEL\"),\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=os.getenv(\"LLM_BINDING_API_KEY\"),\n        base_url=os.getenv(\"LLM_BINDING_HOST\"),\n        **kwargs,\n    )\n\n\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await openai_embed(\n        texts,\n        model=os.getenv(\"EMBEDDING_MODEL\"),\n        api_key=os.getenv(\"EMBEDDING_BINDING_API_KEY\"),\n        base_url=os.getenv(\"EMBEDDING_BINDING_HOST\"),\n    )\n\n\nrerank_model_func = partial(\n    cohere_rerank,\n    model=os.getenv(\"RERANK_MODEL\", \"rerank-v3.5\"),\n    api_key=os.getenv(\"RERANK_BINDING_API_KEY\"),\n    base_url=os.getenv(\"RERANK_BINDING_HOST\", \"https://api.cohere.com/v2/rerank\"),\n    enable_chunking=os.getenv(\"RERANK_ENABLE_CHUNKING\", \"false\").lower() == \"true\",\n    max_tokens_per_doc=int(os.getenv(\"RERANK_MAX_TOKENS_PER_DOC\", \"4096\")),\n)\n\n\nasync def create_rag_with_rerank():\n    \"\"\"Create LightRAG instance with rerank configuration\"\"\"\n\n    # Get embedding dimension\n    test_embedding = await embedding_func([\"test\"])\n    embedding_dim = test_embedding.shape[1]\n    print(f\"Detected embedding dimension: {embedding_dim}\")\n\n    # Method 1: Using custom rerank function\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=llm_model_func,\n        embedding_func=EmbeddingFunc(\n            embedding_dim=embedding_dim,\n            max_token_size=8192,\n            func=embedding_func,\n        ),\n        # Rerank Configuration - provide the rerank function\n        rerank_model_func=rerank_model_func,\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\nasync def test_rerank_with_different_settings():\n    \"\"\"\n    Test rerank functionality with different enable_rerank settings\n    \"\"\"\n    print(\"\\n\\n🚀 Setting up LightRAG with Rerank functionality...\")\n\n    rag = await create_rag_with_rerank()\n\n    # Insert sample documents\n    sample_docs = [\n        \"Reranking improves retrieval quality by re-ordering documents based on relevance.\",\n        \"LightRAG is a powerful retrieval-augmented generation system with multiple query modes.\",\n        \"Vector databases enable efficient similarity search in high-dimensional embedding spaces.\",\n        \"Natural language processing has evolved with large language models and transformers.\",\n        \"Machine learning algorithms can learn patterns from data without explicit programming.\",\n    ]\n\n    print(\"📄 Inserting sample documents...\")\n    await rag.ainsert(sample_docs)\n\n    query = \"How does reranking improve retrieval quality?\"\n    print(f\"\\n🔍 Testing query: '{query}'\")\n    print(\"=\" * 80)\n\n    # Test with rerank enabled (default)\n    print(\"\\n📊 Testing with enable_rerank=True (default):\")\n    result_with_rerank = await rag.aquery(\n        query,\n        param=QueryParam(\n            mode=\"naive\",\n            top_k=10,\n            chunk_top_k=5,\n            enable_rerank=True,  # Explicitly enable rerank\n        ),\n    )\n    print(f\"   Result length: {len(result_with_rerank)} characters\")\n    print(f\"   Preview: {result_with_rerank[:100]}...\")\n\n    # Test with rerank disabled\n    print(\"\\n📊 Testing with enable_rerank=False:\")\n    result_without_rerank = await rag.aquery(\n        query,\n        param=QueryParam(\n            mode=\"naive\",\n            top_k=10,\n            chunk_top_k=5,\n            enable_rerank=False,  # Disable rerank\n        ),\n    )\n    print(f\"   Result length: {len(result_without_rerank)} characters\")\n    print(f\"   Preview: {result_without_rerank[:100]}...\")\n\n    # Test with default settings (enable_rerank defaults to True)\n    print(\"\\n📊 Testing with default settings (enable_rerank defaults to True):\")\n    result_default = await rag.aquery(\n        query, param=QueryParam(mode=\"naive\", top_k=10, chunk_top_k=5)\n    )\n    print(f\"   Result length: {len(result_default)} characters\")\n    print(f\"   Preview: {result_default[:100]}...\")\n\n\nasync def test_direct_rerank():\n    \"\"\"Test rerank function directly\"\"\"\n    print(\"\\n🔧 Direct Rerank API Test\")\n    print(\"=\" * 40)\n\n    documents = [\n        \"Vector search finds semantically similar documents\",\n        \"LightRAG supports advanced reranking capabilities\",\n        \"Reranking significantly improves retrieval quality\",\n        \"Natural language processing with modern transformers\",\n        \"The quick brown fox jumps over the lazy dog\",\n    ]\n\n    query = \"rerank improve quality\"\n    print(f\"Query: '{query}'\")\n    print(f\"Documents: {len(documents)}\")\n\n    try:\n        reranked_results = await rerank_model_func(\n            query=query,\n            documents=documents,\n            top_n=4,\n        )\n\n        print(\"\\n✅ Rerank Results:\")\n        i = 0\n        for result in reranked_results:\n            index = result[\"index\"]\n            score = result[\"relevance_score\"]\n            content = documents[index]\n            print(f\"  {index}. Score: {score:.4f} | {content}...\")\n            i += 1\n\n    except Exception as e:\n        print(f\"❌ Rerank failed: {e}\")\n\n\nasync def main():\n    \"\"\"Main example function\"\"\"\n    print(\"🎯 LightRAG Rerank Integration Example\")\n    print(\"=\" * 60)\n\n    try:\n        # Test direct rerank\n        await test_direct_rerank()\n\n        # Test rerank with different enable_rerank settings\n        await test_rerank_with_different_settings()\n\n        print(\"\\n✅ Example completed successfully!\")\n        print(\"\\n💡 Key Points:\")\n        print(\"   ✓ Rerank is now controlled per query via 'enable_rerank' parameter\")\n        print(\"   ✓ Default value for enable_rerank is True\")\n        print(\"   ✓ Rerank function is configured at LightRAG initialization\")\n        print(\"   ✓ Per-query enable_rerank setting overrides default behavior\")\n        print(\n            \"   ✓ If enable_rerank=True but no rerank model is configured, a warning is issued\"\n        )\n        print(\"   ✓ Monitor API usage and costs when using rerank services\")\n\n    except Exception as e:\n        print(f\"\\n❌ Example failed: {e}\")\n        import traceback\n\n        traceback.print_exc()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/unofficial-sample/copy_llm_cache_to_another_storage.py",
    "content": "\"\"\"\nSometimes you need to switch a storage solution, but you want to save LLM token and time.\nThis handy script helps you to copy the LLM caches from one storage solution to another.\n(Not all the storage impl are supported)\n\"\"\"\n\nimport asyncio\nimport logging\nimport os\nfrom dotenv import load_dotenv\n\nfrom lightrag.kg.postgres_impl import PostgreSQLDB, PGKVStorage\nfrom lightrag.kg.json_kv_impl import JsonKVStorage\nfrom lightrag.namespace import NameSpace\n\nload_dotenv()\nROOT_DIR = os.environ.get(\"ROOT_DIR\")\nWORKING_DIR = f\"{ROOT_DIR}/dickens\"\n\nlogging.basicConfig(format=\"%(levelname)s:%(message)s\", level=logging.INFO)\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n# AGE\nos.environ[\"AGE_GRAPH_NAME\"] = \"chinese\"\n\npostgres_db = PostgreSQLDB(\n    config={\n        \"host\": \"localhost\",\n        \"port\": 15432,\n        \"user\": \"rag\",\n        \"password\": \"rag\",\n        \"database\": \"r2\",\n    }\n)\n\n\nasync def copy_from_postgres_to_json():\n    await postgres_db.initdb()\n\n    from_llm_response_cache = PGKVStorage(\n        namespace=NameSpace.KV_STORE_LLM_RESPONSE_CACHE,\n        global_config={\"embedding_batch_num\": 6},\n        embedding_func=None,\n        db=postgres_db,\n    )\n\n    to_llm_response_cache = JsonKVStorage(\n        namespace=NameSpace.KV_STORE_LLM_RESPONSE_CACHE,\n        global_config={\"working_dir\": WORKING_DIR},\n        embedding_func=None,\n    )\n\n    # Get all cache data using the new flattened structure\n    all_data = await from_llm_response_cache.get_all()\n\n    # Convert flattened data to hierarchical structure for JsonKVStorage\n    kv = {}\n    for flattened_key, cache_entry in all_data.items():\n        # Parse flattened key: {mode}:{cache_type}:{hash}\n        parts = flattened_key.split(\":\", 2)\n        if len(parts) == 3:\n            mode, cache_type, hash_value = parts\n            if mode not in kv:\n                kv[mode] = {}\n            kv[mode][hash_value] = cache_entry\n            print(f\"Copying {flattened_key} -> {mode}[{hash_value}]\")\n        else:\n            print(f\"Skipping invalid key format: {flattened_key}\")\n\n    await to_llm_response_cache.upsert(kv)\n    await to_llm_response_cache.index_done_callback()\n    print(\"Mission accomplished!\")\n\n\nasync def copy_from_json_to_postgres():\n    await postgres_db.initdb()\n\n    from_llm_response_cache = JsonKVStorage(\n        namespace=NameSpace.KV_STORE_LLM_RESPONSE_CACHE,\n        global_config={\"working_dir\": WORKING_DIR},\n        embedding_func=None,\n    )\n\n    to_llm_response_cache = PGKVStorage(\n        namespace=NameSpace.KV_STORE_LLM_RESPONSE_CACHE,\n        global_config={\"embedding_batch_num\": 6},\n        embedding_func=None,\n        db=postgres_db,\n    )\n\n    # Get all cache data from JsonKVStorage (hierarchical structure)\n    all_data = await from_llm_response_cache.get_all()\n\n    # Convert hierarchical data to flattened structure for PGKVStorage\n    flattened_data = {}\n    for mode, mode_data in all_data.items():\n        print(f\"Processing mode: {mode}\")\n        for hash_value, cache_entry in mode_data.items():\n            # Determine cache_type from cache entry or use default\n            cache_type = cache_entry.get(\"cache_type\", \"extract\")\n            # Create flattened key: {mode}:{cache_type}:{hash}\n            flattened_key = f\"{mode}:{cache_type}:{hash_value}\"\n            flattened_data[flattened_key] = cache_entry\n            print(f\"\\tConverting {mode}[{hash_value}] -> {flattened_key}\")\n\n    # Upsert the flattened data\n    await to_llm_response_cache.upsert(flattened_data)\n    print(\"Mission accomplished!\")\n\n\nif __name__ == \"__main__\":\n    asyncio.run(copy_from_json_to_postgres())\n"
  },
  {
    "path": "examples/unofficial-sample/lightrag_bedrock_demo.py",
    "content": "\"\"\"\nLightRAG meets Amazon Bedrock ⛰️\n\"\"\"\n\nimport os\nimport logging\n\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.bedrock import bedrock_complete, bedrock_embed\nfrom lightrag.utils import EmbeddingFunc\n\nimport asyncio\nimport nest_asyncio\n\nnest_asyncio.apply()\n\nlogging.getLogger(\"aiobotocore\").setLevel(logging.WARNING)\n\nWORKING_DIR = \"./dickens\"\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=bedrock_complete,\n        llm_model_name=\"Anthropic Claude 3 Haiku // Amazon Bedrock\",\n        embedding_func=EmbeddingFunc(\n            embedding_dim=1024, max_token_size=8192, func=bedrock_embed\n        ),\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\ndef main():\n    rag = asyncio.run(initialize_rag())\n\n    with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n        rag.insert(f.read())\n\n    for mode in [\"naive\", \"local\", \"global\", \"hybrid\"]:\n        print(\"\\n+-\" + \"-\" * len(mode) + \"-+\")\n        print(f\"| {mode.capitalize()} |\")\n        print(\"+-\" + \"-\" * len(mode) + \"-+\\n\")\n        print(\n            rag.query(\n                \"What are the top themes in this story?\", param=QueryParam(mode=mode)\n            )\n        )\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/unofficial-sample/lightrag_cloudflare_demo.py",
    "content": "import asyncio\nimport os\nimport inspect\nimport logging\nimport logging.config\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.utils import EmbeddingFunc, logger, set_verbose_debug\n\nimport requests\nimport numpy as np\nfrom dotenv import load_dotenv\n\n\"\"\"This code is a modified version of lightrag_openai_demo.py\"\"\"\n\n# ideally, as always, env!\nload_dotenv(dotenv_path=\".env\", override=False)\n\n\n\"\"\"    ----========= IMPORTANT CHANGE THIS! =========----    \"\"\"\ncloudflare_api_key = \"YOUR_API_KEY\"\naccount_id = \"YOUR_ACCOUNT ID\"  # This is unique to your Cloudflare account\n\n# Authomatically changes\napi_base_url = f\"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/\"\n\n\n# choose an embedding model\nEMBEDDING_MODEL = \"@cf/baai/bge-m3\"\n# choose a generative model\nLLM_MODEL = \"@cf/meta/llama-3.2-3b-instruct\"\n\nWORKING_DIR = \"../dickens\"  # you can change output as desired\n\n\n# Cloudflare init\nclass CloudflareWorker:\n    def __init__(\n        self,\n        cloudflare_api_key: str,\n        api_base_url: str,\n        llm_model_name: str,\n        embedding_model_name: str,\n        max_tokens: int = 4080,\n        max_response_tokens: int = 4080,\n    ):\n        self.cloudflare_api_key = cloudflare_api_key\n        self.api_base_url = api_base_url\n        self.llm_model_name = llm_model_name\n        self.embedding_model_name = embedding_model_name\n        self.max_tokens = max_tokens\n        self.max_response_tokens = max_response_tokens\n\n    async def _send_request(self, model_name: str, input_: dict, debug_log: str):\n        headers = {\"Authorization\": f\"Bearer {self.cloudflare_api_key}\"}\n\n        print(f\"\"\"\n        data sent to Cloudflare\n        ~~~~~~~~~~~\n        {debug_log}\n        \"\"\")\n\n        try:\n            response_raw = requests.post(\n                f\"{self.api_base_url}{model_name}\", headers=headers, json=input_\n            ).json()\n            print(f\"\"\"\n        Cloudflare worker responded with:\n        ~~~~~~~~~~~\n        {str(response_raw)}\n            \"\"\")\n            result = response_raw.get(\"result\", {})\n\n            if \"data\" in result:  # Embedding case\n                return np.array(result[\"data\"])\n\n            if \"response\" in result:  # LLM response\n                return result[\"response\"]\n\n            raise ValueError(\"Unexpected Cloudflare response format\")\n\n        except Exception as e:\n            print(f\"\"\"\n            Cloudflare API returned:\n            ~~~~~~~~~\n            Error: {e}\n            \"\"\")\n            input(\"Press Enter to continue...\")\n            return None\n\n    async def query(self, prompt, system_prompt: str = \"\", **kwargs) -> str:\n        # since no caching is used and we don't want to mess with everything lightrag, pop the kwarg it is\n        kwargs.pop(\"hashing_kv\", None)\n\n        message = [\n            {\"role\": \"system\", \"content\": system_prompt},\n            {\"role\": \"user\", \"content\": prompt},\n        ]\n\n        input_ = {\n            \"messages\": message,\n            \"max_tokens\": self.max_tokens,\n            \"response_token_limit\": self.max_response_tokens,\n        }\n\n        return await self._send_request(\n            self.llm_model_name,\n            input_,\n            debug_log=f\"\\n- model used {self.llm_model_name}\\n- system prompt: {system_prompt}\\n- query: {prompt}\",\n        )\n\n    async def embedding_chunk(self, texts: list[str]) -> np.ndarray:\n        print(f\"\"\"\n        TEXT inputted\n        ~~~~~\n        {texts}\n        \"\"\")\n\n        input_ = {\n            \"text\": texts,\n            \"max_tokens\": self.max_tokens,\n            \"response_token_limit\": self.max_response_tokens,\n        }\n\n        return await self._send_request(\n            self.embedding_model_name,\n            input_,\n            debug_log=f\"\\n-llm model name {self.embedding_model_name}\\n- texts: {texts}\",\n        )\n\n\ndef configure_logging():\n    \"\"\"Configure logging for the application\"\"\"\n\n    # Reset any existing handlers to ensure clean configuration\n    for logger_name in [\"uvicorn\", \"uvicorn.access\", \"uvicorn.error\", \"lightrag\"]:\n        logger_instance = logging.getLogger(logger_name)\n        logger_instance.handlers = []\n        logger_instance.filters = []\n\n    # Get log directory path from environment variable or use current directory\n    log_dir = os.getenv(\"LOG_DIR\", os.getcwd())\n    log_file_path = os.path.abspath(\n        os.path.join(log_dir, \"lightrag_cloudflare_worker_demo.log\")\n    )\n\n    print(f\"\\nLightRAG compatible demo log file: {log_file_path}\\n\")\n    os.makedirs(os.path.dirname(log_file_path), exist_ok=True)\n\n    # Get log file max size and backup count from environment variables\n    log_max_bytes = int(os.getenv(\"LOG_MAX_BYTES\", 10485760))  # Default 10MB\n    log_backup_count = int(os.getenv(\"LOG_BACKUP_COUNT\", 5))  # Default 5 backups\n\n    logging.config.dictConfig(\n        {\n            \"version\": 1,\n            \"disable_existing_loggers\": False,\n            \"formatters\": {\n                \"default\": {\n                    \"format\": \"%(levelname)s: %(message)s\",\n                },\n                \"detailed\": {\n                    \"format\": \"%(asctime)s - %(name)s - %(levelname)s - %(message)s\",\n                },\n            },\n            \"handlers\": {\n                \"console\": {\n                    \"formatter\": \"default\",\n                    \"class\": \"logging.StreamHandler\",\n                    \"stream\": \"ext://sys.stderr\",\n                },\n                \"file\": {\n                    \"formatter\": \"detailed\",\n                    \"class\": \"logging.handlers.RotatingFileHandler\",\n                    \"filename\": log_file_path,\n                    \"maxBytes\": log_max_bytes,\n                    \"backupCount\": log_backup_count,\n                    \"encoding\": \"utf-8\",\n                },\n            },\n            \"loggers\": {\n                \"lightrag\": {\n                    \"handlers\": [\"console\", \"file\"],\n                    \"level\": \"INFO\",\n                    \"propagate\": False,\n                },\n            },\n        }\n    )\n\n    # Set the logger level to INFO\n    logger.setLevel(logging.INFO)\n    # Enable verbose debug if needed\n    set_verbose_debug(os.getenv(\"VERBOSE_DEBUG\", \"false\").lower() == \"true\")\n\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n\nasync def initialize_rag():\n    cloudflare_worker = CloudflareWorker(\n        cloudflare_api_key=cloudflare_api_key,\n        api_base_url=api_base_url,\n        embedding_model_name=EMBEDDING_MODEL,\n        llm_model_name=LLM_MODEL,\n    )\n\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        max_parallel_insert=2,\n        llm_model_func=cloudflare_worker.query,\n        llm_model_name=os.getenv(\"LLM_MODEL\", LLM_MODEL),\n        summary_max_tokens=4080,\n        embedding_func=EmbeddingFunc(\n            embedding_dim=int(os.getenv(\"EMBEDDING_DIM\", \"1024\")),\n            max_token_size=int(os.getenv(\"MAX_EMBED_TOKENS\", \"2048\")),\n            func=lambda texts: cloudflare_worker.embedding_chunk(\n                texts,\n            ),\n        ),\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\nasync def print_stream(stream):\n    async for chunk in stream:\n        print(chunk, end=\"\", flush=True)\n\n\nasync def main():\n    try:\n        # Clear old data files\n        files_to_delete = [\n            \"graph_chunk_entity_relation.graphml\",\n            \"kv_store_doc_status.json\",\n            \"kv_store_full_docs.json\",\n            \"kv_store_text_chunks.json\",\n            \"vdb_chunks.json\",\n            \"vdb_entities.json\",\n            \"vdb_relationships.json\",\n        ]\n\n        for file in files_to_delete:\n            file_path = os.path.join(WORKING_DIR, file)\n            if os.path.exists(file_path):\n                os.remove(file_path)\n                print(f\"Deleting old file:: {file_path}\")\n\n        # Initialize RAG instance\n        rag = await initialize_rag()\n\n        # Test embedding function\n        test_text = [\"This is a test string for embedding.\"]\n        embedding = await rag.embedding_func(test_text)\n        embedding_dim = embedding.shape[1]\n        print(\"\\n=======================\")\n        print(\"Test embedding function\")\n        print(\"========================\")\n        print(f\"Test dict: {test_text}\")\n        print(f\"Detected embedding dimension: {embedding_dim}\\n\\n\")\n\n        # Locate the location of what is needed to be added to the knowledge\n        # Can add several simultaneously by modifying code\n        with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n            await rag.ainsert(f.read())\n\n        # Perform naive search\n        print(\"\\n=====================\")\n        print(\"Query mode: naive\")\n        print(\"=====================\")\n        resp = await rag.aquery(\n            \"What are the top themes in this story?\",\n            param=QueryParam(mode=\"naive\", stream=True),\n        )\n        if inspect.isasyncgen(resp):\n            await print_stream(resp)\n        else:\n            print(resp)\n\n        # Perform local search\n        print(\"\\n=====================\")\n        print(\"Query mode: local\")\n        print(\"=====================\")\n        resp = await rag.aquery(\n            \"What are the top themes in this story?\",\n            param=QueryParam(mode=\"local\", stream=True),\n        )\n        if inspect.isasyncgen(resp):\n            await print_stream(resp)\n        else:\n            print(resp)\n\n        # Perform global search\n        print(\"\\n=====================\")\n        print(\"Query mode: global\")\n        print(\"=====================\")\n        resp = await rag.aquery(\n            \"What are the top themes in this story?\",\n            param=QueryParam(mode=\"global\", stream=True),\n        )\n        if inspect.isasyncgen(resp):\n            await print_stream(resp)\n        else:\n            print(resp)\n\n        # Perform hybrid search\n        print(\"\\n=====================\")\n        print(\"Query mode: hybrid\")\n        print(\"=====================\")\n        resp = await rag.aquery(\n            \"What are the top themes in this story?\",\n            param=QueryParam(mode=\"hybrid\", stream=True),\n        )\n        if inspect.isasyncgen(resp):\n            await print_stream(resp)\n        else:\n            print(resp)\n\n        \"\"\" FOR TESTING (if you want to test straight away, after building. Uncomment this part\"\"\"\n\n        \"\"\"\n        print(\"\\n\" + \"=\" * 60)\n        print(\"AI ASSISTANT READY!\")\n        print(\"Ask questions about (your uploaded) regulations\")\n        print(\"Type 'quit' to exit\")\n        print(\"=\" * 60)\n\n        while True:\n            question = input(\"\\n🔥 Your question: \")\n\n            if question.lower() in ['quit', 'exit', 'bye']:\n                break\n\n            print(\"\\nThinking...\")\n            response = await rag.aquery(question, param=QueryParam(mode=\"hybrid\"))\n            print(f\"\\nAnswer: {response}\")\n\n        \"\"\"\n\n    except Exception as e:\n        print(f\"An error occurred: {e}\")\n    finally:\n        if rag:\n            await rag.llm_response_cache.index_done_callback()\n            await rag.finalize_storages()\n\n\nif __name__ == \"__main__\":\n    # Configure logging before running the main function\n    configure_logging()\n    asyncio.run(main())\n    print(\"\\nDone!\")\n"
  },
  {
    "path": "examples/unofficial-sample/lightrag_hf_demo.py",
    "content": "import os\n\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.hf import hf_model_complete, hf_embed\nfrom lightrag.utils import EmbeddingFunc\nfrom transformers import AutoModel, AutoTokenizer\n\nimport asyncio\nimport nest_asyncio\n\nnest_asyncio.apply()\n\nWORKING_DIR = \"./dickens\"\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=hf_model_complete,\n        llm_model_name=\"meta-llama/Llama-3.1-8B-Instruct\",\n        embedding_func=EmbeddingFunc(\n            embedding_dim=384,\n            max_token_size=5000,\n            func=lambda texts: hf_embed(\n                texts,\n                tokenizer=AutoTokenizer.from_pretrained(\n                    \"sentence-transformers/all-MiniLM-L6-v2\"\n                ),\n                embed_model=AutoModel.from_pretrained(\n                    \"sentence-transformers/all-MiniLM-L6-v2\"\n                ),\n            ),\n        ),\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\ndef main():\n    rag = asyncio.run(initialize_rag())\n\n    with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n        rag.insert(f.read())\n\n    # Perform naive search\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"naive\")\n        )\n    )\n\n    # Perform local search\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"local\")\n        )\n    )\n\n    # Perform global search\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"global\")\n        )\n    )\n\n    # Perform hybrid search\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"hybrid\")\n        )\n    )\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/unofficial-sample/lightrag_llamaindex_direct_demo.py",
    "content": "import os\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.llama_index_impl import (\n    llama_index_complete_if_cache,\n    llama_index_embed,\n)\nfrom lightrag.utils import EmbeddingFunc\nfrom llama_index.llms.openai import OpenAI\nfrom llama_index.embeddings.openai import OpenAIEmbedding\nimport asyncio\nimport nest_asyncio\n\nnest_asyncio.apply()\n\n\n# Configure working directory\nWORKING_DIR = \"./index_default\"\nprint(f\"WORKING_DIR: {WORKING_DIR}\")\n\n# Model configuration\nLLM_MODEL = os.environ.get(\"LLM_MODEL\", \"gpt-4\")\nprint(f\"LLM_MODEL: {LLM_MODEL}\")\nEMBEDDING_MODEL = os.environ.get(\"EMBEDDING_MODEL\", \"text-embedding-3-large\")\nprint(f\"EMBEDDING_MODEL: {EMBEDDING_MODEL}\")\nEMBEDDING_MAX_TOKEN_SIZE = int(os.environ.get(\"EMBEDDING_MAX_TOKEN_SIZE\", 8192))\nprint(f\"EMBEDDING_MAX_TOKEN_SIZE: {EMBEDDING_MAX_TOKEN_SIZE}\")\n\n# OpenAI configuration\nOPENAI_API_KEY = os.environ.get(\"OPENAI_API_KEY\", \"your-api-key-here\")\n\nif not os.path.exists(WORKING_DIR):\n    print(f\"Creating working directory: {WORKING_DIR}\")\n    os.mkdir(WORKING_DIR)\n\n\n# Initialize LLM function\nasync def llm_model_func(prompt, system_prompt=None, history_messages=[], **kwargs):\n    try:\n        # Initialize OpenAI if not in kwargs\n        if \"llm_instance\" not in kwargs:\n            llm_instance = OpenAI(\n                model=LLM_MODEL,\n                api_key=OPENAI_API_KEY,\n                temperature=0.7,\n            )\n            kwargs[\"llm_instance\"] = llm_instance\n\n        response = await llama_index_complete_if_cache(\n            kwargs[\"llm_instance\"],\n            prompt,\n            system_prompt=system_prompt,\n            history_messages=history_messages,\n            **kwargs,\n        )\n        return response\n    except Exception as e:\n        print(f\"LLM request failed: {str(e)}\")\n        raise\n\n\n# Initialize embedding function\nasync def embedding_func(texts):\n    try:\n        embed_model = OpenAIEmbedding(\n            model=EMBEDDING_MODEL,\n            api_key=OPENAI_API_KEY,\n        )\n        return await llama_index_embed(texts, embed_model=embed_model)\n    except Exception as e:\n        print(f\"Embedding failed: {str(e)}\")\n        raise\n\n\n# Get embedding dimension\nasync def get_embedding_dim():\n    test_text = [\"This is a test sentence.\"]\n    embedding = await embedding_func(test_text)\n    embedding_dim = embedding.shape[1]\n    print(f\"embedding_dim={embedding_dim}\")\n    return embedding_dim\n\n\nasync def initialize_rag():\n    embedding_dimension = await get_embedding_dim()\n\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=llm_model_func,\n        embedding_func=EmbeddingFunc(\n            embedding_dim=embedding_dimension,\n            max_token_size=EMBEDDING_MAX_TOKEN_SIZE,\n            func=embedding_func,\n        ),\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\ndef main():\n    # Initialize RAG instance\n    rag = asyncio.run(initialize_rag())\n\n    # Insert example text\n    with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n        rag.insert(f.read())\n\n    # Test different query modes\n    print(\"\\nNaive Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"naive\")\n        )\n    )\n\n    print(\"\\nLocal Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"local\")\n        )\n    )\n\n    print(\"\\nGlobal Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"global\")\n        )\n    )\n\n    print(\"\\nHybrid Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"hybrid\")\n        )\n    )\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/unofficial-sample/lightrag_llamaindex_litellm_demo.py",
    "content": "import os\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.llama_index_impl import (\n    llama_index_complete_if_cache,\n    llama_index_embed,\n)\nfrom lightrag.utils import EmbeddingFunc\nfrom llama_index.llms.litellm import LiteLLM\nfrom llama_index.embeddings.litellm import LiteLLMEmbedding\nimport asyncio\nimport nest_asyncio\n\nnest_asyncio.apply()\n\n\n# Configure working directory\nWORKING_DIR = \"./index_default\"\nprint(f\"WORKING_DIR: {WORKING_DIR}\")\n\n# Model configuration\nLLM_MODEL = os.environ.get(\"LLM_MODEL\", \"gpt-4\")\nprint(f\"LLM_MODEL: {LLM_MODEL}\")\nEMBEDDING_MODEL = os.environ.get(\"EMBEDDING_MODEL\", \"text-embedding-3-large\")\nprint(f\"EMBEDDING_MODEL: {EMBEDDING_MODEL}\")\nEMBEDDING_MAX_TOKEN_SIZE = int(os.environ.get(\"EMBEDDING_MAX_TOKEN_SIZE\", 8192))\nprint(f\"EMBEDDING_MAX_TOKEN_SIZE: {EMBEDDING_MAX_TOKEN_SIZE}\")\n\n# LiteLLM configuration\nLITELLM_URL = os.environ.get(\"LITELLM_URL\", \"http://localhost:4000\")\nprint(f\"LITELLM_URL: {LITELLM_URL}\")\nLITELLM_KEY = os.environ.get(\"LITELLM_KEY\", \"sk-1234\")\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n\n# Initialize LLM function\nasync def llm_model_func(prompt, system_prompt=None, history_messages=[], **kwargs):\n    try:\n        # Initialize LiteLLM if not in kwargs\n        if \"llm_instance\" not in kwargs:\n            llm_instance = LiteLLM(\n                model=f\"openai/{LLM_MODEL}\",  # Format: \"provider/model_name\"\n                api_base=LITELLM_URL,\n                api_key=LITELLM_KEY,\n                temperature=0.7,\n            )\n            kwargs[\"llm_instance\"] = llm_instance\n\n        response = await llama_index_complete_if_cache(\n            kwargs[\"llm_instance\"],\n            prompt,\n            system_prompt=system_prompt,\n            history_messages=history_messages,\n        )\n        return response\n    except Exception as e:\n        print(f\"LLM request failed: {str(e)}\")\n        raise\n\n\n# Initialize embedding function\nasync def embedding_func(texts):\n    try:\n        embed_model = LiteLLMEmbedding(\n            model_name=f\"openai/{EMBEDDING_MODEL}\",\n            api_base=LITELLM_URL,\n            api_key=LITELLM_KEY,\n        )\n        return await llama_index_embed(texts, embed_model=embed_model)\n    except Exception as e:\n        print(f\"Embedding failed: {str(e)}\")\n        raise\n\n\n# Get embedding dimension\nasync def get_embedding_dim():\n    test_text = [\"This is a test sentence.\"]\n    embedding = await embedding_func(test_text)\n    embedding_dim = embedding.shape[1]\n    print(f\"embedding_dim={embedding_dim}\")\n    return embedding_dim\n\n\nasync def initialize_rag():\n    embedding_dimension = await get_embedding_dim()\n\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=llm_model_func,\n        embedding_func=EmbeddingFunc(\n            embedding_dim=embedding_dimension,\n            max_token_size=EMBEDDING_MAX_TOKEN_SIZE,\n            func=embedding_func,\n        ),\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\ndef main():\n    # Initialize RAG instance\n    rag = asyncio.run(initialize_rag())\n\n    # Insert example text\n    with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n        rag.insert(f.read())\n\n    # Test different query modes\n    print(\"\\nNaive Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"naive\")\n        )\n    )\n\n    print(\"\\nLocal Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"local\")\n        )\n    )\n\n    print(\"\\nGlobal Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"global\")\n        )\n    )\n\n    print(\"\\nHybrid Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"hybrid\")\n        )\n    )\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/unofficial-sample/lightrag_llamaindex_litellm_opik_demo.py",
    "content": "import os\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.llama_index_impl import (\n    llama_index_complete_if_cache,\n    llama_index_embed,\n)\nfrom lightrag.utils import EmbeddingFunc\nfrom llama_index.llms.litellm import LiteLLM\nfrom llama_index.embeddings.litellm import LiteLLMEmbedding\nimport asyncio\nimport nest_asyncio\n\nnest_asyncio.apply()\n\n\n# Configure working directory\nWORKING_DIR = \"./index_default\"\nprint(f\"WORKING_DIR: {WORKING_DIR}\")\n\n# Model configuration\nLLM_MODEL = os.environ.get(\"LLM_MODEL\", \"gemma-3-4b\")\nprint(f\"LLM_MODEL: {LLM_MODEL}\")\nEMBEDDING_MODEL = os.environ.get(\"EMBEDDING_MODEL\", \"arctic-embed\")\nprint(f\"EMBEDDING_MODEL: {EMBEDDING_MODEL}\")\nEMBEDDING_MAX_TOKEN_SIZE = int(os.environ.get(\"EMBEDDING_MAX_TOKEN_SIZE\", 8192))\nprint(f\"EMBEDDING_MAX_TOKEN_SIZE: {EMBEDDING_MAX_TOKEN_SIZE}\")\n\n# LiteLLM configuration\nLITELLM_URL = os.environ.get(\"LITELLM_URL\", \"http://localhost:4000\")\nprint(f\"LITELLM_URL: {LITELLM_URL}\")\nLITELLM_KEY = os.environ.get(\"LITELLM_KEY\", \"sk-4JdvGFKqSA3S0k_5p0xufw\")\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n\n# Initialize LLM function\nasync def llm_model_func(prompt, system_prompt=None, history_messages=[], **kwargs):\n    try:\n        # Initialize LiteLLM if not in kwargs\n        if \"llm_instance\" not in kwargs:\n            llm_instance = LiteLLM(\n                model=f\"openai/{LLM_MODEL}\",  # Format: \"provider/model_name\"\n                api_base=LITELLM_URL,\n                api_key=LITELLM_KEY,\n                temperature=0.7,\n            )\n            kwargs[\"llm_instance\"] = llm_instance\n\n        chat_kwargs = {}\n        chat_kwargs[\"litellm_params\"] = {\n            \"metadata\": {\n                \"opik\": {\n                    \"project_name\": \"lightrag_llamaindex_litellm_opik_demo\",\n                    \"tags\": [\"lightrag\", \"litellm\"],\n                }\n            }\n        }\n\n        response = await llama_index_complete_if_cache(\n            kwargs[\"llm_instance\"],\n            prompt,\n            system_prompt=system_prompt,\n            history_messages=history_messages,\n            chat_kwargs=chat_kwargs,\n        )\n        return response\n    except Exception as e:\n        print(f\"LLM request failed: {str(e)}\")\n        raise\n\n\n# Initialize embedding function\nasync def embedding_func(texts):\n    try:\n        embed_model = LiteLLMEmbedding(\n            model_name=f\"openai/{EMBEDDING_MODEL}\",\n            api_base=LITELLM_URL,\n            api_key=LITELLM_KEY,\n        )\n        return await llama_index_embed(texts, embed_model=embed_model)\n    except Exception as e:\n        print(f\"Embedding failed: {str(e)}\")\n        raise\n\n\n# Get embedding dimension\nasync def get_embedding_dim():\n    test_text = [\"This is a test sentence.\"]\n    embedding = await embedding_func(test_text)\n    embedding_dim = embedding.shape[1]\n    print(f\"embedding_dim={embedding_dim}\")\n    return embedding_dim\n\n\nasync def initialize_rag():\n    embedding_dimension = await get_embedding_dim()\n\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=llm_model_func,\n        embedding_func=EmbeddingFunc(\n            embedding_dim=embedding_dimension,\n            max_token_size=EMBEDDING_MAX_TOKEN_SIZE,\n            func=embedding_func,\n        ),\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\ndef main():\n    # Initialize RAG instance\n    rag = asyncio.run(initialize_rag())\n\n    # Insert example text\n    with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n        rag.insert(f.read())\n\n    # Test different query modes\n    print(\"\\nNaive Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"naive\")\n        )\n    )\n\n    print(\"\\nLocal Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"local\")\n        )\n    )\n\n    print(\"\\nGlobal Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"global\")\n        )\n    )\n\n    print(\"\\nHybrid Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"hybrid\")\n        )\n    )\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/unofficial-sample/lightrag_lmdeploy_demo.py",
    "content": "import os\n\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.lmdeploy import lmdeploy_model_if_cache\nfrom lightrag.llm.hf import hf_embed\nfrom lightrag.utils import EmbeddingFunc\nfrom transformers import AutoModel, AutoTokenizer\n\nimport asyncio\nimport nest_asyncio\n\nnest_asyncio.apply()\n\nWORKING_DIR = \"./dickens\"\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n\nasync def lmdeploy_model_complete(\n    prompt=None,\n    system_prompt=None,\n    history_messages=[],\n    keyword_extraction=False,\n    **kwargs,\n) -> str:\n    model_name = kwargs[\"hashing_kv\"].global_config[\"llm_model_name\"]\n    return await lmdeploy_model_if_cache(\n        model_name,\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        ## please specify chat_template if your local path does not follow original HF file name,\n        ## or model_name is a pytorch model on huggingface.co,\n        ## you can refer to https://github.com/InternLM/lmdeploy/blob/main/lmdeploy/model.py\n        ## for a list of chat_template available in lmdeploy.\n        chat_template=\"llama3\",\n        # model_format ='awq', # if you are using awq quantization model.\n        # quant_policy=8, # if you want to use online kv cache, 4=kv int4, 8=kv int8.\n        **kwargs,\n    )\n\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=lmdeploy_model_complete,\n        llm_model_name=\"meta-llama/Llama-3.1-8B-Instruct\",  # please use definite path for local model\n        embedding_func=EmbeddingFunc(\n            embedding_dim=384,\n            max_token_size=5000,\n            func=lambda texts: hf_embed(\n                texts,\n                tokenizer=AutoTokenizer.from_pretrained(\n                    \"sentence-transformers/all-MiniLM-L6-v2\"\n                ),\n                embed_model=AutoModel.from_pretrained(\n                    \"sentence-transformers/all-MiniLM-L6-v2\"\n                ),\n            ),\n        ),\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\ndef main():\n    # Initialize RAG instance\n    rag = asyncio.run(initialize_rag())\n\n    # Insert example text\n    with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n        rag.insert(f.read())\n\n    # Test different query modes\n    print(\"\\nNaive Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"naive\")\n        )\n    )\n\n    print(\"\\nLocal Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"local\")\n        )\n    )\n\n    print(\"\\nGlobal Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"global\")\n        )\n    )\n\n    print(\"\\nHybrid Search:\")\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"hybrid\")\n        )\n    )\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/unofficial-sample/lightrag_nvidia_demo.py",
    "content": "import os\nimport asyncio\nimport nest_asyncio\n\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm import (\n    openai_complete_if_cache,\n    nvidia_openai_embed,\n)\nfrom lightrag.utils import EmbeddingFunc\nimport numpy as np\n\n# for custom llm_model_func\nfrom lightrag.utils import locate_json_string_body_from_string\n\nnest_asyncio.apply()\n\nWORKING_DIR = \"./dickens\"\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n# some method to use your API key (choose one)\n# NVIDIA_OPENAI_API_KEY = os.getenv(\"NVIDIA_OPENAI_API_KEY\")\nNVIDIA_OPENAI_API_KEY = \"nvapi-xxxx\"  # your api key\n\n# using pre-defined function for nvidia LLM API. OpenAI compatible\n# llm_model_func = nvidia_openai_complete\n\n\n# If you trying to make custom llm_model_func to use llm model on NVIDIA API like other example:\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], keyword_extraction=False, **kwargs\n) -> str:\n    result = await openai_complete_if_cache(\n        \"nvidia/llama-3.1-nemotron-70b-instruct\",\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=NVIDIA_OPENAI_API_KEY,\n        base_url=\"https://integrate.api.nvidia.com/v1\",\n        **kwargs,\n    )\n    if keyword_extraction:\n        return locate_json_string_body_from_string(result)\n    return result\n\n\n# custom embedding\nnvidia_embed_model = \"nvidia/nv-embedqa-e5-v5\"\n\n\nasync def indexing_embedding_func(texts: list[str]) -> np.ndarray:\n    return await nvidia_openai_embed(\n        texts,\n        model=nvidia_embed_model,  # maximum 512 token\n        # model=\"nvidia/llama-3.2-nv-embedqa-1b-v1\",\n        api_key=NVIDIA_OPENAI_API_KEY,\n        base_url=\"https://integrate.api.nvidia.com/v1\",\n        input_type=\"passage\",\n        trunc=\"END\",  # handling on server side if input token is longer than maximum token\n        encode=\"float\",\n    )\n\n\nasync def query_embedding_func(texts: list[str]) -> np.ndarray:\n    return await nvidia_openai_embed(\n        texts,\n        model=nvidia_embed_model,  # maximum 512 token\n        # model=\"nvidia/llama-3.2-nv-embedqa-1b-v1\",\n        api_key=NVIDIA_OPENAI_API_KEY,\n        base_url=\"https://integrate.api.nvidia.com/v1\",\n        input_type=\"query\",\n        trunc=\"END\",  # handling on server side if input token is longer than maximum token\n        encode=\"float\",\n    )\n\n\n# dimension are same\nasync def get_embedding_dim():\n    test_text = [\"This is a test sentence.\"]\n    embedding = await indexing_embedding_func(test_text)\n    embedding_dim = embedding.shape[1]\n    return embedding_dim\n\n\n# function test\nasync def test_funcs():\n    result = await llm_model_func(\"How are you?\")\n    print(\"llm_model_func: \", result)\n\n    result = await indexing_embedding_func([\"How are you?\"])\n    print(\"embedding_func: \", result)\n\n\n# asyncio.run(test_funcs())\n\n\nasync def initialize_rag():\n    embedding_dimension = await get_embedding_dim()\n    print(f\"Detected embedding dimension: {embedding_dimension}\")\n\n    # lightRAG class during indexing\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=llm_model_func,\n        # llm_model_name=\"meta/llama3-70b-instruct\", #un comment if\n        embedding_func=EmbeddingFunc(\n            embedding_dim=embedding_dimension,\n            max_token_size=512,  # maximum token size, somehow it's still exceed maximum number of token\n            # so truncate (trunc) parameter on embedding_func will handle it and try to examine the tokenizer used in LightRAG\n            # so you can adjust to be able to fit the NVIDIA model (future work)\n            func=indexing_embedding_func,\n        ),\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\nasync def main():\n    try:\n        # Initialize RAG instance\n        rag = await initialize_rag()\n\n        # reading file\n        with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n            await rag.ainsert(f.read())\n\n        # Perform naive search\n        print(\"==============Naive===============\")\n        print(\n            await rag.aquery(\n                \"What are the top themes in this story?\", param=QueryParam(mode=\"naive\")\n            )\n        )\n\n        # Perform local search\n        print(\"==============local===============\")\n        print(\n            await rag.aquery(\n                \"What are the top themes in this story?\", param=QueryParam(mode=\"local\")\n            )\n        )\n\n        # Perform global search\n        print(\"==============global===============\")\n        print(\n            await rag.aquery(\n                \"What are the top themes in this story?\",\n                param=QueryParam(mode=\"global\"),\n            )\n        )\n\n        # Perform hybrid search\n        print(\"==============hybrid===============\")\n        print(\n            await rag.aquery(\n                \"What are the top themes in this story?\",\n                param=QueryParam(mode=\"hybrid\"),\n            )\n        )\n    except Exception as e:\n        print(f\"An error occurred: {e}\")\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/unofficial-sample/lightrag_openai_neo4j_milvus_redis_demo.py",
    "content": "import os\nimport asyncio\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.ollama import ollama_embed, openai_complete_if_cache\nfrom lightrag.utils import EmbeddingFunc\n\n# WorkingDir\nROOT_DIR = os.path.dirname(os.path.abspath(__file__))\nWORKING_DIR = os.path.join(ROOT_DIR, \"myKG\")\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\nprint(f\"WorkingDir: {WORKING_DIR}\")\n\n# redis\nos.environ[\"REDIS_URI\"] = \"redis://localhost:6379\"\n\n# neo4j\nBATCH_SIZE_NODES = 500\nBATCH_SIZE_EDGES = 100\nos.environ[\"NEO4J_URI\"] = \"neo4j://localhost:7687\"\nos.environ[\"NEO4J_USERNAME\"] = \"neo4j\"\nos.environ[\"NEO4J_PASSWORD\"] = \"12345678\"\n\n# milvus\nos.environ[\"MILVUS_URI\"] = \"http://localhost:19530\"\nos.environ[\"MILVUS_USER\"] = \"root\"\nos.environ[\"MILVUS_PASSWORD\"] = \"Milvus\"\nos.environ[\"MILVUS_DB_NAME\"] = \"lightrag\"\n\n\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], keyword_extraction=False, **kwargs\n) -> str:\n    return await openai_complete_if_cache(\n        \"deepseek-chat\",\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=\"\",\n        base_url=\"\",\n        **kwargs,\n    )\n\n\nembedding_func = EmbeddingFunc(\n    embedding_dim=768,\n    max_token_size=512,\n    func=lambda texts: ollama_embed(\n        texts, embed_model=\"shaw/dmeta-embedding-zh\", host=\"http://117.50.173.35:11434\"\n    ),\n)\n\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=llm_model_func,\n        summary_max_tokens=10000,\n        embedding_func=embedding_func,\n        chunk_token_size=512,\n        chunk_overlap_token_size=256,\n        kv_storage=\"RedisKVStorage\",\n        graph_storage=\"Neo4JStorage\",\n        vector_storage=\"MilvusVectorDBStorage\",\n        doc_status_storage=\"RedisKVStorage\",\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\ndef main():\n    # Initialize RAG instance\n    rag = asyncio.run(initialize_rag())\n\n    with open(\"./book.txt\", \"r\", encoding=\"utf-8\") as f:\n        rag.insert(f.read())\n\n    # Perform naive search\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"naive\")\n        )\n    )\n\n    # Perform local search\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"local\")\n        )\n    )\n\n    # Perform global search\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"global\")\n        )\n    )\n\n    # Perform hybrid search\n    print(\n        rag.query(\n            \"What are the top themes in this story?\", param=QueryParam(mode=\"hybrid\")\n        )\n    )\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "k8s-deploy/README-zh.md",
    "content": "# LightRAG Helm Chart\n\n这是用于在Kubernetes集群上部署LightRAG服务的Helm chart。\n\nLightRAG有两种推荐的部署方法：\n1. **轻量级部署**：使用内置轻量级存储，适合测试和小规模使用\n2. **生产环境部署**：使用外部数据库（如PostgreSQL和Neo4J），适合生产环境和大规模使用\n\n> 如果您想要部署过程的视频演示，可以查看[bilibili](https://www.bilibili.com/video/BV1bUJazBEq2/)上的视频教程，对于喜欢视觉指导的用户可能会有所帮助。\n\n## 前提条件\n\n确保安装和配置了以下工具：\n\n* **Kubernetes集群**\n  * 需要一个运行中的Kubernetes集群。\n  * 对于本地开发或演示，可以使用[Minikube](https://minikube.sigs.k8s.io/docs/start/)（需要≥2个CPU，≥4GB内存，以及Docker/VM驱动支持）。\n  * 任何标准的云端或本地Kubernetes集群（EKS、GKE、AKS等）也可以使用。\n\n* **kubectl**\n  * Kubernetes命令行工具，用于管理集群。\n  * 按照官方指南安装：[安装和设置kubectl](https://kubernetes.io/docs/tasks/tools/#kubectl)。\n\n* **Helm**（v3.x+）\n  * Kubernetes包管理器，用于安装LightRAG。\n  * 通过官方指南安装：[安装Helm](https://helm.sh/docs/intro/install/)。\n\n## 轻量级部署（无需外部数据库）\n\n这种部署选项使用内置的轻量级存储组件，非常适合测试、演示或小规模使用场景。无需外部数据库配置。\n\n您可以使用提供的便捷脚本或直接使用Helm命令部署LightRAG。两种方法都配置了`lightrag/values.yaml`文件中定义的相同环境变量。\n\n### 使用便捷脚本（推荐）：\n\n```bash\nexport OPENAI_API_BASE=<您的OPENAI_API_BASE>\nexport OPENAI_API_KEY=<您的OPENAI_API_KEY>\nbash ./install_lightrag_dev.sh\n```\n\n### 或直接使用Helm：\n\n```bash\n# 您可以覆盖任何想要的环境参数\nhelm upgrade --install lightrag ./lightrag \\\n  --namespace rag \\\n  --set-string env.LIGHTRAG_KV_STORAGE=JsonKVStorage \\\n  --set-string env.LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage \\\n  --set-string env.LIGHTRAG_GRAPH_STORAGE=NetworkXStorage \\\n  --set-string env.LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage \\\n  --set-string env.LLM_BINDING=openai \\\n  --set-string env.LLM_MODEL=gpt-4o-mini \\\n  --set-string env.LLM_BINDING_HOST=$OPENAI_API_BASE \\\n  --set-string env.LLM_BINDING_API_KEY=$OPENAI_API_KEY \\\n  --set-string env.EMBEDDING_BINDING=openai \\\n  --set-string env.EMBEDDING_MODEL=text-embedding-ada-002 \\\n  --set-string env.EMBEDDING_DIM=1536 \\\n  --set-string env.EMBEDDING_BINDING_API_KEY=$OPENAI_API_KEY\n```\n\n### 访问应用程序：\n\n```bash\n# 1. 在终端中运行此端口转发命令：\nkubectl --namespace rag port-forward svc/lightrag-dev 9621:9621\n\n# 2. 当命令运行时，打开浏览器并导航到：\n# http://localhost:9621\n```\n\n## 生产环境部署（使用外部数据库）\n\n### 1. 安装数据库\n> 如果您已经准备好了数据库，可以跳过此步骤。详细信息可以在：[README.md](databases%2FREADME.md)中找到。\n\n我们推荐使用KubeBlocks进行数据库部署。KubeBlocks是一个云原生数据库操作符，可以轻松地在Kubernetes上以生产规模运行任何数据库。\n\n首先，安装KubeBlocks和KubeBlocks-Addons（如已安装可跳过）：\n```bash\nbash ./databases/01-prepare.sh\n```\n\n然后安装所需的数据库。默认情况下，这将安装PostgreSQL和Neo4J，但您可以修改[00-config.sh](databases%2F00-config.sh)以根据需要选择不同的数据库：\n```bash\nbash ./databases/02-install-database.sh\n```\n\n验证集群是否正在运行：\n```bash\nkubectl get clusters -n rag\n# 预期输出：\n# NAME            CLUSTER-DEFINITION   TERMINATION-POLICY   STATUS     AGE\n# neo4j-cluster                        Delete               Running    39s\n# pg-cluster      postgresql           Delete               Running    42s\n\nkubectl get po -n rag\n# 预期输出：\n# NAME                      READY   STATUS    RESTARTS   AGE\n# neo4j-cluster-neo4j-0     1/1     Running   0          58s\n# pg-cluster-postgresql-0   4/4     Running   0          59s\n# pg-cluster-postgresql-1   4/4     Running   0          59s\n```\n\n### 2. 安装LightRAG\n\nLightRAG及其数据库部署在同一Kubernetes集群中，使配置变得简单。\n安装脚本会自动从KubeBlocks获取所有数据库连接信息，无需手动设置数据库凭证：\n\n```bash\nexport OPENAI_API_BASE=<您的OPENAI_API_BASE>\nexport OPENAI_API_KEY=<您的OPENAI_API_KEY>\nbash ./install_lightrag.sh\n```\n\n### 访问应用程序：\n\n```bash\n# 1. 在终端中运行此端口转发命令：\nkubectl --namespace rag port-forward svc/lightrag 9621:9621\n\n# 2. 当命令运行时，打开浏览器并导航到：\n# http://localhost:9621\n```\n\n## 配置\n\n### 修改资源配置\n\n您可以通过修改`values.yaml`文件来配置LightRAG的资源使用：\n\n```yaml\nreplicaCount: 1  # 副本数量，可根据需要增加\n\nresources:\n  limits:\n    cpu: 1000m    # CPU限制，可根据需要调整\n    memory: 2Gi   # 内存限制，可根据需要调整\n  requests:\n    cpu: 500m     # CPU请求，可根据需要调整\n    memory: 1Gi   # 内存请求，可根据需要调整\n```\n\n### 修改持久存储\n\n```yaml\npersistence:\n  enabled: true\n  ragStorage:\n    size: 10Gi    # RAG存储大小，可根据需要调整\n  inputs:\n    size: 5Gi     # 输入数据存储大小，可根据需要调整\n```\n\n### 配置环境变量\n\n`values.yaml`文件中的`env`部分包含LightRAG的所有环境配置，类似于`.env`文件。当使用helm upgrade或helm install命令时，可以使用--set标志覆盖这些变量。\n\n```yaml\nenv:\n  HOST: 0.0.0.0\n  PORT: 9621\n  WEBUI_TITLE: Graph RAG Engine\n  WEBUI_DESCRIPTION: Simple and Fast Graph Based RAG System\n\n  # LLM配置\n  LLM_BINDING: openai            # LLM服务提供商\n  LLM_MODEL: gpt-4o-mini         # LLM模型\n  LLM_BINDING_HOST:              # API基础URL（可选）\n  LLM_BINDING_API_KEY:           # API密钥\n\n  # 嵌入配置\n  EMBEDDING_BINDING: openai                 # 嵌入服务提供商\n  EMBEDDING_MODEL: text-embedding-ada-002   # 嵌入模型\n  EMBEDDING_DIM: 1536                       # 嵌入维度\n  EMBEDDING_BINDING_API_KEY:                # API密钥\n\n  # 存储配置\n  LIGHTRAG_KV_STORAGE: PGKVStorage              # 键值存储类型\n  LIGHTRAG_VECTOR_STORAGE: PGVectorStorage      # 向量存储类型\n  LIGHTRAG_GRAPH_STORAGE: Neo4JStorage          # 图存储类型\n  LIGHTRAG_DOC_STATUS_STORAGE: PGDocStatusStorage  # 文档状态存储类型\n```\n\n## 注意事项\n\n- 在部署前确保设置了所有必要的环境变量（API密钥和数据库密码）\n- 出于安全原因，建议使用环境变量传递敏感信息，而不是直接写入脚本或values文件\n- 轻量级部署适合测试和小规模使用，但数据持久性和性能可能有限\n- 生产环境部署（PostgreSQL + Neo4J）推荐用于生产环境和大规模使用\n- 有关更多自定义配置，请参考LightRAG官方文档\n"
  },
  {
    "path": "k8s-deploy/README.md",
    "content": "# LightRAG Helm Chart\n\nThis is the Helm chart for LightRAG, used to deploy LightRAG services on a Kubernetes cluster.\n\nThere are two recommended deployment methods for LightRAG:\n1. **Lightweight Deployment**: Using built-in lightweight storage, suitable for testing and small-scale usage\n2. **Production Deployment**: Using external databases (such as PostgreSQL and Neo4J), suitable for production environments and large-scale usage\n\n> If you'd like a video walkthrough of the deployment process, feel free to check out this optional [video tutorial](https://youtu.be/JW1z7fzeKTw?si=vPzukqqwmdzq9Q4q) on YouTube. It might help clarify some steps for those who prefer visual guidance.\n\n## Prerequisites\n\nMake sure the following tools are installed and configured:\n\n* **Kubernetes cluster**\n  * A running Kubernetes cluster is required.\n  * For local development or demos you can use [Minikube](https://minikube.sigs.k8s.io/docs/start/) (needs ≥ 2 CPUs, ≥ 4 GB RAM, and Docker/VM-driver support).\n  * Any standard cloud or on-premises Kubernetes cluster (EKS, GKE, AKS, etc.) also works.\n\n* **kubectl**\n  * The Kubernetes command-line tool for managing your cluster.\n  * Follow the official guide: [Install and Set Up kubectl](https://kubernetes.io/docs/tasks/tools/#kubectl).\n\n* **Helm** (v3.x+)\n  * Kubernetes package manager used to install LightRAG.\n  * Install it via the official instructions: [Installing Helm](https://helm.sh/docs/intro/install/).\n\n## Lightweight Deployment (No External Databases Required)\n\nThis deployment option uses built-in lightweight storage components that are perfect for testing, demos, or small-scale usage scenarios. No external database configuration is required.\n\nYou can deploy LightRAG using either the provided convenience script or direct Helm commands. Both methods configure the same environment variables defined in the `lightrag/values.yaml` file.\n\n### Using the convenience script (recommended):\n\n```bash\nexport OPENAI_API_BASE=<YOUR_OPENAI_API_BASE>\nexport OPENAI_API_KEY=<YOUR_OPENAI_API_KEY>\nbash ./install_lightrag_dev.sh\n```\n\n### Or using Helm directly:\n\n```bash\n# You can override any env param you want\nhelm upgrade --install lightrag ./lightrag \\\n  --namespace rag \\\n  --set-string env.LIGHTRAG_KV_STORAGE=JsonKVStorage \\\n  --set-string env.LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage \\\n  --set-string env.LIGHTRAG_GRAPH_STORAGE=NetworkXStorage \\\n  --set-string env.LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage \\\n  --set-string env.LLM_BINDING=openai \\\n  --set-string env.LLM_MODEL=gpt-4o-mini \\\n  --set-string env.LLM_BINDING_HOST=$OPENAI_API_BASE \\\n  --set-string env.LLM_BINDING_API_KEY=$OPENAI_API_KEY \\\n  --set-string env.EMBEDDING_BINDING=openai \\\n  --set-string env.EMBEDDING_MODEL=text-embedding-ada-002 \\\n  --set-string env.EMBEDDING_DIM=1536 \\\n  --set-string env.EMBEDDING_BINDING_API_KEY=$OPENAI_API_KEY\n```\n\n### Accessing the application:\n\n```bash\n# 1. Run this port-forward command in your terminal:\nkubectl --namespace rag port-forward svc/lightrag-dev 9621:9621\n\n# 2. While the command is running, open your browser and navigate to:\n# http://localhost:9621\n```\n\n## Production Deployment (Using External Databases)\n\n### 1. Install Databases\n> You can skip this step if you've already prepared databases. Detailed information can be found in: [README.md](databases%2FREADME.md).\n\nWe recommend KubeBlocks for database deployment. KubeBlocks is a cloud-native database operator that makes it easy to run any database on Kubernetes at production scale.\n\nFirst, install KubeBlocks and KubeBlocks-Addons (skip if already installed):\n```bash\nbash ./databases/01-prepare.sh\n```\n\nThen install the required databases. By default, this will install PostgreSQL and Neo4J, but you can modify [00-config.sh](databases%2F00-config.sh) to select different databases based on your needs:\n```bash\nbash ./databases/02-install-database.sh\n```\n\nVerify that the clusters are up and running:\n```bash\nkubectl get clusters -n rag\n# Expected output:\n# NAME            CLUSTER-DEFINITION   TERMINATION-POLICY   STATUS     AGE\n# neo4j-cluster                        Delete               Running    39s\n# pg-cluster      postgresql           Delete               Running    42s\n\nkubectl get po -n rag\n# Expected output:\n# NAME                      READY   STATUS    RESTARTS   AGE\n# neo4j-cluster-neo4j-0     1/1     Running   0          58s\n# pg-cluster-postgresql-0   4/4     Running   0          59s\n# pg-cluster-postgresql-1   4/4     Running   0          59s\n```\n\n### 2. Install LightRAG\n\nLightRAG and its databases are deployed within the same Kubernetes cluster, making configuration straightforward.\nThe installation script automatically retrieves all database connection information from KubeBlocks, eliminating the need to manually set database credentials:\n\n```bash\nexport OPENAI_API_BASE=<YOUR_OPENAI_API_BASE>\nexport OPENAI_API_KEY=<YOUR_OPENAI_API_KEY>\nbash ./install_lightrag.sh\n```\n\n### Accessing the application:\n\n```bash\n# 1. Run this port-forward command in your terminal:\nkubectl --namespace rag port-forward svc/lightrag 9621:9621\n\n# 2. While the command is running, open your browser and navigate to:\n# http://localhost:9621\n```\n\n## Configuration\n\n### Modifying Resource Configuration\n\nYou can configure LightRAG's resource usage by modifying the `values.yaml` file:\n\n```yaml\nreplicaCount: 1  # Number of replicas, can be increased as needed\n\nresources:\n  limits:\n    cpu: 1000m    # CPU limit, can be adjusted as needed\n    memory: 2Gi   # Memory limit, can be adjusted as needed\n  requests:\n    cpu: 500m     # CPU request, can be adjusted as needed\n    memory: 1Gi   # Memory request, can be adjusted as needed\n```\n\n### Modifying Persistent Storage\n\n```yaml\npersistence:\n  enabled: true\n  ragStorage:\n    size: 10Gi    # RAG storage size, can be adjusted as needed\n  inputs:\n    size: 5Gi     # Input data storage size, can be adjusted as needed\n```\n\n### Configuring Environment Variables\n\nThe `env` section in the `values.yaml` file contains all environment configurations for LightRAG, similar to a `.env` file. When using helm upgrade or helm install commands, you can override these with the --set flag.\n\n```yaml\nenv:\n  HOST: 0.0.0.0\n  PORT: 9621\n  WEBUI_TITLE: Graph RAG Engine\n  WEBUI_DESCRIPTION: Simple and Fast Graph Based RAG System\n\n  # LLM Configuration\n  LLM_BINDING: openai            # LLM service provider\n  LLM_MODEL: gpt-4o-mini         # LLM model\n  LLM_BINDING_HOST:              # API base URL (optional)\n  LLM_BINDING_API_KEY:           # API key\n\n  # Embedding Configuration\n  EMBEDDING_BINDING: openai                 # Embedding service provider\n  EMBEDDING_MODEL: text-embedding-ada-002   # Embedding model\n  EMBEDDING_DIM: 1536                       # Embedding dimension\n  EMBEDDING_BINDING_API_KEY:                # API key\n\n  # Storage Configuration\n  LIGHTRAG_KV_STORAGE: PGKVStorage              # Key-value storage type\n  LIGHTRAG_VECTOR_STORAGE: PGVectorStorage      # Vector storage type\n  LIGHTRAG_GRAPH_STORAGE: Neo4JStorage          # Graph storage type\n  LIGHTRAG_DOC_STATUS_STORAGE: PGDocStatusStorage  # Document status storage type\n```\n\n## Notes\n\n- Ensure all necessary environment variables (API keys and database passwords) are set before deployment\n- For security reasons, it's recommended to pass sensitive information using environment variables rather than writing them directly in scripts or values files\n- Lightweight deployment is suitable for testing and small-scale usage, but data persistence and performance may be limited\n- Production deployment (PostgreSQL + Neo4J) is recommended for production environments and large-scale usage\n- For more customized configurations, please refer to the official LightRAG documentation\n"
  },
  {
    "path": "k8s-deploy/databases/00-config.sh",
    "content": "#!/bin/bash\n\n# Get the directory where this script is located\nDATABASE_SCRIPT_DIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )\"\nsource \"$DATABASE_SCRIPT_DIR/scripts/common.sh\"\n\n# Namespace configuration\nNAMESPACE=\"rag\"\n# version\nKB_VERSION=\"1.0.0-beta.48\"\nADDON_CLUSTER_CHART_VERSION=\"1.0.0-alpha.0\"\n# Helm repository\nHELM_REPO=\"https://apecloud.github.io/helm-charts\"\n\n# Set to true to enable the database, false to disable\nENABLE_POSTGRESQL=true\nENABLE_REDIS=false\nENABLE_QDRANT=false\nENABLE_NEO4J=true\nENABLE_ELASTICSEARCH=false\nENABLE_MONGODB=false\n"
  },
  {
    "path": "k8s-deploy/databases/01-prepare.sh",
    "content": "#!/bin/bash\n\n# Get the directory where this script is located\nDATABASE_SCRIPT_DIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )\"\n# Load configuration file\nsource \"$DATABASE_SCRIPT_DIR/00-config.sh\"\n\ncheck_dependencies\n\n# Check if KubeBlocks is already installed, install it if it is not.\nsource \"$DATABASE_SCRIPT_DIR/install-kubeblocks.sh\"\n\n# Create namespaces\nprint \"Creating namespaces...\"\nkubectl create namespace $NAMESPACE 2>/dev/null || true\n\n# Install database addons\nprint \"Installing KubeBlocks database addons...\"\n\n# Add and update Helm repository\nprint \"Adding and updating KubeBlocks Helm repository...\"\nhelm repo add kubeblocks $HELM_REPO\nhelm repo update\n# Install database addons based on configuration\n[ \"$ENABLE_POSTGRESQL\" = true ] && print \"Installing PostgreSQL addon...\" && helm upgrade --install kb-addon-postgresql kubeblocks/postgresql --namespace kb-system --version $ADDON_CLUSTER_CHART_VERSION\n[ \"$ENABLE_REDIS\" = true ] && print \"Installing Redis addon...\" && helm upgrade --install kb-addon-redis kubeblocks/redis --namespace kb-system --version $ADDON_CLUSTER_CHART_VERSION\n[ \"$ENABLE_ELASTICSEARCH\" = true ] && print \"Installing Elasticsearch addon...\" && helm upgrade --install kb-addon-elasticsearch kubeblocks/elasticsearch --namespace kb-system --version $ADDON_CLUSTER_CHART_VERSION\n[ \"$ENABLE_QDRANT\" = true ] && print \"Installing Qdrant addon...\" && helm upgrade --install kb-addon-qdrant kubeblocks/qdrant --namespace kb-system --version $ADDON_CLUSTER_CHART_VERSION\n[ \"$ENABLE_MONGODB\" = true ] && print \"Installing MongoDB addon...\" && helm upgrade --install kb-addon-mongodb kubeblocks/mongodb --namespace kb-system --version $ADDON_CLUSTER_CHART_VERSION\n[ \"$ENABLE_NEO4J\" = true ] && print \"Installing Neo4j addon...\" && helm upgrade --install kb-addon-neo4j kubeblocks/neo4j --namespace kb-system --version $ADDON_CLUSTER_CHART_VERSION\n\nprint_success \"KubeBlocks database addons installation completed!\"\nprint \"Now you can run 02-install-database.sh to install database clusters\"\n"
  },
  {
    "path": "k8s-deploy/databases/02-install-database.sh",
    "content": "#!/bin/bash\n\n# Get the directory where this script is located\nDATABASE_SCRIPT_DIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )\"\n\n# Load configuration file\nsource \"$DATABASE_SCRIPT_DIR/00-config.sh\"\n\nprint \"Installing database clusters...\"\n\n# Install database clusters based on configuration\n[ \"$ENABLE_POSTGRESQL\" = true ] && print \"Installing PostgreSQL cluster...\" && helm upgrade --install pg-cluster kubeblocks/postgresql-cluster -f \"$DATABASE_SCRIPT_DIR/postgresql/values.yaml\" --namespace $NAMESPACE --version $ADDON_CLUSTER_CHART_VERSION\n[ \"$ENABLE_REDIS\" = true ] && print \"Installing Redis cluster...\" && helm upgrade --install redis-cluster kubeblocks/redis-cluster -f \"$DATABASE_SCRIPT_DIR/redis/values.yaml\" --namespace $NAMESPACE --version $ADDON_CLUSTER_CHART_VERSION\n[ \"$ENABLE_ELASTICSEARCH\" = true ] && print \"Installing Elasticsearch cluster...\" && helm upgrade --install es-cluster kubeblocks/elasticsearch-cluster -f \"$DATABASE_SCRIPT_DIR/elasticsearch/values.yaml\" --namespace $NAMESPACE --version $ADDON_CLUSTER_CHART_VERSION\n[ \"$ENABLE_QDRANT\" = true ] && print \"Installing Qdrant cluster...\" && helm upgrade --install qdrant-cluster kubeblocks/qdrant-cluster -f \"$DATABASE_SCRIPT_DIR/qdrant/values.yaml\" --namespace $NAMESPACE --version $ADDON_CLUSTER_CHART_VERSION\n[ \"$ENABLE_MONGODB\" = true ] && print \"Installing MongoDB cluster...\" && helm upgrade --install mongodb-cluster kubeblocks/mongodb-cluster -f \"$DATABASE_SCRIPT_DIR/mongodb/values.yaml\" --namespace $NAMESPACE --version $ADDON_CLUSTER_CHART_VERSION\n[ \"$ENABLE_NEO4J\" = true ] && print \"Installing Neo4j cluster...\" && helm upgrade --install neo4j-cluster kubeblocks/neo4j-cluster -f \"$DATABASE_SCRIPT_DIR/neo4j/values.yaml\" --namespace $NAMESPACE --version $ADDON_CLUSTER_CHART_VERSION\n\n# Wait for databases to be ready\nprint \"Waiting for databases to be ready...\"\nTIMEOUT=600  # Set timeout to 10 minutes\nSTART_TIME=$(date +%s)\n\nwhile true; do\n  CURRENT_TIME=$(date +%s)\n  ELAPSED=$((CURRENT_TIME - START_TIME))\n\n  if [ $ELAPSED -gt $TIMEOUT ]; then\n    print_error \"Timeout waiting for databases to be ready. Please check database status manually and try again\"\n    exit 1\n  fi\n\n  # Build wait conditions for enabled databases\n  WAIT_CONDITIONS=()\n  [ \"$ENABLE_POSTGRESQL\" = true ] && WAIT_CONDITIONS+=(\"kubectl wait --for=condition=ready pods -l app.kubernetes.io/instance=pg-cluster -n $NAMESPACE --timeout=10s\")\n  [ \"$ENABLE_REDIS\" = true ] && WAIT_CONDITIONS+=(\"kubectl wait --for=condition=ready pods -l app.kubernetes.io/instance=redis-cluster -n $NAMESPACE --timeout=10s\")\n  [ \"$ENABLE_ELASTICSEARCH\" = true ] && WAIT_CONDITIONS+=(\"kubectl wait --for=condition=ready pods -l app.kubernetes.io/instance=es-cluster -n $NAMESPACE --timeout=10s\")\n  [ \"$ENABLE_QDRANT\" = true ] && WAIT_CONDITIONS+=(\"kubectl wait --for=condition=ready pods -l app.kubernetes.io/instance=qdrant-cluster -n $NAMESPACE --timeout=10s\")\n  [ \"$ENABLE_MONGODB\" = true ] && WAIT_CONDITIONS+=(\"kubectl wait --for=condition=ready pods -l app.kubernetes.io/instance=mongodb-cluster -n $NAMESPACE --timeout=10s\")\n  [ \"$ENABLE_NEO4J\" = true ] && WAIT_CONDITIONS+=(\"kubectl wait --for=condition=ready pods -l app.kubernetes.io/instance=neo4j-cluster -n $NAMESPACE --timeout=10s\")\n\n  # Check if all enabled databases are ready\n  ALL_READY=true\n  for CONDITION in \"${WAIT_CONDITIONS[@]}\"; do\n    if ! eval \"$CONDITION &> /dev/null\"; then\n      ALL_READY=false\n      break\n    fi\n  done\n\n  if [ \"$ALL_READY\" = true ]; then\n    print \"All database pods are ready, continuing with deployment...\"\n    break\n  fi\n\n  print \"Waiting for database pods to be ready (${ELAPSED}s elapsed)...\"\n  sleep 10\ndone\n\nprint_success \"Database clusters installation completed!\"\nprint \"Use the following command to check the status of installed clusters:\"\nprint \"kubectl get clusters -n $NAMESPACE\"\n"
  },
  {
    "path": "k8s-deploy/databases/03-uninstall-database.sh",
    "content": "#!/bin/bash\n\n# Get the directory where this script is located\nDATABASE_SCRIPT_DIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )\"\n\n# Load configuration file\nsource \"$DATABASE_SCRIPT_DIR/00-config.sh\"\n\nprint \"Uninstalling database clusters...\"\n\n# Uninstall database clusters based on configuration\n[ \"$ENABLE_POSTGRESQL\" = true ] && print \"Uninstalling PostgreSQL cluster...\" && helm uninstall pg-cluster --namespace $NAMESPACE 2>/dev/null || true\n[ \"$ENABLE_REDIS\" = true ] && print \"Uninstalling Redis cluster...\" && helm uninstall redis-cluster --namespace $NAMESPACE 2>/dev/null || true\n[ \"$ENABLE_ELASTICSEARCH\" = true ] && print \"Uninstalling Elasticsearch cluster...\" && helm uninstall es-cluster --namespace $NAMESPACE 2>/dev/null || true\n[ \"$ENABLE_QDRANT\" = true ] && print \"Uninstalling Qdrant cluster...\" && helm uninstall qdrant-cluster --namespace $NAMESPACE 2>/dev/null || true\n[ \"$ENABLE_MONGODB\" = true ] && print \"Uninstalling MongoDB cluster...\" && helm uninstall mongodb-cluster --namespace $NAMESPACE 2>/dev/null || true\n[ \"$ENABLE_NEO4J\" = true ] && print \"Uninstalling Neo4j cluster...\" && helm uninstall neo4j-cluster --namespace $NAMESPACE 2>/dev/null || true\n\nprint_success \"Database clusters uninstalled\"\nprint \"To uninstall database addons and KubeBlocks, run 04-cleanup.sh\"\n"
  },
  {
    "path": "k8s-deploy/databases/04-cleanup.sh",
    "content": "#!/bin/bash\n\n# Get the directory where this script is located\nDATABASE_SCRIPT_DIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )\"\n\n# Load configuration file\nsource \"$DATABASE_SCRIPT_DIR/00-config.sh\"\n\nprint \"Uninstalling KubeBlocks database addons...\"\n\n# Uninstall database addons based on configuration\n[ \"$ENABLE_POSTGRESQL\" = true ] && print \"Uninstalling PostgreSQL addon...\" && helm uninstall kb-addon-postgresql --namespace kb-system 2>/dev/null || true\n[ \"$ENABLE_REDIS\" = true ] && print \"Uninstalling Redis addon...\" && helm uninstall kb-addon-redis --namespace kb-system 2>/dev/null || true\n[ \"$ENABLE_ELASTICSEARCH\" = true ] && print \"Uninstalling Elasticsearch addon...\" && helm uninstall kb-addon-elasticsearch --namespace kb-system 2>/dev/null || true\n[ \"$ENABLE_QDRANT\" = true ] && print \"Uninstalling Qdrant addon...\" && helm uninstall kb-addon-qdrant --namespace kb-system 2>/dev/null || true\n[ \"$ENABLE_MONGODB\" = true ] && print \"Uninstalling MongoDB addon...\" && helm uninstall kb-addon-mongodb --namespace kb-system 2>/dev/null || true\n[ \"$ENABLE_NEO4J\" = true ] && print \"Uninstalling Neo4j addon...\" && helm uninstall kb-addon-neo4j --namespace kb-system 2>/dev/null || true\n\nprint_success \"Database addons uninstallation completed!\"\n\nsource \"$DATABASE_SCRIPT_DIR/uninstall-kubeblocks.sh\"\n\nkubectl delete namespace $NAMESPACE\nkubectl delete namespace kb-system\n\nprint_success \"KubeBlocks uninstallation completed!\"\n"
  },
  {
    "path": "k8s-deploy/databases/README.md",
    "content": "# Using KubeBlocks to Deploy and Manage Databases\n\nLearn how to quickly deploy and manage various databases in a Kubernetes (K8s) environment through KubeBlocks.\n\n## Introduction to KubeBlocks\n\nKubeBlocks is a production-ready, open-source toolkit that runs any database--SQL, NoSQL, vector, or document--on Kubernetes.\nIt scales smoothly from quick dev tests to full production clusters, making it a solid choice for RAG workloads like FastGPT that need several data stores working together.\n\n## Prerequisites\n\nMake sure the following tools are installed and configured:\n\n* **Kubernetes cluster**\n  * A running Kubernetes cluster is required.\n  * For local development or demos you can use [Minikube](https://minikube.sigs.k8s.io/docs/start/) (needs ≥ 2 CPUs, ≥ 4 GB RAM, and Docker/VM-driver support).\n  * Any standard cloud or on-premises Kubernetes cluster (EKS, GKE, AKS, etc.) also works.\n\n* **kubectl**\n  * The Kubernetes command-line interface.\n  * Follow the official guide: [Install and Set Up kubectl](https://kubernetes.io/docs/tasks/tools/#kubectl).\n\n* **Helm** (v3.x+)\n  * Kubernetes package manager used by the scripts below.\n  * Install it via the official instructions: [Installing Helm](https://helm.sh/docs/intro/install/).\n\n## Installing\n\n1. **Configure the databases you want**\n    Edit `00-config.sh` file. Based on your requirements, set the variable to `true` for the databases you want to install.\n    For example, to install PostgreSQL and Neo4j:\n\n   ```bash\n   ENABLE_POSTGRESQL=true\n   ENABLE_REDIS=false\n   ENABLE_ELASTICSEARCH=false\n   ENABLE_QDRANT=false\n   ENABLE_MONGODB=false\n   ENABLE_NEO4J=true\n   ```\n\n2. **Prepare the environment and install KubeBlocks add-ons**\n\n   ```bash\n   bash ./01-prepare.sh\n   ```\n\n   *What the script does*\n   `01-prepare.sh` performs basic pre-checks (Helm, kubectl, cluster reachability), adds the KubeBlocks Helm repo, and installs any core CRDs or controllers that KubeBlocks itself needs. It also installs the addons for every database you enabled in `00-config.sh`, but **does not** create the actual database clusters yet.\n\n3. **(Optional) Modify database settings**\n   Before deployment you can edit the `values.yaml` file inside each `<db>/` directory to change `version`, `replicas`, `CPU`, `memory`, `storage size`, etc.\n\n4. **Install the database clusters**\n\n   ```bash\n   bash ./02-install-database.sh\n   ```\n\n   *What the script does*\n   `02-install-database.sh` **actually deploys the chosen databases to Kubernetes**.\n\n   When the script completes, confirm that the clusters are up. It may take a few minutes for all the clusters to become ready,\n   especially if this is the first time running the script as Kubernetes needs to pull container images from registries.\n   You can monitor the progress using the following commands:\n\n   ```bash\n   kubectl get clusters -n rag\n   NAME              CLUSTER-DEFINITION   TERMINATION-POLICY   STATUS    AGE\n   es-cluster                             Delete               Running   11m\n   mongodb-cluster   mongodb              Delete               Running   11m\n   pg-cluster        postgresql           Delete               Running   11m\n   qdrant-cluster    qdrant               Delete               Running   11m\n   redis-cluster     redis                Delete               Running   11m\n   ```\n\n   You can see all the Database `Pods` created by KubeBlocks.\n   Initially, you might see pods in `ContainerCreating` or `Pending` status - this is normal while images are being pulled and containers are starting up.\n   Wait until all pods show `Running` status:\n\n   ```bash\n   kubectl get po -n rag\n   NAME                        READY   STATUS    RESTARTS   AGE\n   es-cluster-mdit-0           2/2     Running   0          11m\n   mongodb-cluster-mongodb-0   2/2     Running   0          11m\n   pg-cluster-postgresql-0     4/4     Running   0          11m\n   pg-cluster-postgresql-1     4/4     Running   0          11m\n   qdrant-cluster-qdrant-0     2/2     Running   0          11m\n   redis-cluster-redis-0       2/2     Running   0          11m\n   ```\n\n   You can also check the detailed status of a specific pod if it's taking longer than expected:\n\n   ```bash\n   kubectl describe pod <pod-name> -n rag\n   ```\n\n## Connect to Databases\n\nTo connect to your databases, follow these steps to identify available accounts, retrieve credentials, and establish connections:\n\n### 1. List Available Database Clusters\n\nFirst, view the database clusters running in your namespace:\n\n```bash\nkubectl get cluster -n rag\n```\n\n### 2. Retrieve Authentication Credentials\n\nFor PostgreSQL, retrieve the username and password from Kubernetes secrets:\n\n```bash\n# Get PostgreSQL username\nkubectl get secrets -n rag pg-cluster-postgresql-account-postgres -o jsonpath='{.data.username}' | base64 -d\n# Get PostgreSQL password\nkubectl get secrets -n rag pg-cluster-postgresql-account-postgres -o jsonpath='{.data.password}' | base64 -d\n```\n\nIf you have trouble finding the correct secret name, list all secrets:\n\n```bash\nkubectl get secrets -n rag\n```\n\n### 3. Port Forward to Local Machine\n\nUse port forwarding to access PostgreSQL from your local machine:\n\n```bash\n# Forward PostgreSQL port (5432) to your local machine\n# You can see all services with: kubectl get svc -n rag\nkubectl port-forward -n rag svc/pg-cluster-postgresql-postgresql 5432:5432\n```\n\n### 4. Connect Using Database Client\n\nNow you can connect using your preferred PostgreSQL client with the retrieved credentials:\n\n```bash\n# Example: connecting with psql\nexport PGUSER=$(kubectl get secrets -n rag pg-cluster-postgresql-account-postgres -o jsonpath='{.data.username}' | base64 -d)\nexport PGPASSWORD=$(kubectl get secrets -n rag pg-cluster-postgresql-account-postgres -o jsonpath='{.data.password}' | base64 -d)\npsql -h localhost -p 5432 -U $PGUSER\n```\n\nKeep the port-forwarding terminal running while you're connecting to the database.\n\n\n## Uninstalling\n\n1. **Remove the database clusters**\n\n   ```bash\n   bash ./03-uninstall-database.sh\n   ```\n\n   The script deletes the database clusters that were enabled in `00-config.sh`.\n\n2. **Clean up KubeBlocks add-ons**\n\n   ```bash\n   bash ./04-cleanup.sh\n   ```\n\n   This removes the addons installed by `01-prepare.sh`.\n\n## Reference\n* [Kubeblocks Documentation](https://kubeblocks.io/docs/preview/user_docs/overview/introduction)\n"
  },
  {
    "path": "k8s-deploy/databases/elasticsearch/values.yaml",
    "content": "## description: The version of ElasticSearch.\n## default: 8.8.2\nversion: \"8.8.2\"\n\n## description: Mode for ElasticSearch\n## default: multi-node\n## one of: [single-node, multi-node]\nmode: single-node\n\n## description: The number of replicas, for single-node mode, the replicas is 1, for multi-node mode, the default replicas is 3.\n## default: 1\n## minimum: 1\n## maximum: 5\nreplicas: 1\n\n## description: CPU cores.\n## default: 1\n## minimum: 0.5\n## maximum: 64\ncpu: 1\n\n## description: Memory, the unit is Gi.\n## default: 2\n## minimum: 1\n## maximum: 1000\nmemory: 2\n\n## description: Storage size, the unit is Gi.\n## default: 20\n## minimum: 1\n## maximum: 10000\nstorage: 5\n\nextra:\n  terminationPolicy: Delete\n  disableExporter: true\n"
  },
  {
    "path": "k8s-deploy/databases/install-kubeblocks.sh",
    "content": "#!/bin/bash\n\n# Get the directory where this script is located\nDATABASE_SCRIPT_DIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )\"\n# Load configuration file\nsource \"$DATABASE_SCRIPT_DIR/00-config.sh\"\n\n# Check dependencies\ncheck_dependencies\n\n# Function for installing KubeBlocks\ninstall_kubeblocks() {\n    print \"Ready to install KubeBlocks.\"\n\n    # Install CSI Snapshotter CRDs\n    kubectl create -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.2.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshotclasses.yaml\n    kubectl create -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.2.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshots.yaml\n    kubectl create -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.2.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshotcontents.yaml\n\n    # Add and update Piraeus repository\n    helm repo add piraeus-charts https://piraeus.io/helm-charts/\n    helm repo update\n\n    # Install snapshot controller\n    helm install snapshot-controller piraeus-charts/snapshot-controller -n kb-system --create-namespace\n    kubectl wait --for=condition=ready pods -l app.kubernetes.io/name=snapshot-controller -n kb-system --timeout=60s\n    print_success \"snapshot-controller installation complete!\"\n\n    # Install KubeBlocks CRDs\n    kubectl create -f https://github.com/apecloud/kubeblocks/releases/download/v${KB_VERSION}/kubeblocks_crds.yaml\n\n    # Add and update KubeBlocks repository\n    helm repo add kubeblocks $HELM_REPO\n    helm repo update\n\n    # Install KubeBlocks\n    helm install kubeblocks kubeblocks/kubeblocks --namespace kb-system --create-namespace --version=${KB_VERSION}\n\n    # Verify installation\n    print \"Waiting for KubeBlocks to be ready...\"\n    kubectl wait --for=condition=ready pods -l app.kubernetes.io/instance=kubeblocks -n kb-system --timeout=120s\n    print_success \"KubeBlocks installation complete!\"\n}\n\n# Check if KubeBlocks is already installed\nprint \"Checking if KubeBlocks is already installed in kb-system namespace...\"\nif kubectl get namespace kb-system &>/dev/null && kubectl get deployment kubeblocks -n kb-system &>/dev/null; then\n    print_success \"KubeBlocks is already installed in kb-system namespace.\"\nelse\n    # Call the function to install KubeBlocks\n    install_kubeblocks\nfi\n"
  },
  {
    "path": "k8s-deploy/databases/mongodb/values.yaml",
    "content": "## description: Cluster version.\n## default: 6.0.16\n## one of: [8.0.8, 8.0.6, 8.0.4, 7.0.19, 7.0.16, 7.0.12, 6.0.22, 6.0.20, 6.0.16, 5.0.30, 5.0.28, 4.4.29, 4.2.24, 4.0.28]\nversion: 6.0.16\n\n## description: Cluster topology mode.\n## default: standalone\n## one of: [standalone, replicaset]\nmode: standalone\n\n## description: CPU cores.\n## default: 0.5\n## minimum: 0.5\n## maximum: 64\ncpu: 1\n\n## description: Memory, the unit is Gi.\n## default: 0.5\n## minimum: 0.5\n## maximum: 1000\nmemory: 1\n\n## description: Storage size, the unit is Gi.\n## default: 20\n## minimum: 1\n## maximum: 10000\nstorage: 20\n\n## default: enabled\n## one of: [enabled, disabled]\nhostnetwork: \"disabled\"\n\nextra:\n  terminationPolicy: Delete\n"
  },
  {
    "path": "k8s-deploy/databases/neo4j/values.yaml",
    "content": "# Version\n# description: Cluster version.\n# default: 5.26.5\n# one of: [5.26.5, 4.4.42]\nversion: 5.26.5\n\n# Mode\n# description: Cluster topology mode.\n# default: singlealone\n# one of: [singlealone]\nmode: singlealone\n\n# CPU\n# description: CPU cores.\n# default: 2\n# minimum: 2\n# maximum: 64\ncpu: 2\n\n# Memory(Gi)\n# description: Memory, the unit is Gi.\n# default: 2\n# minimum: 2\n# maximum: 1000\nmemory: 4\n\n# Storage(Gi)\n# description: Storage size, the unit is Gi.\n# default: 20\n# minimum: 1\n# maximum: 10000\nstorage: 20\n\n# Replicas\n# description: The number of replicas, for standalone mode, the replicas is 1, for replicaset mode, the default replicas is 3.\n# default: 1\n# minimum: 1\n# maximum: 5\nreplicas: 1\n\n# Storage Class Name\n# description: Storage class name of the data volume\nstorageClassName: \"\"\n\nextra:\n  terminationPolicy: Delete\n"
  },
  {
    "path": "k8s-deploy/databases/postgresql/values.yaml",
    "content": "## description: service version.\n## default: 15.7.0\nversion: 16.4.0\n\n## mode postgresql cluster topology mode replication\nmode: replication\n\n## description: The number of replicas, for standalone mode, the replicas is 1, for replication mode, the default replicas is 2.\n## default: 1\n## minimum: 1\n## maximum: 5\nreplicas: 2\n\n## description: CPU cores.\n## default: 0.5\n## minimum: 0.5\n## maximum: 64\ncpu: 1\n\n## description: Memory, the unit is Gi.\n## default: 0.5\n## minimum: 0.5\n## maximum: 1000\nmemory: 1\n\n## description: Storage size, the unit is Gi.\n## default: 20\n## minimum: 1\n## maximum: 10000\nstorage: 5\n\n## terminationPolicy define Cluster termination policy. One of DoNotTerminate, Delete, WipeOut.\nterminationPolicy: Delete\n"
  },
  {
    "path": "k8s-deploy/databases/qdrant/values.yaml",
    "content": "## description: The version of Qdrant.\n## default: 1.10.0\nversion: 1.10.0\n\n## description: The number of replicas.\n## default: 1\n## minimum: 1\n## maximum: 16\nreplicas: 1\n\n## description: CPU cores.\n## default: 1\n## minimum: 0.5\n## maximum: 64\ncpu: 1\n\n## description: Memory, the unit is Gi.\n## default: 2\n## minimum: 0.5\n## maximum: 1000\nmemory: 1\n\n## description: Storage size, the unit is Gi.\n## default: 20\n## minimum: 1\n## maximum: 10000\nstorage: 20\n\n## customized default values to override kblib chart's values\nextra:\n  terminationPolicy: Delete\n"
  },
  {
    "path": "k8s-deploy/databases/redis/values.yaml",
    "content": "## description: Cluster version.\n## default: 7.2.7\nversion: 7.2.7\n\n## description: Cluster topology mode.\n## default: replication\n## one of: [standalone, replication, cluster, replication-twemproxy]\nmode: standalone\n\n## description: The number of replicas, for standalone mode, the replicas is 1, for replication mode, the default replicas is 2.\n## default: 1\n## minimum: 1\n## maximum: 5\nreplicas: 1\n\n## description: CPU cores.\n## default: 0.5\n## minimum: 0.5\n## maximum: 64\ncpu: 0.5\n\n## description: Memory, the unit is Gi.\n## default: 0.5\n## minimum: 0.5\n## maximum: 1000\nmemory: 1\n\n## description: Storage size, the unit is Gi.\n## default: 20\n## minimum: 1\n## maximum: 10000\nstorage: 20\nextra:\n  disableExporter: true\n"
  },
  {
    "path": "k8s-deploy/databases/scripts/common.sh",
    "content": "#!/bin/bash\n\nprint_title() {\n  echo \"============================================\"\n  echo \"$1\"\n  echo \"============================================\"\n}\n\nprint_success() {\n  echo \"✅ $1\"\n}\n\nprint_error() {\n  echo \"❌ $1\"\n}\n\nprint_warning() {\n  echo \"⚠️ $1\"\n}\n\nprint_info() {\n  echo \"🔹 $1\"\n}\n\nprint() {\n  echo \"$1\"\n}\n\n# Check dependencies\ncheck_dependencies(){\n  print \"Checking dependencies...\"\n  command -v kubectl >/dev/null 2>&1 || { print \"Error: kubectl command not found\"; exit 1; }\n  command -v helm >/dev/null 2>&1 || { print \"Error: helm command not found\"; exit 1; }\n\n  # Check if Kubernetes is available\n  print \"Checking if Kubernetes is available...\"\n  kubectl cluster-info &>/dev/null\n  if [ $? -ne 0 ]; then\n      print \"Error: Kubernetes cluster is not accessible. Please ensure you have proper access to a Kubernetes cluster.\"\n      exit 1\n  fi\n  print_success \"Kubernetes cluster is accessible.\"\n}\n"
  },
  {
    "path": "k8s-deploy/databases/uninstall-kubeblocks.sh",
    "content": "#!/bin/bash\n\n# Get the directory where this script is located\nDATABASE_SCRIPT_DIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )\"\n# Load configuration file\nsource \"$DATABASE_SCRIPT_DIR/00-config.sh\"\n\n# Check dependencies\nprint \"Checking dependencies...\"\ncommand -v kubectl >/dev/null 2>&1 || { print \"Error: kubectl command not found\"; exit 1; }\ncommand -v helm >/dev/null 2>&1 || { print \"Error: helm command not found\"; exit 1; }\n\nprint \"Checking if Kubernetes is available...\"\nif ! kubectl cluster-info &>/dev/null; then\n    print \"Error: Kubernetes cluster is not accessible. Please ensure you have proper access to a Kubernetes cluster.\"\n    exit 1\nfi\n\nprint \"Checking if KubeBlocks is installed in kb-system namespace...\"\nif ! kubectl get namespace kb-system &>/dev/null; then\n    print \"KubeBlocks is not installed in kb-system namespace.\"\n    exit 0\nfi\n\n# Function for uninstalling KubeBlocks\nuninstall_kubeblocks() {\n    print \"Uninstalling KubeBlocks...\"\n\n    # Uninstall KubeBlocks Helm chart\n    helm uninstall kubeblocks -n kb-system\n\n    # Uninstall snapshot controller\n    helm uninstall snapshot-controller -n kb-system\n\n    # Delete KubeBlocks CRDs\n    kubectl delete -f https://github.com/apecloud/kubeblocks/releases/download/v${KB_VERSION}/kubeblocks_crds.yaml --ignore-not-found=true\n\n    # Delete CSI Snapshotter CRDs\n    kubectl delete -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.2.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshotclasses.yaml --ignore-not-found=true\n    kubectl delete -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.2.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshots.yaml --ignore-not-found=true\n    kubectl delete -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v8.2.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshotcontents.yaml --ignore-not-found=true\n\n    # Delete the kb-system namespace\n    print \"Waiting for resources to be removed...\"\n    kubectl delete namespace kb-system --timeout=180s\n\n    print \"KubeBlocks has been successfully uninstalled!\"\n}\n\n# Call the function to uninstall KubeBlocks\nuninstall_kubeblocks\n"
  },
  {
    "path": "k8s-deploy/install_lightrag.sh",
    "content": "#!/bin/bash\n\nNAMESPACE=rag\n\nSCRIPT_DIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )\"\n\nif [ -z \"$OPENAI_API_KEY\" ]; then\n  echo \"OPENAI_API_KEY environment variable is not set\"\n  read -s -p \"Enter your OpenAI API key: \" OPENAI_API_KEY\n  if [ -z \"$OPENAI_API_KEY\" ]; then\n    echo \"Error: OPENAI_API_KEY must be provided\"\n    exit 1\n  fi\n  export OPENAI_API_KEY=$OPENAI_API_KEY\nfi\n\nif [ -z \"$OPENAI_API_BASE\" ]; then\n  echo \"OPENAI_API_BASE environment variable is not set, will use default value\"\n  read -p \"Enter OpenAI API base URL (press Enter to skip if not needed): \" OPENAI_API_BASE\n  export OPENAI_API_BASE=$OPENAI_API_BASE\nfi\n\n# Install KubeBlocks (if not already installed)\nbash \"$SCRIPT_DIR/databases/01-prepare.sh\"\n\n# Install database clusters\nbash \"$SCRIPT_DIR/databases/02-install-database.sh\"\n\n# Create vector extension in PostgreSQL if enabled\nprint \"Waiting for PostgreSQL pods to be ready...\"\nif kubectl wait --for=condition=ready pods -l kubeblocks.io/role=primary,app.kubernetes.io/instance=pg-cluster -n $NAMESPACE --timeout=300s; then\n    print \"Creating vector extension in PostgreSQL...\"\n    kubectl exec -it $(kubectl get pods -l kubeblocks.io/role=primary,app.kubernetes.io/instance=pg-cluster -n $NAMESPACE -o name) -n $NAMESPACE -- psql -c \"CREATE EXTENSION vector;\"\n    print_success \"Vector extension created successfully.\"\nelse\n    print \"Warning: PostgreSQL pods not ready within timeout. Vector extension not created.\"\nfi\n\n# Get database passwords from Kubernetes secrets\necho \"Retrieving database credentials from Kubernetes secrets...\"\nPOSTGRES_PASSWORD=$(kubectl get secrets -n rag pg-cluster-postgresql-account-postgres -o jsonpath='{.data.password}' | base64 -d)\nif [ -z \"$POSTGRES_PASSWORD\" ]; then\n  echo \"Error: Could not retrieve PostgreSQL password. Make sure PostgreSQL is deployed and the secret exists.\"\n  exit 1\nfi\nexport POSTGRES_PASSWORD=$POSTGRES_PASSWORD\n\nNEO4J_PASSWORD=$(kubectl get secrets -n rag neo4j-cluster-neo4j-account-neo4j -o jsonpath='{.data.password}' | base64 -d)\nif [ -z \"$NEO4J_PASSWORD\" ]; then\n  echo \"Error: Could not retrieve Neo4J password. Make sure Neo4J is deployed and the secret exists.\"\n  exit 1\nfi\nexport NEO4J_PASSWORD=$NEO4J_PASSWORD\n\n#REDIS_PASSWORD=$(kubectl get secrets -n rag redis-cluster-redis-account-default -o jsonpath='{.data.password}' | base64 -d)\n#if [ -z \"$REDIS_PASSWORD\" ]; then\n#  echo \"Error: Could not retrieve Redis password. Make sure Redis is deployed and the secret exists.\"\n#  exit 1\n#fi\n#export REDIS_PASSWORD=$REDIS_PASSWORD\n\necho \"Deploying production LightRAG (using external databases)...\"\n\nif ! kubectl get namespace rag &> /dev/null; then\n  echo \"creating namespace 'rag'...\"\n  kubectl create namespace rag\nfi\n\nhelm upgrade --install lightrag $SCRIPT_DIR/lightrag \\\n  --namespace $NAMESPACE \\\n  --set-string env.POSTGRES_PASSWORD=$POSTGRES_PASSWORD \\\n  --set-string env.NEO4J_PASSWORD=$NEO4J_PASSWORD \\\n  --set-string env.LLM_BINDING=openai \\\n  --set-string env.LLM_MODEL=gpt-4o-mini \\\n  --set-string env.LLM_BINDING_HOST=$OPENAI_API_BASE \\\n  --set-string env.LLM_BINDING_API_KEY=$OPENAI_API_KEY \\\n  --set-string env.EMBEDDING_BINDING=openai \\\n  --set-string env.EMBEDDING_MODEL=text-embedding-ada-002 \\\n  --set-string env.EMBEDDING_DIM=1536 \\\n  --set-string env.EMBEDDING_BINDING_API_KEY=$OPENAI_API_KEY\n#  --set-string env.REDIS_URI=\"redis://default:${REDIS_PASSWORD}@redis-cluster-redis-redis:6379\"\n\n# Wait for LightRAG pod to be ready\necho \"\"\necho \"Waiting for lightrag pod to be ready...\"\nkubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=lightrag --timeout=300s -n rag\necho \"lightrag pod is ready\"\necho \"\"\necho \"Running Port-Forward:\"\necho \"    kubectl --namespace rag port-forward svc/lightrag 9621:9621\"\necho \"===========================================\"\necho \"\"\necho \"✅ You can visit LightRAG at: http://localhost:9621\"\necho \"\"\nkubectl --namespace rag port-forward svc/lightrag 9621:9621\n"
  },
  {
    "path": "k8s-deploy/install_lightrag_dev.sh",
    "content": "#!/bin/bash\n\nNAMESPACE=rag\n\nSCRIPT_DIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )\"\n\ncheck_dependencies(){\n  echo \"Checking dependencies...\"\n  command -v kubectl >/dev/null 2>&1 || { echo \"Error: kubectl command not found\"; exit 1; }\n  command -v helm >/dev/null 2>&1 || { echo \"Error: helm command not found\"; exit 1; }\n\n  # Check if Kubernetes is available\n  echo \"Checking if Kubernetes is available...\"\n  kubectl cluster-info &>/dev/null\n  if [ $? -ne 0 ]; then\n      echo \"Error: Kubernetes cluster is not accessible. Please ensure you have proper access to a Kubernetes cluster.\"\n      exit 1\n  fi\n  echo \"Kubernetes cluster is accessible.\"\n}\n\ncheck_dependencies\n\nif [ -z \"$OPENAI_API_KEY\" ]; then\n  echo \"OPENAI_API_KEY environment variable is not set\"\n  read -s -p \"Enter your OpenAI API key: \" OPENAI_API_KEY\n  if [ -z \"$OPENAI_API_KEY\" ]; then\n    echo \"Error: OPENAI_API_KEY must be provided\"\n    exit 1\n  fi\n  export OPENAI_API_KEY=$OPENAI_API_KEY\nfi\n\nif [ -z \"$OPENAI_API_BASE\" ]; then\n  echo \"OPENAI_API_BASE environment variable is not set, will use default value\"\n  read -p \"Enter OpenAI API base URL (press Enter to skip if not needed): \" OPENAI_API_BASE\n  export OPENAI_API_BASE=$OPENAI_API_BASE\nfi\n\nrequired_env_vars=(\"OPENAI_API_BASE\" \"OPENAI_API_KEY\")\n\nfor var in \"${required_env_vars[@]}\"; do\n  if [ -z \"${!var}\" ]; then\n    echo \"Error: $var environment variable is not set\"\n    exit 1\n  fi\ndone\n\nif ! kubectl get namespace rag &> /dev/null; then\n  echo \"creating namespace 'rag'...\"\n  kubectl create namespace rag\nfi\n\nhelm upgrade --install lightrag-dev $SCRIPT_DIR/lightrag \\\n  --namespace rag \\\n  --set-string env.LIGHTRAG_KV_STORAGE=JsonKVStorage \\\n  --set-string env.LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage \\\n  --set-string env.LIGHTRAG_GRAPH_STORAGE=NetworkXStorage \\\n  --set-string env.LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage \\\n  --set-string env.LLM_BINDING=openai \\\n  --set-string env.LLM_MODEL=gpt-4o-mini \\\n  --set-string env.LLM_BINDING_HOST=$OPENAI_API_BASE \\\n  --set-string env.LLM_BINDING_API_KEY=$OPENAI_API_KEY \\\n  --set-string env.EMBEDDING_BINDING=openai \\\n  --set-string env.EMBEDDING_MODEL=text-embedding-ada-002 \\\n  --set-string env.EMBEDDING_DIM=1536 \\\n  --set-string env.EMBEDDING_BINDING_API_KEY=$OPENAI_API_KEY\n\n# Wait for LightRAG pod to be ready\necho \"\"\necho \"Waiting for lightrag-dev pod to be ready...\"\nkubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=lightrag-dev --timeout=300s -n rag\necho \"lightrag-dev pod is ready\"\necho \"\"\necho \"Running Port-Forward:\"\necho \"    kubectl --namespace rag port-forward svc/lightrag-dev 9621:9621\"\necho \"===========================================\"\necho \"\"\necho \"✅ You can visit LightRAG at: http://localhost:9621\"\necho \"\"\nkubectl --namespace rag port-forward svc/lightrag-dev 9621:9621\n"
  },
  {
    "path": "k8s-deploy/lightrag/.helmignore",
    "content": "# Patterns to ignore when building packages.\n# This supports shell glob matching, relative path matching, and\n# negation (prefixed with !). Only one pattern per line.\n.DS_Store\n# Common VCS dirs\n.git/\n.gitignore\n.bzr/\n.bzrignore\n.hg/\n.hgignore\n.svn/\n# Common backup files\n*.swp\n*.bak\n*.tmp\n*.orig\n*~\n# Various IDEs\n.project\n.idea/\n*.tmproj\n.vscode/\n"
  },
  {
    "path": "k8s-deploy/lightrag/Chart.yaml",
    "content": "apiVersion: v2\nname: lightrag\ndescription: A Helm chart for LightRAG, an efficient and lightweight RAG system\ntype: application\nversion: 0.1.1\nappVersion: \"1.0.0\"\nmaintainers:\n  - name: LightRAG Team\n  - name: earayu\n    email: earayu@gmail.com\n"
  },
  {
    "path": "k8s-deploy/lightrag/templates/NOTES.txt",
    "content": "===========================================\n LightRAG has been successfully deployed!\n===========================================\n\nView application logs:\n  kubectl logs -f --namespace {{ .Release.Namespace }} deploy/{{ include \"lightrag.fullname\" . }}\n\n===========================================\n\nAccess the application:\n{{- if contains \"NodePort\" .Values.service.type }}\n  Run these commands to get access information:\n  -----------------------------------------\n  export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath=\"{.spec.ports[0].nodePort}\" services {{ include \"lightrag.fullname\" . }})\n  export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath=\"{.items[0].status.addresses[0].address}\")\n  echo \"LightRAG is accessible at: http://$NODE_IP:$NODE_PORT\"\n  -----------------------------------------\n{{- else if contains \"LoadBalancer\" .Values.service.type }}\n  Run these commands to get access information (external IP may take a minute to assign):\n  -----------------------------------------\n  export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include \"lightrag.fullname\" . }} --template \"{{ \"{{ range (index .status.loadBalancer.ingress 0) }}{{ . }}{{ end }}\" }}\")\n  echo \"LightRAG is accessible at: http://$SERVICE_IP:{{ .Values.service.port }}\"\n  -----------------------------------------\n  If SERVICE_IP is empty, retry the command or check service status with:\n  kubectl get svc --namespace {{ .Release.Namespace }} {{ include \"lightrag.fullname\" . }}\n{{- else if contains \"ClusterIP\" .Values.service.type }}\n  For development environments, to access LightRAG from your local machine:\n\n  1. Run this port-forward command in your terminal:\n    kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include \"lightrag.fullname\" . }} {{ .Values.service.port }}:{{ .Values.env.PORT }}\n\n  2. While the command is running, open your browser and navigate to:\n     http://localhost:{{ .Values.service.port }}\n\n  Note: To stop port-forwarding, press Ctrl+C in the terminal.\n{{- end }}\n\n===========================================\n"
  },
  {
    "path": "k8s-deploy/lightrag/templates/_helpers.tpl",
    "content": "{{/*\nApplication name\n*/}}\n{{- define \"lightrag.name\" -}}\n{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nFull application name\n*/}}\n{{- define \"lightrag.fullname\" -}}\n{{- default .Release.Name .Values.fullnameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCommon labels\n*/}}\n{{- define \"lightrag.labels\" -}}\napp.kubernetes.io/name: {{ include \"lightrag.name\" . }}\napp.kubernetes.io/instance: {{ .Release.Name }}\napp.kubernetes.io/managed-by: {{ .Release.Service }}\n{{- end }}\n\n{{/*\nSelector labels\n*/}}\n{{- define \"lightrag.selectorLabels\" -}}\napp.kubernetes.io/name: {{ include \"lightrag.name\" . }}\napp.kubernetes.io/instance: {{ .Release.Name }}\n{{- end }}\n\n{{/*\n.env file content\n*/}}\n{{- define \"lightrag.envContent\" -}}\n{{- $first := true -}}\n{{- range $key, $val := .Values.env -}}\n{{- if not $first -}}{{- \"\\n\" -}}{{- end -}}\n{{- $first = false -}}\n{{ $key }}={{ $val }}\n{{- end -}}\n{{- end -}}\n"
  },
  {
    "path": "k8s-deploy/lightrag/templates/deployment.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: {{ include \"lightrag.fullname\" . }}\n  labels:\n    {{- include \"lightrag.labels\" . | nindent 4 }}\nspec:\n  replicas: {{ .Values.replicaCount }}\n  selector:\n    matchLabels:\n      {{- include \"lightrag.selectorLabels\" . | nindent 6 }}\n  template:\n    metadata:\n      annotations:\n        checksum/config: {{ include \"lightrag.envContent\" . | sha256sum }}\n      labels:\n        {{- include \"lightrag.selectorLabels\" . | nindent 8 }}\n    spec:\n      containers:\n        - name: {{ .Chart.Name }}\n          image: \"{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}\"\n          imagePullPolicy: IfNotPresent\n          ports:\n            - name: http\n              containerPort: {{ .Values.env.PORT }}\n              protocol: TCP\n          readinessProbe:\n            httpGet:\n              path: /health\n              port: http\n            initialDelaySeconds: 10\n            periodSeconds: 5\n            timeoutSeconds: 2\n            successThreshold: 1\n            failureThreshold: 3\n          resources:\n            {{- toYaml .Values.resources | nindent 12 }}\n          volumeMounts:\n            - name: rag-storage\n              mountPath: /app/data/rag_storage\n            - name: inputs\n              mountPath: /app/data/inputs\n            - name: env-file\n              mountPath: /app/.env\n              subPath: .env\n          {{- $envFrom := default (dict) .Values.envFrom }}\n          {{- $envFromEntries := list }}\n          {{- range (default (list) (index $envFrom \"secrets\")) }}\n          {{- $envFromEntries = append $envFromEntries (dict \"secretRef\" (dict \"name\" .name)) }}\n          {{- end }}\n          {{- range (default (list) (index $envFrom \"configmaps\")) }}\n          {{- $envFromEntries = append $envFromEntries (dict \"configMapRef\" (dict \"name\" .name)) }}\n          {{- end }}\n          {{- if gt (len $envFromEntries) 0 }}\n          envFrom:\n{{- toYaml $envFromEntries | nindent 12 }}\n          {{- end }}\n      {{- with .Values.image.imagePullSecrets }}\n      imagePullSecrets:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      volumes:\n        - name: env-file\n          secret:\n            secretName: {{ include \"lightrag.fullname\" . }}-env\n        {{- if .Values.persistence.enabled }}\n        - name: rag-storage\n          persistentVolumeClaim:\n            claimName: {{ include \"lightrag.fullname\" . }}-rag-storage\n        - name: inputs\n          persistentVolumeClaim:\n            claimName: {{ include \"lightrag.fullname\" . }}-inputs\n        {{- else }}\n        - name: rag-storage\n          emptyDir: {}\n        - name: inputs\n          emptyDir: {}\n        {{- end }}\n\n  strategy:\n    {{- toYaml .Values.updateStrategy | nindent 4 }}\n"
  },
  {
    "path": "k8s-deploy/lightrag/templates/pvc.yaml",
    "content": "{{- if .Values.persistence.enabled }}\n---\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n  name: {{ include \"lightrag.fullname\" . }}-rag-storage\n  labels:\n    {{- include \"lightrag.labels\" . | nindent 4 }}\nspec:\n  accessModes:\n    - ReadWriteOnce\n  resources:\n    requests:\n      storage: {{ .Values.persistence.ragStorage.size }}\n---\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n  name: {{ include \"lightrag.fullname\" . }}-inputs\n  labels:\n    {{- include \"lightrag.labels\" . | nindent 4 }}\nspec:\n  accessModes:\n    - ReadWriteOnce\n  resources:\n    requests:\n      storage: {{ .Values.persistence.inputs.size }}\n{{- end }}\n"
  },
  {
    "path": "k8s-deploy/lightrag/templates/secret.yaml",
    "content": "apiVersion: v1\nkind: Secret\nmetadata:\n  name: {{ include \"lightrag.fullname\" . }}-env\n  labels:\n    {{- include \"lightrag.labels\" . | nindent 4 }}\ntype: Opaque\nstringData:\n  .env: |-\n    {{- include \"lightrag.envContent\" . | nindent 4 }}\n"
  },
  {
    "path": "k8s-deploy/lightrag/templates/service.yaml",
    "content": "apiVersion: v1\nkind: Service\nmetadata:\n  name: {{ include \"lightrag.fullname\" . }}\n  labels:\n    {{- include \"lightrag.labels\" . | nindent 4 }}\nspec:\n  type: {{ .Values.service.type }}\n  ports:\n    - port: {{ .Values.service.port }}\n      targetPort: {{ .Values.env.PORT }}\n      protocol: TCP\n      name: http\n  selector:\n    {{- include \"lightrag.selectorLabels\" . | nindent 4 }}\n"
  },
  {
    "path": "k8s-deploy/lightrag/values.yaml",
    "content": "replicaCount: 1\n\nimage:\n  repository: ghcr.io/hkuds/lightrag\n  tag: latest\n  # Optionally specify imagePullSecrets if your image is in a private registry\n  # example:\n  # imagePullSecrets:\n  #   - name: my-registry-secret\n  imagePullSecrets: []\n\n# Specify a deployment strategy\n# example:\n# updateStrategy:\n#   type: RollingUpdate\n#   rollingUpdate:\n#     maxUnavailable: 25%\n#     maxSurge: 25%\n# Default for now should be Recreate as any RollingUpdate will cause issues with\n# multiple instances trying to access the same persistent storage if not using RWX volumes.\nupdateStrategy:\n  type: Recreate\n\nservice:\n  type: ClusterIP\n  port: 9621\n\nresources:\n  limits:\n    cpu: 1000m\n    memory: 2Gi\n  requests:\n    cpu: 500m\n    memory: 1Gi\n\npersistence:\n  enabled: true\n  ragStorage:\n    size: 10Gi\n  inputs:\n    size: 5Gi\n\n# Allow specifying additional environment variables from ConfigMaps or Secrets created outside of this chart\nenvFrom:\n  configmaps: []\n    # - name: my-shiny-configmap-1\n  secrets: []\n    # - name: my-shiny-secret-1\n\nenv:\n  HOST: 0.0.0.0\n  PORT: 9621\n  WEBUI_TITLE: Graph RAG Engine\n  WEBUI_DESCRIPTION: Simple and Fast Graph Based RAG System\n  LLM_BINDING: openai\n  LLM_MODEL: gpt-4o-mini\n  LLM_BINDING_HOST:\n  LLM_BINDING_API_KEY:\n  EMBEDDING_BINDING: openai\n  EMBEDDING_MODEL: text-embedding-ada-002\n  EMBEDDING_DIM: 1536\n  EMBEDDING_BINDING_API_KEY:\n  LIGHTRAG_KV_STORAGE: PGKVStorage\n  LIGHTRAG_VECTOR_STORAGE: PGVectorStorage\n  #  LIGHTRAG_KV_STORAGE: RedisKVStorage\n  #  LIGHTRAG_VECTOR_STORAGE: QdrantVectorDBStorage\n  LIGHTRAG_GRAPH_STORAGE: Neo4JStorage\n  LIGHTRAG_DOC_STATUS_STORAGE: PGDocStatusStorage\n  # Replace with your POSTGRES credentials\n  POSTGRES_HOST: pg-cluster-postgresql-postgresql\n  POSTGRES_PORT: 5432\n  POSTGRES_USER: postgres\n  POSTGRES_PASSWORD:\n  POSTGRES_DATABASE: postgres\n  POSTGRES_WORKSPACE: default\n  # Replace with your NEO4J credentials\n  NEO4J_URI: neo4j://neo4j-cluster-neo4j:7687\n  NEO4J_USERNAME: neo4j\n  NEO4J_PASSWORD:\n  # Replace with your Qdrant credentials\n  QDRANT_URL: http://qdrant-cluster-qdrant-qdrant:6333\n  # REDIS_URI: redis://default:${REDIS_PASSWORD}@redis-cluster-redis-redis:6379\n"
  },
  {
    "path": "k8s-deploy/uninstall_lightrag.sh",
    "content": "#!/bin/bash\n\nNAMESPACE=rag\nhelm uninstall lightrag --namespace $NAMESPACE\n"
  },
  {
    "path": "k8s-deploy/uninstall_lightrag_dev.sh",
    "content": "#!/bin/bash\n\nNAMESPACE=rag\nhelm uninstall lightrag-dev --namespace $NAMESPACE\n"
  },
  {
    "path": "lightrag/__init__.py",
    "content": "from .lightrag import LightRAG as LightRAG, QueryParam as QueryParam\n\n__version__ = \"1.4.11\"\n__author__ = \"Zirui Guo\"\n__url__ = \"https://github.com/HKUDS/LightRAG\"\n"
  },
  {
    "path": "lightrag/api/.gitignore",
    "content": "inputs\nrag_storage\n"
  },
  {
    "path": "lightrag/api/README-zh.md",
    "content": "# LightRAG 服务器和 WebUI\n\nLightRAG 服务器旨在提供 Web 界面和 API 支持。Web 界面便于文档索引、知识图谱探索和简单的 RAG 查询界面。LightRAG 服务器还提供了与 Ollama 兼容的接口，旨在将 LightRAG 模拟为 Ollama 聊天模型。这使得 AI 聊天机器人（如 Open WebUI）可以轻松访问 LightRAG。\n\n![image-20250323122538997](./README.assets/image-20250323122538997.png)\n\n![image-20250323122754387](./README.assets/image-20250323122754387.png)\n\n![image-20250323123011220](./README.assets/image-20250323123011220.png)\n\n## 入门指南\n\n### 安装\n\n* 从 PyPI 安装\n\n```bash\n### 使用 uv 安装 LightRAG 服务器（作为工具，推荐)\nuv tool install \"lightrag-hku[api]\"\n\n### 或使用 pip\n# python -m venv .venv\n# source .venv/bin/activate  # Windows: .venv\\Scripts\\activate\n# pip install \"lightrag-hku[api]\"\n```\n\n* 从源代码安装\n\n```bash\n# 克隆仓库\ngit clone https://github.com/HKUDS/lightrag.git\n\n# 进入仓库目录\ncd lightrag\n\n# 使用 uv (推荐)\n# 注意: uv sync 会自动在 .venv/ 目录创建虚拟环境\nuv sync --extra api\nsource .venv/bin/activate  # 激活虚拟环境 (Linux/macOS)\n# Windows 系统: .venv\\Scripts\\activate\n\n# 或使用 pip 与虚拟环境\n# python -m venv .venv\n# source .venv/bin/activate  # Windows: .venv\\Scripts\\activate\n# pip install -e \".[api]\"\n\n# 构建前端代码\ncd lightrag_webui\nbun install --frozen-lockfile\nbun run build\ncd ..\n```\n\n### 启动 LightRAG 服务器前的准备\n\nLightRAG 需要同时集成 LLM（大型语言模型）和嵌入模型以有效执行文档索引和查询操作。在首次部署 LightRAG 服务器之前，必须配置 LLM 和嵌入模型的设置。LightRAG 支持绑定到各种 LLM/嵌入后端：\n\n* ollama\n* lollms\n* openai 或 openai 兼容\n* azure_openai\n* aws_bedrock\n* gemini\n\n建议使用环境变量来配置 LightRAG 服务器。项目根目录中有一个名为 `env.example` 的示例环境变量文件。请将此文件复制到启动目录并重命名为 `.env`。之后，您可以在 `.env` 文件中修改与 LLM 和嵌入模型相关的参数。需要注意的是，LightRAG 服务器每次启动时都会将 `.env` 中的环境变量加载到系统环境变量中。**LightRAG 服务器会优先使用系统环境变量中的设置**。\n\n> 由于安装了 Python 扩展的 VS Code 可能会在集成终端中自动加载 .env 文件，请在每次修改 .env 文件后打开新的终端会话。\n\n以下是 LLM 和嵌入模型的一些常见设置示例：\n\n* OpenAI LLM + Ollama 嵌入\n\n```\nLLM_BINDING=openai\nLLM_MODEL=gpt-4o\nLLM_BINDING_HOST=https://api.openai.com/v1\nLLM_BINDING_API_KEY=your_api_key\n\nEMBEDDING_BINDING=ollama\nEMBEDDING_BINDING_HOST=http://localhost:11434\nEMBEDDING_MODEL=bge-m3:latest\nEMBEDDING_DIM=1024\n# EMBEDDING_BINDING_API_KEY=your_api_key\n```\n\n> 如果改为使用 Google Gemini, 设置 `LLM_BINDING=gemini`, 选择模型 `LLM_MODEL=gemini-flash-latest`, 并设置访问密钥 `LLM_BINDING_API_KEY` (或 `GEMINI_API_KEY`).\n\n* Ollama LLM + Ollama 嵌入\n\n```\nLLM_BINDING=ollama\nLLM_MODEL=mistral-nemo:latest\nLLM_BINDING_HOST=http://localhost:11434\n# LLM_BINDING_API_KEY=your_api_key\n###  Ollama 服务器上下文 token 数（必须大于 MAX_TOTAL_TOKENS+2000）\nOLLAMA_LLM_NUM_CTX=8192\n\nEMBEDDING_BINDING=ollama\nEMBEDDING_BINDING_HOST=http://localhost:11434\nEMBEDDING_MODEL=bge-m3:latest\nEMBEDDING_DIM=1024\n# EMBEDDING_BINDING_API_KEY=your_api_key\n```\n\n> **重要提示**：在文档索引前必须确定使用的Embedding模型，且在文档查询阶段必须沿用与索引阶段相同的模型。有些存储（例如PostgreSQL）在首次建立数表的时候需要确定向量维度，因此更换Embedding模型后需要删除向量相关库表，以便让LightRAG重建新的库表。\n\n### 使用 Setup 工具创建 .env 文件\n\n除了手动编辑 `env.example` 之外，您还可以使用交互式向导生成配置好的 `.env`，并在需要时生成 `docker-compose.final.yml`：\n\n```bash\nmake env-base           # 必跑第一步：配置 LLM、Embedding、Reranker\nmake env-storage        # 可选：配置存储后端和数据库服务\nmake env-server         # 可选：配置服务端口、鉴权和 SSL\nmake env-security-check # 可选：审计当前 .env 中的安全风险\n```\n\n每个目标的详细说明请参阅 [docs/InteractiveSetup.md](../../docs/InteractiveSetup.md)。\n这些 setup 向导只负责更新配置；如需在部署前审计当前 `.env` 的安全风险，请额外运行\n`make env-security-check`。\n\n### 启动 LightRAG 服务器\n\nLightRAG 服务器支持两种运行模式：\n* 简单高效的 Uvicorn 模式\n\n```\nlightrag-server\n```\n* 多进程 Gunicorn + Uvicorn 模式（生产模式，不支持 Windows 环境）\n\n```\nlightrag-gunicorn --workers 4\n```\n\n启动LightRAG的时候，当前工作目录必须含有`.env`配置文件。**要求将.env文件置于启动目录中是经过特意设计的**。 这样做的目的是支持用户同时启动多个LightRAG实例，并为不同实例配置不同的.env文件。**修改.env文件后，您需要重新打开终端以使新设置生效**。 这是因为每次启动时，LightRAG Server会将.env文件中的环境变量加载至系统环境变量，且系统环境变量的设置具有更高优先级。\n\n启动时可以通过命令行参数覆盖`.env`文件中的配置。常用的命令行参数包括：\n\n- `--host`：服务器监听地址（默认：0.0.0.0）\n- `--port`：服务器监听端口（默认：9621）\n- `--timeout`：LLM 请求超时时间（默认：150 秒）\n- `--log-level`：日志级别（默认：INFO）\n- `--working-dir`：数据库持久化目录（默认：./rag_storage）\n- `--input-dir`：上传文件存放目录（默认：./inputs）\n- `--workspace`: 工作空间名称，用于逻辑上隔离多个LightRAG实例之间的数据（默认：空）\n\n### 使用 Docker 启动 LightRAG 服务器\n\n使用 Docker Compose 是部署和运行 LightRAG Server 最便捷的方式。\n\n- 创建一个项目目录。\n- 将 LightRAG 仓库中的 `docker-compose.yml` 文件复制到您的项目目录中。\n- 准备 `.env` 文件：复制示例文件 [`env.example`](https://ai.znipower.com:5013/c/env.example) 创建自定义的 `.env` 文件，并根据您的具体需求配置 LLM 和嵌入参数。\n- 通过以下命令启动 LightRAG 服务器：\n\n```shell\ndocker compose up\n# 如果希望启动后让程序退到后台运行，需要在命令的最后添加 -d 参数\n```\n\n> 可以通过以下链接获取官方的docker compose文件：[docker-compose.yml]( https://raw.githubusercontent.com/HKUDS/LightRAG/refs/heads/main/docker-compose.yml) 。如需获取LightRAG的历史版本镜像，可以访问以下链接: [LightRAG Docker Images]( https://github.com/HKUDS/LightRAG/pkgs/container/lightrag). 如需获取更多关于docker部署的信息，请参阅 [DockerDeployment.md](./../../docs/DockerDeployment.md).\n\n### Nginx 反向代理配置\n\n在 LightRAG 服务器前使用 Nginx 作为反向代理时，需要为 `/documents/upload` 端点配置 `client_max_body_size` 以处理大文件上传。如果不进行此配置，Nginx 将拒绝大于 1MB（默认限制）的文件，并在请求到达 LightRAG 之前返回 `413 Request Entity Too Large` 错误。\n\n**推荐配置：**\n\n```nginx\nserver {\n    listen 80;\n    server_name your-domain.com;\n\n    # 全局默认：8MB 用于 LLM 长上下文查询\n    client_max_body_size 8M;\n\n    # 上传端点：100MB 用于大文件上传\n    location /documents/upload {\n        client_max_body_size 100M;\n\n        proxy_pass http://localhost:9621;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n\n        # 大文件上传需要更长超时时间\n        proxy_read_timeout 300s;\n        proxy_send_timeout 300s;\n    }\n\n    # 流式端点：LLM 响应流式传输\n    location ~ ^/(query/stream|api/chat|api/generate) {\n        gzip off;  # 禁用流式响应的压缩\n\n        proxy_pass http://localhost:9621;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n\n        # LLM 生成需要较长超时\n        proxy_read_timeout 300s;\n    }\n\n    # 其他端点\n    location / {\n        proxy_pass http://localhost:9621;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n```\n\n**关键要点：**\n\n1. **全局限制（8MB）**：足以处理具有长对话历史和上下文的 LLM 查询（128K tokens ≈ 512KB + JSON 开销）。\n2. **上传端点（100MB）**：必须匹配或超过 `.env` 文件中的 `MAX_UPLOAD_SIZE`。默认 `MAX_UPLOAD_SIZE` 为 100MB。\n3. **流式端点**：为流式端点禁用 gzip 压缩（`gzip off`）以确保实时响应传输。LightRAG 自动设置 `X-Accel-Buffering: no` 头以禁用响应缓冲。\n4. **超时设置**：大文件上传和 LLM 生成需要更长的超时时间；相应调整 `proxy_read_timeout` 和 `proxy_send_timeout`。\n5. **大小验证层**：\n   - Nginx 首先验证 `Content-Length` 头\n   - LightRAG 在上传过程中执行流式验证\n   - 在两层设置适当的限制可确保更好的错误消息和安全性\n\n### 离线部署\n\n官方的 LightRAG Docker 镜像完全兼容离线或隔离网络环境。如需搭建自己的离线部署环境，请参考 [离线部署指南](./../../docs/OfflineDeployment.md)。\n\n### 启动多个LightRAG实例\n\n有两种方式可以启动多个LightRAG实例。第一种方式是为每个实例配置一个完全独立的工作环境。此时需要为每个实例创建一个独立的工作目录，然后在这个工作目录上放置一个当前实例专用的`.env`配置文件。不同实例的配置文件中的服务器监听端口不能重复，然后在工作目录上执行 lightrag-server 启动服务即可。\n\n第二种方式是所有实例共享一套相同的`.env`配置文件，然后通过命令行参数来为每个实例指定不同的服务器监听端口和工作空间。你可以在同一个工作目录中通过不同的命令行参数启动多个LightRAG实例。例如：\n\n```\n# 启动实例1\nlightrag-server --port 9621 --workspace space1\n\n# 启动实例2\nlightrag-server --port 9622 --workspace space2\n```\n\n工作空间的作用是实现不同实例之间的数据隔离。因此不同实例之间的`workspace`参数必须不同，否则会导致数据混乱，数据将会被破坏。\n\n通过 Docker Compose 启动多个 LightRAG 实例时，只需在 `docker-compose.yml` 中为每个容器指定不同的 `WORKSPACE` 和 `PORT` 环境变量即可。即使所有实例共享同一个 `.env` 文件，Compose 中定义的容器环境变量也会优先覆盖 `.env` 文件中的同名设置，从而确保每个实例拥有独立的配置。\n\n### LightRAG实例间的数据隔离\n\n每个实例配置一个独立的工作目录和专用`.env`配置文件通常能够保证内存数据库中的本地持久化文件保存在各自的工作目录，实现数据的相互隔离。LightRAG默认存储全部都是内存数据库，通过这种方式进行数据隔离是没有问题的。但是如果使用的是外部数据库，如果不同实例访问的是同一个数据库实例，就需要通过配置工作空间来实现数据隔离，否则不同实例的数据将会出现冲突并被破坏。\n\n命令行的 workspace 参数和`.env`文件中的环境变量`WORKSPACE` 都可以用于指定当前实例的工作空间名字，命令行参数的优先级别更高。下面是不同类型的存储实现工作空间的方式：\n\n- **对于本地基于文件的数据库，数据隔离通过工作空间子目录实现：** JsonKVStorage, JsonDocStatusStorage, NetworkXStorage, NanoVectorDBStorage, FaissVectorDBStorage。\n- **对于将数据存储在集合（collection）中的数据库，通过在集合名称前添加工作空间前缀来实现：** RedisKVStorage, RedisDocStatusStorage, MilvusVectorDBStorage, QdrantVectorDBStorage, MongoKVStorage, MongoDocStatusStorage, MongoVectorDBStorage, MongoGraphStorage, PGGraphStorage。\n- **对于关系型数据库，数据隔离通过向表中添加 `workspace` 字段进行数据的逻辑隔离：** PGKVStorage, PGVectorStorage, PGDocStatusStorage。\n\n* **对于Neo4j图数据库，通过label来实现数据的逻辑隔离**：Neo4JStorage\n* **对于OpenSearch，通过索引名称前缀实现数据隔离**：OpenSearchKVStorage、OpenSearchDocStatusStorage、OpenSearchGraphStorage、OpenSearchVectorDBStorage\n\n为了保持对遗留数据的兼容，在未配置工作空间时PostgreSQL的默认工作空间为`default`，Neo4j的默认工作空间为`base`。对于所有的外部存储，系统都提供了专用的工作空间环境变量，用于覆盖公共的 `WORKSPACE`环境变量配置。这些适用于指定存储类型的工作空间环境变量为：`REDIS_WORKSPACE`, `MILVUS_WORKSPACE`, `QDRANT_WORKSPACE`, `MONGODB_WORKSPACE`, `POSTGRES_WORKSPACE`, `NEO4J_WORKSPACE`, `OPENSEARCH_WORKSPACE`。\n\n### Gunicorn + Uvicorn 的多工作进程\n\nLightRAG 服务器可以在 `Gunicorn + Uvicorn` 预加载模式下运行。Gunicorn 的多工作进程（多进程）功能可以防止文档索引任务阻塞 RAG 查询。使用 CPU 密集型文档提取工具（如 docling）在纯 Uvicorn 模式下可能会导致整个系统被阻塞。\n\n虽然 LightRAG 服务器使用一个工作进程来处理文档索引流程，但通过 Uvicorn 的异步任务支持，可以并行处理多个文件。文档索引速度的瓶颈主要在于 LLM。如果您的 LLM 支持高并发，您可以通过增加 LLM 的并发级别来加速文档索引。以下是几个与并发处理相关的环境变量及其默认值：\n\n```\n### 工作进程数，数字不大于 (2 x 核心数) + 1\nWORKERS=2\n### 一批中并行处理的文件数\nMAX_PARALLEL_INSERT=2\n# LLM 的最大并发请求数\nMAX_ASYNC=4\n```\n\n### 将 Lightrag 安装为 Linux 服务\n\n从示例文件 `lightrag.service.example` 创建您的服务文件 `lightrag.service`。修改服务文件中的服务启动定义：\n\n```text\n# Set Enviroment to your Python virtual enviroment\nEnvironment=\"PATH=/home/netman/lightrag-xyj/venv/bin\"\nWorkingDirectory=/home/netman/lightrag-xyj\n# ExecStart=/home/netman/lightrag-xyj/venv/bin/lightrag-server\nExecStart=/home/netman/lightrag-xyj/venv/bin/lightrag-gunicorn\n```\n\n> ExecStart命令必须是 lightrag-gunicorn 或 lightrag-server 中的一个，不能使用其它脚本包裹它们。因为停止服务必须要求主进程必须是这两个进程。\n\n安装 LightRAG 服务。如果您的系统是 Ubuntu，以下命令将生效：\n\n```shell\nsudo cp lightrag.service /etc/systemd/system/\nsudo systemctl daemon-reload\nsudo systemctl start lightrag.service\nsudo systemctl status lightrag.service\nsudo systemctl enable lightrag.service\n```\n\n## Ollama 模拟\n\n我们为 LightRAG 提供了 Ollama 兼容接口，旨在将 LightRAG 模拟为 Ollama 聊天模型。这使得支持 Ollama 的 AI 聊天前端（如 Open WebUI）可以轻松访问 LightRAG。\n\n### 将 Open WebUI 连接到 LightRAG\n\n启动 lightrag-server 后，您可以在 Open WebUI 管理面板中添加 Ollama 类型的连接。然后，一个名为 `lightrag:latest` 的模型将出现在 Open WebUI 的模型管理界面中。用户随后可以通过聊天界面向 LightRAG 发送查询。对于这种用例，最好将 LightRAG 安装为服务。\n\nOpen WebUI 使用 LLM 来执行会话标题和会话关键词生成任务。因此，Ollama 聊天补全 API 会检测并将 OpenWebUI 会话相关请求直接转发给底层 LLM。Open WebUI 的截图：\n\n![image-20250323194750379](./README.assets/image-20250323194750379.png)\n\n### 在聊天中选择查询模式\n\n如果您从 LightRAG 的 Ollama 接口发送消息（查询），默认查询模式是 `hybrid`。您可以通过发送带有查询前缀的消息来选择查询模式。\n\n查询字符串中的查询前缀可以决定使用哪种 LightRAG 查询模式来生成响应。支持的前缀包括：\n\n```\n/local\n/global\n/hybrid\n/naive\n/mix\n\n/bypass\n/context\n/localcontext\n/globalcontext\n/hybridcontext\n/naivecontext\n/mixcontext\n```\n\n例如，聊天消息 \"/mix 唐僧有几个徒弟\" 将触发 LightRAG 的混合模式查询。没有查询前缀的聊天消息默认会触发混合模式查询。\n\n\"/bypass\" 不是 LightRAG 查询模式，它会告诉 API 服务器将查询连同聊天历史直接传递给底层 LLM。因此用户可以使用 LLM 基于聊天历史回答问题。如果您使用 Open WebUI 作为前端，您可以直接切换到普通 LLM 模型，而不是使用 /bypass 前缀。\n\n\"/context\" 也不是 LightRAG 查询模式，它会告诉 LightRAG 只返回为 LLM 准备的上下文信息。您可以检查上下文是否符合您的需求，或者自行处理上下文。\n\n### 在聊天中添加用户提示词\n\n使用LightRAG进行内容查询时，应避免将搜索过程与无关的输出处理相结合，这会显著影响查询效果。用户提示（user prompt）正是为解决这一问题而设计 -- 它不参与RAG检索阶段，而是在查询完成后指导大语言模型（LLM）如何处理检索结果。我们可以在查询前缀末尾添加方括号，从而向LLM传递用户提示词：\n\n```\n/[使用mermaid格式画图] 请画出 Scrooge 的人物关系图谱\n/mix[使用mermaid格式画图] 请画出 Scrooge 的人物关系图谱\n```\n\n## API 密钥和认证\n\n默认情况下，LightRAG 服务器可以在没有任何认证的情况下访问。我们可以使用 API 密钥或账户凭证配置服务器以确保其安全。\n\n* API 密钥\n\n```\nLIGHTRAG_API_KEY=your-secure-api-key-here\nWHITELIST_PATHS=/health,/api/*\n```\n\n> 健康检查和 Ollama 模拟端点默认不进行 API 密钥检查。为了安全原因，如果不需要提供Ollama服务，应该把`/api/*`从WHITELIST_PATHS中移除。\n\nAPI Key使用的请求头是 `X-API-Key` 。以下是使用API访问LightRAG Server的一个例子：\n\n```\ncurl -X 'POST' \\\n  'http://localhost:9621/documents/scan' \\\n  -H 'accept: application/json' \\\n  -H 'X-API-Key: your-secure-api-key-here-123' \\\n  -d ''\n```\n\n* 账户凭证（Web 界面需要登录后才能访问）\n\nLightRAG API 服务器使用基于 HS256 算法的 JWT 认证。要启用安全访问控制，需要以下环境变量：\n\n```bash\n# JWT 认证\nAUTH_ACCOUNTS='admin:admin123,user1:pass456'\nTOKEN_SECRET='your-key'\nTOKEN_EXPIRE_HOURS=4\n```\n\n> 目前仅支持配置一个管理员账户和密码。尚未开发和实现完整的账户系统。\n\n如果未配置账户凭证，Web 界面将以访客身份访问系统。因此，即使仅配置了 API 密钥，所有 API 仍然可以通过访客账户访问，这仍然不安全。因此，要保护 API，需要同时配置这两种认证方法。\n\n## Azure OpenAI 后端配置\n\n可以使用以下 Azure CLI 命令创建 Azure OpenAI API（您需要先从 [https://docs.microsoft.com/en-us/cli/azure/install-azure-cli](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) 安装 Azure CLI）：\n\n```bash\n# 根据需要更改资源组名称、位置和 OpenAI 资源名称\nRESOURCE_GROUP_NAME=LightRAG\nLOCATION=swedencentral\nRESOURCE_NAME=LightRAG-OpenAI\n\naz login\naz group create --name $RESOURCE_GROUP_NAME --location $LOCATION\naz cognitiveservices account create --name $RESOURCE_NAME --resource-group $RESOURCE_GROUP_NAME  --kind OpenAI --sku S0 --location swedencentral\naz cognitiveservices account deployment create --resource-group $RESOURCE_GROUP_NAME  --model-format OpenAI --name $RESOURCE_NAME --deployment-name gpt-4o --model-name gpt-4o --model-version \"2024-08-06\"  --sku-capacity 100 --sku-name \"Standard\"\naz cognitiveservices account deployment create --resource-group $RESOURCE_GROUP_NAME  --model-format OpenAI --name $RESOURCE_NAME --deployment-name text-embedding-3-large --model-name text-embedding-3-large --model-version \"1\"  --sku-capacity 80 --sku-name \"Standard\"\naz cognitiveservices account show --name $RESOURCE_NAME --resource-group $RESOURCE_GROUP_NAME --query \"properties.endpoint\"\naz cognitiveservices account keys list --name $RESOURCE_NAME -g $RESOURCE_GROUP_NAME\n```\n\n最后一个命令的输出将提供 OpenAI API 的端点和密钥。您可以使用这些值在 `.env` 文件中设置环境变量。\n\n```\n# .env 中的 Azure OpenAI 配置\nLLM_BINDING=azure_openai\nLLM_BINDING_HOST=your-azure-endpoint\nLLM_MODEL=your-model-deployment-name\nLLM_BINDING_API_KEY=your-azure-api-key\n### API Version可选，默认为最新版本\nAZURE_OPENAI_API_VERSION=2024-08-01-preview\n\n### 如果使用 Azure OpenAI 进行嵌入\nEMBEDDING_BINDING=azure_openai\nEMBEDDING_MODEL=your-embedding-deployment-name\n```\n\n## LightRAG 服务器详细配置\n\nAPI 服务器可以通过三种方式配置（优先级从高到低）：\n\n* 命令行参数\n* 环境变量或 .env 文件\n* Config.ini（仅用于存储配置）\n\n大多数配置都有默认设置，详细信息请查看示例文件：`.env.example`。数据存储配置也可以通过 config.ini 设置。为方便起见，提供了示例文件 `config.ini.example`。\n\n### 支持的 LLM 和嵌入后端\n\nLightRAG 支持绑定到各种 LLM/嵌入后端：\n\n* ollama\n* openai (含openai 兼容)\n* azure_openai\n* lollms\n* aws_bedrock\n\n使用环境变量 `LLM_BINDING` 或 CLI 参数 `--llm-binding` 选择 LLM 后端类型。使用环境变量 `EMBEDDING_BINDING` 或 CLI 参数 `--embedding-binding` 选择嵌入后端类型。\n\nLLM和Embedding配置例子请查看项目根目录的 env.example 文件。OpenAI和Ollama兼容LLM接口的支持的完整配置选型可以通过一下命令查看：\n\n```\nlightrag-server --llm-binding openai --help\nlightrag-server --llm-binding ollama --help\nlightrag-server --embedding-binding ollama --help\n```\n\n> 请使用openai兼容方式访问OpenRouter、vLLM或SLang部署的LLM。可以通过 `OPENAI_LLM_EXTRA_BODY` 环境变量给OpenRouter、vLLM或SGLang推理框架传递额外的参数，实现推理模式的关闭或者其它个性化控制。\n\n设置 `max_tokens` 参数旨在**防止在实体关系提取阶段出现LLM 响应输出过长或无休止的循环输出的问题**。设置 `max_tokens` 参数的目的是在超时发生之前截断 LLM 输出，从而防止文档提取失败。这解决了某些包含大量实体和关系的文本块（例如表格或引文）可能导致 LLM 产生过长甚至无限循环输出的问题。此设置对于本地部署的小参数模型尤为重要。`max_tokens` 值可以通过以下公式计算：\n\n```\n# For vLLM/SGLang doployed models, or most of OpenAI compatible API provider\nOPENAI_LLM_MAX_TOKENS=9000\n\n# For Ollama Deployed Modeles\nOLLAMA_LLM_NUM_PREDICT=9000\n\n# For OpenAI o1-mini or newer modles\nOPENAI_LLM_MAX_COMPLETION_TOKENS=9000\n```\n\n### 实体提取配置\n\n* ENABLE_LLM_CACHE_FOR_EXTRACT：为实体提取启用 LLM 缓存（默认：true）\n\n在测试环境中将 `ENABLE_LLM_CACHE_FOR_EXTRACT` 设置为 true 以减少 LLM 调用成本是很常见的做法。\n\n### 支持的存储类型\n\nLightRAG 使用 4 种类型的存储用于不同目的：\n\n* KV_STORAGE：llm 响应缓存、文本块、文档信息\n* VECTOR_STORAGE：实体向量、关系向量、块向量\n* GRAPH_STORAGE：实体关系图\n* DOC_STATUS_STORAGE：文档索引状态\n\n每种存储类型都有多种存储实现方式。LightRAG Server默认的存储实现为内存数据库，数据通过文件持久化保存到WORKING_DIR目录。LightRAG还支持PostgreSQL、MongoDB、FAISS、Milvus、Qdrant、Neo4j、Memgraph和Redis等存储实现方式。详细的存储支持方式请参考根目录下的`README.md`文件中关于存储的相关内容。\n\n**Milvus 索引配置:** LightRAG 现在可通过环境变量支持对 Milvus 向量存储的可配置索引类型（AUTOINDEX、HNSW、HNSW_SQ、IVF_FLAT 等）。HNSW_SQ 需要 Milvus 2.6.8 或更高版本，并能显著节省内存。有关完整的配置选项，请参阅主 README.md 文件中的“使用 Milvus 进行向量存储”部分。\n\n您可以通过环境变量选择存储实现。例如，在首次启动 API 服务器之前，您可以将以下环境变量设置为特定的存储实现名称：\n\n```\nLIGHTRAG_KV_STORAGE=PGKVStorage\nLIGHTRAG_VECTOR_STORAGE=PGVectorStorage\nLIGHTRAG_GRAPH_STORAGE=PGGraphStorage\nLIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage\n```\n\n在向 LightRAG 添加文档后，您不能更改存储实现选择。目前尚不支持从一个存储实现迁移到另一个存储实现。更多配置信息请阅读示例 `env.exampl`e文件。\n\n### 在不同存储类型之间迁移LLM缓存\n\n当LightRAG更换存储实现方式的时候，可以LLM缓存从就的存储迁移到新的存储。先以后在新的存储上重新上传文件时，将利用利用原有存储的LLM缓存大幅度加快文件处理的速度。LLM缓存迁移工具的使用方法请参考[README_MIGRATE_LLM_CACHE.md](../tools/README_MIGRATE_LLM_CACHE.md)\n\n### LightRag API 服务器命令行选项\n\n| 参数 | 默认值 | 描述 |\n|-----------|---------|-------------|\n| --host | 0.0.0.0 | 服务器主机 |\n| --port | 9621 | 服务器端口 |\n| --working-dir | ./rag_storage | RAG 存储的工作目录 |\n| --input-dir | ./inputs | 包含输入文档的目录 |\n| --max-async | 4 | 最大异步操作数 |\n| --log-level | INFO | 日志级别（DEBUG、INFO、WARNING、ERROR、CRITICAL） |\n| --verbose | - | 详细调试输出（True、False） |\n| --key | None | 用于认证的 API 密钥。保护 lightrag 服务器免受未授权访问 |\n| --ssl | False | 启用 HTTPS |\n| --ssl-certfile | None | SSL 证书文件路径（如果启用 --ssl 则必需） |\n| --ssl-keyfile | None | SSL 私钥文件路径（如果启用 --ssl 则必需） |\n| --llm-binding | ollama | LLM 绑定类型（lollms、ollama、openai、openai-ollama、azure_openai、aws_bedrock） |\n| --embedding-binding | ollama | 嵌入绑定类型（lollms、ollama、openai、azure_openai、aws_bedrock） |\n\n### Reranking 配置\n\nReranking 查询召回的块可以显著提高检索质量，它通过基于优化的相关性评分模型对文档重新排序。LightRAG 目前支持以下 rerank 提供商：\n\n- **Cohere / vLLM**：提供与 Cohere AI 的 `v2/rerank` 端点的完整 API 集成。由于 vLLM 提供了与 Cohere 兼容的 reranker API，因此也支持所有通过 vLLM 部署的 reranker 模型。\n- **Jina AI**：提供与所有 Jina rerank 模型的完全实现兼容性。\n- **阿里云**：具有旨在支持阿里云 rerank API 格式的自定义实现。\n\nRerank 提供商通过 `.env` 文件进行配置。以下是使用 vLLM 本地部署的 rerank 模型的示例配置：\n\n```\nRERANK_BINDING=cohere\nRERANK_MODEL=BAAI/bge-reranker-v2-m3\nRERANK_BINDING_HOST=http://localhost:8000/rerank\nRERANK_BINDING_API_KEY=your_rerank_api_key_here\n```\n\n以下是使用阿里云提供的 Reranker 服务的示例配置：\n\n```\nRERANK_BINDING=aliyun\nRERANK_MODEL=gte-rerank-v2\nRERANK_BINDING_HOST=https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank\nRERANK_BINDING_API_KEY=your_rerank_api_key_here\n```\n\n有关完整的 reranker 配置示例，请参阅 `env.example` 文件。\n\n### 启用 Reranking\n\n可以按查询启用或禁用 Reranking。\n\n`/query` 和 `/query/stream` API 端点包含一个 `enable_rerank` 参数，默认设置为 `true`，用于控制当前查询是否激活 reranking。要将 `enable_rerank` 参数的默认值更改为 `false`，请设置以下环境变量：\n\n```\nRERANK_BY_DEFAULT=False\n```\n\n### 在参考文件中包含文本块内容\n\n默认情况下 `/query` and `/query/stream` 端点在返回引用内容仅包括 `reference_id` 和 `file_path`. 为了评估、调试或引用的需要，你可以要求在返回的引用内容包括实际检索到的文本块内容.\n\n参数 `include_chunk_content` (默认值: `false`) 将控制返回的引用内容总是否包含召回文本块中的原文内容。这对于一下情形是非常有用的:\n\n- **RAG 评估**: 类似 RAGAS 这一类评估系统的工作需要获取到召回的原文才能工作\n- **Debugging**: 检查和验证用于生成答案到底使用了哪些原文\n- **Citation Display**: 向用户展现回答应用了哪些原文\n- **Transparency**: 为RAG检索提供一个可以观察的过程\n\n**重要**: `content` 字段是一个**字符串数组**，其中每个字符串代表来自同一文件的分块（chunk）。由于单个文件可能对应多个分块，因此内容以列表形式返回，以保留分块边界。\n\n**API请求示例:**\n\n```json\n{\n  \"query\": \"What is LightRAG?\",\n  \"mode\": \"mix\",\n  \"include_references\": true,\n  \"include_chunk_content\": true\n}\n```\n\n**响应示例(含文本块内容):**\n\n```json\n{\n  \"response\": \"LightRAG is a graph-based RAG system...\",\n  \"references\": [\n    {\n      \"reference_id\": \"1\",\n      \"file_path\": \"/documents/intro.md\",\n      \"content\": [\n        \"LightRAG is a retrieval-augmented generation system that combines knowledge graphs with vector similarity search...\",\n        \"The system uses a dual-indexing approach with both vector embeddings and graph structures for enhanced retrieval...\"\n      ]\n    },\n    {\n      \"reference_id\": \"2\",\n      \"file_path\": \"/documents/features.md\",\n      \"content\": [\n        \"The system provides multiple query modes including local, global, hybrid, and mix modes...\"\n      ]\n    }\n  ]\n}\n```\n\n**说明**:\n- 此参数仅用于配合 `include_references=true` 参数工作. 如果没有包含引用参数，`include_chunk_content=true` 设置是不会生效的.\n- **破坏性变化**: 之前版本返回的 `content` 是一个链接在一起的字符串。现在返回的是一个字符串数组，每个字符串代表一个分块的内容。这是为了保留分块边界，避免在合并时丢失信息。如果需要将所有分块合并为一个字符串，可使用 `\"\\n\\n\".join(content)` 等方法。\n\n### .env 文件示例\n\n```bash\n### Server Configuration\n# HOST=0.0.0.0\nPORT=9621\nWORKERS=2\n\n### Settings for document indexing\nENABLE_LLM_CACHE_FOR_EXTRACT=true\nSUMMARY_LANGUAGE=Chinese\nMAX_PARALLEL_INSERT=2\n\n### LLM Configuration (Use valid host. For local services installed with docker, you can use host.docker.internal)\nTIMEOUT=150\nMAX_ASYNC=4\n\nLLM_BINDING=openai\nLLM_MODEL=gpt-4o-mini\nLLM_BINDING_HOST=https://api.openai.com/v1\nLLM_BINDING_API_KEY=your-api-key\n\n### Embedding Configuration (Use valid host. For local services installed with docker, you can use host.docker.internal)\n# see also env.ollama-binding-options.example for fine tuning ollama\nEMBEDDING_MODEL=bge-m3:latest\nEMBEDDING_DIM=1024\nEMBEDDING_BINDING=ollama\nEMBEDDING_BINDING_HOST=http://localhost:11434\n\n### For JWT Auth\n# AUTH_ACCOUNTS='admin:admin123,user1:pass456'\n# TOKEN_SECRET=your-key-for-LightRAG-API-Server-xxx\n# TOKEN_EXPIRE_HOURS=48\n\n# LIGHTRAG_API_KEY=your-secure-api-key-here-123\n# WHITELIST_PATHS=/api/*\n# WHITELIST_PATHS=/health,/api/*\n```\n\n## 文档和块处理逻辑说明\n\nLightRAG 中的文档处理流程有些复杂，分为两个主要阶段：提取阶段（实体和关系提取）和合并阶段（实体和关系合并）。有两个关键参数控制流程并发性：并行处理的最大文件数（`MAX_PARALLEL_INSERT`）和最大并发 LLM 请求数（`MAX_ASYNC`）。工作流程描述如下：\n\n1. `MAX_ASYNC` 限制系统中并发 LLM 请求的总数，包括查询、提取和合并的请求。LLM 请求具有不同的优先级：查询操作优先级最高，其次是合并，然后是提取。\n2. `MAX_PARALLEL_INSERT` 控制提取阶段并行处理的文件数量。`MAX_PARALLEL_INSERT`建议设置为2～10之间，通常设置为 `MAX_ASYNC/3`，设置太大会导致合并阶段不同文档之间实体和关系重名的机会增大，降低合并阶段的效率。\n3. 在单个文件中，来自不同文本块的实体和关系提取是并发处理的，并发度由 `MAX_ASYNC` 设置。只有在处理完 `MAX_ASYNC` 个文本块后，系统才会继续处理同一文件中的下一批文本块。\n4. 当一个文件完成实体和关系提后，将进入实体和关系合并阶段。这一阶段也会并发处理多个实体和关系，其并发度同样是由 `MAX_ASYNC` 控制。\n5. 合并阶段的 LLM 请求的优先级别高于提取阶段，目的是让进入合并阶段的文件尽快完成处理，并让处理结果尽快更新到向量数据库中。\n6. 为防止竞争条件，合并阶段会避免并发处理同一个实体或关系，当多个文件中都涉及同一个实体或关系需要合并的时候他们会串行执行。\n7. 每个文件在流程中被视为一个原子处理单元。只有当其所有文本块都完成提取和合并后，文件才会被标记为成功处理。如果在处理过程中发生任何错误，整个文件将被标记为失败，并且必须重新处理。\n8. 当由于错误而重新处理文件时，由于 LLM 缓存，先前处理的文本块可以快速跳过。尽管 LLM 缓存在合并阶段也会被利用，但合并顺序的不一致可能会限制其在此阶段的有效性。\n9. 如果在提取过程中发生错误，系统不会保留任何中间结果。如果在合并过程中发生错误，已合并的实体和关系可能会被保留；当重新处理同一文件时，重新提取的实体和关系将与现有实体和关系合并，而不会影响查询结果。\n10. 在合并阶段结束时，所有实体和关系数据都会在向量数据库中更新。如果此时发生错误，某些更新可能会被保留。但是，下一次处理尝试将覆盖先前结果，确保成功重新处理的文件不会影响未来查询结果的完整性。\n\n大型文件应分割成较小的片段以启用增量处理。可以通过在 Web UI 上按“扫描”按钮来启动失败文件的重新处理。\n\n## API 端点\n\n所有服务器（LoLLMs、Ollama、OpenAI 和 Azure OpenAI）都为 RAG 功能提供相同的 REST API 端点。当 API 服务器运行时，访问：\n\n- Swagger UI：http://localhost:9621/docs\n- ReDoc：http://localhost:9621/redoc\n\n您可以使用提供的 curl 命令或通过 Swagger UI 界面测试 API 端点。确保：\n\n1. 启动适当的后端服务（LoLLMs、Ollama 或 OpenAI）\n2. 启动 RAG 服务器\n3. 使用文档管理端点上传一些文档\n4. 使用查询端点查询系统\n5. 如果在输入目录中放入新文件，触发文档扫描\n\n## 异步文档索引与进度跟踪\n\nLightRAG采用异步文档索引机制，便于前端监控和查询文档处理进度。用户通过指定端点上传文件或插入文本时，系统将返回唯一的跟踪ID，以便实时监控处理进度。\n\n**支持生成跟踪ID的API端点：**\n\n* `/documents/upload`\n* `/documents/text`\n* `/documents/texts`\n\n**文档处理状态查询端点：**\n* `/track_status/{track_id}`\n\n该端点提供全面的状态信息，包括：\n* 文档处理状态（待处理/处理中/已处理/失败）\n* 内容摘要和元数据\n* 处理失败时的错误信息\n* 创建和更新时间戳\n"
  },
  {
    "path": "lightrag/api/README.md",
    "content": "# LightRAG Server and WebUI\n\nThe LightRAG Server is designed to provide a Web UI and API support. The Web UI facilitates document indexing, knowledge graph exploration, and a simple RAG query interface. LightRAG Server also provides an Ollama-compatible interface, aiming to emulate LightRAG as an Ollama chat model. This allows AI chat bots, such as Open WebUI, to access LightRAG easily.\n\n![image-20250323122538997](./README.assets/image-20250323122538997.png)\n\n![image-20250323122754387](./README.assets/image-20250323122754387.png)\n\n![image-20250323123011220](./README.assets/image-20250323123011220.png)\n\n## Getting Started\n\n### Installation\n\n* Install from PyPI\n\n```bash\n### Install LightRAG Server as tool using uv (recommended)\nuv tool install \"lightrag-hku[api]\"\n\n### Or using pip\n# python -m venv .venv\n# source .venv/bin/activate  # Windows: .venv\\Scripts\\activate\n# pip install \"lightrag-hku[api]\"\n```\n\n* Installation from Source\n\n```bash\n# Clone the repository\ngit clone https://github.com/HKUDS/lightrag.git\n\n# Change to the repository directory\ncd lightrag\n\n# Using uv (recommended)\n# Note: uv sync automatically creates a virtual environment in .venv/\nuv sync --extra api\nsource .venv/bin/activate  # Activate the virtual environment (Linux/macOS)\n# Or on Windows: .venv\\Scripts\\activate\n\n# Or using pip with virtual environment\n# python -m venv .venv\n# source .venv/bin/activate  # Windows: .venv\\Scripts\\activate\n# pip install -e \".[api]\"\n\n# Build front-end artifacts\ncd lightrag_webui\nbun install --frozen-lockfile\nbun run build\ncd ..\n```\n\n### Before Starting LightRAG Server\n\nLightRAG necessitates the integration of both an LLM (Large Language Model) and an Embedding Model to effectively execute document indexing and querying operations. Prior to the initial deployment of the LightRAG server, it is essential to configure the settings for both the LLM and the Embedding Model. LightRAG supports binding to various LLM/Embedding backends:\n\n* ollama\n* lollms\n* openai or openai compatible\n* azure_openai\n* aws_bedrock\n* gemini\n\nIt is recommended to use environment variables to configure the LightRAG Server. There is an example environment variable file named `env.example` in the root directory of the project. Please copy this file to the startup directory and rename it to `.env`. After that, you can modify the parameters related to the LLM and Embedding models in the `.env` file. It is important to note that the LightRAG Server will load the environment variables from `.env` into the system environment variables each time it starts. **LightRAG Server will prioritize the settings in the system environment variables to .env file**.\n\n> Since VS Code with the Python extension may automatically load the .env file in the integrated terminal, please open a new terminal session after each modification to the .env file.\n\nHere are some examples of common settings for LLM and Embedding models:\n\n* OpenAI LLM + Ollama Embedding:\n\n```\nLLM_BINDING=openai\nLLM_MODEL=gpt-4o\nLLM_BINDING_HOST=https://api.openai.com/v1\nLLM_BINDING_API_KEY=your_api_key\n\nEMBEDDING_BINDING=ollama\nEMBEDDING_BINDING_HOST=http://localhost:11434\nEMBEDDING_MODEL=bge-m3:latest\nEMBEDDING_DIM=1024\n# EMBEDDING_BINDING_API_KEY=your_api_key\n```\n\n> When targeting Google Gemini, set `LLM_BINDING=gemini`, choose a model such as `LLM_MODEL=gemini-flash-latest`, and provide your Gemini key via `LLM_BINDING_API_KEY` (or `GEMINI_API_KEY`).\n\n* Ollama LLM + Ollama Embedding:\n\n```\nLLM_BINDING=ollama\nLLM_MODEL=mistral-nemo:latest\nLLM_BINDING_HOST=http://localhost:11434\n# LLM_BINDING_API_KEY=your_api_key\n###  Ollama Server context length (Must be larger than MAX_TOTAL_TOKENS+2000)\nOLLAMA_LLM_NUM_CTX=16384\n\nEMBEDDING_BINDING=ollama\nEMBEDDING_BINDING_HOST=http://localhost:11434\nEMBEDDING_MODEL=bge-m3:latest\nEMBEDDING_DIM=1024\n# EMBEDDING_BINDING_API_KEY=your_api_key\n```\n\n> **Important Note**: The Embedding model must be determined before document indexing, and the same model must be used during the document query phase. For certain storage solutions (e.g., PostgreSQL), the vector dimension must be defined upon initial table creation. Therefore, when changing embedding models, it is necessary to delete the existing vector-related tables and allow LightRAG to recreate them with the new dimensions.\n\n### Create .env File With Setup Tool\n\nInstead of editing `env.example` by hand, you can use the interactive setup wizard to generate a configured `.env` and, when needed, `docker-compose.final.yml`:\n\n```bash\nmake env-base           # Required first step: LLM, embedding, reranker\nmake env-storage        # Optional: storage backends and database services\nmake env-server         # Optional: server port, auth, and SSL\nmake env-security-check # Optional: audit the current .env for security risks\n```\n\nFor a full description of every target and what each flow does, see\n[docs/InteractiveSetup.md](../../docs/InteractiveSetup.md).\nThe setup wizards update configuration only; run `make env-security-check` separately to audit the\ncurrent `.env` for security risks before deployment.\n\n### Starting LightRAG Server\n\nThe LightRAG Server supports two operational modes:\n* The simple and efficient Uvicorn mode:\n\n```\nlightrag-server\n```\n* The multiprocess Gunicorn + Uvicorn mode (production mode, not supported on Windows environments):\n\n```\nlightrag-gunicorn --workers 4\n```\n\nWhen starting LightRAG, the current working directory must contain the `.env` configuration file. **It is intentionally designed that the `.env` file must be placed in the startup directory**. The purpose of this is to allow users to launch multiple LightRAG instances simultaneously and configure different `.env` files for different instances. **After modifying the `.env` file, you need to reopen the terminal for the new settings to take effect.** This is because each time LightRAG Server starts, it loads the environment variables from the `.env` file into the system environment variables, and system environment variables have higher precedence.\n\nDuring startup, configurations in the `.env` file can be overridden by command-line parameters. Common command-line parameters include:\n\n- `--host`: Server listening address (default: 0.0.0.0)\n- `--port`: Server listening port (default: 9621)\n- `--timeout`: LLM request timeout (default: 150 seconds)\n- `--log-level`: Log level (default: INFO)\n- `--working-dir`: Database persistence directory (default: ./rag_storage)\n- `--input-dir`: Directory for uploaded files (default: ./inputs)\n- `--workspace`: Workspace name, used to logically isolate data between multiple LightRAG instances (default: empty)\n\n### Launching LightRAG Server with Docker\n\nUsing Docker Compose is the most convenient way to deploy and run the LightRAG Server.\n\n- Create a project directory.\n- Copy the `docker-compose.yml` file from the LightRAG repository into your project directory.\n- Prepare the `.env` file: Duplicate the sample file [`env.example`](https://ai.znipower.com:5013/c/env.example)to create a customized `.env` file, and configure the LLM and embedding parameters according to your specific requirements.\n- Start the LightRAG Server with the following command:\n\n```shell\ndocker compose up\n# If you want the program to run in the background after startup, add the -d parameter at the end of the command.\n```\n\nYou can get the official docker compose file from here: [docker-compose.yml](https://raw.githubusercontent.com/HKUDS/LightRAG/refs/heads/main/docker-compose.yml). For historical versions of LightRAG docker images, visit this link: [LightRAG Docker Images](https://github.com/HKUDS/LightRAG/pkgs/container/lightrag). For more details about docker deployment, please refer to [DockerDeployment.md](./../../docs/DockerDeployment.md).\n\n### Nginx Reverse Proxy Configuration\n\nWhen using Nginx as a reverse proxy in front of LightRAG Server, you need to configure `client_max_body_size` for the `/documents/upload` endpoint to handle large file uploads. Without this configuration, Nginx will reject files larger than 1MB (the default limit) with a `413 Request Entity Too Large` error before the request reaches LightRAG.\n\n**Recommended Configuration:**\n\n```nginx\nserver {\n    listen 80;\n    server_name your-domain.com;\n\n    # Global default: 8MB for LLM queries with long context\n    client_max_body_size 8M;\n\n    # Upload endpoint: 100MB for large file uploads\n    location /documents/upload {\n        client_max_body_size 100M;\n\n        proxy_pass http://localhost:9621;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n\n        # Increase timeouts for large file uploads\n        proxy_read_timeout 300s;\n        proxy_send_timeout 300s;\n    }\n\n    # Streaming endpoints: LLM response streaming\n    location ~ ^/(query/stream|api/chat|api/generate) {\n        gzip off;  # Disable compression for streaming responses\n\n        proxy_pass http://localhost:9621;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n\n        # Long timeout for LLM generation\n        proxy_read_timeout 300s;\n    }\n\n    # Other endpoints\n    location / {\n        proxy_pass http://localhost:9621;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n```\n\n**Key Points:**\n\n1. **Global Limit (8MB)**: Sufficient for LLM queries with long conversation history and context (128K tokens ≈ 512KB + JSON overhead).\n2. **Upload Endpoint (100MB)**: Must match or exceed `MAX_UPLOAD_SIZE` in your `.env` file. The default `MAX_UPLOAD_SIZE` is 100MB.\n3. **Streaming Endpoints**: Disable gzip compression (`gzip off`) for streaming endpoints to ensure real-time response delivery. LightRAG automatically sets `X-Accel-Buffering: no` header to disable response buffering.\n4. **Timeout Settings**: Large file uploads and LLM generation require longer timeouts; adjust `proxy_read_timeout` and `proxy_send_timeout` accordingly.\n5. **Size Validation Layers**:\n   - Nginx validates the `Content-Length` header first\n   - LightRAG performs streaming validation during upload\n   - Setting appropriate limits at both layers ensures better error messages and security\n\n### Offline Deployment\n\nOfficial LightRAG Docker images are fully compatible with offline or air-gapped environments. If you want to build up you own  offline enviroment, please refer to [Offline Deployment Guide](./../../docs/OfflineDeployment.md).\n\n### Starting Multiple LightRAG Instances\n\nThere are two ways to start multiple LightRAG instances. The first way is to configure a completely independent working environment for each instance. This requires creating a separate working directory for each instance and placing a dedicated `.env` configuration file in that directory. The server listening ports in the configuration files of different instances cannot be the same. Then, you can start the service by running `lightrag-server` in the working directory.\n\nThe second way is for all instances to share the same set of `.env` configuration files, and then use command-line arguments to specify different server listening ports and workspaces for each instance. You can start multiple LightRAG instances in the same working directory with different command-line arguments. For example:\n\n```\n# Start instance 1\nlightrag-server --port 9621 --workspace space1\n\n# Start instance 2\nlightrag-server --port 9622 --workspace space2\n```\n\nThe purpose of a workspace is to achieve data isolation between different instances. Therefore, the `workspace` parameter must be different for different instances; otherwise, it will lead to data confusion and corruption.\n\nWhen launching multiple LightRAG instances via Docker Compose, simply specify unique `WORKSPACE` and `PORT` environment variables for each container within your `docker-compose.yml`. Even if all instances share a common `.env` file, the container-specific environment variables defined in Compose will take precedence, ensuring independent configurations for each instance.\n\n### Data Isolation Between LightRAG Instances\n\nConfiguring an independent working directory and a dedicated `.env` configuration file for each instance can generally ensure that locally persisted files in the in-memory database are saved in their respective working directories, achieving data isolation. By default, LightRAG uses all in-memory databases, and this method of data isolation is sufficient. However, if you are using an external database, and different instances access the same database instance, you need to use workspaces to achieve data isolation; otherwise, the data of different instances will conflict and be destroyed.\n\nThe command-line `workspace` argument and the `WORKSPACE` environment variable in the `.env` file can both be used to specify the workspace name for the current instance, with the command-line argument having higher priority. Here is how workspaces are implemented for different types of storage:\n\n- **For local file-based databases, data isolation is achieved through workspace subdirectories:** `JsonKVStorage`, `JsonDocStatusStorage`, `NetworkXStorage`, `NanoVectorDBStorage`, `FaissVectorDBStorage`.\n- **For databases that store data in collections, it's done by adding a workspace prefix to the collection name:** `RedisKVStorage`, `RedisDocStatusStorage`, `MilvusVectorDBStorage`, `MongoKVStorage`, `MongoDocStatusStorage`, `MongoVectorDBStorage`, `MongoGraphStorage`, `PGGraphStorage`.\n- **For Qdrant vector database, data isolation is achieved through payload-based partitioning (Qdrant's recommended multitenancy approach):** `QdrantVectorDBStorage` uses shared collections with payload filtering for unlimited workspace scalability.\n- **For relational databases, data isolation is achieved by adding a `workspace` field to the tables for logical data separation:** `PGKVStorage`, `PGVectorStorage`, `PGDocStatusStorage`.\n- **For graph databases, logical data isolation is achieved through labels:** `Neo4JStorage`, `MemgraphStorage`\n- **For OpenSearch, data isolation is achieved through index name prefixes:** `OpenSearchKVStorage`, `OpenSearchDocStatusStorage`, `OpenSearchGraphStorage`, `OpenSearchVectorDBStorage`\n\nTo maintain compatibility with legacy data, the default workspace for PostgreSQL is `default` and for Neo4j is `base` when no workspace is configured. For all external storages, the system provides dedicated workspace environment variables to override the common `WORKSPACE` environment variable configuration. These storage-specific workspace environment variables are: `REDIS_WORKSPACE`, `MILVUS_WORKSPACE`, `QDRANT_WORKSPACE`, `MONGODB_WORKSPACE`, `POSTGRES_WORKSPACE`, `NEO4J_WORKSPACE`, `MEMGRAPH_WORKSPACE`, `OPENSEARCH_WORKSPACE`.\n\n### Multiple workers for Gunicorn + Uvicorn\n\nThe LightRAG Server can operate in the `Gunicorn + Uvicorn` preload mode. Gunicorn's multiple worker (multiprocess) capability prevents document indexing tasks from blocking RAG queries. Using CPU-exhaustive document extraction tools, such as docling, can lead to the entire system being blocked in pure Uvicorn mode.\n\nThough LightRAG Server uses one worker to process the document indexing pipeline, with the async task support of Uvicorn, multiple files can be processed in parallel. The bottleneck of document indexing speed mainly lies with the LLM. If your LLM supports high concurrency, you can accelerate document indexing by increasing the concurrency level of the LLM. Below are several environment variables related to concurrent processing, along with their default values:\n\n```\n### Number of worker processes, not greater than (2 x number_of_cores) + 1\nWORKERS=2\n### Number of parallel files to process in one batch\nMAX_PARALLEL_INSERT=2\n### Max concurrent requests to the LLM\nMAX_ASYNC=4\n```\n\n### Install LightRAG as a Linux Service\n\nCreate your service file `lightrag.service` from the sample file: `lightrag.service.example`. Modify the start options the service file:\n\n```text\n# Set Enviroment to your Python virtual enviroment\nEnvironment=\"PATH=/home/netman/lightrag-xyj/venv/bin\"\nWorkingDirectory=/home/netman/lightrag-xyj\n# ExecStart=/home/netman/lightrag-xyj/venv/bin/lightrag-server\nExecStart=/home/netman/lightrag-xyj/venv/bin/lightrag-gunicorn\n```\n\n> The ExecStart command must be either `lightrag-gunicorn` or `lightrag-server`; no wrapper scripts are allowed. This is because service termination requires the main process to be one of these two executables.\n\nInstall LightRAG service. If your system is Ubuntu, the following commands will work:\n\n```shell\nsudo cp lightrag.service /etc/systemd/system/\nsudo systemctl daemon-reload\nsudo systemctl start lightrag.service\nsudo systemctl status lightrag.service\nsudo systemctl enable lightrag.service\n```\n\n## Ollama Emulation\n\nWe provide Ollama-compatible interfaces for LightRAG, aiming to emulate LightRAG as an Ollama chat model. This allows AI chat frontends supporting Ollama, such as Open WebUI, to access LightRAG easily.\n\n### Connect Open WebUI to LightRAG\n\nAfter starting the lightrag-server, you can add an Ollama-type connection in the Open WebUI admin panel. And then a model named `lightrag:latest` will appear in Open WebUI's model management interface. Users can then send queries to LightRAG through the chat interface. You should install LightRAG as a service for this use case.\n\nOpen WebUI uses an LLM to do the session title and session keyword generation task. So the Ollama chat completion API detects and forwards OpenWebUI session-related requests directly to the underlying LLM. Screenshot from Open WebUI:\n\n![image-20250323194750379](./README.assets/image-20250323194750379.png)\n\n### Choose Query mode in chat\n\nThe default query mode is `hybrid` if you send a message (query) from the Ollama interface of LightRAG. You can select query mode by sending a message with a query prefix.\n\nA query prefix in the query string can determine which LightRAG query mode is used to generate the response for the query. The supported prefixes include:\n\n```\n/local\n/global\n/hybrid\n/naive\n/mix\n\n/bypass\n/context\n/localcontext\n/globalcontext\n/hybridcontext\n/naivecontext\n/mixcontext\n```\n\nFor example, the chat message `/mix What's LightRAG?` will trigger a mix mode query for LightRAG. A chat message without a query prefix will trigger a hybrid mode query by default.\n\n`/bypass` is not a LightRAG query mode; it will tell the API Server to pass the query directly to the underlying LLM, including the chat history. So the user can use the LLM to answer questions based on the chat history. If you are using Open WebUI as a front end, you can just switch the model to a normal LLM instead of using the `/bypass` prefix.\n\n`/context` is also not a LightRAG query mode; it will tell LightRAG to return only the context information prepared for the LLM. You can check the context if it's what you want, or process the context by yourself.\n\n### Add user prompt in chat\n\nWhen using LightRAG for content queries, avoid combining the search process with unrelated output processing, as this significantly impacts query effectiveness. User prompt is specifically designed to address this issue — it does not participate in the RAG retrieval phase, but rather guides the LLM on how to process the retrieved results after the query is completed. We can append square brackets to the query prefix to provide the LLM with the user prompt:\n\n```\n/[Use mermaid format for diagrams] Please draw a character relationship diagram for Scrooge\n/mix[Use mermaid format for diagrams] Please draw a character relationship diagram for Scrooge\n```\n\n## API Key and Authentication\n\nBy default, the LightRAG Server can be accessed without any authentication. We can configure the server with an API Key or account credentials to secure it.\n\n* API Key:\n\n```\nLIGHTRAG_API_KEY=your-secure-api-key-here\nWHITELIST_PATHS=/health,/api/*\n```\n\n> Health check and Ollama emulation endpoints are excluded from API Key check by default. For security reasons, remove `/api/*` from `WHITELIST_PATHS` if the Ollama service is not required.\n\nThe API key is passed using the request header `X-API-Key`. Below is an example of accessing the LightRAG Server via API:\n\n```\ncurl -X 'POST' \\\n  'http://localhost:9621/documents/scan' \\\n  -H 'accept: application/json' \\\n  -H 'X-API-Key: your-secure-api-key-here-123' \\\n  -d ''\n```\n\n* Account credentials (the Web UI requires login before access can be granted):\n\nLightRAG API Server implements JWT-based authentication using the HS256 algorithm. To enable secure access control, the following environment variables are required:\n\n```bash\n# For jwt auth\nAUTH_ACCOUNTS='admin:admin123,user1:pass456'\nTOKEN_SECRET='your-key'\nTOKEN_EXPIRE_HOURS=4\n```\n\n> Currently, only the configuration of an administrator account and password is supported. A comprehensive account system is yet to be developed and implemented.\n\nIf Account credentials are not configured, the Web UI will access the system as a Guest. Therefore, even if only an API Key is configured, all APIs can still be accessed through the Guest account, which remains insecure. Hence, to safeguard the API, it is necessary to configure both authentication methods simultaneously.\n\n## For Azure OpenAI Backend\n\nAzure OpenAI API can be created using the following commands in Azure CLI (you need to install Azure CLI first from [https://docs.microsoft.com/en-us/cli/azure/install-azure-cli](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli)):\n\n```bash\n# Change the resource group name, location, and OpenAI resource name as needed\nRESOURCE_GROUP_NAME=LightRAG\nLOCATION=swedencentral\nRESOURCE_NAME=LightRAG-OpenAI\n\naz login\naz group create --name $RESOURCE_GROUP_NAME --location $LOCATION\naz cognitiveservices account create --name $RESOURCE_NAME --resource-group $RESOURCE_GROUP_NAME  --kind OpenAI --sku S0 --location swedencentral\naz cognitiveservices account deployment create --resource-group $RESOURCE_GROUP_NAME  --model-format OpenAI --name $RESOURCE_NAME --deployment-name gpt-4o --model-name gpt-4o --model-version \"2024-08-06\"  --sku-capacity 100 --sku-name \"Standard\"\naz cognitiveservices account deployment create --resource-group $RESOURCE_GROUP_NAME  --model-format OpenAI --name $RESOURCE_NAME --deployment-name text-embedding-3-large --model-name text-embedding-3-large --model-version \"1\"  --sku-capacity 80 --sku-name \"Standard\"\naz cognitiveservices account show --name $RESOURCE_NAME --resource-group $RESOURCE_GROUP_NAME --query \"properties.endpoint\"\naz cognitiveservices account keys list --name $RESOURCE_NAME -g $RESOURCE_GROUP_NAME\n\n```\n\nThe output of the last command will give you the endpoint and the key for the OpenAI API. You can use these values to set the environment variables in the `.env` file.\n\n```\n# Azure OpenAI Configuration in .env:\nLLM_BINDING=azure_openai\nLLM_BINDING_HOST=your-azure-endpoint\nLLM_MODEL=your-model-deployment-name\nLLM_BINDING_API_KEY=your-azure-api-key\n### API version is optional, defaults to latest version\nAZURE_OPENAI_API_VERSION=2024-08-01-preview\n\n### If using Azure OpenAI for embeddings\nEMBEDDING_BINDING=azure_openai\nEMBEDDING_MODEL=your-embedding-deployment-name\n```\n\n## LightRAG Server Configuration in Detail\n\nThe API Server can be configured in three ways (highest priority first):\n\n* Command line arguments\n* Environment variables or .env file\n* Config.ini (Only for storage configuration)\n\nMost of the configurations come with default settings; check out the details in the sample file: `.env.example`. Data storage configuration can also be set by config.ini. A sample file `config.ini.example` is provided for your convenience.\n\n### LLM and Embedding Backend Supported\n\nLightRAG supports binding to various LLM/Embedding backends:\n\n* ollama\n* openai (including openai compatible)\n* azure_openai\n* lollms\n* aws_bedrock\n\nUse environment variables `LLM_BINDING` or CLI argument `--llm-binding` to select the LLM backend type. Use environment variables `EMBEDDING_BINDING` or CLI argument `--embedding-binding` to select the Embedding backend type.\n\nFor LLM and embedding configuration examples, please refer to the `env.example` file in the project's root directory. To view the complete list of configurable options for OpenAI and Ollama-compatible LLM interfaces, use the following commands:\n```\nlightrag-server --llm-binding openai --help\nlightrag-server --llm-binding ollama --help\nlightrag-server --embedding-binding ollama --help\n```\n\n> Please use OpenAI-compatible method to access LLMs deployed by OpenRouter or vLLM/SGLang. You can pass additional parameters to OpenRouter or vLLM/SGLang through the `OPENAI_LLM_EXTRA_BODY` environment variable to disable reasoning mode or achieve other personalized controls.\n\nSet the max_tokens to **prevent excessively long or endless output loop** during the entity relationship extraction phase for Large Language Model (LLM) responses.  The purpose of setting max_tokens parameter is to truncate LLM output before timeouts occur, thereby preventing document extraction failures. This addresses issues where certain text blocks (e.g., tables or citations) containing numerous entities and relationships can lead to overly long or even endless loop outputs from LLMs. This setting is particularly crucial for locally deployed, smaller-parameter models. Max tokens value can be calculated by this formula: `LLM_TIMEOUT * llm_output_tokens/second` (i.e. `180s * 50 tokens/s = 9000`)\n\n```\n# For vLLM/SGLang doployed models, or most of OpenAI compatible API provider\nOPENAI_LLM_MAX_TOKENS=9000\n\n# For Ollama Deployed Modeles\nOLLAMA_LLM_NUM_PREDICT=9000\n\n# For OpenAI o1-mini or newer modles\nOPENAI_LLM_MAX_COMPLETION_TOKENS=9000\n```\n\n### Entity Extraction Configuration\n\n* ENABLE_LLM_CACHE_FOR_EXTRACT: Enable LLM cache for entity extraction (default: true)\n\nIt's very common to set `ENABLE_LLM_CACHE_FOR_EXTRACT` to true for a test environment to reduce the cost of LLM calls.\n\n### Storage Types Supported\n\nLightRAG uses 4 types of storage for different purposes:\n\n* KV_STORAGE: llm response cache, text chunks, document information\n* VECTOR_STORAGE: entities vectors, relation vectors, chunks vectors\n* GRAPH_STORAGE: entity relation graph\n* DOC_STATUS_STORAGE: document indexing status\n\nLightRAG Server offers various storage implementations, with the default being an in-memory database that persists data to the WORKING_DIR directory. Additionally, LightRAG supports a wide range of storage solutions including PostgreSQL, MongoDB, FAISS, Milvus, Qdrant, Neo4j, Memgraph, and Redis. For detailed information on supported storage options, please refer to the storage section in the README.md file located in the root directory.\n\n**Milvus Index Configuration:** LightRAG now supports configurable index types for Milvus vector storage (AUTOINDEX, HNSW, HNSW_SQ, IVF_FLAT, etc.) through environment variables. HNSW_SQ requires Milvus 2.6.8+ and provides significant memory savings. See the \"Using Milvus for Vector Storage\" section in the main README.md for complete configuration options.\n\nYou can select the storage implementation by configuring environment variables. For instance, prior to the initial launch of the API server, you can set the following environment variable to specify your desired storage implementation:\n\n```\nLIGHTRAG_KV_STORAGE=PGKVStorage\nLIGHTRAG_VECTOR_STORAGE=PGVectorStorage\nLIGHTRAG_GRAPH_STORAGE=PGGraphStorage\nLIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage\n```\n\nYou cannot change storage implementation selection after adding documents to LightRAG. Data migration from one storage implementation to another is not supported yet. For further information, please read the sample env file or config.ini file.\n\n### LLM Cache Migration Between Storage Types\n\nWhen switching the storage implementation in LightRAG, the LLM cache can be migrated from the existing storage to the new one. Subsequently, when re-uploading files to the new storage, the pre-existing LLM cache will significantly accelerate file processing. For detailed instructions on using the LLM cache migration tool, please refer to[README_MIGRATE_LLM_CACHE.md](../tools/README_MIGRATE_LLM_CACHE.md)\n\n### LightRAG API Server Command Line Options\n\n| Parameter             | Default       | Description                                                                                                                     |\n| --------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------- |\n| --host                | 0.0.0.0       | Server host                                                                                                                     |\n| --port                | 9621          | Server port                                                                                                                     |\n| --working-dir         | ./rag_storage | Working directory for RAG storage                                                                                               |\n| --input-dir           | ./inputs      | Directory containing input documents                                                                                            |\n| --max-async           | 4             | Maximum number of async operations                                                                                              |\n| --log-level           | INFO          | Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)                                                                           |\n| --verbose             | -             | Verbose debug output (True, False)                                                                                              |\n| --key                 | None          | API key for authentication. Protects the LightRAG server against unauthorized access                                            |\n| --ssl                 | False         | Enable HTTPS                                                                                                                    |\n| --ssl-certfile        | None          | Path to SSL certificate file (required if --ssl is enabled)                                                                     |\n| --ssl-keyfile         | None          | Path to SSL private key file (required if --ssl is enabled)                                                                     |\n| --llm-binding         | ollama        | LLM binding type (lollms, ollama, openai, openai-ollama, azure_openai, aws_bedrock)                                                          |\n| --embedding-binding   | ollama        | Embedding binding type (lollms, ollama, openai, azure_openai, aws_bedrock)                                                                   |\n\n### Reranking Configuration\n\nReranking query-recalled chunks can significantly enhance retrieval quality by re-ordering documents based on an optimized relevance scoring model. LightRAG currently supports the following rerank providers:\n\n- **Cohere / vLLM**: Offers full API integration with Cohere AI's `v2/rerank` endpoint. As vLLM provides a Cohere-compatible reranker API, all reranker models deployed via vLLM are also supported.\n- **Jina AI**: Provides complete implementation compatibility with all Jina rerank models.\n- **Aliyun**: Features a custom implementation designed to support Aliyun's rerank API format.\n\nThe rerank provider is configured via the `.env` file. Below is an example configuration for a rerank model deployed locally using vLLM:\n\n```\nRERANK_BINDING=cohere\nRERANK_MODEL=BAAI/bge-reranker-v2-m3\nRERANK_BINDING_HOST=http://localhost:8000/rerank\nRERANK_BINDING_API_KEY=your_rerank_api_key_here\n```\n\nHere is an example configuration for utilizing the Reranker service provided by Aliyun:\n\n```\nRERANK_BINDING=aliyun\nRERANK_MODEL=gte-rerank-v2\nRERANK_BINDING_HOST=https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank\nRERANK_BINDING_API_KEY=your_rerank_api_key_here\n```\n\nFor comprehensive reranker configuration examples, please refer to the `env.example` file.\n\n### Enable Reranking\n\nReranking can be enabled or disabled on a per-query basis.\n\nThe `/query` and `/query/stream` API endpoints include an `enable_rerank` parameter, which is set to `true` by default, controlling whether reranking is active for the current query. To change the default value of the `enable_rerank` parameter to `false`, set the following environment variable:\n\n```\nRERANK_BY_DEFAULT=False\n```\n\n### Include Chunk Content in References\n\nBy default, the `/query` and `/query/stream` endpoints return references with only `reference_id` and `file_path`. For evaluation, debugging, or citation purposes, you can request the actual retrieved chunk content to be included in references.\n\nThe `include_chunk_content` parameter (default: `false`) controls whether the actual text content of retrieved chunks is included in the response references. This is particularly useful for:\n\n- **RAG Evaluation**: Testing systems like RAGAS that need access to retrieved contexts\n- **Debugging**: Verifying what content was actually used to generate the answer\n- **Citation Display**: Showing users the exact text passages that support the response\n- **Transparency**: Providing full visibility into the RAG retrieval process\n\n**Important**: The `content` field is an **array of strings**, where each string represents a chunk from the same file. A single file may correspond to multiple chunks, so the content is returned as a list to preserve chunk boundaries.\n\n**Example API Request:**\n\n```json\n{\n  \"query\": \"What is LightRAG?\",\n  \"mode\": \"mix\",\n  \"include_references\": true,\n  \"include_chunk_content\": true\n}\n```\n\n**Example Response (with chunk content):**\n\n```json\n{\n  \"response\": \"LightRAG is a graph-based RAG system...\",\n  \"references\": [\n    {\n      \"reference_id\": \"1\",\n      \"file_path\": \"/documents/intro.md\",\n      \"content\": [\n        \"LightRAG is a retrieval-augmented generation system that combines knowledge graphs with vector similarity search...\",\n        \"The system uses a dual-indexing approach with both vector embeddings and graph structures for enhanced retrieval...\"\n      ]\n    },\n    {\n      \"reference_id\": \"2\",\n      \"file_path\": \"/documents/features.md\",\n      \"content\": [\n        \"The system provides multiple query modes including local, global, hybrid, and mix modes...\"\n      ]\n    }\n  ]\n}\n```\n\n**Notes**:\n- This parameter only works when `include_references=true`. Setting `include_chunk_content=true` without including references has no effect.\n- **Breaking Change**: Prior versions returned `content` as a single concatenated string. Now it returns an array of strings to preserve individual chunk boundaries. If you need a single string, join the array elements with your preferred separator (e.g., `\"\\n\\n\".join(content)`).\n\n### .env Examples\n\n```bash\n### Server Configuration\n# HOST=0.0.0.0\nPORT=9621\nWORKERS=2\n\n### Settings for document indexing\nENABLE_LLM_CACHE_FOR_EXTRACT=true\nSUMMARY_LANGUAGE=Chinese\nMAX_PARALLEL_INSERT=2\n\n### LLM Configuration (Use valid host. For local services installed with docker, you can use host.docker.internal)\nTIMEOUT=150\nMAX_ASYNC=4\n\nLLM_BINDING=openai\nLLM_MODEL=gpt-4o-mini\nLLM_BINDING_HOST=https://api.openai.com/v1\nLLM_BINDING_API_KEY=your-api-key\n\n### Embedding Configuration (Use valid host. For local services installed with docker, you can use host.docker.internal)\n# see also env.ollama-binding-options.example for fine tuning ollama\nEMBEDDING_MODEL=bge-m3:latest\nEMBEDDING_DIM=1024\nEMBEDDING_BINDING=ollama\nEMBEDDING_BINDING_HOST=http://localhost:11434\n\n### For JWT Auth\n# AUTH_ACCOUNTS='admin:admin123,user1:pass456'\n# TOKEN_SECRET=your-key-for-LightRAG-API-Server-xxx\n# TOKEN_EXPIRE_HOURS=48\n\n# LIGHTRAG_API_KEY=your-secure-api-key-here-123\n# WHITELIST_PATHS=/api/*\n# WHITELIST_PATHS=/health,/api/*\n```\n\n## Document and Chunk Processing\n\nThe document processing pipeline in LightRAG is somewhat complex and is divided into two primary stages: the Extraction stage (entity and relationship extraction) and the Merging stage (entity and relationship merging). There are two key parameters that control pipeline concurrency: the maximum number of files processed in parallel (MAX_PARALLEL_INSERT) and the maximum number of concurrent LLM requests (MAX_ASYNC). The workflow is described as follows:\n\n1. MAX_ASYNC limits the total number of concurrent LLM requests in the system, including those for querying, extraction, and merging. LLM requests have different priorities: query operations have the highest priority, followed by merging, and then extraction.\n2. MAX_PARALLEL_INSERT controls the number of files processed in parallel during the extraction stage. For optimal performance, MAX_PARALLEL_INSERT is recommended to be set between 2 and 10, typically MAX_ASYNC/3. Setting this value too high can increase the likelihood of naming conflicts among entities and relationships across different documents during the merge phase, thereby reducing its overall efficiency.\n3. Within a single file, entity and relationship extractions from different text blocks are processed concurrently, with the degree of concurrency set by MAX_ASYNC. Only after MAX_ASYNC text blocks are processed will the system proceed to the next batch within the same file.\n4. When a file completes entity and relationship extraction, it enters the entity and relationship merging stage. This stage also processes multiple entities and relationships concurrently, with the concurrency level also controlled by `MAX_ASYNC`.\n5. LLM requests for the merging stage are prioritized over the extraction stage to ensure that files in the merging phase are processed quickly and their results are promptly updated in the vector database.\n6. To prevent race conditions, the merging stage avoids concurrent processing of the same entity or relationship. When multiple files involve the same entity or relationship that needs to be merged, they are processed serially.\n7. Each file is treated as an atomic processing unit in the pipeline. A file is marked as successfully processed only after all its text blocks have completed extraction and merging. If any error occurs during processing, the entire file is marked as failed and must be reprocessed.\n8. When a file is reprocessed due to errors, previously processed text blocks can be quickly skipped thanks to LLM caching. Although LLM cache is also utilized during the merging stage, inconsistencies in merging order may limit its effectiveness in this stage.\n9. If an error occurs during extraction, the system does not retain any intermediate results. If an error occurs during merging, already merged entities and relationships might be preserved; when the same file is reprocessed, re-extracted entities and relationships will be merged with the existing ones, without impacting the query results.\n10. At the end of the merging stage, all entity and relationship data are updated in the vector database. Should an error occur at this point, some updates may be retained. However, the next processing attempt will overwrite previous results, ensuring that successfully reprocessed files do not affect the integrity of future query results.\n\nLarge files should be divided into smaller segments to enable incremental processing. Reprocessing of failed files can be initiated by pressing the \"Scan\" button on the web UI.\n\n## API Endpoints\n\nAll servers (LoLLMs, Ollama, OpenAI and Azure OpenAI) provide the same REST API endpoints for RAG functionality. When the API Server is running, visit:\n\n- Swagger UI: http://localhost:9621/docs\n- ReDoc: http://localhost:9621/redoc\n\nYou can test the API endpoints using the provided curl commands or through the Swagger UI interface. Make sure to:\n\n1. Start the appropriate backend service (LoLLMs, Ollama, or OpenAI)\n2. Start the RAG server\n3. Upload some documents using the document management endpoints\n4. Query the system using the query endpoints\n5. Trigger document scan if new files are put into the inputs directory\n\n## Asynchronous Document Indexing with Progress Tracking\n\nLightRAG implements asynchronous document indexing to enable frontend monitoring and querying of document processing progress. Upon uploading files or inserting text through designated endpoints, a unique Track ID is returned to facilitate real-time progress monitoring.\n\n**API Endpoints Supporting Track ID Generation:**\n\n* `/documents/upload`\n* `/documents/text`\n* `/documents/texts`\n\n**Document Processing Status Query Endpoint:**\n* `/track_status/{track_id}`\n\nThis endpoint provides comprehensive status information including:\n* Document processing status (pending/processing/processed/failed)\n* Content summary and metadata\n* Error messages if processing failed\n* Timestamps for creation and updates\n"
  },
  {
    "path": "lightrag/api/__init__.py",
    "content": "__api_version__ = \"0276\"\n"
  },
  {
    "path": "lightrag/api/auth.py",
    "content": "from datetime import datetime, timedelta\n\nimport jwt\nfrom dotenv import load_dotenv\nfrom fastapi import HTTPException, status\nfrom pydantic import BaseModel\n\nfrom .config import global_args\n\n# use the .env that is inside the current folder\n# allows to use different .env file for each lightrag instance\n# the OS environment variables take precedence over the .env file\nload_dotenv(dotenv_path=\".env\", override=False)\n\n\nclass TokenPayload(BaseModel):\n    sub: str  # Username\n    exp: datetime  # Expiration time\n    role: str = \"user\"  # User role, default is regular user\n    metadata: dict = {}  # Additional metadata\n\n\nclass AuthHandler:\n    def __init__(self):\n        self.secret = global_args.token_secret\n        self.algorithm = global_args.jwt_algorithm\n        self.expire_hours = global_args.token_expire_hours\n        self.guest_expire_hours = global_args.guest_token_expire_hours\n        self.accounts = {}\n        auth_accounts = global_args.auth_accounts\n        if auth_accounts:\n            for account in auth_accounts.split(\",\"):\n                username, password = account.split(\":\", 1)\n                self.accounts[username] = password\n\n    def create_token(\n        self,\n        username: str,\n        role: str = \"user\",\n        custom_expire_hours: int = None,\n        metadata: dict = None,\n    ) -> str:\n        \"\"\"\n        Create JWT token\n\n        Args:\n            username: Username\n            role: User role, default is \"user\", guest is \"guest\"\n            custom_expire_hours: Custom expiration time (hours), if None use default value\n            metadata: Additional metadata\n\n        Returns:\n            str: Encoded JWT token\n        \"\"\"\n        # Choose default expiration time based on role\n        if custom_expire_hours is None:\n            if role == \"guest\":\n                expire_hours = self.guest_expire_hours\n            else:\n                expire_hours = self.expire_hours\n        else:\n            expire_hours = custom_expire_hours\n\n        expire = datetime.utcnow() + timedelta(hours=expire_hours)\n\n        # Create payload\n        payload = TokenPayload(\n            sub=username, exp=expire, role=role, metadata=metadata or {}\n        )\n\n        return jwt.encode(payload.dict(), self.secret, algorithm=self.algorithm)\n\n    def validate_token(self, token: str) -> dict:\n        \"\"\"\n        Validate JWT token\n\n        Args:\n            token: JWT token\n\n        Returns:\n            dict: Dictionary containing user information\n\n        Raises:\n            HTTPException: If token is invalid or expired\n        \"\"\"\n        try:\n            payload = jwt.decode(token, self.secret, algorithms=[self.algorithm])\n            expire_timestamp = payload[\"exp\"]\n            expire_time = datetime.utcfromtimestamp(expire_timestamp)\n\n            if datetime.utcnow() > expire_time:\n                raise HTTPException(\n                    status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Token expired\"\n                )\n\n            # Return complete payload instead of just username\n            return {\n                \"username\": payload[\"sub\"],\n                \"role\": payload.get(\"role\", \"user\"),\n                \"metadata\": payload.get(\"metadata\", {}),\n                \"exp\": expire_time,\n            }\n        except jwt.PyJWTError:\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Invalid token\"\n            )\n\n\nauth_handler = AuthHandler()\n"
  },
  {
    "path": "lightrag/api/config.py",
    "content": "\"\"\"\nConfigs for the LightRAG API.\n\"\"\"\n\nimport os\nimport re\nimport argparse\nimport logging\nfrom dotenv import load_dotenv\nfrom lightrag.utils import get_env_value\nfrom lightrag.llm.binding_options import (\n    GeminiEmbeddingOptions,\n    GeminiLLMOptions,\n    OllamaEmbeddingOptions,\n    OllamaLLMOptions,\n    OpenAILLMOptions,\n)\nfrom lightrag.base import OllamaServerInfos\nimport sys\n\nfrom lightrag.constants import (\n    DEFAULT_WOKERS,\n    DEFAULT_TIMEOUT,\n    DEFAULT_TOP_K,\n    DEFAULT_CHUNK_TOP_K,\n    DEFAULT_HISTORY_TURNS,\n    DEFAULT_MAX_ENTITY_TOKENS,\n    DEFAULT_MAX_RELATION_TOKENS,\n    DEFAULT_MAX_TOTAL_TOKENS,\n    DEFAULT_COSINE_THRESHOLD,\n    DEFAULT_RELATED_CHUNK_NUMBER,\n    DEFAULT_MIN_RERANK_SCORE,\n    DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,\n    DEFAULT_MAX_ASYNC,\n    DEFAULT_SUMMARY_MAX_TOKENS,\n    DEFAULT_SUMMARY_LENGTH_RECOMMENDED,\n    DEFAULT_SUMMARY_CONTEXT_SIZE,\n    DEFAULT_SUMMARY_LANGUAGE,\n    DEFAULT_EMBEDDING_FUNC_MAX_ASYNC,\n    DEFAULT_EMBEDDING_BATCH_NUM,\n    DEFAULT_OLLAMA_MODEL_NAME,\n    DEFAULT_OLLAMA_MODEL_TAG,\n    DEFAULT_RERANK_BINDING,\n    DEFAULT_ENTITY_TYPES,\n)\n\n# use the .env that is inside the current folder\n# allows to use different .env file for each lightrag instance\n# the OS environment variables take precedence over the .env file\nload_dotenv(dotenv_path=\".env\", override=False)\n\n\nollama_server_infos = OllamaServerInfos()\n\n\nclass DefaultRAGStorageConfig:\n    KV_STORAGE = \"JsonKVStorage\"\n    VECTOR_STORAGE = \"NanoVectorDBStorage\"\n    GRAPH_STORAGE = \"NetworkXStorage\"\n    DOC_STATUS_STORAGE = \"JsonDocStatusStorage\"\n\n\ndef get_default_host(binding_type: str) -> str:\n    default_hosts = {\n        \"ollama\": os.getenv(\"LLM_BINDING_HOST\", \"http://localhost:11434\"),\n        \"lollms\": os.getenv(\"LLM_BINDING_HOST\", \"http://localhost:9600\"),\n        \"azure_openai\": os.getenv(\"AZURE_OPENAI_ENDPOINT\", \"https://api.openai.com/v1\"),\n        \"openai\": os.getenv(\"LLM_BINDING_HOST\", \"https://api.openai.com/v1\"),\n        \"gemini\": os.getenv(\n            \"LLM_BINDING_HOST\", \"https://generativelanguage.googleapis.com\"\n        ),\n    }\n    return default_hosts.get(\n        binding_type, os.getenv(\"LLM_BINDING_HOST\", \"http://localhost:11434\")\n    )  # fallback to ollama if unknown\n\n\ndef parse_args() -> argparse.Namespace:\n    \"\"\"\n    Parse command line arguments with environment variable fallback\n\n    Args:\n        is_uvicorn_mode: Whether running under uvicorn mode\n\n    Returns:\n        argparse.Namespace: Parsed arguments\n    \"\"\"\n\n    parser = argparse.ArgumentParser(description=\"LightRAG API Server\")\n\n    # Server configuration\n    parser.add_argument(\n        \"--host\",\n        default=get_env_value(\"HOST\", \"0.0.0.0\"),\n        help=\"Server host (default: from env or 0.0.0.0)\",\n    )\n    parser.add_argument(\n        \"--port\",\n        type=int,\n        default=get_env_value(\"PORT\", 9621, int),\n        help=\"Server port (default: from env or 9621)\",\n    )\n\n    # Directory configuration\n    parser.add_argument(\n        \"--working-dir\",\n        default=get_env_value(\"WORKING_DIR\", \"./rag_storage\"),\n        help=\"Working directory for RAG storage (default: from env or ./rag_storage)\",\n    )\n    parser.add_argument(\n        \"--input-dir\",\n        default=get_env_value(\"INPUT_DIR\", \"./inputs\"),\n        help=\"Directory containing input documents (default: from env or ./inputs)\",\n    )\n\n    parser.add_argument(\n        \"--timeout\",\n        default=get_env_value(\"TIMEOUT\", DEFAULT_TIMEOUT, int, special_none=True),\n        type=int,\n        help=\"Timeout in seconds (useful when using slow AI). Use None for infinite timeout\",\n    )\n\n    # RAG configuration\n    parser.add_argument(\n        \"--max-async\",\n        type=int,\n        default=get_env_value(\"MAX_ASYNC\", DEFAULT_MAX_ASYNC, int),\n        help=f\"Maximum async operations (default: from env or {DEFAULT_MAX_ASYNC})\",\n    )\n    parser.add_argument(\n        \"--summary-max-tokens\",\n        type=int,\n        default=get_env_value(\"SUMMARY_MAX_TOKENS\", DEFAULT_SUMMARY_MAX_TOKENS, int),\n        help=f\"Maximum token size for entity/relation summary(default: from env or {DEFAULT_SUMMARY_MAX_TOKENS})\",\n    )\n    parser.add_argument(\n        \"--summary-context-size\",\n        type=int,\n        default=get_env_value(\n            \"SUMMARY_CONTEXT_SIZE\", DEFAULT_SUMMARY_CONTEXT_SIZE, int\n        ),\n        help=f\"LLM Summary Context size (default: from env or {DEFAULT_SUMMARY_CONTEXT_SIZE})\",\n    )\n    parser.add_argument(\n        \"--summary-length-recommended\",\n        type=int,\n        default=get_env_value(\n            \"SUMMARY_LENGTH_RECOMMENDED\", DEFAULT_SUMMARY_LENGTH_RECOMMENDED, int\n        ),\n        help=f\"LLM Summary Context size (default: from env or {DEFAULT_SUMMARY_LENGTH_RECOMMENDED})\",\n    )\n\n    # Logging configuration\n    parser.add_argument(\n        \"--log-level\",\n        default=get_env_value(\"LOG_LEVEL\", \"INFO\"),\n        choices=[\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"],\n        help=\"Logging level (default: from env or INFO)\",\n    )\n    parser.add_argument(\n        \"--verbose\",\n        action=\"store_true\",\n        default=get_env_value(\"VERBOSE\", False, bool),\n        help=\"Enable verbose debug output(only valid for DEBUG log-level)\",\n    )\n\n    parser.add_argument(\n        \"--key\",\n        type=str,\n        default=get_env_value(\"LIGHTRAG_API_KEY\", None),\n        help=\"API key for authentication. This protects lightrag server against unauthorized access\",\n    )\n\n    # Optional https parameters\n    parser.add_argument(\n        \"--ssl\",\n        action=\"store_true\",\n        default=get_env_value(\"SSL\", False, bool),\n        help=\"Enable HTTPS (default: from env or False)\",\n    )\n    parser.add_argument(\n        \"--ssl-certfile\",\n        default=get_env_value(\"SSL_CERTFILE\", None),\n        help=\"Path to SSL certificate file (required if --ssl is enabled)\",\n    )\n    parser.add_argument(\n        \"--ssl-keyfile\",\n        default=get_env_value(\"SSL_KEYFILE\", None),\n        help=\"Path to SSL private key file (required if --ssl is enabled)\",\n    )\n\n    # Ollama model configuration\n    parser.add_argument(\n        \"--simulated-model-name\",\n        type=str,\n        default=get_env_value(\"OLLAMA_EMULATING_MODEL_NAME\", DEFAULT_OLLAMA_MODEL_NAME),\n        help=\"Name for the simulated Ollama model (default: from env or lightrag)\",\n    )\n\n    parser.add_argument(\n        \"--simulated-model-tag\",\n        type=str,\n        default=get_env_value(\"OLLAMA_EMULATING_MODEL_TAG\", DEFAULT_OLLAMA_MODEL_TAG),\n        help=\"Tag for the simulated Ollama model (default: from env or latest)\",\n    )\n\n    # Namespace\n    parser.add_argument(\n        \"--workspace\",\n        type=str,\n        default=get_env_value(\"WORKSPACE\", \"\"),\n        help=\"Default workspace for all storage\",\n    )\n\n    # Server workers configuration\n    parser.add_argument(\n        \"--workers\",\n        type=int,\n        default=get_env_value(\"WORKERS\", DEFAULT_WOKERS, int),\n        help=\"Number of worker processes (default: from env or 1)\",\n    )\n\n    # LLM and embedding bindings\n    parser.add_argument(\n        \"--llm-binding\",\n        type=str,\n        default=get_env_value(\"LLM_BINDING\", \"ollama\"),\n        choices=[\n            \"lollms\",\n            \"ollama\",\n            \"openai\",\n            \"openai-ollama\",\n            \"azure_openai\",\n            \"aws_bedrock\",\n            \"gemini\",\n        ],\n        help=\"LLM binding type (default: from env or ollama)\",\n    )\n    parser.add_argument(\n        \"--embedding-binding\",\n        type=str,\n        default=get_env_value(\"EMBEDDING_BINDING\", \"ollama\"),\n        choices=[\n            \"lollms\",\n            \"ollama\",\n            \"openai\",\n            \"azure_openai\",\n            \"aws_bedrock\",\n            \"jina\",\n            \"gemini\",\n        ],\n        help=\"Embedding binding type (default: from env or ollama)\",\n    )\n    parser.add_argument(\n        \"--rerank-binding\",\n        type=str,\n        default=get_env_value(\"RERANK_BINDING\", DEFAULT_RERANK_BINDING),\n        choices=[\"null\", \"cohere\", \"jina\", \"aliyun\"],\n        help=f\"Rerank binding type (default: from env or {DEFAULT_RERANK_BINDING})\",\n    )\n\n    # Document loading engine configuration\n    parser.add_argument(\n        \"--docling\",\n        action=\"store_true\",\n        default=False,\n        help=\"Enable DOCLING document loading engine (default: from env or DEFAULT)\",\n    )\n\n    # Conditionally add binding-specific options (Ollama, OpenAI, Azure OpenAI, Gemini)\n    # This registers command line arguments (e.g., --openai-llm-temperature)\n    # and reads corresponding environment variables (e.g., OPENAI_LLM_TEMPERATURE)\n\n    # Determine LLM binding value consistently from command line or environment\n    llm_binding_value = None\n    if \"--llm-binding\" in sys.argv:\n        try:\n            idx = sys.argv.index(\"--llm-binding\")\n            if idx + 1 < len(sys.argv) and not sys.argv[idx + 1].startswith(\"-\"):\n                llm_binding_value = sys.argv[idx + 1]\n        except IndexError:\n            pass\n\n    # Fall back to environment variable using same function as argparse default\n    if llm_binding_value is None:\n        llm_binding_value = get_env_value(\"LLM_BINDING\", \"ollama\")\n\n    # Add LLM binding options based on determined value\n    if llm_binding_value == \"ollama\":\n        OllamaLLMOptions.add_args(parser)\n    elif llm_binding_value in [\"openai\", \"azure_openai\"]:\n        OpenAILLMOptions.add_args(parser)\n    elif llm_binding_value == \"gemini\":\n        GeminiLLMOptions.add_args(parser)\n\n    # Determine embedding binding value consistently from command line or environment\n    embedding_binding_value = None\n    if \"--embedding-binding\" in sys.argv:\n        try:\n            idx = sys.argv.index(\"--embedding-binding\")\n            if idx + 1 < len(sys.argv) and not sys.argv[idx + 1].startswith(\"-\"):\n                embedding_binding_value = sys.argv[idx + 1]\n        except IndexError:\n            pass\n\n    # Fall back to environment variable using same function as argparse default\n    if embedding_binding_value is None:\n        embedding_binding_value = get_env_value(\"EMBEDDING_BINDING\", \"ollama\")\n\n    # Add embedding binding options based on determined value\n    if embedding_binding_value == \"ollama\":\n        OllamaEmbeddingOptions.add_args(parser)\n    elif embedding_binding_value == \"gemini\":\n        GeminiEmbeddingOptions.add_args(parser)\n\n    args = parser.parse_args()\n\n    # convert relative path to absolute path\n    args.working_dir = os.path.abspath(args.working_dir)\n    args.input_dir = os.path.abspath(args.input_dir)\n\n    # Inject storage configuration from environment variables\n    args.kv_storage = get_env_value(\n        \"LIGHTRAG_KV_STORAGE\", DefaultRAGStorageConfig.KV_STORAGE\n    )\n    args.doc_status_storage = get_env_value(\n        \"LIGHTRAG_DOC_STATUS_STORAGE\", DefaultRAGStorageConfig.DOC_STATUS_STORAGE\n    )\n    args.graph_storage = get_env_value(\n        \"LIGHTRAG_GRAPH_STORAGE\", DefaultRAGStorageConfig.GRAPH_STORAGE\n    )\n    args.vector_storage = get_env_value(\n        \"LIGHTRAG_VECTOR_STORAGE\", DefaultRAGStorageConfig.VECTOR_STORAGE\n    )\n\n    # Get MAX_PARALLEL_INSERT from environment\n    args.max_parallel_insert = get_env_value(\"MAX_PARALLEL_INSERT\", 2, int)\n\n    # Get MAX_GRAPH_NODES from environment\n    args.max_graph_nodes = get_env_value(\"MAX_GRAPH_NODES\", 1000, int)\n\n    # Handle openai-ollama special case\n    if args.llm_binding == \"openai-ollama\":\n        args.llm_binding = \"openai\"\n        args.embedding_binding = \"ollama\"\n\n    # Ollama ctx_num\n    args.ollama_num_ctx = get_env_value(\"OLLAMA_NUM_CTX\", 32768, int)\n\n    args.llm_binding_host = get_env_value(\n        \"LLM_BINDING_HOST\", get_default_host(args.llm_binding)\n    )\n    args.embedding_binding_host = get_env_value(\n        \"EMBEDDING_BINDING_HOST\", get_default_host(args.embedding_binding)\n    )\n    args.llm_binding_api_key = get_env_value(\"LLM_BINDING_API_KEY\", None)\n    args.embedding_binding_api_key = get_env_value(\"EMBEDDING_BINDING_API_KEY\", \"\")\n\n    # Inject model configuration\n    args.llm_model = get_env_value(\"LLM_MODEL\", \"mistral-nemo:latest\")\n    # EMBEDDING_MODEL defaults to None - each binding will use its own default model\n    # e.g., OpenAI uses \"text-embedding-3-small\", Jina uses \"jina-embeddings-v4\"\n    args.embedding_model = get_env_value(\"EMBEDDING_MODEL\", None, special_none=True)\n    # EMBEDDING_DIM defaults to None - each binding will use its own default dimension\n    # Value is inherited from provider defaults via wrap_embedding_func_with_attrs decorator\n    args.embedding_dim = get_env_value(\"EMBEDDING_DIM\", None, int, special_none=True)\n    args.embedding_send_dim = get_env_value(\"EMBEDDING_SEND_DIM\", False, bool)\n\n    # Inject chunk configuration\n    args.chunk_size = get_env_value(\"CHUNK_SIZE\", 1200, int)\n    args.chunk_overlap_size = get_env_value(\"CHUNK_OVERLAP_SIZE\", 100, int)\n\n    # Inject LLM cache configuration\n    args.enable_llm_cache_for_extract = get_env_value(\n        \"ENABLE_LLM_CACHE_FOR_EXTRACT\", True, bool\n    )\n    args.enable_llm_cache = get_env_value(\"ENABLE_LLM_CACHE\", True, bool)\n\n    # Set document_loading_engine from --docling flag\n    if args.docling:\n        args.document_loading_engine = \"DOCLING\"\n    else:\n        args.document_loading_engine = get_env_value(\n            \"DOCUMENT_LOADING_ENGINE\", \"DEFAULT\"\n        )\n\n    # PDF decryption password\n    args.pdf_decrypt_password = get_env_value(\"PDF_DECRYPT_PASSWORD\", None)\n\n    # Add environment variables that were previously read directly\n    args.cors_origins = get_env_value(\"CORS_ORIGINS\", \"*\")\n    args.summary_language = get_env_value(\"SUMMARY_LANGUAGE\", DEFAULT_SUMMARY_LANGUAGE)\n    args.entity_types = get_env_value(\"ENTITY_TYPES\", DEFAULT_ENTITY_TYPES, list)\n    args.whitelist_paths = get_env_value(\"WHITELIST_PATHS\", \"/health,/api/*\")\n\n    # For JWT Auth\n    args.auth_accounts = get_env_value(\"AUTH_ACCOUNTS\", \"\")\n    args.token_secret = get_env_value(\n        \"TOKEN_SECRET\", \"lightrag-jwt-default-secret-key!\"\n    )\n    args.token_expire_hours = get_env_value(\"TOKEN_EXPIRE_HOURS\", 48, float)\n    args.guest_token_expire_hours = get_env_value(\"GUEST_TOKEN_EXPIRE_HOURS\", 24, float)\n    args.jwt_algorithm = get_env_value(\"JWT_ALGORITHM\", \"HS256\")\n\n    # Token auto-renewal configuration (sliding window expiration)\n    args.token_auto_renew = get_env_value(\"TOKEN_AUTO_RENEW\", True, bool)\n    args.token_renew_threshold = get_env_value(\"TOKEN_RENEW_THRESHOLD\", 0.5, float)\n\n    # Rerank model configuration\n    args.rerank_model = get_env_value(\"RERANK_MODEL\", None)\n    args.rerank_binding_host = get_env_value(\"RERANK_BINDING_HOST\", None)\n    args.rerank_binding_api_key = get_env_value(\"RERANK_BINDING_API_KEY\", None)\n    # Note: rerank_binding is already set by argparse, no need to override from env\n\n    # Min rerank score configuration\n    args.min_rerank_score = get_env_value(\n        \"MIN_RERANK_SCORE\", DEFAULT_MIN_RERANK_SCORE, float\n    )\n\n    # Query configuration\n    args.history_turns = get_env_value(\"HISTORY_TURNS\", DEFAULT_HISTORY_TURNS, int)\n    args.top_k = get_env_value(\"TOP_K\", DEFAULT_TOP_K, int)\n    args.chunk_top_k = get_env_value(\"CHUNK_TOP_K\", DEFAULT_CHUNK_TOP_K, int)\n    args.max_entity_tokens = get_env_value(\n        \"MAX_ENTITY_TOKENS\", DEFAULT_MAX_ENTITY_TOKENS, int\n    )\n    args.max_relation_tokens = get_env_value(\n        \"MAX_RELATION_TOKENS\", DEFAULT_MAX_RELATION_TOKENS, int\n    )\n    args.max_total_tokens = get_env_value(\n        \"MAX_TOTAL_TOKENS\", DEFAULT_MAX_TOTAL_TOKENS, int\n    )\n    args.cosine_threshold = get_env_value(\n        \"COSINE_THRESHOLD\", DEFAULT_COSINE_THRESHOLD, float\n    )\n    args.related_chunk_number = get_env_value(\n        \"RELATED_CHUNK_NUMBER\", DEFAULT_RELATED_CHUNK_NUMBER, int\n    )\n\n    # Add missing environment variables for health endpoint\n    args.force_llm_summary_on_merge = get_env_value(\n        \"FORCE_LLM_SUMMARY_ON_MERGE\", DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE, int\n    )\n    args.embedding_func_max_async = get_env_value(\n        \"EMBEDDING_FUNC_MAX_ASYNC\", DEFAULT_EMBEDDING_FUNC_MAX_ASYNC, int\n    )\n    args.embedding_batch_num = get_env_value(\n        \"EMBEDDING_BATCH_NUM\", DEFAULT_EMBEDDING_BATCH_NUM, int\n    )\n\n    # Embedding token limit configuration\n    args.embedding_token_limit = get_env_value(\n        \"EMBEDDING_TOKEN_LIMIT\", None, int, special_none=True\n    )\n\n    # File upload size limit (in bytes, None for unlimited)\n    # Default: 100MB (104857600 bytes)\n    args.max_upload_size = get_env_value(\n        \"MAX_UPLOAD_SIZE\", 104857600, int, special_none=True\n    )\n\n    ollama_server_infos.LIGHTRAG_NAME = args.simulated_model_name\n    ollama_server_infos.LIGHTRAG_TAG = args.simulated_model_tag\n\n    # Sanitize workspace: only alphanumeric characters and underscores are allowed\n    if args.workspace:\n        sanitized = re.sub(r\"[^a-zA-Z0-9_]\", \"_\", args.workspace)\n        if sanitized != args.workspace:\n            logging.warning(\n                f\"Workspace name '{args.workspace}' contains invalid characters. \"\n                f\"It has been sanitized to '{sanitized}'. \"\n                \"Only alphanumeric characters and underscores are allowed.\"\n            )\n            args.workspace = sanitized\n\n    return args\n\n\ndef update_uvicorn_mode_config():\n    # If in uvicorn mode and workers > 1, force it to 1 and log warning\n    if global_args.workers > 1:\n        original_workers = global_args.workers\n        global_args.workers = 1\n        # Log warning directly here\n        logging.warning(\n            f\">> Forcing workers=1 in uvicorn mode(Ignoring workers={original_workers})\"\n        )\n\n\n# Global configuration with lazy initialization\n_global_args = None\n_initialized = False\n\n\ndef initialize_config(args=None, force=False):\n    \"\"\"Initialize global configuration\n\n    This function allows explicit initialization of the configuration,\n    which is useful for programmatic usage, testing, or embedding LightRAG\n    in other applications.\n\n    Args:\n        args: Pre-parsed argparse.Namespace or None to parse from sys.argv\n        force: Force re-initialization even if already initialized\n\n    Returns:\n        argparse.Namespace: The configured arguments\n\n    Example:\n        # Use parsed command line arguments (default)\n        initialize_config()\n\n        # Use custom configuration programmatically\n        custom_args = argparse.Namespace(\n            host='localhost',\n            port=8080,\n            working_dir='./custom_rag',\n            # ... other config\n        )\n        initialize_config(custom_args)\n    \"\"\"\n    global _global_args, _initialized\n\n    if _initialized and not force:\n        return _global_args\n\n    _global_args = args if args is not None else parse_args()\n    _initialized = True\n    return _global_args\n\n\ndef get_config():\n    \"\"\"Get global configuration, auto-initializing if needed\n\n    Returns:\n        argparse.Namespace: The configured arguments\n    \"\"\"\n    if not _initialized:\n        initialize_config()\n    return _global_args\n\n\nclass _GlobalArgsProxy:\n    \"\"\"Proxy object that auto-initializes configuration on first access\n\n    This maintains backward compatibility with existing code while\n    allowing programmatic control over initialization timing.\n\n    The proxy fully delegates to the underlying argparse.Namespace,\n    including support for vars() calls which is used by binding_options\n    to extract provider-specific configuration options.\n    \"\"\"\n\n    def __getattribute__(self, name):\n        \"\"\"Override attribute access to support vars() and regular attribute access.\n\n        This method intercepts __dict__ access (used by vars()) and delegates\n        to the underlying _global_args namespace, ensuring binding options\n        can be properly extracted.\n        \"\"\"\n        global _initialized, _global_args\n\n        # Handle __dict__ access for vars() support\n        if name == \"__dict__\":\n            if not _initialized:\n                initialize_config()\n            return vars(_global_args)\n\n        # Handle class-level attributes that should come from the proxy itself\n        if name in (\"__class__\", \"__repr__\", \"__getattribute__\", \"__setattr__\"):\n            return object.__getattribute__(self, name)\n\n        # Delegate all other attribute access to the underlying namespace\n        if not _initialized:\n            initialize_config()\n        return getattr(_global_args, name)\n\n    def __setattr__(self, name, value):\n        global _initialized, _global_args\n        if not _initialized:\n            initialize_config()\n        setattr(_global_args, name, value)\n\n    def __repr__(self):\n        global _initialized, _global_args\n        if not _initialized:\n            return \"<GlobalArgsProxy: Not initialized>\"\n        return repr(_global_args)\n\n\n# Create proxy instance for backward compatibility\n# Existing code like `from config import global_args` continues to work\n# The proxy will auto-initialize on first attribute access\nglobal_args = _GlobalArgsProxy()\n"
  },
  {
    "path": "lightrag/api/gunicorn_config.py",
    "content": "# gunicorn_config.py\nimport os\nimport logging\nfrom lightrag.kg.shared_storage import finalize_share_data\nfrom lightrag.utils import setup_logger, get_env_value\nfrom lightrag.constants import (\n    DEFAULT_LOG_MAX_BYTES,\n    DEFAULT_LOG_BACKUP_COUNT,\n    DEFAULT_LOG_FILENAME,\n)\n\n\n# Get log directory path from environment variable\nlog_dir = os.getenv(\"LOG_DIR\", os.getcwd())\nlog_file_path = os.path.abspath(os.path.join(log_dir, DEFAULT_LOG_FILENAME))\n\n# Ensure log directory exists\nos.makedirs(os.path.dirname(log_file_path), exist_ok=True)\n\n# Get log file max size and backup count from environment variables\nlog_max_bytes = get_env_value(\"LOG_MAX_BYTES\", DEFAULT_LOG_MAX_BYTES, int)\nlog_backup_count = get_env_value(\"LOG_BACKUP_COUNT\", DEFAULT_LOG_BACKUP_COUNT, int)\n\n# These variables will be set by run_with_gunicorn.py\nworkers = None\nbind = None\nloglevel = None\ncertfile = None\nkeyfile = None\n\n# Enable preload_app option\npreload_app = True\n\n# Use Uvicorn worker\nworker_class = \"uvicorn.workers.UvicornWorker\"\n\n# Other Gunicorn configurations\n\n# Logging configuration\nerrorlog = os.getenv(\"ERROR_LOG\", log_file_path)  # Default write to lightrag.log\naccesslog = os.getenv(\"ACCESS_LOG\", log_file_path)  # Default write to lightrag.log\n\nlogconfig_dict = {\n    \"version\": 1,\n    \"disable_existing_loggers\": False,\n    \"formatters\": {\n        \"standard\": {\"format\": \"%(asctime)s [%(levelname)s] %(name)s: %(message)s\"},\n    },\n    \"handlers\": {\n        \"console\": {\n            \"class\": \"logging.StreamHandler\",\n            \"formatter\": \"standard\",\n            \"stream\": \"ext://sys.stdout\",\n        },\n        \"file\": {\n            \"class\": \"logging.handlers.RotatingFileHandler\",\n            \"formatter\": \"standard\",\n            \"filename\": log_file_path,\n            \"maxBytes\": log_max_bytes,\n            \"backupCount\": log_backup_count,\n            \"encoding\": \"utf8\",\n        },\n    },\n    \"filters\": {\n        \"path_filter\": {\n            \"()\": \"lightrag.utils.LightragPathFilter\",\n        },\n    },\n    \"loggers\": {\n        \"lightrag\": {\n            \"handlers\": [\"console\", \"file\"],\n            \"level\": loglevel.upper() if loglevel else \"INFO\",\n            \"propagate\": False,\n        },\n        \"gunicorn\": {\n            \"handlers\": [\"console\", \"file\"],\n            \"level\": loglevel.upper() if loglevel else \"INFO\",\n            \"propagate\": False,\n        },\n        \"gunicorn.error\": {\n            \"handlers\": [\"console\", \"file\"],\n            \"level\": loglevel.upper() if loglevel else \"INFO\",\n            \"propagate\": False,\n        },\n        \"gunicorn.access\": {\n            \"handlers\": [\"console\", \"file\"],\n            \"level\": loglevel.upper() if loglevel else \"INFO\",\n            \"propagate\": False,\n            \"filters\": [\"path_filter\"],\n        },\n    },\n}\n\n\ndef on_starting(server):\n    \"\"\"\n    Executed when Gunicorn starts, before forking the first worker processes\n    You can use this function to do more initialization tasks for all processes\n    \"\"\"\n    print(\"=\" * 80)\n    print(f\"GUNICORN MASTER PROCESS: on_starting jobs for {workers} worker(s)\")\n    print(f\"Process ID: {os.getpid()}\")\n    print(\"=\" * 80)\n\n    # Memory usage monitoring\n    try:\n        import psutil\n\n        process = psutil.Process(os.getpid())\n        memory_info = process.memory_info()\n        msg = (\n            f\"Memory usage after initialization: {memory_info.rss / 1024 / 1024:.2f} MB\"\n        )\n        print(msg)\n    except ImportError:\n        print(\"psutil not installed, skipping memory usage reporting\")\n\n    # Log the location of the LightRAG log file\n    print(f\"LightRAG log file: {log_file_path}\\n\")\n\n    print(\"Gunicorn initialization complete, forking workers...\\n\")\n\n\ndef on_exit(server):\n    \"\"\"\n    Executed when Gunicorn is shutting down.\n    This is a good place to release shared resources.\n    \"\"\"\n    print(\"=\" * 80)\n    print(\"GUNICORN MASTER PROCESS: Shutting down\")\n    print(f\"Process ID: {os.getpid()}\")\n\n    print(\"Finalizing shared storage...\")\n    finalize_share_data()\n\n    print(\"Gunicorn shutdown complete\")\n    print(\"=\" * 80)\n\n\ndef post_fork(server, worker):\n    \"\"\"\n    Executed after a worker has been forked.\n    This is a good place to set up worker-specific configurations.\n    \"\"\"\n    # Set up main loggers\n    log_level = loglevel.upper() if loglevel else \"INFO\"\n    setup_logger(\"uvicorn\", log_level, add_filter=False, log_file_path=log_file_path)\n    setup_logger(\n        \"uvicorn.access\", log_level, add_filter=True, log_file_path=log_file_path\n    )\n    setup_logger(\"lightrag\", log_level, add_filter=True, log_file_path=log_file_path)\n\n    # Set up lightrag submodule loggers\n    for name in logging.root.manager.loggerDict:\n        if name.startswith(\"lightrag.\"):\n            setup_logger(name, log_level, add_filter=True, log_file_path=log_file_path)\n\n    # Disable uvicorn.error logger\n    uvicorn_error_logger = logging.getLogger(\"uvicorn.error\")\n    uvicorn_error_logger.handlers = []\n    uvicorn_error_logger.setLevel(logging.CRITICAL)\n    uvicorn_error_logger.propagate = False\n"
  },
  {
    "path": "lightrag/api/lightrag_server.py",
    "content": "\"\"\"\nLightRAG FastAPI Server\n\"\"\"\n\nfrom fastapi import FastAPI, Depends, HTTPException, Request\nfrom fastapi.exceptions import RequestValidationError\nfrom fastapi.responses import JSONResponse\nfrom fastapi.openapi.docs import (\n    get_swagger_ui_html,\n    get_swagger_ui_oauth2_redirect_html,\n)\nimport os\nimport re\nimport logging\nimport logging.config\nimport sys\nimport uvicorn\nimport pipmaster as pm\nfrom fastapi.staticfiles import StaticFiles\nfrom fastapi.responses import RedirectResponse\nfrom pathlib import Path\nimport configparser\nfrom ascii_colors import ASCIIColors\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom contextlib import asynccontextmanager\nfrom dotenv import load_dotenv\nfrom lightrag.api.utils_api import (\n    get_combined_auth_dependency,\n    display_splash_screen,\n    check_env_file,\n)\nfrom .config import (\n    global_args,\n    update_uvicorn_mode_config,\n    get_default_host,\n)\nfrom lightrag.utils import get_env_value\nfrom lightrag import LightRAG, __version__ as core_version\nfrom lightrag.api import __api_version__\nfrom lightrag.types import GPTKeywordExtractionFormat\nfrom lightrag.utils import EmbeddingFunc\nfrom lightrag.constants import (\n    DEFAULT_LOG_MAX_BYTES,\n    DEFAULT_LOG_BACKUP_COUNT,\n    DEFAULT_LOG_FILENAME,\n    DEFAULT_LLM_TIMEOUT,\n    DEFAULT_EMBEDDING_TIMEOUT,\n)\nfrom lightrag.api.routers.document_routes import (\n    DocumentManager,\n    create_document_routes,\n)\nfrom lightrag.api.routers.query_routes import create_query_routes\nfrom lightrag.api.routers.graph_routes import create_graph_routes\nfrom lightrag.api.routers.ollama_api import OllamaAPI\n\nfrom lightrag.utils import logger, set_verbose_debug\nfrom lightrag.kg.shared_storage import (\n    get_namespace_data,\n    get_default_workspace,\n    # set_default_workspace,\n    cleanup_keyed_lock,\n    finalize_share_data,\n)\nfrom fastapi.security import OAuth2PasswordRequestForm\nfrom lightrag.api.auth import auth_handler\n\n# use the .env that is inside the current folder\n# allows to use different .env file for each lightrag instance\n# the OS environment variables take precedence over the .env file\nload_dotenv(dotenv_path=\".env\", override=False)\n\n\nwebui_title = os.getenv(\"WEBUI_TITLE\")\nwebui_description = os.getenv(\"WEBUI_DESCRIPTION\")\n\n# Initialize config parser\nconfig = configparser.ConfigParser()\nconfig.read(\"config.ini\")\n\n# Global authentication configuration\nauth_configured = bool(auth_handler.accounts)\n\n\nclass LLMConfigCache:\n    \"\"\"Smart LLM and Embedding configuration cache class\"\"\"\n\n    def __init__(self, args):\n        self.args = args\n\n        # Initialize configurations based on binding conditions\n        self.openai_llm_options = None\n        self.gemini_llm_options = None\n        self.gemini_embedding_options = None\n        self.ollama_llm_options = None\n        self.ollama_embedding_options = None\n\n        # Only initialize and log OpenAI options when using OpenAI-related bindings\n        if args.llm_binding in [\"openai\", \"azure_openai\"]:\n            from lightrag.llm.binding_options import OpenAILLMOptions\n\n            self.openai_llm_options = OpenAILLMOptions.options_dict(args)\n            logger.info(f\"OpenAI LLM Options: {self.openai_llm_options}\")\n\n        if args.llm_binding == \"gemini\":\n            from lightrag.llm.binding_options import GeminiLLMOptions\n\n            self.gemini_llm_options = GeminiLLMOptions.options_dict(args)\n            logger.info(f\"Gemini LLM Options: {self.gemini_llm_options}\")\n\n        # Only initialize and log Ollama LLM options when using Ollama LLM binding\n        if args.llm_binding == \"ollama\":\n            try:\n                from lightrag.llm.binding_options import OllamaLLMOptions\n\n                self.ollama_llm_options = OllamaLLMOptions.options_dict(args)\n                logger.info(f\"Ollama LLM Options: {self.ollama_llm_options}\")\n            except ImportError:\n                logger.warning(\n                    \"OllamaLLMOptions not available, using default configuration\"\n                )\n                self.ollama_llm_options = {}\n\n        # Only initialize and log Ollama Embedding options when using Ollama Embedding binding\n        if args.embedding_binding == \"ollama\":\n            try:\n                from lightrag.llm.binding_options import OllamaEmbeddingOptions\n\n                self.ollama_embedding_options = OllamaEmbeddingOptions.options_dict(\n                    args\n                )\n                logger.info(\n                    f\"Ollama Embedding Options: {self.ollama_embedding_options}\"\n                )\n            except ImportError:\n                logger.warning(\n                    \"OllamaEmbeddingOptions not available, using default configuration\"\n                )\n                self.ollama_embedding_options = {}\n\n        # Only initialize and log Gemini Embedding options when using Gemini Embedding binding\n        if args.embedding_binding == \"gemini\":\n            try:\n                from lightrag.llm.binding_options import GeminiEmbeddingOptions\n\n                self.gemini_embedding_options = GeminiEmbeddingOptions.options_dict(\n                    args\n                )\n                logger.info(\n                    f\"Gemini Embedding Options: {self.gemini_embedding_options}\"\n                )\n            except ImportError:\n                logger.warning(\n                    \"GeminiEmbeddingOptions not available, using default configuration\"\n                )\n                self.gemini_embedding_options = {}\n\n\ndef check_frontend_build():\n    \"\"\"Check if frontend is built and optionally check if source is up-to-date\n\n    Returns:\n        tuple: (assets_exist: bool, is_outdated: bool)\n            - assets_exist: True if WebUI build files exist\n            - is_outdated: True if source is newer than build (only in dev environment)\n    \"\"\"\n    webui_dir = Path(__file__).parent / \"webui\"\n    index_html = webui_dir / \"index.html\"\n\n    # 1. Check if build files exist\n    if not index_html.exists():\n        ASCIIColors.yellow(\"\\n\" + \"=\" * 80)\n        ASCIIColors.yellow(\"WARNING: Frontend Not Built\")\n        ASCIIColors.yellow(\"=\" * 80)\n        ASCIIColors.yellow(\"The WebUI frontend has not been built yet.\")\n        ASCIIColors.yellow(\"The API server will start without the WebUI interface.\")\n        ASCIIColors.yellow(\n            \"\\nTo enable WebUI, build the frontend using these commands:\\n\"\n        )\n        ASCIIColors.cyan(\"    cd lightrag_webui\")\n        ASCIIColors.cyan(\"    bun install --frozen-lockfile\")\n        ASCIIColors.cyan(\"    bun run build\")\n        ASCIIColors.cyan(\"    cd ..\")\n        ASCIIColors.yellow(\"\\nThen restart the service.\\n\")\n        ASCIIColors.cyan(\n            \"Note: Make sure you have Bun installed. Visit https://bun.sh for installation.\"\n        )\n        ASCIIColors.yellow(\"=\" * 80 + \"\\n\")\n        return (False, False)  # Assets don't exist, not outdated\n\n    # 2. Check if this is a development environment (source directory exists)\n    try:\n        source_dir = Path(__file__).parent.parent.parent / \"lightrag_webui\"\n        src_dir = source_dir / \"src\"\n\n        # Determine if this is a development environment: source directory exists and contains src directory\n        if not source_dir.exists() or not src_dir.exists():\n            # Production environment, skip source code check\n            logger.debug(\n                \"Production environment detected, skipping source freshness check\"\n            )\n            return (True, False)  # Assets exist, not outdated (prod environment)\n\n        # Development environment, perform source code timestamp check\n        logger.debug(\"Development environment detected, checking source freshness\")\n\n        # Source code file extensions (files to check)\n        source_extensions = {\n            \".ts\",\n            \".tsx\",\n            \".js\",\n            \".jsx\",\n            \".mjs\",\n            \".cjs\",  # TypeScript/JavaScript\n            \".css\",\n            \".scss\",\n            \".sass\",\n            \".less\",  # Style files\n            \".json\",\n            \".jsonc\",  # Configuration/data files\n            \".html\",\n            \".htm\",  # Template files\n            \".md\",\n            \".mdx\",  # Markdown\n        }\n\n        # Key configuration files (in lightrag_webui root directory)\n        key_files = [\n            source_dir / \"package.json\",\n            source_dir / \"bun.lock\",\n            source_dir / \"vite.config.ts\",\n            source_dir / \"tsconfig.json\",\n            source_dir / \"tailraid.config.js\",\n            source_dir / \"index.html\",\n        ]\n\n        # Get the latest modification time of source code\n        latest_source_time = 0\n\n        # Check source code files in src directory\n        for file_path in src_dir.rglob(\"*\"):\n            if file_path.is_file():\n                # Only check source code files, ignore temporary files and logs\n                if file_path.suffix.lower() in source_extensions:\n                    mtime = file_path.stat().st_mtime\n                    latest_source_time = max(latest_source_time, mtime)\n\n        # Check key configuration files\n        for key_file in key_files:\n            if key_file.exists():\n                mtime = key_file.stat().st_mtime\n                latest_source_time = max(latest_source_time, mtime)\n\n        # Get build time\n        build_time = index_html.stat().st_mtime\n\n        # Compare timestamps (5 second tolerance to avoid file system time precision issues)\n        if latest_source_time > build_time + 5:\n            ASCIIColors.yellow(\"\\n\" + \"=\" * 80)\n            ASCIIColors.yellow(\"WARNING: Frontend Source Code Has Been Updated\")\n            ASCIIColors.yellow(\"=\" * 80)\n            ASCIIColors.yellow(\n                \"The frontend source code is newer than the current build.\"\n            )\n            ASCIIColors.yellow(\n                \"This might happen after 'git pull' or manual code changes.\\n\"\n            )\n            ASCIIColors.cyan(\n                \"Recommended: Rebuild the frontend to use the latest changes:\"\n            )\n            ASCIIColors.cyan(\"    cd lightrag_webui\")\n            ASCIIColors.cyan(\"    bun install --frozen-lockfile\")\n            ASCIIColors.cyan(\"    bun run build\")\n            ASCIIColors.cyan(\"    cd ..\")\n            ASCIIColors.yellow(\"\\nThe server will continue with the current build.\")\n            ASCIIColors.yellow(\"=\" * 80 + \"\\n\")\n            return (True, True)  # Assets exist, outdated\n        else:\n            logger.info(\"Frontend build is up-to-date\")\n            return (True, False)  # Assets exist, up-to-date\n\n    except Exception as e:\n        # If check fails, log warning but don't affect startup\n        logger.warning(f\"Failed to check frontend source freshness: {e}\")\n        return (True, False)  # Assume assets exist and up-to-date on error\n\n\ndef create_app(args):\n    # Check frontend build first and get status\n    webui_assets_exist, is_frontend_outdated = check_frontend_build()\n\n    # Create unified API version display with warning symbol if frontend is outdated\n    api_version_display = (\n        f\"{__api_version__}⚠️\" if is_frontend_outdated else __api_version__\n    )\n\n    # Setup logging\n    logger.setLevel(args.log_level)\n    set_verbose_debug(args.verbose)\n\n    # Create configuration cache (this will output configuration logs)\n    config_cache = LLMConfigCache(args)\n\n    # Verify that bindings are correctly setup\n    if args.llm_binding not in [\n        \"lollms\",\n        \"ollama\",\n        \"openai\",\n        \"azure_openai\",\n        \"aws_bedrock\",\n        \"gemini\",\n    ]:\n        raise Exception(\"llm binding not supported\")\n\n    if args.embedding_binding not in [\n        \"lollms\",\n        \"ollama\",\n        \"openai\",\n        \"azure_openai\",\n        \"aws_bedrock\",\n        \"jina\",\n        \"gemini\",\n    ]:\n        raise Exception(\"embedding binding not supported\")\n\n    # Set default hosts if not provided\n    if args.llm_binding_host is None:\n        args.llm_binding_host = get_default_host(args.llm_binding)\n\n    if args.embedding_binding_host is None:\n        args.embedding_binding_host = get_default_host(args.embedding_binding)\n\n    # Add SSL validation\n    if args.ssl:\n        if not args.ssl_certfile or not args.ssl_keyfile:\n            raise Exception(\n                \"SSL certificate and key files must be provided when SSL is enabled\"\n            )\n        if not os.path.exists(args.ssl_certfile):\n            raise Exception(f\"SSL certificate file not found: {args.ssl_certfile}\")\n        if not os.path.exists(args.ssl_keyfile):\n            raise Exception(f\"SSL key file not found: {args.ssl_keyfile}\")\n\n    # Check if API key is provided either through env var or args\n    api_key = os.getenv(\"LIGHTRAG_API_KEY\") or args.key\n\n    # Initialize document manager with workspace support for data isolation\n    doc_manager = DocumentManager(args.input_dir, workspace=args.workspace)\n\n    @asynccontextmanager\n    async def lifespan(app: FastAPI):\n        \"\"\"Lifespan context manager for startup and shutdown events\"\"\"\n        # Store background tasks\n        app.state.background_tasks = set()\n\n        try:\n            # Initialize database connections\n            # Note: initialize_storages() now auto-initializes pipeline_status for rag.workspace\n            await rag.initialize_storages()\n\n            # Data migration regardless of storage implementation\n            await rag.check_and_migrate_data()\n\n            ASCIIColors.green(\"\\nServer is ready to accept connections! 🚀\\n\")\n\n            yield\n\n        finally:\n            # Clean up database connections\n            await rag.finalize_storages()\n\n            if \"LIGHTRAG_GUNICORN_MODE\" not in os.environ:\n                # Only perform cleanup in Uvicorn single-process mode\n                logger.debug(\"Unvicorn Mode: finalizing shared storage...\")\n                finalize_share_data()\n            else:\n                # In Gunicorn mode with preload_app=True, cleanup is handled by on_exit hooks\n                logger.debug(\n                    \"Gunicorn Mode: postpone shared storage finalization to master process\"\n                )\n\n    # Initialize FastAPI\n    base_description = (\n        \"Providing API for LightRAG core, Web UI and Ollama Model Emulation\"\n    )\n    swagger_description = (\n        base_description\n        + (\" (API-Key Enabled)\" if api_key else \"\")\n        + \"\\n\\n[View ReDoc documentation](/redoc)\"\n    )\n    app_kwargs = {\n        \"title\": \"LightRAG Server API\",\n        \"description\": swagger_description,\n        \"version\": __api_version__,\n        \"openapi_url\": \"/openapi.json\",  # Explicitly set OpenAPI schema URL\n        \"docs_url\": None,  # Disable default docs, we'll create custom endpoint\n        \"redoc_url\": \"/redoc\",  # Explicitly set redoc URL\n        \"lifespan\": lifespan,\n    }\n\n    # Configure Swagger UI parameters\n    # Enable persistAuthorization and tryItOutEnabled for better user experience\n    app_kwargs[\"swagger_ui_parameters\"] = {\n        \"persistAuthorization\": True,\n        \"tryItOutEnabled\": True,\n    }\n\n    app = FastAPI(**app_kwargs)\n\n    # Add custom validation error handler for /query/data endpoint\n    @app.exception_handler(RequestValidationError)\n    async def validation_exception_handler(\n        request: Request, exc: RequestValidationError\n    ):\n        # Check if this is a request to /query/data endpoint\n        if request.url.path.endswith(\"/query/data\"):\n            # Extract error details\n            error_details = []\n            for error in exc.errors():\n                field_path = \" -> \".join(str(loc) for loc in error[\"loc\"])\n                error_details.append(f\"{field_path}: {error['msg']}\")\n\n            error_message = \"; \".join(error_details)\n\n            # Return in the expected format for /query/data\n            return JSONResponse(\n                status_code=400,\n                content={\n                    \"status\": \"failure\",\n                    \"message\": f\"Validation error: {error_message}\",\n                    \"data\": {},\n                    \"metadata\": {},\n                },\n            )\n        else:\n            # For other endpoints, return the default FastAPI validation error\n            return JSONResponse(status_code=422, content={\"detail\": exc.errors()})\n\n    def get_cors_origins():\n        \"\"\"Get allowed origins from global_args\n        Returns a list of allowed origins, defaults to [\"*\"] if not set\n        \"\"\"\n        origins_str = global_args.cors_origins\n        if origins_str == \"*\":\n            return [\"*\"]\n        return [origin.strip() for origin in origins_str.split(\",\")]\n\n    # Add CORS middleware\n    app.add_middleware(\n        CORSMiddleware,\n        allow_origins=get_cors_origins(),\n        allow_credentials=True,\n        allow_methods=[\"*\"],\n        allow_headers=[\"*\"],\n        expose_headers=[\n            \"X-New-Token\"\n        ],  # Expose token renewal header for cross-origin requests\n    )\n\n    # Create combined auth dependency for all endpoints\n    combined_auth = get_combined_auth_dependency(api_key)\n\n    def get_workspace_from_request(request: Request) -> str | None:\n        \"\"\"\n        Extract workspace from HTTP request header or use default.\n\n        This enables multi-workspace API support by checking the custom\n        'LIGHTRAG-WORKSPACE' header. If not present, falls back to the\n        server's default workspace configuration.\n\n        Args:\n            request: FastAPI Request object\n\n        Returns:\n            Workspace identifier (may be empty string for global namespace)\n        \"\"\"\n        # Check custom header first\n        workspace = request.headers.get(\"LIGHTRAG-WORKSPACE\", \"\").strip()\n\n        if not workspace:\n            workspace = None\n        else:\n            sanitized = re.sub(r\"[^a-zA-Z0-9_]\", \"_\", workspace)\n            if sanitized != workspace:\n                logger.warning(\n                    f\"Workspace header '{workspace}' contains invalid characters. \"\n                    f\"Sanitized to '{sanitized}'.\"\n                )\n                workspace = sanitized\n\n        return workspace\n\n    # Create working directory if it doesn't exist\n    Path(args.working_dir).mkdir(parents=True, exist_ok=True)\n\n    def create_optimized_openai_llm_func(\n        config_cache: LLMConfigCache, args, llm_timeout: int\n    ):\n        \"\"\"Create optimized OpenAI LLM function with pre-processed configuration\"\"\"\n\n        async def optimized_openai_alike_model_complete(\n            prompt,\n            system_prompt=None,\n            history_messages=None,\n            keyword_extraction=False,\n            **kwargs,\n        ) -> str:\n            from lightrag.llm.openai import openai_complete_if_cache\n\n            keyword_extraction = kwargs.pop(\"keyword_extraction\", None)\n            if keyword_extraction:\n                kwargs[\"response_format\"] = GPTKeywordExtractionFormat\n            if history_messages is None:\n                history_messages = []\n\n            # Use pre-processed configuration to avoid repeated parsing\n            kwargs[\"timeout\"] = llm_timeout\n            if config_cache.openai_llm_options:\n                kwargs.update(config_cache.openai_llm_options)\n\n            return await openai_complete_if_cache(\n                args.llm_model,\n                prompt,\n                system_prompt=system_prompt,\n                history_messages=history_messages,\n                base_url=args.llm_binding_host,\n                api_key=args.llm_binding_api_key,\n                **kwargs,\n            )\n\n        return optimized_openai_alike_model_complete\n\n    def create_optimized_azure_openai_llm_func(\n        config_cache: LLMConfigCache, args, llm_timeout: int\n    ):\n        \"\"\"Create optimized Azure OpenAI LLM function with pre-processed configuration\"\"\"\n\n        async def optimized_azure_openai_model_complete(\n            prompt,\n            system_prompt=None,\n            history_messages=None,\n            keyword_extraction=False,\n            **kwargs,\n        ) -> str:\n            from lightrag.llm.azure_openai import azure_openai_complete_if_cache\n\n            keyword_extraction = kwargs.pop(\"keyword_extraction\", None)\n            if keyword_extraction:\n                kwargs[\"response_format\"] = GPTKeywordExtractionFormat\n            if history_messages is None:\n                history_messages = []\n\n            # Use pre-processed configuration to avoid repeated parsing\n            kwargs[\"timeout\"] = llm_timeout\n            if config_cache.openai_llm_options:\n                kwargs.update(config_cache.openai_llm_options)\n\n            return await azure_openai_complete_if_cache(\n                args.llm_model,\n                prompt,\n                system_prompt=system_prompt,\n                history_messages=history_messages,\n                base_url=args.llm_binding_host,\n                api_key=os.getenv(\"AZURE_OPENAI_API_KEY\", args.llm_binding_api_key),\n                api_version=os.getenv(\"AZURE_OPENAI_API_VERSION\", \"2024-08-01-preview\"),\n                **kwargs,\n            )\n\n        return optimized_azure_openai_model_complete\n\n    def create_optimized_gemini_llm_func(\n        config_cache: LLMConfigCache, args, llm_timeout: int\n    ):\n        \"\"\"Create optimized Gemini LLM function with cached configuration\"\"\"\n\n        async def optimized_gemini_model_complete(\n            prompt,\n            system_prompt=None,\n            history_messages=None,\n            keyword_extraction=False,\n            **kwargs,\n        ) -> str:\n            from lightrag.llm.gemini import gemini_complete_if_cache\n\n            if history_messages is None:\n                history_messages = []\n\n            # Use pre-processed configuration to avoid repeated parsing\n            kwargs[\"timeout\"] = llm_timeout\n            if (\n                config_cache.gemini_llm_options is not None\n                and \"generation_config\" not in kwargs\n            ):\n                kwargs[\"generation_config\"] = dict(config_cache.gemini_llm_options)\n\n            return await gemini_complete_if_cache(\n                args.llm_model,\n                prompt,\n                system_prompt=system_prompt,\n                history_messages=history_messages,\n                api_key=args.llm_binding_api_key,\n                base_url=args.llm_binding_host,\n                keyword_extraction=keyword_extraction,\n                **kwargs,\n            )\n\n        return optimized_gemini_model_complete\n\n    def create_llm_model_func(binding: str):\n        \"\"\"\n        Create LLM model function based on binding type.\n        Uses optimized functions for OpenAI bindings and lazy import for others.\n        \"\"\"\n        try:\n            if binding == \"lollms\":\n                from lightrag.llm.lollms import lollms_model_complete\n\n                return lollms_model_complete\n            elif binding == \"ollama\":\n                from lightrag.llm.ollama import ollama_model_complete\n\n                return ollama_model_complete\n            elif binding == \"aws_bedrock\":\n                return bedrock_model_complete  # Already defined locally\n            elif binding == \"azure_openai\":\n                # Use optimized function with pre-processed configuration\n                return create_optimized_azure_openai_llm_func(\n                    config_cache, args, llm_timeout\n                )\n            elif binding == \"gemini\":\n                return create_optimized_gemini_llm_func(config_cache, args, llm_timeout)\n            else:  # openai and compatible\n                # Use optimized function with pre-processed configuration\n                return create_optimized_openai_llm_func(config_cache, args, llm_timeout)\n        except ImportError as e:\n            raise Exception(f\"Failed to import {binding} LLM binding: {e}\")\n\n    def create_llm_model_kwargs(binding: str, args, llm_timeout: int) -> dict:\n        \"\"\"\n        Create LLM model kwargs based on binding type.\n        Uses lazy import for binding-specific options.\n        \"\"\"\n        if binding in [\"lollms\", \"ollama\"]:\n            try:\n                from lightrag.llm.binding_options import OllamaLLMOptions\n\n                return {\n                    \"host\": args.llm_binding_host,\n                    \"timeout\": llm_timeout,\n                    \"options\": OllamaLLMOptions.options_dict(args),\n                    \"api_key\": args.llm_binding_api_key,\n                }\n            except ImportError as e:\n                raise Exception(f\"Failed to import {binding} options: {e}\")\n        return {}\n\n    def create_optimized_embedding_function(\n        config_cache: LLMConfigCache, binding, model, host, api_key, args\n    ) -> EmbeddingFunc:\n        \"\"\"\n        Create optimized embedding function and return an EmbeddingFunc instance\n        with proper max_token_size inheritance from provider defaults.\n\n        This function:\n        1. Imports the provider embedding function\n        2. Extracts max_token_size and embedding_dim from provider if it's an EmbeddingFunc\n        3. Creates an optimized wrapper that calls the underlying function directly (avoiding double-wrapping)\n        4. Returns a properly configured EmbeddingFunc instance\n\n        Configuration Rules:\n        - When EMBEDDING_MODEL is not set: Uses provider's default model and dimension\n          (e.g., jina-embeddings-v4 with 2048 dims, text-embedding-3-small with 1536 dims)\n        - When EMBEDDING_MODEL is set to a custom model: User MUST also set EMBEDDING_DIM\n          to match the custom model's dimension (e.g., for jina-embeddings-v3, set EMBEDDING_DIM=1024)\n\n        Note: The embedding_dim parameter is automatically injected by EmbeddingFunc wrapper\n        when send_dimensions=True (enabled for Jina and Gemini bindings). This wrapper calls\n        the underlying provider function directly (.func) to avoid double-wrapping, so we must\n        explicitly pass embedding_dim to the provider's underlying function.\n        \"\"\"\n\n        # Step 1: Import provider function and extract default attributes\n        provider_func = None\n        provider_max_token_size = None\n        provider_embedding_dim = None\n\n        try:\n            if binding == \"openai\":\n                from lightrag.llm.openai import openai_embed\n\n                provider_func = openai_embed\n            elif binding == \"ollama\":\n                from lightrag.llm.ollama import ollama_embed\n\n                provider_func = ollama_embed\n            elif binding == \"gemini\":\n                from lightrag.llm.gemini import gemini_embed\n\n                provider_func = gemini_embed\n            elif binding == \"jina\":\n                from lightrag.llm.jina import jina_embed\n\n                provider_func = jina_embed\n            elif binding == \"azure_openai\":\n                from lightrag.llm.azure_openai import azure_openai_embed\n\n                provider_func = azure_openai_embed\n            elif binding == \"aws_bedrock\":\n                from lightrag.llm.bedrock import bedrock_embed\n\n                provider_func = bedrock_embed\n            elif binding == \"lollms\":\n                from lightrag.llm.lollms import lollms_embed\n\n                provider_func = lollms_embed\n\n            # Extract attributes if provider is an EmbeddingFunc\n            if provider_func and isinstance(provider_func, EmbeddingFunc):\n                provider_max_token_size = provider_func.max_token_size\n                provider_embedding_dim = provider_func.embedding_dim\n                logger.debug(\n                    f\"Extracted from {binding} provider: \"\n                    f\"max_token_size={provider_max_token_size}, \"\n                    f\"embedding_dim={provider_embedding_dim}\"\n                )\n        except ImportError as e:\n            logger.warning(f\"Could not import provider function for {binding}: {e}\")\n\n        # Step 2: Apply priority (user config > provider default)\n        # For max_token_size: explicit env var > provider default > None\n        final_max_token_size = args.embedding_token_limit or provider_max_token_size\n        # For embedding_dim: user config (always has value) takes priority\n        # Only use provider default if user config is explicitly None (which shouldn't happen)\n        final_embedding_dim = (\n            args.embedding_dim if args.embedding_dim else provider_embedding_dim\n        )\n\n        # Step 3: Create optimized embedding function (calls underlying function directly)\n        # Note: When model is None, each binding will use its own default model\n        async def optimized_embedding_function(texts, embedding_dim=None):\n            try:\n                if binding == \"lollms\":\n                    from lightrag.llm.lollms import lollms_embed\n\n                    # Get real function, skip EmbeddingFunc wrapper if present\n                    actual_func = (\n                        lollms_embed.func\n                        if isinstance(lollms_embed, EmbeddingFunc)\n                        else lollms_embed\n                    )\n                    # lollms embed_model is not used (server uses configured vectorizer)\n                    # Only pass base_url and api_key\n                    return await actual_func(texts, base_url=host, api_key=api_key)\n                elif binding == \"ollama\":\n                    from lightrag.llm.ollama import ollama_embed\n\n                    # Get real function, skip EmbeddingFunc wrapper if present\n                    actual_func = (\n                        ollama_embed.func\n                        if isinstance(ollama_embed, EmbeddingFunc)\n                        else ollama_embed\n                    )\n\n                    # Use pre-processed configuration if available\n                    if config_cache.ollama_embedding_options is not None:\n                        ollama_options = config_cache.ollama_embedding_options\n                    else:\n                        from lightrag.llm.binding_options import OllamaEmbeddingOptions\n\n                        ollama_options = OllamaEmbeddingOptions.options_dict(args)\n\n                    # Pass embed_model only if provided, let function use its default (bge-m3:latest)\n                    kwargs = {\n                        \"texts\": texts,\n                        \"host\": host,\n                        \"api_key\": api_key,\n                        \"options\": ollama_options,\n                    }\n                    if model:\n                        kwargs[\"embed_model\"] = model\n                    return await actual_func(**kwargs)\n                elif binding == \"azure_openai\":\n                    from lightrag.llm.azure_openai import azure_openai_embed\n\n                    actual_func = (\n                        azure_openai_embed.func\n                        if isinstance(azure_openai_embed, EmbeddingFunc)\n                        else azure_openai_embed\n                    )\n                    # Pass model only if provided, let function use its default otherwise\n                    kwargs = {\n                        \"texts\": texts,\n                        \"api_key\": api_key,\n                        \"embedding_dim\": embedding_dim,\n                    }\n                    if model:\n                        kwargs[\"model\"] = model\n                    return await actual_func(**kwargs)\n                elif binding == \"aws_bedrock\":\n                    from lightrag.llm.bedrock import bedrock_embed\n\n                    actual_func = (\n                        bedrock_embed.func\n                        if isinstance(bedrock_embed, EmbeddingFunc)\n                        else bedrock_embed\n                    )\n                    # Pass model only if provided, let function use its default otherwise\n                    kwargs = {\"texts\": texts}\n                    if model:\n                        kwargs[\"model\"] = model\n                    return await actual_func(**kwargs)\n                elif binding == \"jina\":\n                    from lightrag.llm.jina import jina_embed\n\n                    actual_func = (\n                        jina_embed.func\n                        if isinstance(jina_embed, EmbeddingFunc)\n                        else jina_embed\n                    )\n                    # Pass model only if provided, let function use its default (jina-embeddings-v4)\n                    kwargs = {\n                        \"texts\": texts,\n                        \"embedding_dim\": embedding_dim,\n                        \"base_url\": host,\n                        \"api_key\": api_key,\n                    }\n                    if model:\n                        kwargs[\"model\"] = model\n                    return await actual_func(**kwargs)\n                elif binding == \"gemini\":\n                    from lightrag.llm.gemini import gemini_embed\n\n                    actual_func = (\n                        gemini_embed.func\n                        if isinstance(gemini_embed, EmbeddingFunc)\n                        else gemini_embed\n                    )\n\n                    # Use pre-processed configuration if available\n                    if config_cache.gemini_embedding_options is not None:\n                        gemini_options = config_cache.gemini_embedding_options\n                    else:\n                        from lightrag.llm.binding_options import GeminiEmbeddingOptions\n\n                        gemini_options = GeminiEmbeddingOptions.options_dict(args)\n\n                    # Pass model only if provided, let function use its default (gemini-embedding-001)\n                    kwargs = {\n                        \"texts\": texts,\n                        \"base_url\": host,\n                        \"api_key\": api_key,\n                        \"embedding_dim\": embedding_dim,\n                        \"task_type\": gemini_options.get(\n                            \"task_type\", \"RETRIEVAL_DOCUMENT\"\n                        ),\n                    }\n                    if model:\n                        kwargs[\"model\"] = model\n                    return await actual_func(**kwargs)\n                else:  # openai and compatible\n                    from lightrag.llm.openai import openai_embed\n\n                    actual_func = (\n                        openai_embed.func\n                        if isinstance(openai_embed, EmbeddingFunc)\n                        else openai_embed\n                    )\n                    # Pass model only if provided, let function use its default (text-embedding-3-small)\n                    kwargs = {\n                        \"texts\": texts,\n                        \"base_url\": host,\n                        \"api_key\": api_key,\n                        \"embedding_dim\": embedding_dim,\n                    }\n                    if model:\n                        kwargs[\"model\"] = model\n                    return await actual_func(**kwargs)\n            except ImportError as e:\n                raise Exception(f\"Failed to import {binding} embedding: {e}\")\n\n        # Step 4: Wrap in EmbeddingFunc and return\n        embedding_func_instance = EmbeddingFunc(\n            embedding_dim=final_embedding_dim,\n            func=optimized_embedding_function,\n            max_token_size=final_max_token_size,\n            send_dimensions=False,  # Will be set later based on binding requirements\n            model_name=model,\n        )\n\n        # Log final embedding configuration\n        logger.info(\n            f\"Embedding config: binding={binding} model={model} \"\n            f\"embedding_dim={final_embedding_dim} max_token_size={final_max_token_size}\"\n        )\n\n        return embedding_func_instance\n\n    llm_timeout = get_env_value(\"LLM_TIMEOUT\", DEFAULT_LLM_TIMEOUT, int)\n    embedding_timeout = get_env_value(\n        \"EMBEDDING_TIMEOUT\", DEFAULT_EMBEDDING_TIMEOUT, int\n    )\n\n    async def bedrock_model_complete(\n        prompt,\n        system_prompt=None,\n        history_messages=None,\n        keyword_extraction=False,\n        **kwargs,\n    ) -> str:\n        # Lazy import\n        from lightrag.llm.bedrock import bedrock_complete_if_cache\n\n        keyword_extraction = kwargs.pop(\"keyword_extraction\", None)\n        if keyword_extraction:\n            kwargs[\"response_format\"] = GPTKeywordExtractionFormat\n        if history_messages is None:\n            history_messages = []\n\n        # Use global temperature for Bedrock\n        kwargs[\"temperature\"] = get_env_value(\"BEDROCK_LLM_TEMPERATURE\", 1.0, float)\n\n        return await bedrock_complete_if_cache(\n            args.llm_model,\n            prompt,\n            system_prompt=system_prompt,\n            history_messages=history_messages,\n            **kwargs,\n        )\n\n    # Create embedding function with optimized configuration and max_token_size inheritance\n    import inspect\n\n    # Create the EmbeddingFunc instance (now returns complete EmbeddingFunc with max_token_size)\n    embedding_func = create_optimized_embedding_function(\n        config_cache=config_cache,\n        binding=args.embedding_binding,\n        model=args.embedding_model,\n        host=args.embedding_binding_host,\n        api_key=args.embedding_binding_api_key,\n        args=args,\n    )\n\n    # Get embedding_send_dim from centralized configuration\n    embedding_send_dim = args.embedding_send_dim\n\n    # Check if the underlying function signature has embedding_dim parameter\n    sig = inspect.signature(embedding_func.func)\n    has_embedding_dim_param = \"embedding_dim\" in sig.parameters\n\n    # Determine send_dimensions value based on binding type\n    # Jina and Gemini REQUIRE dimension parameter (forced to True)\n    # OpenAI and others: controlled by EMBEDDING_SEND_DIM environment variable\n    if args.embedding_binding in [\"jina\", \"gemini\"]:\n        # Jina and Gemini APIs require dimension parameter - always send it\n        send_dimensions = has_embedding_dim_param\n        dimension_control = f\"forced by {args.embedding_binding.title()} API\"\n    else:\n        # For OpenAI and other bindings, respect EMBEDDING_SEND_DIM setting\n        send_dimensions = embedding_send_dim and has_embedding_dim_param\n        if send_dimensions or not embedding_send_dim:\n            dimension_control = \"by env var\"\n        else:\n            dimension_control = \"by not hasparam\"\n\n    # Set send_dimensions on the EmbeddingFunc instance\n    embedding_func.send_dimensions = send_dimensions\n\n    logger.info(\n        f\"Send embedding dimension: {send_dimensions} {dimension_control} \"\n        f\"(dimensions={embedding_func.embedding_dim}, has_param={has_embedding_dim_param}, \"\n        f\"binding={args.embedding_binding})\"\n    )\n\n    # Log max_token_size source\n    if embedding_func.max_token_size:\n        source = (\n            \"env variable\"\n            if args.embedding_token_limit\n            else f\"{args.embedding_binding} provider default\"\n        )\n        logger.info(\n            f\"Embedding max_token_size: {embedding_func.max_token_size} (from {source})\"\n        )\n    else:\n        logger.info(\n            \"Embedding max_token_size: None (Embedding token limit is disabled).\"\n        )\n\n    # Configure rerank function based on args.rerank_bindingparameter\n    rerank_model_func = None\n    if args.rerank_binding != \"null\":\n        from lightrag.rerank import cohere_rerank, jina_rerank, ali_rerank\n\n        # Map rerank binding to corresponding function\n        rerank_functions = {\n            \"cohere\": cohere_rerank,\n            \"jina\": jina_rerank,\n            \"aliyun\": ali_rerank,\n        }\n\n        # Select the appropriate rerank function based on binding\n        selected_rerank_func = rerank_functions.get(args.rerank_binding)\n        if not selected_rerank_func:\n            logger.error(f\"Unsupported rerank binding: {args.rerank_binding}\")\n            raise ValueError(f\"Unsupported rerank binding: {args.rerank_binding}\")\n\n        # Get default values from selected_rerank_func if args values are None\n        if args.rerank_model is None or args.rerank_binding_host is None:\n            sig = inspect.signature(selected_rerank_func)\n\n            # Set default model if args.rerank_model is None\n            if args.rerank_model is None and \"model\" in sig.parameters:\n                default_model = sig.parameters[\"model\"].default\n                if default_model != inspect.Parameter.empty:\n                    args.rerank_model = default_model\n\n            # Set default base_url if args.rerank_binding_host is None\n            if args.rerank_binding_host is None and \"base_url\" in sig.parameters:\n                default_base_url = sig.parameters[\"base_url\"].default\n                if default_base_url != inspect.Parameter.empty:\n                    args.rerank_binding_host = default_base_url\n\n        async def server_rerank_func(\n            query: str, documents: list, top_n: int = None, extra_body: dict = None\n        ):\n            \"\"\"Server rerank function with configuration from environment variables\"\"\"\n            # Prepare kwargs for rerank function\n            kwargs = {\n                \"query\": query,\n                \"documents\": documents,\n                \"top_n\": top_n,\n                \"api_key\": args.rerank_binding_api_key,\n                \"model\": args.rerank_model,\n                \"base_url\": args.rerank_binding_host,\n            }\n\n            # Add Cohere-specific parameters if using cohere binding\n            if args.rerank_binding == \"cohere\":\n                # Enable chunking if configured (useful for models with token limits like ColBERT)\n                kwargs[\"enable_chunking\"] = (\n                    os.getenv(\"RERANK_ENABLE_CHUNKING\", \"false\").lower() == \"true\"\n                )\n                kwargs[\"max_tokens_per_doc\"] = int(\n                    os.getenv(\"RERANK_MAX_TOKENS_PER_DOC\", \"4096\")\n                )\n\n            return await selected_rerank_func(**kwargs, extra_body=extra_body)\n\n        rerank_model_func = server_rerank_func\n        logger.info(\n            f\"Reranking is enabled: {args.rerank_model or 'default model'} using {args.rerank_binding} provider\"\n        )\n    else:\n        logger.info(\"Reranking is disabled\")\n\n    # Create ollama_server_infos from command line arguments\n    from lightrag.api.config import OllamaServerInfos\n\n    ollama_server_infos = OllamaServerInfos(\n        name=args.simulated_model_name, tag=args.simulated_model_tag\n    )\n\n    # Initialize RAG with unified configuration\n    try:\n        rag = LightRAG(\n            working_dir=args.working_dir,\n            workspace=args.workspace,\n            llm_model_func=create_llm_model_func(args.llm_binding),\n            llm_model_name=args.llm_model,\n            llm_model_max_async=args.max_async,\n            summary_max_tokens=args.summary_max_tokens,\n            summary_context_size=args.summary_context_size,\n            chunk_token_size=int(args.chunk_size),\n            chunk_overlap_token_size=int(args.chunk_overlap_size),\n            llm_model_kwargs=create_llm_model_kwargs(\n                args.llm_binding, args, llm_timeout\n            ),\n            embedding_func=embedding_func,\n            default_llm_timeout=llm_timeout,\n            default_embedding_timeout=embedding_timeout,\n            kv_storage=args.kv_storage,\n            graph_storage=args.graph_storage,\n            vector_storage=args.vector_storage,\n            doc_status_storage=args.doc_status_storage,\n            vector_db_storage_cls_kwargs={\n                \"cosine_better_than_threshold\": args.cosine_threshold\n            },\n            enable_llm_cache_for_entity_extract=args.enable_llm_cache_for_extract,\n            enable_llm_cache=args.enable_llm_cache,\n            rerank_model_func=rerank_model_func,\n            max_parallel_insert=args.max_parallel_insert,\n            max_graph_nodes=args.max_graph_nodes,\n            addon_params={\n                \"language\": args.summary_language,\n                \"entity_types\": args.entity_types,\n            },\n            ollama_server_infos=ollama_server_infos,\n        )\n    except Exception as e:\n        logger.error(f\"Failed to initialize LightRAG: {e}\")\n        raise\n\n    # Add routes\n    app.include_router(\n        create_document_routes(\n            rag,\n            doc_manager,\n            api_key,\n        )\n    )\n    app.include_router(create_query_routes(rag, api_key, args.top_k))\n    app.include_router(create_graph_routes(rag, api_key))\n\n    # Add Ollama API routes\n    ollama_api = OllamaAPI(rag, top_k=args.top_k, api_key=api_key)\n    app.include_router(ollama_api.router, prefix=\"/api\")\n\n    # Custom Swagger UI endpoint for offline support\n    @app.get(\"/docs\", include_in_schema=False)\n    async def custom_swagger_ui_html():\n        \"\"\"Custom Swagger UI HTML with local static files\"\"\"\n        return get_swagger_ui_html(\n            openapi_url=app.openapi_url,\n            title=app.title + \" - Swagger UI\",\n            oauth2_redirect_url=\"/docs/oauth2-redirect\",\n            swagger_js_url=\"/static/swagger-ui/swagger-ui-bundle.js\",\n            swagger_css_url=\"/static/swagger-ui/swagger-ui.css\",\n            swagger_favicon_url=\"/static/swagger-ui/favicon-32x32.png\",\n            swagger_ui_parameters=app.swagger_ui_parameters,\n        )\n\n    @app.get(\"/docs/oauth2-redirect\", include_in_schema=False)\n    async def swagger_ui_redirect():\n        \"\"\"OAuth2 redirect for Swagger UI\"\"\"\n        return get_swagger_ui_oauth2_redirect_html()\n\n    @app.get(\"/\")\n    async def redirect_to_webui():\n        \"\"\"Redirect root path based on WebUI availability\"\"\"\n        if webui_assets_exist:\n            return RedirectResponse(url=\"/webui\")\n        else:\n            return RedirectResponse(url=\"/docs\")\n\n    @app.get(\"/auth-status\")\n    async def get_auth_status():\n        \"\"\"Get authentication status and guest token if auth is not configured\"\"\"\n\n        if not auth_handler.accounts:\n            # Authentication not configured, return guest token\n            guest_token = auth_handler.create_token(\n                username=\"guest\", role=\"guest\", metadata={\"auth_mode\": \"disabled\"}\n            )\n            return {\n                \"auth_configured\": False,\n                \"access_token\": guest_token,\n                \"token_type\": \"bearer\",\n                \"auth_mode\": \"disabled\",\n                \"message\": \"Authentication is disabled. Using guest access.\",\n                \"core_version\": core_version,\n                \"api_version\": api_version_display,\n                \"webui_title\": webui_title,\n                \"webui_description\": webui_description,\n            }\n\n        return {\n            \"auth_configured\": True,\n            \"auth_mode\": \"enabled\",\n            \"core_version\": core_version,\n            \"api_version\": api_version_display,\n            \"webui_title\": webui_title,\n            \"webui_description\": webui_description,\n        }\n\n    @app.post(\"/login\")\n    async def login(form_data: OAuth2PasswordRequestForm = Depends()):\n        if not auth_handler.accounts:\n            # Authentication not configured, return guest token\n            guest_token = auth_handler.create_token(\n                username=\"guest\", role=\"guest\", metadata={\"auth_mode\": \"disabled\"}\n            )\n            return {\n                \"access_token\": guest_token,\n                \"token_type\": \"bearer\",\n                \"auth_mode\": \"disabled\",\n                \"message\": \"Authentication is disabled. Using guest access.\",\n                \"core_version\": core_version,\n                \"api_version\": api_version_display,\n                \"webui_title\": webui_title,\n                \"webui_description\": webui_description,\n            }\n        username = form_data.username\n        if auth_handler.accounts.get(username) != form_data.password:\n            raise HTTPException(status_code=401, detail=\"Incorrect credentials\")\n\n        # Regular user login\n        user_token = auth_handler.create_token(\n            username=username, role=\"user\", metadata={\"auth_mode\": \"enabled\"}\n        )\n        return {\n            \"access_token\": user_token,\n            \"token_type\": \"bearer\",\n            \"auth_mode\": \"enabled\",\n            \"core_version\": core_version,\n            \"api_version\": api_version_display,\n            \"webui_title\": webui_title,\n            \"webui_description\": webui_description,\n        }\n\n    @app.get(\n        \"/health\",\n        dependencies=[Depends(combined_auth)],\n        summary=\"Get system health and configuration status\",\n        description=\"Returns comprehensive system status including WebUI availability, configuration, and operational metrics\",\n        response_description=\"System health status with configuration details\",\n        responses={\n            200: {\n                \"description\": \"Successful response with system status\",\n                \"content\": {\n                    \"application/json\": {\n                        \"example\": {\n                            \"status\": \"healthy\",\n                            \"webui_available\": True,\n                            \"working_directory\": \"/path/to/working/dir\",\n                            \"input_directory\": \"/path/to/input/dir\",\n                            \"configuration\": {\n                                \"llm_binding\": \"openai\",\n                                \"llm_model\": \"gpt-4\",\n                                \"embedding_binding\": \"openai\",\n                                \"embedding_model\": \"text-embedding-ada-002\",\n                                \"workspace\": \"default\",\n                            },\n                            \"auth_mode\": \"enabled\",\n                            \"pipeline_busy\": False,\n                            \"core_version\": \"0.0.1\",\n                            \"api_version\": \"0.0.1\",\n                        }\n                    }\n                },\n            }\n        },\n    )\n    async def get_status(request: Request):\n        \"\"\"Get current system status including WebUI availability\"\"\"\n        try:\n            workspace = get_workspace_from_request(request)\n            default_workspace = get_default_workspace()\n            if workspace is None:\n                workspace = default_workspace\n            pipeline_status = await get_namespace_data(\n                \"pipeline_status\", workspace=workspace\n            )\n\n            if not auth_configured:\n                auth_mode = \"disabled\"\n            else:\n                auth_mode = \"enabled\"\n\n            # Cleanup expired keyed locks and get status\n            keyed_lock_info = cleanup_keyed_lock()\n\n            return {\n                \"status\": \"healthy\",\n                \"webui_available\": webui_assets_exist,\n                \"working_directory\": str(args.working_dir),\n                \"input_directory\": str(args.input_dir),\n                \"configuration\": {\n                    # LLM configuration binding/host address (if applicable)/model (if applicable)\n                    \"llm_binding\": args.llm_binding,\n                    \"llm_binding_host\": args.llm_binding_host,\n                    \"llm_model\": args.llm_model,\n                    # embedding model configuration binding/host address (if applicable)/model (if applicable)\n                    \"embedding_binding\": args.embedding_binding,\n                    \"embedding_binding_host\": args.embedding_binding_host,\n                    \"embedding_model\": args.embedding_model,\n                    \"summary_max_tokens\": args.summary_max_tokens,\n                    \"summary_context_size\": args.summary_context_size,\n                    \"kv_storage\": args.kv_storage,\n                    \"doc_status_storage\": args.doc_status_storage,\n                    \"graph_storage\": args.graph_storage,\n                    \"vector_storage\": args.vector_storage,\n                    \"enable_llm_cache_for_extract\": args.enable_llm_cache_for_extract,\n                    \"enable_llm_cache\": args.enable_llm_cache,\n                    \"workspace\": default_workspace,\n                    \"max_graph_nodes\": args.max_graph_nodes,\n                    # Rerank configuration\n                    \"enable_rerank\": rerank_model_func is not None,\n                    \"rerank_binding\": args.rerank_binding,\n                    \"rerank_model\": args.rerank_model if rerank_model_func else None,\n                    \"rerank_binding_host\": args.rerank_binding_host\n                    if rerank_model_func\n                    else None,\n                    # Environment variable status (requested configuration)\n                    \"summary_language\": args.summary_language,\n                    \"force_llm_summary_on_merge\": args.force_llm_summary_on_merge,\n                    \"max_parallel_insert\": args.max_parallel_insert,\n                    \"cosine_threshold\": args.cosine_threshold,\n                    \"min_rerank_score\": args.min_rerank_score,\n                    \"related_chunk_number\": args.related_chunk_number,\n                    \"max_async\": args.max_async,\n                    \"embedding_func_max_async\": args.embedding_func_max_async,\n                    \"embedding_batch_num\": args.embedding_batch_num,\n                },\n                \"auth_mode\": auth_mode,\n                \"pipeline_busy\": pipeline_status.get(\"busy\", False),\n                \"keyed_locks\": keyed_lock_info,\n                \"core_version\": core_version,\n                \"api_version\": api_version_display,\n                \"webui_title\": webui_title,\n                \"webui_description\": webui_description,\n            }\n        except Exception as e:\n            logger.error(f\"Error getting health status: {str(e)}\")\n            raise HTTPException(status_code=500, detail=str(e))\n\n    # Custom StaticFiles class for smart caching\n    class SmartStaticFiles(StaticFiles):  # Renamed from NoCacheStaticFiles\n        async def get_response(self, path: str, scope):\n            response = await super().get_response(path, scope)\n\n            is_html = path.endswith(\".html\") or response.media_type == \"text/html\"\n\n            if is_html:\n                response.headers[\"Cache-Control\"] = (\n                    \"no-cache, no-store, must-revalidate\"\n                )\n                response.headers[\"Pragma\"] = \"no-cache\"\n                response.headers[\"Expires\"] = \"0\"\n            elif (\n                \"/assets/\" in path\n            ):  # Assets (JS, CSS, images, fonts) generated by Vite with hash in filename\n                response.headers[\"Cache-Control\"] = (\n                    \"public, max-age=31536000, immutable\"\n                )\n            # Add other rules here if needed for non-HTML, non-asset files\n\n            # Ensure correct Content-Type\n            if path.endswith(\".js\"):\n                response.headers[\"Content-Type\"] = \"application/javascript\"\n            elif path.endswith(\".css\"):\n                response.headers[\"Content-Type\"] = \"text/css\"\n\n            return response\n\n    # Mount Swagger UI static files for offline support\n    swagger_static_dir = Path(__file__).parent / \"static\" / \"swagger-ui\"\n    if swagger_static_dir.exists():\n        app.mount(\n            \"/static/swagger-ui\",\n            StaticFiles(directory=swagger_static_dir),\n            name=\"swagger-ui-static\",\n        )\n\n    # Conditionally mount WebUI only if assets exist\n    if webui_assets_exist:\n        static_dir = Path(__file__).parent / \"webui\"\n        static_dir.mkdir(exist_ok=True)\n        app.mount(\n            \"/webui\",\n            SmartStaticFiles(\n                directory=static_dir, html=True, check_dir=True\n            ),  # Use SmartStaticFiles\n            name=\"webui\",\n        )\n        logger.info(\"WebUI assets mounted at /webui\")\n    else:\n        logger.info(\"WebUI assets not available, /webui route not mounted\")\n\n        # Add redirect for /webui when assets are not available\n        @app.get(\"/webui\")\n        @app.get(\"/webui/\")\n        async def webui_redirect_to_docs():\n            \"\"\"Redirect /webui to /docs when WebUI is not available\"\"\"\n            return RedirectResponse(url=\"/docs\")\n\n    return app\n\n\ndef get_application(args=None):\n    \"\"\"Factory function for creating the FastAPI application\"\"\"\n    if args is None:\n        args = global_args\n    return create_app(args)\n\n\ndef configure_logging():\n    \"\"\"Configure logging for uvicorn startup\"\"\"\n\n    # Reset any existing handlers to ensure clean configuration\n    for logger_name in [\"uvicorn\", \"uvicorn.access\", \"uvicorn.error\", \"lightrag\"]:\n        logger = logging.getLogger(logger_name)\n        logger.handlers = []\n        logger.filters = []\n\n    # Get log directory path from environment variable\n    log_dir = os.getenv(\"LOG_DIR\", os.getcwd())\n    log_file_path = os.path.abspath(os.path.join(log_dir, DEFAULT_LOG_FILENAME))\n\n    print(f\"\\nLightRAG log file: {log_file_path}\\n\")\n    os.makedirs(os.path.dirname(log_dir), exist_ok=True)\n\n    # Get log file max size and backup count from environment variables\n    log_max_bytes = get_env_value(\"LOG_MAX_BYTES\", DEFAULT_LOG_MAX_BYTES, int)\n    log_backup_count = get_env_value(\"LOG_BACKUP_COUNT\", DEFAULT_LOG_BACKUP_COUNT, int)\n\n    logging.config.dictConfig(\n        {\n            \"version\": 1,\n            \"disable_existing_loggers\": False,\n            \"formatters\": {\n                \"default\": {\n                    \"format\": \"%(levelname)s: %(message)s\",\n                },\n                \"detailed\": {\n                    \"format\": \"%(asctime)s - %(name)s - %(levelname)s - %(message)s\",\n                },\n            },\n            \"handlers\": {\n                \"console\": {\n                    \"formatter\": \"default\",\n                    \"class\": \"logging.StreamHandler\",\n                    \"stream\": \"ext://sys.stderr\",\n                },\n                \"file\": {\n                    \"formatter\": \"detailed\",\n                    \"class\": \"logging.handlers.RotatingFileHandler\",\n                    \"filename\": log_file_path,\n                    \"maxBytes\": log_max_bytes,\n                    \"backupCount\": log_backup_count,\n                    \"encoding\": \"utf-8\",\n                },\n            },\n            \"loggers\": {\n                # Configure all uvicorn related loggers\n                \"uvicorn\": {\n                    \"handlers\": [\"console\", \"file\"],\n                    \"level\": \"INFO\",\n                    \"propagate\": False,\n                },\n                \"uvicorn.access\": {\n                    \"handlers\": [\"console\", \"file\"],\n                    \"level\": \"INFO\",\n                    \"propagate\": False,\n                    \"filters\": [\"path_filter\"],\n                },\n                \"uvicorn.error\": {\n                    \"handlers\": [\"console\", \"file\"],\n                    \"level\": \"INFO\",\n                    \"propagate\": False,\n                },\n                \"lightrag\": {\n                    \"handlers\": [\"console\", \"file\"],\n                    \"level\": \"INFO\",\n                    \"propagate\": False,\n                    \"filters\": [\"path_filter\"],\n                },\n            },\n            \"filters\": {\n                \"path_filter\": {\n                    \"()\": \"lightrag.utils.LightragPathFilter\",\n                },\n            },\n        }\n    )\n\n\ndef check_and_install_dependencies():\n    \"\"\"Check and install required dependencies\"\"\"\n    required_packages = [\n        \"uvicorn\",\n        \"tiktoken\",\n        \"fastapi\",\n        # Add other required packages here\n    ]\n\n    for package in required_packages:\n        if not pm.is_installed(package):\n            print(f\"Installing {package}...\")\n            pm.install(package)\n            print(f\"{package} installed successfully\")\n\n\ndef main():\n    # On Windows, ProactorEventLoop (default since Python 3.8) has known\n    # race conditions with uvicorn's socket binding that can cause the server\n    # to report it's running while the port is never actually bound.\n    # Using SelectorEventLoop resolves this issue.\n    # See: https://github.com/HKUDS/LightRAG/issues/2438\n    if sys.platform == \"win32\":\n        import asyncio\n\n        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())\n\n    # Explicitly initialize configuration for clarity\n    # (The proxy will auto-initialize anyway, but this makes intent clear)\n    from .config import initialize_config\n\n    initialize_config()\n\n    # Check if running under Gunicorn\n    if \"GUNICORN_CMD_ARGS\" in os.environ:\n        # If started with Gunicorn, return directly as Gunicorn will call get_application\n        print(\"Running under Gunicorn - worker management handled by Gunicorn\")\n        return\n\n    # Check .env file\n    if not check_env_file():\n        sys.exit(1)\n\n    # Check and install dependencies\n    check_and_install_dependencies()\n\n    from multiprocessing import freeze_support\n\n    freeze_support()\n\n    # Configure logging before parsing args\n    configure_logging()\n    update_uvicorn_mode_config()\n    display_splash_screen(global_args)\n\n    # Note: Signal handlers are NOT registered here because:\n    # - Uvicorn has built-in signal handling that properly calls lifespan shutdown\n    # - Custom signal handlers can interfere with uvicorn's graceful shutdown\n    # - Cleanup is handled by the lifespan context manager's finally block\n\n    # Create application instance directly instead of using factory function\n    app = create_app(global_args)\n\n    # Start Uvicorn in single process mode\n    uvicorn_config = {\n        \"app\": app,  # Pass application instance directly instead of string path\n        \"host\": global_args.host,\n        \"port\": global_args.port,\n        \"log_config\": None,  # Disable default config\n    }\n\n    if global_args.ssl:\n        uvicorn_config.update(\n            {\n                \"ssl_certfile\": global_args.ssl_certfile,\n                \"ssl_keyfile\": global_args.ssl_keyfile,\n            }\n        )\n\n    print(\n        f\"Starting Uvicorn server in single-process mode on {global_args.host}:{global_args.port}\"\n    )\n    uvicorn.run(**uvicorn_config)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "lightrag/api/routers/__init__.py",
    "content": "\"\"\"\nThis module contains all the routers for the LightRAG API.\n\"\"\"\n\nfrom .document_routes import router as document_router\nfrom .query_routes import router as query_router\nfrom .graph_routes import router as graph_router\nfrom .ollama_api import OllamaAPI\n\n__all__ = [\"document_router\", \"query_router\", \"graph_router\", \"OllamaAPI\"]\n"
  },
  {
    "path": "lightrag/api/routers/document_routes.py",
    "content": "\"\"\"\nThis module contains all document-related routes for the LightRAG API.\n\"\"\"\n\nimport asyncio\nfrom functools import lru_cache\nfrom lightrag.utils import logger, get_pinyin_sort_key\nimport aiofiles\nimport traceback\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Dict, List, Optional, Any, Literal\nfrom io import BytesIO\nfrom fastapi import (\n    APIRouter,\n    BackgroundTasks,\n    Depends,\n    File,\n    HTTPException,\n    UploadFile,\n)\nfrom pydantic import BaseModel, ConfigDict, Field, field_validator\n\nfrom lightrag import LightRAG\nfrom lightrag.base import DeletionResult, DocProcessingStatus, DocStatus\nfrom lightrag.utils import (\n    generate_track_id,\n    compute_mdhash_id,\n    sanitize_text_for_encoding,\n)\nfrom lightrag.api.utils_api import get_combined_auth_dependency\nfrom ..config import global_args\n\n\n@lru_cache(maxsize=1)\ndef _is_docling_available() -> bool:\n    \"\"\"Check if docling is available (cached check).\n\n    This function uses lru_cache to avoid repeated import attempts.\n    The result is cached after the first call.\n\n    Returns:\n        bool: True if docling is available, False otherwise\n    \"\"\"\n    try:\n        import docling  # noqa: F401  # type: ignore[import-not-found]\n\n        return True\n    except ImportError:\n        return False\n\n\n# Function to format datetime to ISO format string with timezone information\ndef format_datetime(dt: Any) -> Optional[str]:\n    \"\"\"Format datetime to ISO format string with timezone information\n\n    Args:\n        dt: Datetime object, string, or None\n\n    Returns:\n        ISO format string with timezone information, or None if input is None\n    \"\"\"\n    if dt is None:\n        return None\n    if isinstance(dt, str):\n        return dt\n\n    # Check if datetime object has timezone information\n    if isinstance(dt, datetime):\n        # If datetime object has no timezone info (naive datetime), add UTC timezone\n        if dt.tzinfo is None:\n            dt = dt.replace(tzinfo=timezone.utc)\n\n    # Return ISO format string with timezone information\n    return dt.isoformat()\n\n\nrouter = APIRouter(\n    prefix=\"/documents\",\n    tags=[\"documents\"],\n)\n\n# Temporary file prefix\ntemp_prefix = \"__tmp__\"\nUNKNOWN_FILE_SOURCE = \"unknown_source\"\nLEGACY_EMPTY_FILE_PATH_SENTINELS = {\"\", \"no-file-path\"}\n\n\ndef normalize_file_path(file_path: str | None) -> str:\n    \"\"\"Normalize missing document sources to a single non-null sentinel.\"\"\"\n    if file_path is None:\n        return UNKNOWN_FILE_SOURCE\n\n    normalized = file_path.strip()\n    if normalized in LEGACY_EMPTY_FILE_PATH_SENTINELS:\n        return UNKNOWN_FILE_SOURCE\n\n    return normalized\n\n\ndef sanitize_filename(filename: str, input_dir: Path) -> str:\n    \"\"\"\n    Sanitize uploaded filename to prevent Path Traversal attacks.\n\n    Args:\n        filename: The original filename from the upload\n        input_dir: The target input directory\n\n    Returns:\n        str: Sanitized filename that is safe to use\n\n    Raises:\n        HTTPException: If the filename is unsafe or invalid\n    \"\"\"\n    # Basic validation\n    if not filename or not filename.strip():\n        raise HTTPException(status_code=400, detail=\"Filename cannot be empty\")\n\n    # Remove path separators and traversal sequences\n    clean_name = filename.replace(\"/\", \"\").replace(\"\\\\\", \"\")\n    clean_name = clean_name.replace(\"..\", \"\")\n\n    # Remove control characters and null bytes\n    clean_name = \"\".join(c for c in clean_name if ord(c) >= 32 and c != \"\\x7f\")\n\n    # Remove leading/trailing whitespace and dots\n    clean_name = clean_name.strip().strip(\".\")\n\n    # Check if anything is left after sanitization\n    if not clean_name:\n        raise HTTPException(\n            status_code=400, detail=\"Invalid filename after sanitization\"\n        )\n\n    # Verify the final path stays within the input directory\n    try:\n        final_path = (input_dir / clean_name).resolve()\n        if not final_path.is_relative_to(input_dir.resolve()):\n            raise HTTPException(status_code=400, detail=\"Unsafe filename detected\")\n    except (OSError, ValueError):\n        raise HTTPException(status_code=400, detail=\"Invalid filename\")\n\n    return clean_name\n\n\nclass ScanResponse(BaseModel):\n    \"\"\"Response model for document scanning operation\n\n    Attributes:\n        status: Status of the scanning operation\n        message: Optional message with additional details\n        track_id: Tracking ID for monitoring scanning progress\n    \"\"\"\n\n    status: Literal[\"scanning_started\"] = Field(\n        description=\"Status of the scanning operation\"\n    )\n    message: Optional[str] = Field(\n        default=None, description=\"Additional details about the scanning operation\"\n    )\n    track_id: str = Field(description=\"Tracking ID for monitoring scanning progress\")\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"status\": \"scanning_started\",\n                \"message\": \"Scanning process has been initiated in the background\",\n                \"track_id\": \"scan_20250729_170612_abc123\",\n            }\n        }\n    )\n\n\nclass ReprocessResponse(BaseModel):\n    \"\"\"Response model for reprocessing failed documents operation\n\n    Attributes:\n        status: Status of the reprocessing operation\n        message: Message describing the operation result\n        track_id: Always empty string. Reprocessed documents retain their original track_id.\n    \"\"\"\n\n    status: Literal[\"reprocessing_started\"] = Field(\n        description=\"Status of the reprocessing operation\"\n    )\n    message: str = Field(description=\"Human-readable message describing the operation\")\n    track_id: str = Field(\n        default=\"\",\n        description=\"Always empty string. Reprocessed documents retain their original track_id from initial upload.\",\n    )\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"status\": \"reprocessing_started\",\n                \"message\": \"Reprocessing of failed documents has been initiated in background\",\n                \"track_id\": \"\",\n            }\n        }\n    )\n\n\nclass CancelPipelineResponse(BaseModel):\n    \"\"\"Response model for pipeline cancellation operation\n\n    Attributes:\n        status: Status of the cancellation request\n        message: Message describing the operation result\n    \"\"\"\n\n    status: Literal[\"cancellation_requested\", \"not_busy\"] = Field(\n        description=\"Status of the cancellation request\"\n    )\n    message: str = Field(description=\"Human-readable message describing the operation\")\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"status\": \"cancellation_requested\",\n                \"message\": \"Pipeline cancellation has been requested. Documents will be marked as FAILED.\",\n            }\n        }\n    )\n\n\nclass InsertTextRequest(BaseModel):\n    \"\"\"Request model for inserting a single text document\n\n    Attributes:\n        text: The text content to be inserted into the RAG system\n        file_source: Source of the text (optional)\n    \"\"\"\n\n    text: str = Field(\n        min_length=1,\n        description=\"The text to insert\",\n    )\n    file_source: Optional[str] = Field(\n        default=None, min_length=0, description=\"File Source\"\n    )\n\n    @field_validator(\"text\", mode=\"after\")\n    @classmethod\n    def strip_text_after(cls, text: str) -> str:\n        return text.strip()\n\n    @field_validator(\"file_source\", mode=\"before\")\n    @classmethod\n    def normalize_source_before(cls, file_source: Optional[str]) -> str:\n        return normalize_file_path(file_source)\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"text\": \"This is a sample text to be inserted into the RAG system.\",\n                \"file_source\": \"Source of the text (optional)\",\n            }\n        }\n    )\n\n\nclass InsertTextsRequest(BaseModel):\n    \"\"\"Request model for inserting multiple text documents\n\n    Attributes:\n        texts: List of text contents to be inserted into the RAG system\n        file_sources: Sources of the texts (optional)\n    \"\"\"\n\n    texts: list[str] = Field(\n        min_length=1,\n        description=\"The texts to insert\",\n    )\n    file_sources: Optional[list[str]] = Field(\n        default=None, min_length=0, description=\"Sources of the texts\"\n    )\n\n    @field_validator(\"texts\", mode=\"after\")\n    @classmethod\n    def strip_texts_after(cls, texts: list[str]) -> list[str]:\n        return [text.strip() for text in texts]\n\n    @field_validator(\"file_sources\", mode=\"before\")\n    @classmethod\n    def normalize_sources_before(\n        cls, file_sources: Optional[list[str]]\n    ) -> Optional[list[str]]:\n        if file_sources is None:\n            return None\n\n        return [normalize_file_path(file_source) for file_source in file_sources]\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"texts\": [\n                    \"This is the first text to be inserted.\",\n                    \"This is the second text to be inserted.\",\n                ],\n                \"file_sources\": [\n                    \"First file source (optional)\",\n                ],\n            }\n        }\n    )\n\n\nclass InsertResponse(BaseModel):\n    \"\"\"Response model for document insertion operations\n\n    Attributes:\n        status: Status of the operation (success, duplicated, partial_success, failure)\n        message: Detailed message describing the operation result\n        track_id: Tracking ID for monitoring processing status\n    \"\"\"\n\n    status: Literal[\"success\", \"duplicated\", \"partial_success\", \"failure\"] = Field(\n        description=\"Status of the operation\"\n    )\n    message: str = Field(description=\"Message describing the operation result\")\n    track_id: str = Field(description=\"Tracking ID for monitoring processing status\")\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"status\": \"success\",\n                \"message\": \"File 'document.pdf' uploaded successfully. Processing will continue in background.\",\n                \"track_id\": \"upload_20250729_170612_abc123\",\n            }\n        }\n    )\n\n\nclass ClearDocumentsResponse(BaseModel):\n    \"\"\"Response model for document clearing operation\n\n    Attributes:\n        status: Status of the clear operation\n        message: Detailed message describing the operation result\n    \"\"\"\n\n    status: Literal[\"success\", \"partial_success\", \"busy\", \"fail\"] = Field(\n        description=\"Status of the clear operation\"\n    )\n    message: str = Field(description=\"Message describing the operation result\")\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"status\": \"success\",\n                \"message\": \"All documents cleared successfully. Deleted 15 files.\",\n            }\n        }\n    )\n\n\nclass ClearCacheRequest(BaseModel):\n    \"\"\"Request model for clearing cache\n\n    This model is kept for API compatibility but no longer accepts any parameters.\n    All cache will be cleared regardless of the request content.\n    \"\"\"\n\n    model_config = ConfigDict(json_schema_extra={\"example\": {}})\n\n\nclass ClearCacheResponse(BaseModel):\n    \"\"\"Response model for cache clearing operation\n\n    Attributes:\n        status: Status of the clear operation\n        message: Detailed message describing the operation result\n    \"\"\"\n\n    status: Literal[\"success\", \"fail\"] = Field(\n        description=\"Status of the clear operation\"\n    )\n    message: str = Field(description=\"Message describing the operation result\")\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"status\": \"success\",\n                \"message\": \"Successfully cleared cache for modes: ['default', 'naive']\",\n            }\n        }\n    )\n\n\n\"\"\"Response model for document status\n\nAttributes:\n    id: Document identifier\n    content_summary: Summary of document content\n    content_length: Length of document content\n    status: Current processing status\n    created_at: Creation timestamp (ISO format string)\n    updated_at: Last update timestamp (ISO format string)\n    chunks_count: Number of chunks (optional)\n    error: Error message if any (optional)\n    metadata: Additional metadata (optional)\n    file_path: Path to the document file\n\"\"\"\n\n\nclass DeleteDocRequest(BaseModel):\n    doc_ids: List[str] = Field(..., description=\"The IDs of the documents to delete.\")\n    delete_file: bool = Field(\n        default=False,\n        description=\"Whether to delete the corresponding file in the upload directory.\",\n    )\n    delete_llm_cache: bool = Field(\n        default=False,\n        description=\"Whether to delete cached LLM extraction results for the documents.\",\n    )\n\n    @field_validator(\"doc_ids\", mode=\"after\")\n    @classmethod\n    def validate_doc_ids(cls, doc_ids: List[str]) -> List[str]:\n        if not doc_ids:\n            raise ValueError(\"Document IDs list cannot be empty\")\n\n        validated_ids = []\n        for doc_id in doc_ids:\n            if not doc_id or not doc_id.strip():\n                raise ValueError(\"Document ID cannot be empty\")\n            validated_ids.append(doc_id.strip())\n\n        # Check for duplicates\n        if len(validated_ids) != len(set(validated_ids)):\n            raise ValueError(\"Document IDs must be unique\")\n\n        return validated_ids\n\n\nclass DeleteEntityRequest(BaseModel):\n    entity_name: str = Field(..., description=\"The name of the entity to delete.\")\n\n    @field_validator(\"entity_name\", mode=\"after\")\n    @classmethod\n    def validate_entity_name(cls, entity_name: str) -> str:\n        if not entity_name or not entity_name.strip():\n            raise ValueError(\"Entity name cannot be empty\")\n        return entity_name.strip()\n\n\nclass DeleteRelationRequest(BaseModel):\n    source_entity: str = Field(..., description=\"The name of the source entity.\")\n    target_entity: str = Field(..., description=\"The name of the target entity.\")\n\n    @field_validator(\"source_entity\", \"target_entity\", mode=\"after\")\n    @classmethod\n    def validate_entity_names(cls, entity_name: str) -> str:\n        if not entity_name or not entity_name.strip():\n            raise ValueError(\"Entity name cannot be empty\")\n        return entity_name.strip()\n\n\nclass DocStatusResponse(BaseModel):\n    id: str = Field(description=\"Document identifier\")\n    content_summary: str = Field(description=\"Summary of document content\")\n    content_length: int = Field(description=\"Length of document content in characters\")\n    status: DocStatus = Field(description=\"Current processing status\")\n    created_at: str = Field(description=\"Creation timestamp (ISO format string)\")\n    updated_at: str = Field(description=\"Last update timestamp (ISO format string)\")\n    track_id: Optional[str] = Field(\n        default=None, description=\"Tracking ID for monitoring progress\"\n    )\n    chunks_count: Optional[int] = Field(\n        default=None, description=\"Number of chunks the document was split into\"\n    )\n    error_msg: Optional[str] = Field(\n        default=None, description=\"Error message if processing failed\"\n    )\n    metadata: Optional[dict[str, Any]] = Field(\n        default=None, description=\"Additional metadata about the document\"\n    )\n    file_path: str = Field(description=\"Path to the document file\")\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"id\": \"doc_123456\",\n                \"content_summary\": \"Research paper on machine learning\",\n                \"content_length\": 15240,\n                \"status\": \"processed\",\n                \"created_at\": \"2025-03-31T12:34:56\",\n                \"updated_at\": \"2025-03-31T12:35:30\",\n                \"track_id\": \"upload_20250729_170612_abc123\",\n                \"chunks_count\": 12,\n                \"error\": None,\n                \"metadata\": {\"author\": \"John Doe\", \"year\": 2025},\n                \"file_path\": \"research_paper.pdf\",\n            }\n        }\n    )\n\n\nclass DocsStatusesResponse(BaseModel):\n    \"\"\"Response model for document statuses\n\n    Attributes:\n        statuses: Dictionary mapping document status to lists of document status responses\n    \"\"\"\n\n    statuses: Dict[DocStatus, List[DocStatusResponse]] = Field(\n        default_factory=dict,\n        description=\"Dictionary mapping document status to lists of document status responses\",\n    )\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"statuses\": {\n                    \"PENDING\": [\n                        {\n                            \"id\": \"doc_123\",\n                            \"content_summary\": \"Pending document\",\n                            \"content_length\": 5000,\n                            \"status\": \"pending\",\n                            \"created_at\": \"2025-03-31T10:00:00\",\n                            \"updated_at\": \"2025-03-31T10:00:00\",\n                            \"track_id\": \"upload_20250331_100000_abc123\",\n                            \"chunks_count\": None,\n                            \"error\": None,\n                            \"metadata\": None,\n                            \"file_path\": \"pending_doc.pdf\",\n                        }\n                    ],\n                    \"PREPROCESSED\": [\n                        {\n                            \"id\": \"doc_789\",\n                            \"content_summary\": \"Document pending final indexing\",\n                            \"content_length\": 7200,\n                            \"status\": \"preprocessed\",\n                            \"created_at\": \"2025-03-31T09:30:00\",\n                            \"updated_at\": \"2025-03-31T09:35:00\",\n                            \"track_id\": \"upload_20250331_093000_xyz789\",\n                            \"chunks_count\": 10,\n                            \"error\": None,\n                            \"metadata\": None,\n                            \"file_path\": \"preprocessed_doc.pdf\",\n                        }\n                    ],\n                    \"PROCESSED\": [\n                        {\n                            \"id\": \"doc_456\",\n                            \"content_summary\": \"Processed document\",\n                            \"content_length\": 8000,\n                            \"status\": \"processed\",\n                            \"created_at\": \"2025-03-31T09:00:00\",\n                            \"updated_at\": \"2025-03-31T09:05:00\",\n                            \"track_id\": \"insert_20250331_090000_def456\",\n                            \"chunks_count\": 8,\n                            \"error\": None,\n                            \"metadata\": {\"author\": \"John Doe\"},\n                            \"file_path\": \"processed_doc.pdf\",\n                        }\n                    ],\n                }\n            }\n        }\n    )\n\n\nclass TrackStatusResponse(BaseModel):\n    \"\"\"Response model for tracking document processing status by track_id\n\n    Attributes:\n        track_id: The tracking ID\n        documents: List of documents associated with this track_id\n        total_count: Total number of documents for this track_id\n        status_summary: Count of documents by status\n    \"\"\"\n\n    track_id: str = Field(description=\"The tracking ID\")\n    documents: List[DocStatusResponse] = Field(\n        description=\"List of documents associated with this track_id\"\n    )\n    total_count: int = Field(description=\"Total number of documents for this track_id\")\n    status_summary: Dict[str, int] = Field(description=\"Count of documents by status\")\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"track_id\": \"upload_20250729_170612_abc123\",\n                \"documents\": [\n                    {\n                        \"id\": \"doc_123456\",\n                        \"content_summary\": \"Research paper on machine learning\",\n                        \"content_length\": 15240,\n                        \"status\": \"PROCESSED\",\n                        \"created_at\": \"2025-03-31T12:34:56\",\n                        \"updated_at\": \"2025-03-31T12:35:30\",\n                        \"track_id\": \"upload_20250729_170612_abc123\",\n                        \"chunks_count\": 12,\n                        \"error\": None,\n                        \"metadata\": {\"author\": \"John Doe\", \"year\": 2025},\n                        \"file_path\": \"research_paper.pdf\",\n                    }\n                ],\n                \"total_count\": 1,\n                \"status_summary\": {\"PROCESSED\": 1},\n            }\n        }\n    )\n\n\nclass DocumentsRequest(BaseModel):\n    \"\"\"Request model for paginated document queries\n\n    Attributes:\n        status_filter: Filter by document status, None for all statuses\n        page: Page number (1-based)\n        page_size: Number of documents per page (10-200)\n        sort_field: Field to sort by ('created_at', 'updated_at', 'id', 'file_path')\n        sort_direction: Sort direction ('asc' or 'desc')\n    \"\"\"\n\n    status_filter: Optional[DocStatus] = Field(\n        default=None, description=\"Filter by document status, None for all statuses\"\n    )\n    page: int = Field(default=1, ge=1, description=\"Page number (1-based)\")\n    page_size: int = Field(\n        default=50, ge=10, le=200, description=\"Number of documents per page (10-200)\"\n    )\n    sort_field: Literal[\"created_at\", \"updated_at\", \"id\", \"file_path\"] = Field(\n        default=\"updated_at\", description=\"Field to sort by\"\n    )\n    sort_direction: Literal[\"asc\", \"desc\"] = Field(\n        default=\"desc\", description=\"Sort direction\"\n    )\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"status_filter\": \"PROCESSED\",\n                \"page\": 1,\n                \"page_size\": 50,\n                \"sort_field\": \"updated_at\",\n                \"sort_direction\": \"desc\",\n            }\n        }\n    )\n\n\nclass PaginationInfo(BaseModel):\n    \"\"\"Pagination information\n\n    Attributes:\n        page: Current page number\n        page_size: Number of items per page\n        total_count: Total number of items\n        total_pages: Total number of pages\n        has_next: Whether there is a next page\n        has_prev: Whether there is a previous page\n    \"\"\"\n\n    page: int = Field(description=\"Current page number\")\n    page_size: int = Field(description=\"Number of items per page\")\n    total_count: int = Field(description=\"Total number of items\")\n    total_pages: int = Field(description=\"Total number of pages\")\n    has_next: bool = Field(description=\"Whether there is a next page\")\n    has_prev: bool = Field(description=\"Whether there is a previous page\")\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"page\": 1,\n                \"page_size\": 50,\n                \"total_count\": 150,\n                \"total_pages\": 3,\n                \"has_next\": True,\n                \"has_prev\": False,\n            }\n        }\n    )\n\n\nclass PaginatedDocsResponse(BaseModel):\n    \"\"\"Response model for paginated document queries\n\n    Attributes:\n        documents: List of documents for the current page\n        pagination: Pagination information\n        status_counts: Count of documents by status for all documents\n    \"\"\"\n\n    documents: List[DocStatusResponse] = Field(\n        description=\"List of documents for the current page\"\n    )\n    pagination: PaginationInfo = Field(description=\"Pagination information\")\n    status_counts: Dict[str, int] = Field(\n        description=\"Count of documents by status for all documents\"\n    )\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"documents\": [\n                    {\n                        \"id\": \"doc_123456\",\n                        \"content_summary\": \"Research paper on machine learning\",\n                        \"content_length\": 15240,\n                        \"status\": \"PROCESSED\",\n                        \"created_at\": \"2025-03-31T12:34:56\",\n                        \"updated_at\": \"2025-03-31T12:35:30\",\n                        \"track_id\": \"upload_20250729_170612_abc123\",\n                        \"chunks_count\": 12,\n                        \"error_msg\": None,\n                        \"metadata\": {\"author\": \"John Doe\", \"year\": 2025},\n                        \"file_path\": \"research_paper.pdf\",\n                    }\n                ],\n                \"pagination\": {\n                    \"page\": 1,\n                    \"page_size\": 50,\n                    \"total_count\": 150,\n                    \"total_pages\": 3,\n                    \"has_next\": True,\n                    \"has_prev\": False,\n                },\n                \"status_counts\": {\n                    \"PENDING\": 10,\n                    \"PROCESSING\": 5,\n                    \"PREPROCESSED\": 5,\n                    \"PROCESSED\": 130,\n                    \"FAILED\": 5,\n                },\n            }\n        }\n    )\n\n\nclass StatusCountsResponse(BaseModel):\n    \"\"\"Response model for document status counts\n\n    Attributes:\n        status_counts: Count of documents by status\n    \"\"\"\n\n    status_counts: Dict[str, int] = Field(description=\"Count of documents by status\")\n\n    model_config = ConfigDict(\n        json_schema_extra={\n            \"example\": {\n                \"status_counts\": {\n                    \"PENDING\": 10,\n                    \"PROCESSING\": 5,\n                    \"PREPROCESSED\": 5,\n                    \"PROCESSED\": 130,\n                    \"FAILED\": 5,\n                }\n            }\n        }\n    )\n\n\nclass PipelineStatusResponse(BaseModel):\n    \"\"\"Response model for pipeline status\n\n    Attributes:\n        autoscanned: Whether auto-scan has started\n        busy: Whether the pipeline is currently busy\n        job_name: Current job name (e.g., indexing files/indexing texts)\n        job_start: Job start time as ISO format string with timezone (optional)\n        docs: Total number of documents to be indexed\n        batchs: Number of batches for processing documents\n        cur_batch: Current processing batch\n        request_pending: Flag for pending request for processing\n        latest_message: Latest message from pipeline processing\n        history_messages: List of history messages\n        update_status: Status of update flags for all namespaces\n    \"\"\"\n\n    autoscanned: bool = False\n    busy: bool = False\n    job_name: str = \"Default Job\"\n    job_start: Optional[str] = None\n    docs: int = 0\n    batchs: int = 0\n    cur_batch: int = 0\n    request_pending: bool = False\n    latest_message: str = \"\"\n    history_messages: Optional[List[str]] = None\n    update_status: Optional[dict] = None\n\n    @field_validator(\"job_start\", mode=\"before\")\n    @classmethod\n    def parse_job_start(cls, value):\n        \"\"\"Process datetime and return as ISO format string with timezone\"\"\"\n        return format_datetime(value)\n\n    model_config = ConfigDict(extra=\"allow\")\n\n\nclass DocumentManager:\n    def __init__(\n        self,\n        input_dir: str,\n        workspace: str = \"\",  # New parameter for workspace isolation\n        supported_extensions: tuple = (\n            \".txt\",\n            \".md\",\n            \".mdx\",  # MDX (Markdown + JSX)\n            \".pdf\",\n            \".docx\",\n            \".pptx\",\n            \".xlsx\",\n            \".rtf\",  # Rich Text Format\n            \".odt\",  # OpenDocument Text\n            \".tex\",  # LaTeX\n            \".epub\",  # Electronic Publication\n            \".html\",  # HyperText Markup Language\n            \".htm\",  # HyperText Markup Language\n            \".csv\",  # Comma-Separated Values\n            \".json\",  # JavaScript Object Notation\n            \".xml\",  # eXtensible Markup Language\n            \".yaml\",  # YAML Ain't Markup Language\n            \".yml\",  # YAML\n            \".log\",  # Log files\n            \".conf\",  # Configuration files\n            \".ini\",  # Initialization files\n            \".properties\",  # Java properties files\n            \".sql\",  # SQL scripts\n            \".bat\",  # Batch files\n            \".sh\",  # Shell scripts\n            \".c\",  # C source code\n            \".h\",  # C header\n            \".cpp\",  # C++ source code\n            \".hpp\",  # C++ header\n            \".py\",  # Python source code\n            \".java\",  # Java source code\n            \".js\",  # JavaScript source code\n            \".ts\",  # TypeScript source code\n            \".swift\",  # Swift source code\n            \".go\",  # Go source code\n            \".rb\",  # Ruby source code\n            \".php\",  # PHP source code\n            \".css\",  # Cascading Style Sheets\n            \".scss\",  # Sassy CSS\n            \".less\",  # LESS CSS\n        ),\n    ):\n        # Store the base input directory and workspace\n        self.base_input_dir = Path(input_dir)\n        self.workspace = workspace\n        self.supported_extensions = supported_extensions\n        self.indexed_files = set()\n\n        # Create workspace-specific input directory\n        # If workspace is provided, create a subdirectory for data isolation\n        if workspace:\n            self.input_dir = self.base_input_dir / workspace\n        else:\n            self.input_dir = self.base_input_dir\n\n        # Create input directory if it doesn't exist\n        self.input_dir.mkdir(parents=True, exist_ok=True)\n\n    def scan_directory_for_new_files(self) -> List[Path]:\n        \"\"\"Scan input directory for new files\"\"\"\n        new_files = []\n        for ext in self.supported_extensions:\n            logger.debug(f\"Scanning for {ext} files in {self.input_dir}\")\n            for file_path in self.input_dir.glob(f\"*{ext}\"):\n                if file_path not in self.indexed_files:\n                    new_files.append(file_path)\n        return new_files\n\n    def mark_as_indexed(self, file_path: Path):\n        self.indexed_files.add(file_path)\n\n    def is_supported_file(self, filename: str) -> bool:\n        return any(filename.lower().endswith(ext) for ext in self.supported_extensions)\n\n\ndef validate_file_path_security(file_path_str: str, base_dir: Path) -> Optional[Path]:\n    \"\"\"\n    Validate file path security to prevent Path Traversal attacks.\n\n    Args:\n        file_path_str: The file path string to validate\n        base_dir: The base directory that the file must be within\n\n    Returns:\n        Path: Safe file path if valid, None if unsafe or invalid\n    \"\"\"\n    if not file_path_str or not file_path_str.strip():\n        return None\n\n    try:\n        # Clean the file path string\n        clean_path_str = file_path_str.strip()\n\n        # Check for obvious path traversal patterns before processing\n        # This catches both Unix (..) and Windows (..\\) style traversals\n        if \"..\" in clean_path_str:\n            # Additional check for Windows-style backslash traversal\n            if (\n                \"\\\\..\\\\\" in clean_path_str\n                or clean_path_str.startswith(\"..\\\\\")\n                or clean_path_str.endswith(\"\\\\..\")\n            ):\n                # logger.warning(\n                #     f\"Security violation: Windows path traversal attempt detected - {file_path_str}\"\n                # )\n                return None\n\n        # Normalize path separators (convert backslashes to forward slashes)\n        # This helps handle Windows-style paths on Unix systems\n        normalized_path = clean_path_str.replace(\"\\\\\", \"/\")\n\n        # Create path object and resolve it (handles symlinks and relative paths)\n        candidate_path = (base_dir / normalized_path).resolve()\n        base_dir_resolved = base_dir.resolve()\n\n        # Check if the resolved path is within the base directory\n        if not candidate_path.is_relative_to(base_dir_resolved):\n            # logger.warning(\n            #     f\"Security violation: Path traversal attempt detected - {file_path_str}\"\n            # )\n            return None\n\n        return candidate_path\n\n    except (OSError, ValueError, Exception) as e:\n        logger.warning(f\"Invalid file path detected: {file_path_str} - {str(e)}\")\n        return None\n\n\ndef get_unique_filename_in_enqueued(target_dir: Path, original_name: str) -> str:\n    \"\"\"Generate a unique filename in the target directory by adding numeric suffixes if needed\n\n    Args:\n        target_dir: Target directory path\n        original_name: Original filename\n\n    Returns:\n        str: Unique filename (may have numeric suffix added)\n    \"\"\"\n    import time\n\n    original_path = Path(original_name)\n    base_name = original_path.stem\n    extension = original_path.suffix\n\n    # Try original name first\n    if not (target_dir / original_name).exists():\n        return original_name\n\n    # Try with numeric suffixes 001-999\n    for i in range(1, 1000):\n        suffix = f\"{i:03d}\"\n        new_name = f\"{base_name}_{suffix}{extension}\"\n        if not (target_dir / new_name).exists():\n            return new_name\n\n    # Fallback with timestamp if all 999 slots are taken\n    timestamp = int(time.time())\n    return f\"{base_name}_{timestamp}{extension}\"\n\n\n# Document processing helper functions (synchronous)\n# These functions run in thread pool via asyncio.to_thread() to avoid blocking the event loop\n\n\ndef _convert_with_docling(file_path: Path) -> str:\n    \"\"\"Convert document using docling (synchronous).\n\n    Args:\n        file_path: Path to the document file\n\n    Returns:\n        str: Extracted markdown content\n    \"\"\"\n    from docling.document_converter import DocumentConverter  # type: ignore\n\n    converter = DocumentConverter()\n    result = converter.convert(file_path)\n    return result.document.export_to_markdown()\n\n\ndef _extract_pdf_pypdf(file_bytes: bytes, password: str = None) -> str:\n    \"\"\"Extract PDF content using pypdf (synchronous).\n\n    Args:\n        file_bytes: PDF file content as bytes\n        password: Optional password for encrypted PDFs\n\n    Returns:\n        str: Extracted text content\n\n    Raises:\n        Exception: If PDF is encrypted and password is incorrect or missing\n    \"\"\"\n    from pypdf import PdfReader  # type: ignore\n\n    pdf_file = BytesIO(file_bytes)\n    reader = PdfReader(pdf_file)\n\n    # Check if PDF is encrypted\n    if reader.is_encrypted:\n        if not password:\n            raise Exception(\"PDF is encrypted but no password provided\")\n\n        decrypt_result = reader.decrypt(password)\n        if decrypt_result == 0:\n            raise Exception(\"Incorrect PDF password\")\n\n    # Extract text from all pages\n    content = \"\"\n    for page in reader.pages:\n        content += page.extract_text() + \"\\n\"\n\n    return content\n\n\ndef _extract_docx(file_bytes: bytes) -> str:\n    \"\"\"Extract DOCX content including tables in document order (synchronous).\n\n    Args:\n        file_bytes: DOCX file content as bytes\n\n    Returns:\n        str: Extracted text content with tables in their original positions.\n             Tables are separated from paragraphs with blank lines for clarity.\n    \"\"\"\n    from docx import Document  # type: ignore\n    from docx.table import Table  # type: ignore\n    from docx.text.paragraph import Paragraph  # type: ignore\n\n    docx_file = BytesIO(file_bytes)\n    doc = Document(docx_file)\n\n    def escape_cell(cell_value: str | None) -> str:\n        \"\"\"Escape characters that would break tab-delimited layout.\n\n        Escape order is critical: backslashes first, then tabs/newlines.\n        This prevents double-escaping issues.\n\n        Args:\n            cell_value: The cell value to escape (can be None or str)\n\n        Returns:\n            str: Escaped cell value safe for tab-delimited format\n        \"\"\"\n        if cell_value is None:\n            return \"\"\n        text = str(cell_value)\n        # CRITICAL: Escape backslash first to avoid double-escaping\n        return (\n            text.replace(\"\\\\\", \"\\\\\\\\\")  # Must be first: \\ -> \\\\\n            .replace(\"\\t\", \"&emsp;&emsp;\")  # Tab -> \\t (visible)\n            .replace(\"\\r\\n\", \"<br>\")  # Windows newline -> \\n\n            .replace(\"\\r\", \"<br>\")  # Mac newline -> \\n\n            .replace(\"\\n\", \"<br>\")  # Unix newline -> \\n\n        )\n\n    content_parts = []\n    in_table = False  # Track if we're currently processing a table\n\n    # Iterate through all body elements in document order\n    for element in doc.element.body:\n        # Check if element is a paragraph\n        if element.tag.endswith(\"p\"):\n            # If coming out of a table, add blank line after table\n            if in_table:\n                content_parts.append(\"\")  # Blank line after table\n                in_table = False\n\n            paragraph = Paragraph(element, doc)\n            text = paragraph.text\n            # Always append to preserve document spacing (including blank paragraphs)\n            content_parts.append(text)\n\n        # Check if element is a table\n        elif element.tag.endswith(\"tbl\"):\n            # Add blank line before table (if content exists)\n            if content_parts and not in_table:\n                content_parts.append(\"\")  # Blank line before table\n\n            in_table = True\n            table = Table(element, doc)\n            for row in table.rows:\n                row_text = []\n                for cell in row.cells:\n                    cell_text = cell.text\n                    # Escape special characters to preserve tab-delimited structure\n                    row_text.append(escape_cell(cell_text))\n                # Only add row if at least one cell has content\n                if any(cell for cell in row_text):\n                    content_parts.append(\"\\t\".join(row_text))\n\n    return \"\\n\".join(content_parts)\n\n\ndef _extract_pptx(file_bytes: bytes) -> str:\n    \"\"\"Extract PPTX content (synchronous).\n\n    Args:\n        file_bytes: PPTX file content as bytes\n\n    Returns:\n        str: Extracted text content\n    \"\"\"\n    from pptx import Presentation  # type: ignore\n\n    pptx_file = BytesIO(file_bytes)\n    prs = Presentation(pptx_file)\n    content = \"\"\n    for slide in prs.slides:\n        for shape in slide.shapes:\n            if hasattr(shape, \"text\"):\n                content += shape.text + \"\\n\"\n    return content\n\n\ndef _extract_xlsx(file_bytes: bytes) -> str:\n    \"\"\"Extract XLSX content in tab-delimited format with clear sheet separation.\n\n    This function processes Excel workbooks and converts them to a structured text format\n    suitable for LLM prompts and RAG systems. Each sheet is clearly delimited with\n    separator lines, and special characters are escaped to preserve the tab-delimited structure.\n\n    Features:\n    - Each sheet is wrapped with '====================' separators for visual distinction\n    - Special characters (tabs, newlines, backslashes) are escaped to prevent structure corruption\n    - Column alignment is preserved across all rows to maintain tabular structure\n    - Empty rows are preserved as blank lines to maintain row structure\n    - Uses sheet.max_column to determine column width efficiently\n\n    Args:\n        file_bytes: XLSX file content as bytes\n\n    Returns:\n        str: Extracted text content with all sheets in tab-delimited format.\n             Format: Sheet separators, sheet name, then tab-delimited rows.\n\n    Example output:\n        ==================== Sheet: Data ====================\n        Name\\tAge\\tCity\n        Alice\\t30\\tNew York\n        Bob\\t25\\tLondon\n\n        ==================== Sheet: Summary ====================\n        Total\\t2\n        ====================\n    \"\"\"\n    from openpyxl import load_workbook  # type: ignore\n\n    xlsx_file = BytesIO(file_bytes)\n    wb = load_workbook(xlsx_file)\n\n    def escape_cell(cell_value: str | int | float | None) -> str:\n        \"\"\"Escape characters that would break tab-delimited layout.\n\n        Escape order is critical: backslashes first, then tabs/newlines.\n        This prevents double-escaping issues.\n\n        Args:\n            cell_value: The cell value to escape (can be None, str, int, or float)\n\n        Returns:\n            str: Escaped cell value safe for tab-delimited format\n        \"\"\"\n        if cell_value is None:\n            return \"\"\n        text = str(cell_value)\n        # CRITICAL: Escape backslash first to avoid double-escaping\n        return (\n            text.replace(\"\\\\\", \"\\\\\\\\\")  # Must be first: \\ -> \\\\\n            .replace(\"\\t\", \"\\\\t\")  # Tab -> \\t (visible)\n            .replace(\"\\r\\n\", \"\\\\n\")  # Windows newline -> \\n\n            .replace(\"\\r\", \"\\\\n\")  # Mac newline -> \\n\n            .replace(\"\\n\", \"\\\\n\")  # Unix newline -> \\n\n        )\n\n    def escape_sheet_title(title: str) -> str:\n        \"\"\"Escape sheet title to prevent formatting issues in separators.\n\n        Args:\n            title: Original sheet title\n\n        Returns:\n            str: Sanitized sheet title with tabs/newlines replaced\n        \"\"\"\n        return str(title).replace(\"\\n\", \" \").replace(\"\\t\", \" \").replace(\"\\r\", \" \")\n\n    content_parts: list[str] = []\n    sheet_separator = \"=\" * 20\n\n    for idx, sheet in enumerate(wb):\n        if idx > 0:\n            content_parts.append(\"\")  # Blank line between sheets for readability\n\n        # Escape sheet title to handle edge cases with special characters\n        safe_title = escape_sheet_title(sheet.title)\n        content_parts.append(f\"{sheet_separator} Sheet: {safe_title} {sheet_separator}\")\n\n        # Use sheet.max_column to get the maximum column width directly\n        max_columns = sheet.max_column if sheet.max_column else 0\n\n        # Extract rows with consistent width to preserve column alignment\n        for row in sheet.iter_rows(values_only=True):\n            row_parts = []\n\n            # Build row up to max_columns width\n            for idx in range(max_columns):\n                if idx < len(row):\n                    row_parts.append(escape_cell(row[idx]))\n                else:\n                    row_parts.append(\"\")  # Pad short rows\n\n            # Check if row is completely empty\n            if all(part == \"\" for part in row_parts):\n                # Preserve empty rows as blank lines (maintains row structure)\n                content_parts.append(\"\")\n            else:\n                # Join all columns to maintain consistent column count\n                content_parts.append(\"\\t\".join(row_parts))\n\n    # Final separator for symmetry (makes parsing easier)\n    content_parts.append(sheet_separator)\n    return \"\\n\".join(content_parts)\n\n\nasync def pipeline_enqueue_file(\n    rag: LightRAG, file_path: Path, track_id: str = None\n) -> tuple[bool, str]:\n    \"\"\"Add a file to the queue for processing\n\n    Args:\n        rag: LightRAG instance\n        file_path: Path to the saved file\n        track_id: Optional tracking ID, if not provided will be generated\n    Returns:\n        tuple: (success: bool, track_id: str)\n    \"\"\"\n\n    # Generate track_id if not provided\n    if track_id is None:\n        track_id = generate_track_id(\"unknown\")\n\n    try:\n        content = \"\"\n        ext = file_path.suffix.lower()\n        file_size = 0\n\n        # Get file size for error reporting\n        try:\n            file_size = file_path.stat().st_size\n        except Exception:\n            file_size = 0\n\n        file = None\n        try:\n            async with aiofiles.open(file_path, \"rb\") as f:\n                file = await f.read()\n        except PermissionError as e:\n            error_files = [\n                {\n                    \"file_path\": str(file_path.name),\n                    \"error_description\": \"[File Extraction]Permission denied - cannot read file\",\n                    \"original_error\": str(e),\n                    \"file_size\": file_size,\n                }\n            ]\n            await rag.apipeline_enqueue_error_documents(error_files, track_id)\n            logger.error(\n                f\"[File Extraction]Permission denied reading file: {file_path.name}\"\n            )\n            return False, track_id\n        except FileNotFoundError as e:\n            error_files = [\n                {\n                    \"file_path\": str(file_path.name),\n                    \"error_description\": \"[File Extraction]File not found\",\n                    \"original_error\": str(e),\n                    \"file_size\": file_size,\n                }\n            ]\n            await rag.apipeline_enqueue_error_documents(error_files, track_id)\n            logger.error(f\"[File Extraction]File not found: {file_path.name}\")\n            return False, track_id\n        except Exception as e:\n            error_files = [\n                {\n                    \"file_path\": str(file_path.name),\n                    \"error_description\": \"[File Extraction]File reading error\",\n                    \"original_error\": str(e),\n                    \"file_size\": file_size,\n                }\n            ]\n            await rag.apipeline_enqueue_error_documents(error_files, track_id)\n            logger.error(\n                f\"[File Extraction]Error reading file {file_path.name}: {str(e)}\"\n            )\n            return False, track_id\n\n        # Process based on file type\n        try:\n            match ext:\n                case (\n                    \".txt\"\n                    | \".md\"\n                    | \".mdx\"\n                    | \".html\"\n                    | \".htm\"\n                    | \".tex\"\n                    | \".json\"\n                    | \".xml\"\n                    | \".yaml\"\n                    | \".yml\"\n                    | \".rtf\"\n                    | \".odt\"\n                    | \".epub\"\n                    | \".csv\"\n                    | \".log\"\n                    | \".conf\"\n                    | \".ini\"\n                    | \".properties\"\n                    | \".sql\"\n                    | \".bat\"\n                    | \".sh\"\n                    | \".c\"\n                    | \".h\"\n                    | \".cpp\"\n                    | \".hpp\"\n                    | \".py\"\n                    | \".java\"\n                    | \".js\"\n                    | \".ts\"\n                    | \".swift\"\n                    | \".go\"\n                    | \".rb\"\n                    | \".php\"\n                    | \".css\"\n                    | \".scss\"\n                    | \".less\"\n                ):\n                    try:\n                        # Try to decode as UTF-8\n                        content = file.decode(\"utf-8\")\n\n                        # Validate content\n                        if not content or len(content.strip()) == 0:\n                            error_files = [\n                                {\n                                    \"file_path\": str(file_path.name),\n                                    \"error_description\": \"[File Extraction]Empty file content\",\n                                    \"original_error\": \"File contains no content or only whitespace\",\n                                    \"file_size\": file_size,\n                                }\n                            ]\n                            await rag.apipeline_enqueue_error_documents(\n                                error_files, track_id\n                            )\n                            logger.error(\n                                f\"[File Extraction]Empty content in file: {file_path.name}\"\n                            )\n                            return False, track_id\n\n                        # Check if content looks like binary data string representation\n                        if content.startswith(\"b'\") or content.startswith('b\"'):\n                            error_files = [\n                                {\n                                    \"file_path\": str(file_path.name),\n                                    \"error_description\": \"[File Extraction]Binary data in text file\",\n                                    \"original_error\": \"File appears to contain binary data representation instead of text\",\n                                    \"file_size\": file_size,\n                                }\n                            ]\n                            await rag.apipeline_enqueue_error_documents(\n                                error_files, track_id\n                            )\n                            logger.error(\n                                f\"[File Extraction]File {file_path.name} appears to contain binary data representation instead of text\"\n                            )\n                            return False, track_id\n\n                    except UnicodeDecodeError as e:\n                        error_files = [\n                            {\n                                \"file_path\": str(file_path.name),\n                                \"error_description\": \"[File Extraction]UTF-8 encoding error, please convert it to UTF-8 before processing\",\n                                \"original_error\": f\"File is not valid UTF-8 encoded text: {str(e)}\",\n                                \"file_size\": file_size,\n                            }\n                        ]\n                        await rag.apipeline_enqueue_error_documents(\n                            error_files, track_id\n                        )\n                        logger.error(\n                            f\"[File Extraction]File {file_path.name} is not valid UTF-8 encoded text. Please convert it to UTF-8 before processing.\"\n                        )\n                        return False, track_id\n\n                case \".pdf\":\n                    try:\n                        # Try DOCLING first if configured and available\n                        if (\n                            global_args.document_loading_engine == \"DOCLING\"\n                            and _is_docling_available()\n                        ):\n                            content = await asyncio.to_thread(\n                                _convert_with_docling, file_path\n                            )\n                        else:\n                            if (\n                                global_args.document_loading_engine == \"DOCLING\"\n                                and not _is_docling_available()\n                            ):\n                                logger.warning(\n                                    f\"DOCLING engine configured but not available for {file_path.name}. Falling back to pypdf.\"\n                                )\n                            # Use pypdf (non-blocking via to_thread)\n                            content = await asyncio.to_thread(\n                                _extract_pdf_pypdf,\n                                file,\n                                global_args.pdf_decrypt_password,\n                            )\n                    except Exception as e:\n                        error_files = [\n                            {\n                                \"file_path\": str(file_path.name),\n                                \"error_description\": \"[File Extraction]PDF processing error\",\n                                \"original_error\": f\"Failed to extract text from PDF: {str(e)}\",\n                                \"file_size\": file_size,\n                            }\n                        ]\n                        await rag.apipeline_enqueue_error_documents(\n                            error_files, track_id\n                        )\n                        logger.error(\n                            f\"[File Extraction]Error processing PDF {file_path.name}: {str(e)}\"\n                        )\n                        return False, track_id\n\n                case \".docx\":\n                    try:\n                        # Try DOCLING first if configured and available\n                        if (\n                            global_args.document_loading_engine == \"DOCLING\"\n                            and _is_docling_available()\n                        ):\n                            content = await asyncio.to_thread(\n                                _convert_with_docling, file_path\n                            )\n                        else:\n                            if (\n                                global_args.document_loading_engine == \"DOCLING\"\n                                and not _is_docling_available()\n                            ):\n                                logger.warning(\n                                    f\"DOCLING engine configured but not available for {file_path.name}. Falling back to python-docx.\"\n                                )\n                            # Use python-docx (non-blocking via to_thread)\n                            content = await asyncio.to_thread(_extract_docx, file)\n                    except Exception as e:\n                        error_files = [\n                            {\n                                \"file_path\": str(file_path.name),\n                                \"error_description\": \"[File Extraction]DOCX processing error\",\n                                \"original_error\": f\"Failed to extract text from DOCX: {str(e)}\",\n                                \"file_size\": file_size,\n                            }\n                        ]\n                        await rag.apipeline_enqueue_error_documents(\n                            error_files, track_id\n                        )\n                        logger.error(\n                            f\"[File Extraction]Error processing DOCX {file_path.name}: {str(e)}\"\n                        )\n                        return False, track_id\n\n                case \".pptx\":\n                    try:\n                        # Try DOCLING first if configured and available\n                        if (\n                            global_args.document_loading_engine == \"DOCLING\"\n                            and _is_docling_available()\n                        ):\n                            content = await asyncio.to_thread(\n                                _convert_with_docling, file_path\n                            )\n                        else:\n                            if (\n                                global_args.document_loading_engine == \"DOCLING\"\n                                and not _is_docling_available()\n                            ):\n                                logger.warning(\n                                    f\"DOCLING engine configured but not available for {file_path.name}. Falling back to python-pptx.\"\n                                )\n                            # Use python-pptx (non-blocking via to_thread)\n                            content = await asyncio.to_thread(_extract_pptx, file)\n                    except Exception as e:\n                        error_files = [\n                            {\n                                \"file_path\": str(file_path.name),\n                                \"error_description\": \"[File Extraction]PPTX processing error\",\n                                \"original_error\": f\"Failed to extract text from PPTX: {str(e)}\",\n                                \"file_size\": file_size,\n                            }\n                        ]\n                        await rag.apipeline_enqueue_error_documents(\n                            error_files, track_id\n                        )\n                        logger.error(\n                            f\"[File Extraction]Error processing PPTX {file_path.name}: {str(e)}\"\n                        )\n                        return False, track_id\n\n                case \".xlsx\":\n                    try:\n                        # Try DOCLING first if configured and available\n                        if (\n                            global_args.document_loading_engine == \"DOCLING\"\n                            and _is_docling_available()\n                        ):\n                            content = await asyncio.to_thread(\n                                _convert_with_docling, file_path\n                            )\n                        else:\n                            if (\n                                global_args.document_loading_engine == \"DOCLING\"\n                                and not _is_docling_available()\n                            ):\n                                logger.warning(\n                                    f\"DOCLING engine configured but not available for {file_path.name}. Falling back to openpyxl.\"\n                                )\n                            # Use openpyxl (non-blocking via to_thread)\n                            content = await asyncio.to_thread(_extract_xlsx, file)\n                    except Exception as e:\n                        error_files = [\n                            {\n                                \"file_path\": str(file_path.name),\n                                \"error_description\": \"[File Extraction]XLSX processing error\",\n                                \"original_error\": f\"Failed to extract text from XLSX: {str(e)}\",\n                                \"file_size\": file_size,\n                            }\n                        ]\n                        await rag.apipeline_enqueue_error_documents(\n                            error_files, track_id\n                        )\n                        logger.error(\n                            f\"[File Extraction]Error processing XLSX {file_path.name}: {str(e)}\"\n                        )\n                        return False, track_id\n\n                case _:\n                    error_files = [\n                        {\n                            \"file_path\": str(file_path.name),\n                            \"error_description\": f\"[File Extraction]Unsupported file type: {ext}\",\n                            \"original_error\": f\"File extension {ext} is not supported\",\n                            \"file_size\": file_size,\n                        }\n                    ]\n                    await rag.apipeline_enqueue_error_documents(error_files, track_id)\n                    logger.error(\n                        f\"[File Extraction]Unsupported file type: {file_path.name} (extension {ext})\"\n                    )\n                    return False, track_id\n\n        except Exception as e:\n            error_files = [\n                {\n                    \"file_path\": str(file_path.name),\n                    \"error_description\": \"[File Extraction]File format processing error\",\n                    \"original_error\": f\"Unexpected error during file extracting: {str(e)}\",\n                    \"file_size\": file_size,\n                }\n            ]\n            await rag.apipeline_enqueue_error_documents(error_files, track_id)\n            logger.error(\n                f\"[File Extraction]Unexpected error during {file_path.name} extracting: {str(e)}\"\n            )\n            return False, track_id\n\n        # Insert into the RAG queue\n        if content:\n            # Check if content contains only whitespace characters\n            if not content.strip():\n                error_files = [\n                    {\n                        \"file_path\": str(file_path.name),\n                        \"error_description\": \"[File Extraction]File contains only whitespace\",\n                        \"original_error\": \"File content contains only whitespace characters\",\n                        \"file_size\": file_size,\n                    }\n                ]\n                await rag.apipeline_enqueue_error_documents(error_files, track_id)\n                logger.warning(\n                    f\"[File Extraction]File contains only whitespace characters: {file_path.name}\"\n                )\n                return False, track_id\n\n            try:\n                await rag.apipeline_enqueue_documents(\n                    content, file_paths=file_path.name, track_id=track_id\n                )\n\n                logger.info(\n                    f\"Successfully extracted and enqueued file: {file_path.name}\"\n                )\n\n                # Move file to __enqueued__ directory after enqueuing\n                try:\n                    enqueued_dir = file_path.parent / \"__enqueued__\"\n                    enqueued_dir.mkdir(exist_ok=True)\n\n                    # Generate unique filename to avoid conflicts\n                    unique_filename = get_unique_filename_in_enqueued(\n                        enqueued_dir, file_path.name\n                    )\n                    target_path = enqueued_dir / unique_filename\n\n                    # Move the file\n                    file_path.rename(target_path)\n                    logger.debug(\n                        f\"Moved file to enqueued directory: {file_path.name} -> {unique_filename}\"\n                    )\n\n                except Exception as move_error:\n                    logger.error(\n                        f\"Failed to move file {file_path.name} to __enqueued__ directory: {move_error}\"\n                    )\n                    # Don't affect the main function's success status\n\n                return True, track_id\n\n            except Exception as e:\n                error_files = [\n                    {\n                        \"file_path\": str(file_path.name),\n                        \"error_description\": \"Document enqueue error\",\n                        \"original_error\": f\"Failed to enqueue document: {str(e)}\",\n                        \"file_size\": file_size,\n                    }\n                ]\n                await rag.apipeline_enqueue_error_documents(error_files, track_id)\n                logger.error(f\"Error enqueueing document {file_path.name}: {str(e)}\")\n                return False, track_id\n        else:\n            error_files = [\n                {\n                    \"file_path\": str(file_path.name),\n                    \"error_description\": \"No content extracted\",\n                    \"original_error\": \"No content could be extracted from file\",\n                    \"file_size\": file_size,\n                }\n            ]\n            await rag.apipeline_enqueue_error_documents(error_files, track_id)\n            logger.error(f\"No content extracted from file: {file_path.name}\")\n            return False, track_id\n\n    except Exception as e:\n        # Catch-all for any unexpected errors\n        try:\n            file_size = file_path.stat().st_size if file_path.exists() else 0\n        except Exception:\n            file_size = 0\n\n        error_files = [\n            {\n                \"file_path\": str(file_path.name),\n                \"error_description\": \"Unexpected processing error\",\n                \"original_error\": f\"Unexpected error: {str(e)}\",\n                \"file_size\": file_size,\n            }\n        ]\n        await rag.apipeline_enqueue_error_documents(error_files, track_id)\n        logger.error(f\"Enqueuing file {file_path.name} error: {str(e)}\")\n        logger.error(traceback.format_exc())\n        return False, track_id\n    finally:\n        if file_path.name.startswith(temp_prefix):\n            try:\n                file_path.unlink()\n            except Exception as e:\n                logger.error(f\"Error deleting file {file_path}: {str(e)}\")\n\n\nasync def pipeline_index_file(rag: LightRAG, file_path: Path, track_id: str = None):\n    \"\"\"Index a file with track_id\n\n    Args:\n        rag: LightRAG instance\n        file_path: Path to the saved file\n        track_id: Optional tracking ID\n    \"\"\"\n    try:\n        success, returned_track_id = await pipeline_enqueue_file(\n            rag, file_path, track_id\n        )\n        if success:\n            await rag.apipeline_process_enqueue_documents()\n\n    except Exception as e:\n        logger.error(f\"Error indexing file {file_path.name}: {str(e)}\")\n        logger.error(traceback.format_exc())\n\n\nasync def pipeline_index_files(\n    rag: LightRAG, file_paths: List[Path], track_id: str = None\n):\n    \"\"\"Index multiple files sequentially to avoid high CPU load\n\n    Args:\n        rag: LightRAG instance\n        file_paths: Paths to the files to index\n        track_id: Optional tracking ID to pass to all files\n    \"\"\"\n    if not file_paths:\n        return\n    try:\n        enqueued = False\n\n        # Use get_pinyin_sort_key for Chinese pinyin sorting\n        sorted_file_paths = sorted(\n            file_paths, key=lambda p: get_pinyin_sort_key(str(p))\n        )\n\n        # Process files sequentially with track_id\n        for file_path in sorted_file_paths:\n            success, _ = await pipeline_enqueue_file(rag, file_path, track_id)\n            if success:\n                enqueued = True\n\n        # Process the queue only if at least one file was successfully enqueued\n        if enqueued:\n            await rag.apipeline_process_enqueue_documents()\n    except Exception as e:\n        logger.error(f\"Error indexing files: {str(e)}\")\n        logger.error(traceback.format_exc())\n\n\nasync def pipeline_index_texts(\n    rag: LightRAG,\n    texts: List[str],\n    file_sources: List[str] = None,\n    track_id: str = None,\n):\n    \"\"\"Index a list of texts with track_id\n\n    Args:\n        rag: LightRAG instance\n        texts: The texts to index\n        file_sources: Sources of the texts\n        track_id: Optional tracking ID\n    \"\"\"\n    if not texts:\n        return\n\n    normalized_file_sources: list[str] | None = None\n    if file_sources:\n        normalized_file_sources = [\n            normalize_file_path(source) for source in file_sources\n        ]\n        if len(normalized_file_sources) > len(texts):\n            raise ValueError(\"Number of file sources must not exceed number of texts\")\n        if len(normalized_file_sources) < len(texts):\n            normalized_file_sources.extend(\n                [UNKNOWN_FILE_SOURCE] * (len(texts) - len(normalized_file_sources))\n            )\n\n    await rag.apipeline_enqueue_documents(\n        input=texts, file_paths=normalized_file_sources, track_id=track_id\n    )\n    await rag.apipeline_process_enqueue_documents()\n\n\nasync def run_scanning_process(\n    rag: LightRAG, doc_manager: DocumentManager, track_id: str = None\n):\n    \"\"\"Background task to scan and index documents\n\n    Args:\n        rag: LightRAG instance\n        doc_manager: DocumentManager instance\n        track_id: Optional tracking ID to pass to all scanned files\n    \"\"\"\n    try:\n        new_files = doc_manager.scan_directory_for_new_files()\n        total_files = len(new_files)\n        logger.info(f\"Found {total_files} files to index.\")\n\n        if new_files:\n            # Check for files with PROCESSED status and filter them out\n            valid_files = []\n            processed_files = []\n\n            for file_path in new_files:\n                filename = file_path.name\n                existing_doc_data = await rag.doc_status.get_doc_by_file_path(filename)\n\n                if existing_doc_data and existing_doc_data.get(\"status\") == \"processed\":\n                    # File is already PROCESSED, skip it with warning\n                    processed_files.append(filename)\n                    logger.warning(f\"Skipping already processed file: {filename}\")\n                else:\n                    # File is new or in non-PROCESSED status, add to processing list\n                    valid_files.append(file_path)\n\n            # Process valid files (new files + non-PROCESSED status files)\n            if valid_files:\n                await pipeline_index_files(rag, valid_files, track_id)\n                if processed_files:\n                    logger.info(\n                        f\"Scanning process completed: {len(valid_files)} files Processed {len(processed_files)} skipped.\"\n                    )\n                else:\n                    logger.info(\n                        f\"Scanning process completed: {len(valid_files)} files Processed.\"\n                    )\n            else:\n                logger.info(\n                    \"No files to process after filtering already processed files.\"\n                )\n        else:\n            # No new files to index, check if there are any documents in the queue\n            logger.info(\n                \"No upload file found, check if there are any documents in the queue...\"\n            )\n            await rag.apipeline_process_enqueue_documents()\n\n    except Exception as e:\n        logger.error(f\"Error during scanning process: {str(e)}\")\n        logger.error(traceback.format_exc())\n\n\nasync def background_delete_documents(\n    rag: LightRAG,\n    doc_manager: DocumentManager,\n    doc_ids: List[str],\n    delete_file: bool = False,\n    delete_llm_cache: bool = False,\n):\n    \"\"\"Background task to delete multiple documents\"\"\"\n    from lightrag.kg.shared_storage import (\n        get_namespace_data,\n        get_namespace_lock,\n    )\n\n    pipeline_status = await get_namespace_data(\n        \"pipeline_status\", workspace=rag.workspace\n    )\n    pipeline_status_lock = get_namespace_lock(\n        \"pipeline_status\", workspace=rag.workspace\n    )\n\n    total_docs = len(doc_ids)\n    successful_deletions = []\n    failed_deletions = []\n\n    # Double-check pipeline status before proceeding\n    async with pipeline_status_lock:\n        if pipeline_status.get(\"busy\", False):\n            logger.warning(\"Error: Unexpected pipeline busy state, aborting deletion.\")\n            return  # Abort deletion operation\n\n        # Set pipeline status to busy for deletion\n        pipeline_status.update(\n            {\n                \"busy\": True,\n                # Job name can not be changed, it's verified in adelete_by_doc_id()\n                \"job_name\": f\"Deleting {total_docs} Documents\",\n                \"job_start\": datetime.now().isoformat(),\n                \"docs\": total_docs,\n                \"batchs\": total_docs,\n                \"cur_batch\": 0,\n                \"latest_message\": \"Starting document deletion process\",\n            }\n        )\n        # Use slice assignment to clear the list in place\n        pipeline_status[\"history_messages\"][:] = [\"Starting document deletion process\"]\n        if delete_llm_cache:\n            pipeline_status[\"history_messages\"].append(\n                \"LLM cache cleanup requested for this deletion job\"\n            )\n\n    try:\n        # Loop through each document ID and delete them one by one\n        for i, doc_id in enumerate(doc_ids, 1):\n            # Check for cancellation at the start of each document deletion\n            async with pipeline_status_lock:\n                if pipeline_status.get(\"cancellation_requested\", False):\n                    cancel_msg = f\"Deletion cancelled by user at document {i}/{total_docs}. {len(successful_deletions)} deleted, {total_docs - i + 1} remaining.\"\n                    logger.info(cancel_msg)\n                    pipeline_status[\"latest_message\"] = cancel_msg\n                    pipeline_status[\"history_messages\"].append(cancel_msg)\n                    # Add remaining documents to failed list with cancellation reason\n                    failed_deletions.extend(\n                        doc_ids[i - 1 :]\n                    )  # i-1 because enumerate starts at 1\n                    break  # Exit the loop, remaining documents unchanged\n\n                start_msg = f\"Deleting document {i}/{total_docs}: {doc_id}\"\n                logger.info(start_msg)\n                pipeline_status[\"cur_batch\"] = i\n                pipeline_status[\"latest_message\"] = start_msg\n                pipeline_status[\"history_messages\"].append(start_msg)\n\n            file_path = \"#\"\n            try:\n                result = await rag.adelete_by_doc_id(\n                    doc_id, delete_llm_cache=delete_llm_cache\n                )\n                file_path = (\n                    getattr(result, \"file_path\", \"-\") if \"result\" in locals() else \"-\"\n                )\n                if result.status == \"success\":\n                    successful_deletions.append(doc_id)\n                    success_msg = (\n                        f\"Document deleted {i}/{total_docs}: {doc_id}[{file_path}]\"\n                    )\n                    logger.info(success_msg)\n                    async with pipeline_status_lock:\n                        pipeline_status[\"history_messages\"].append(success_msg)\n\n                    # Handle file deletion if requested and file_path is available\n                    if (\n                        delete_file\n                        and result.file_path\n                        and result.file_path != \"unknown_source\"\n                    ):\n                        try:\n                            deleted_files = []\n                            # SECURITY FIX: Use secure path validation to prevent arbitrary file deletion\n                            safe_file_path = validate_file_path_security(\n                                result.file_path, doc_manager.input_dir\n                            )\n\n                            if safe_file_path is None:\n                                # Security violation detected - log and skip file deletion\n                                security_msg = f\"Security violation: Unsafe file path detected for deletion - {result.file_path}\"\n                                logger.warning(security_msg)\n                                async with pipeline_status_lock:\n                                    pipeline_status[\"latest_message\"] = security_msg\n                                    pipeline_status[\"history_messages\"].append(\n                                        security_msg\n                                    )\n                            else:\n                                # check and delete files from input_dir directory\n                                if safe_file_path.exists():\n                                    try:\n                                        safe_file_path.unlink()\n                                        deleted_files.append(safe_file_path.name)\n                                        file_delete_msg = f\"Successfully deleted input_dir file: {result.file_path}\"\n                                        logger.info(file_delete_msg)\n                                        async with pipeline_status_lock:\n                                            pipeline_status[\"latest_message\"] = (\n                                                file_delete_msg\n                                            )\n                                            pipeline_status[\"history_messages\"].append(\n                                                file_delete_msg\n                                            )\n                                    except Exception as file_error:\n                                        file_error_msg = f\"Failed to delete input_dir file {result.file_path}: {str(file_error)}\"\n                                        logger.debug(file_error_msg)\n                                        async with pipeline_status_lock:\n                                            pipeline_status[\"latest_message\"] = (\n                                                file_error_msg\n                                            )\n                                            pipeline_status[\"history_messages\"].append(\n                                                file_error_msg\n                                            )\n\n                                # Also check and delete files from __enqueued__ directory\n                                enqueued_dir = doc_manager.input_dir / \"__enqueued__\"\n                                if enqueued_dir.exists():\n                                    # SECURITY FIX: Validate that the file path is safe before processing\n                                    # Only proceed if the original path validation passed\n                                    base_name = Path(result.file_path).stem\n                                    extension = Path(result.file_path).suffix\n\n                                    # Search for exact match and files with numeric suffixes\n                                    for enqueued_file in enqueued_dir.glob(\n                                        f\"{base_name}*{extension}\"\n                                    ):\n                                        # Additional security check: ensure enqueued file is within enqueued directory\n                                        safe_enqueued_path = (\n                                            validate_file_path_security(\n                                                enqueued_file.name, enqueued_dir\n                                            )\n                                        )\n                                        if safe_enqueued_path is not None:\n                                            try:\n                                                enqueued_file.unlink()\n                                                deleted_files.append(enqueued_file.name)\n                                                logger.info(\n                                                    f\"Successfully deleted enqueued file: {enqueued_file.name}\"\n                                                )\n                                            except Exception as enqueued_error:\n                                                file_error_msg = f\"Failed to delete enqueued file {enqueued_file.name}: {str(enqueued_error)}\"\n                                                logger.debug(file_error_msg)\n                                                async with pipeline_status_lock:\n                                                    pipeline_status[\n                                                        \"latest_message\"\n                                                    ] = file_error_msg\n                                                    pipeline_status[\n                                                        \"history_messages\"\n                                                    ].append(file_error_msg)\n                                        else:\n                                            security_msg = f\"Security violation: Unsafe enqueued file path detected - {enqueued_file.name}\"\n                                            logger.warning(security_msg)\n\n                            if deleted_files == []:\n                                file_error_msg = f\"File deletion skipped, missing or unsafe file: {result.file_path}\"\n                                logger.warning(file_error_msg)\n                                async with pipeline_status_lock:\n                                    pipeline_status[\"latest_message\"] = file_error_msg\n                                    pipeline_status[\"history_messages\"].append(\n                                        file_error_msg\n                                    )\n\n                        except Exception as file_error:\n                            file_error_msg = f\"Failed to delete file {result.file_path}: {str(file_error)}\"\n                            logger.error(file_error_msg)\n                            async with pipeline_status_lock:\n                                pipeline_status[\"latest_message\"] = file_error_msg\n                                pipeline_status[\"history_messages\"].append(\n                                    file_error_msg\n                                )\n                    elif delete_file:\n                        no_file_msg = (\n                            f\"File deletion skipped, missing file path: {doc_id}\"\n                        )\n                        logger.warning(no_file_msg)\n                        async with pipeline_status_lock:\n                            pipeline_status[\"latest_message\"] = no_file_msg\n                            pipeline_status[\"history_messages\"].append(no_file_msg)\n                else:\n                    failed_deletions.append(doc_id)\n                    error_msg = f\"Failed to delete {i}/{total_docs}: {doc_id}[{file_path}] - {result.message}\"\n                    logger.error(error_msg)\n                    async with pipeline_status_lock:\n                        pipeline_status[\"latest_message\"] = error_msg\n                        pipeline_status[\"history_messages\"].append(error_msg)\n\n            except Exception as e:\n                failed_deletions.append(doc_id)\n                error_msg = f\"Error deleting document {i}/{total_docs}: {doc_id}[{file_path}] - {str(e)}\"\n                logger.error(error_msg)\n                logger.error(traceback.format_exc())\n                async with pipeline_status_lock:\n                    pipeline_status[\"latest_message\"] = error_msg\n                    pipeline_status[\"history_messages\"].append(error_msg)\n\n    except Exception as e:\n        error_msg = f\"Critical error during batch deletion: {str(e)}\"\n        logger.error(error_msg)\n        logger.error(traceback.format_exc())\n        async with pipeline_status_lock:\n            pipeline_status[\"history_messages\"].append(error_msg)\n    finally:\n        # Final summary and check for pending requests\n        async with pipeline_status_lock:\n            pipeline_status[\"busy\"] = False\n            pipeline_status[\"pending_requests\"] = False  # Reset pending requests flag\n            pipeline_status[\"cancellation_requested\"] = (\n                False  # Always reset cancellation flag\n            )\n            completion_msg = f\"Deletion completed: {len(successful_deletions)} successful, {len(failed_deletions)} failed\"\n            pipeline_status[\"latest_message\"] = completion_msg\n            pipeline_status[\"history_messages\"].append(completion_msg)\n\n            # Check if there are pending document indexing requests\n            has_pending_request = pipeline_status.get(\"request_pending\", False)\n\n        # If there are pending requests, start document processing pipeline\n        if has_pending_request:\n            try:\n                logger.info(\n                    \"Processing pending document indexing requests after deletion\"\n                )\n                await rag.apipeline_process_enqueue_documents()\n            except Exception as e:\n                logger.error(f\"Error processing pending documents after deletion: {e}\")\n\n\ndef create_document_routes(\n    rag: LightRAG, doc_manager: DocumentManager, api_key: Optional[str] = None\n):\n    # Create combined auth dependency for document routes\n    combined_auth = get_combined_auth_dependency(api_key)\n\n    @router.post(\n        \"/scan\", response_model=ScanResponse, dependencies=[Depends(combined_auth)]\n    )\n    async def scan_for_new_documents(background_tasks: BackgroundTasks):\n        \"\"\"\n        Trigger the scanning process for new documents.\n\n        This endpoint initiates a background task that scans the input directory for new documents\n        and processes them. If a scanning process is already running, it returns a status indicating\n        that fact.\n\n        Returns:\n            ScanResponse: A response object containing the scanning status and track_id\n        \"\"\"\n        # Generate track_id with \"scan\" prefix for scanning operation\n        track_id = generate_track_id(\"scan\")\n\n        # Start the scanning process in the background with track_id\n        background_tasks.add_task(run_scanning_process, rag, doc_manager, track_id)\n        return ScanResponse(\n            status=\"scanning_started\",\n            message=\"Scanning process has been initiated in the background\",\n            track_id=track_id,\n        )\n\n    @router.post(\n        \"/upload\", response_model=InsertResponse, dependencies=[Depends(combined_auth)]\n    )\n    async def upload_to_input_dir(\n        background_tasks: BackgroundTasks, file: UploadFile = File(...)\n    ):\n        \"\"\"\n        Upload a file to the input directory and index it.\n\n        This API endpoint accepts a file through an HTTP POST request, checks if the\n        uploaded file is of a supported type, saves it in the specified input directory,\n        indexes it for retrieval, and returns a success status with relevant details.\n\n        **File Size Limit:**\n        - Configurable via `MAX_UPLOAD_SIZE` environment variable (default: 100MB)\n        - Set to `None` or `0` for unlimited upload size\n        - Returns HTTP 413 (Request Entity Too Large) if file exceeds limit\n\n        **Duplicate Detection Behavior:**\n\n        This endpoint handles two types of duplicate scenarios differently:\n\n        1. **Filename Duplicate (Synchronous Detection)**:\n           - Detected immediately before file processing\n           - Returns `status=\"duplicated\"` with the existing document's track_id\n           - Two cases:\n             - If filename exists in document storage: returns existing track_id\n             - If filename exists in file system only: returns empty track_id (\"\")\n\n        2. **Content Duplicate (Asynchronous Detection)**:\n           - Detected during background processing after content extraction\n           - Returns `status=\"success\"` with a new track_id immediately\n           - The duplicate is detected later when processing the file content\n           - Use `/documents/track_status/{track_id}` to check the final result:\n             - Document will have `status=\"FAILED\"`\n             - `error_msg` contains \"Content already exists. Original doc_id: xxx\"\n             - `metadata.is_duplicate=true` with reference to original document\n             - `metadata.original_doc_id` points to the existing document\n             - `metadata.original_track_id` shows the original upload's track_id\n\n        **Why Different Behavior?**\n        - Filename check is fast (simple lookup), done synchronously\n        - Content extraction is expensive (PDF/DOCX parsing), done asynchronously\n        - This design prevents blocking the client during expensive operations\n\n        Args:\n            background_tasks: FastAPI BackgroundTasks for async processing\n            file (UploadFile): The file to be uploaded. It must have an allowed extension.\n\n        Returns:\n            InsertResponse: A response object containing the upload status and a message.\n                - status=\"success\": File accepted and queued for processing\n                - status=\"duplicated\": Filename already exists (see track_id for existing document)\n\n        Raises:\n            HTTPException: If the file type is not supported (400), file too large (413), or other errors occur (500).\n        \"\"\"\n        try:\n            # Sanitize filename to prevent Path Traversal attacks\n            safe_filename = sanitize_filename(file.filename, doc_manager.input_dir)\n\n            if not doc_manager.is_supported_file(safe_filename):\n                raise HTTPException(\n                    status_code=400,\n                    detail=f\"Unsupported file type. Supported types: {doc_manager.supported_extensions}\",\n                )\n\n            # Check file size limit (if configured)\n            if (\n                global_args.max_upload_size is not None\n                and global_args.max_upload_size > 0\n            ):\n                # Safe access to file size (not available in older Starlette versions)\n                file_size = getattr(file, \"size\", None)\n\n                # Pre-flight size check (only if size is available)\n                if file_size is not None:\n                    if file_size > global_args.max_upload_size:\n                        raise HTTPException(\n                            status_code=413,\n                            detail=f\"File too large. Maximum size: {global_args.max_upload_size / 1024 / 1024:.1f}MB, uploaded: {file_size / 1024 / 1024:.1f}MB\",\n                        )\n                else:\n                    # If size not available, we'll check during streaming\n                    logger.debug(\n                        f\"File size not available in UploadFile for {safe_filename}, will check during streaming\"\n                    )\n\n            # Check if filename already exists in doc_status storage\n            existing_doc_data = await rag.doc_status.get_doc_by_file_path(safe_filename)\n            if existing_doc_data:\n                # Get document status and track_id from existing document\n                status = existing_doc_data.get(\"status\", \"unknown\")\n                # Use `or \"\"` to handle both missing key and None value (e.g., legacy rows without track_id)\n                existing_track_id = existing_doc_data.get(\"track_id\") or \"\"\n                return InsertResponse(\n                    status=\"duplicated\",\n                    message=f\"File '{safe_filename}' already exists in document storage (Status: {status}).\",\n                    track_id=existing_track_id,\n                )\n\n            file_path = doc_manager.input_dir / safe_filename\n            # Check if file already exists in file system\n            if file_path.exists():\n                return InsertResponse(\n                    status=\"duplicated\",\n                    message=f\"File '{safe_filename}' already exists in the input directory.\",\n                    track_id=\"\",\n                )\n\n            # Async streaming write with size check\n            bytes_written = 0\n            chunk_size = 1024 * 1024  # 1MB chunks\n            needs_cleanup = False\n\n            async with aiofiles.open(file_path, \"wb\") as out_file:\n                while True:\n                    # Read chunk from upload stream\n                    chunk = await file.read(chunk_size)\n                    if not chunk:\n                        break\n\n                    # Check size limit during streaming (if not checked before)\n                    if (\n                        global_args.max_upload_size is not None\n                        and global_args.max_upload_size > 0\n                    ):\n                        bytes_written += len(chunk)\n                        if bytes_written > global_args.max_upload_size:\n                            needs_cleanup = True\n                            break\n\n                    # Write chunk to file\n                    await out_file.write(chunk)\n\n            # Cleanup after file is closed\n            if needs_cleanup:\n                try:\n                    file_path.unlink()\n                except Exception as cleanup_error:\n                    logger.error(\n                        f\"Error cleaning up oversized file {safe_filename}: {cleanup_error}\"\n                    )\n\n                raise HTTPException(\n                    status_code=413,\n                    detail=f\"File too large. Maximum size: {global_args.max_upload_size / 1024 / 1024:.1f}MB, uploaded: {bytes_written / 1024 / 1024:.1f}MB\",\n                )\n\n            track_id = generate_track_id(\"upload\")\n\n            # Add to background tasks and get track_id\n            background_tasks.add_task(pipeline_index_file, rag, file_path, track_id)\n\n            return InsertResponse(\n                status=\"success\",\n                message=f\"File '{safe_filename}' uploaded successfully. Processing will continue in background.\",\n                track_id=track_id,\n            )\n\n        except HTTPException:\n            # Re-raise HTTP exceptions (400, 413, etc.)\n            raise\n        except Exception as e:\n            logger.error(f\"Error /documents/upload: {file.filename}: {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(status_code=500, detail=str(e))\n\n    @router.post(\n        \"/text\", response_model=InsertResponse, dependencies=[Depends(combined_auth)]\n    )\n    async def insert_text(\n        request: InsertTextRequest, background_tasks: BackgroundTasks\n    ):\n        \"\"\"\n        Insert text into the RAG system.\n\n        This endpoint allows you to insert text data into the RAG system for later retrieval\n        and use in generating responses.\n\n        Args:\n            request (InsertTextRequest): The request body containing the text to be inserted.\n            background_tasks: FastAPI BackgroundTasks for async processing\n\n        Returns:\n            InsertResponse: A response object containing the status of the operation.\n\n        Raises:\n            HTTPException: If an error occurs during text processing (500).\n        \"\"\"\n        try:\n            # Check if file_source already exists in doc_status storage\n            if (\n                request.file_source\n                and request.file_source.strip()\n                and request.file_source != \"unknown_source\"\n            ):\n                existing_doc_data = await rag.doc_status.get_doc_by_file_path(\n                    request.file_source\n                )\n                if existing_doc_data:\n                    # Get document status and track_id from existing document\n                    status = existing_doc_data.get(\"status\", \"unknown\")\n                    # Use `or \"\"` to handle both missing key and None value (e.g., legacy rows without track_id)\n                    existing_track_id = existing_doc_data.get(\"track_id\") or \"\"\n                    return InsertResponse(\n                        status=\"duplicated\",\n                        message=f\"File source '{request.file_source}' already exists in document storage (Status: {status}).\",\n                        track_id=existing_track_id,\n                    )\n\n            # Check if content already exists by computing content hash (doc_id)\n            sanitized_text = sanitize_text_for_encoding(request.text)\n            content_doc_id = compute_mdhash_id(sanitized_text, prefix=\"doc-\")\n            existing_doc = await rag.doc_status.get_by_id(content_doc_id)\n            if existing_doc:\n                # Content already exists, return duplicated with existing track_id\n                status = existing_doc.get(\"status\", \"unknown\")\n                existing_track_id = existing_doc.get(\"track_id\") or \"\"\n                return InsertResponse(\n                    status=\"duplicated\",\n                    message=f\"Identical content already exists in document storage (doc_id: {content_doc_id}, Status: {status}).\",\n                    track_id=existing_track_id,\n                )\n\n            # Generate track_id for text insertion\n            track_id = generate_track_id(\"insert\")\n\n            background_tasks.add_task(\n                pipeline_index_texts,\n                rag,\n                [request.text],\n                file_sources=[request.file_source],\n                track_id=track_id,\n            )\n\n            return InsertResponse(\n                status=\"success\",\n                message=\"Text successfully received. Processing will continue in background.\",\n                track_id=track_id,\n            )\n        except Exception as e:\n            logger.error(f\"Error /documents/text: {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(status_code=500, detail=str(e))\n\n    @router.post(\n        \"/texts\",\n        response_model=InsertResponse,\n        dependencies=[Depends(combined_auth)],\n    )\n    async def insert_texts(\n        request: InsertTextsRequest, background_tasks: BackgroundTasks\n    ):\n        \"\"\"\n        Insert multiple texts into the RAG system.\n\n        This endpoint allows you to insert multiple text entries into the RAG system\n        in a single request.\n\n        Args:\n            request (InsertTextsRequest): The request body containing the list of texts.\n            background_tasks: FastAPI BackgroundTasks for async processing\n\n        Returns:\n            InsertResponse: A response object containing the status of the operation.\n\n        Raises:\n            HTTPException: If an error occurs during text processing (500).\n        \"\"\"\n        try:\n            # Check if any file_sources already exist in doc_status storage\n            if request.file_sources:\n                for file_source in request.file_sources:\n                    if (\n                        file_source\n                        and file_source.strip()\n                        and file_source != \"unknown_source\"\n                    ):\n                        existing_doc_data = await rag.doc_status.get_doc_by_file_path(\n                            file_source\n                        )\n                        if existing_doc_data:\n                            # Get document status and track_id from existing document\n                            status = existing_doc_data.get(\"status\", \"unknown\")\n                            # Use `or \"\"` to handle both missing key and None value (e.g., legacy rows without track_id)\n                            existing_track_id = existing_doc_data.get(\"track_id\") or \"\"\n                            return InsertResponse(\n                                status=\"duplicated\",\n                                message=f\"File source '{file_source}' already exists in document storage (Status: {status}).\",\n                                track_id=existing_track_id,\n                            )\n\n            # Check if any content already exists by computing content hash (doc_id)\n            for text in request.texts:\n                sanitized_text = sanitize_text_for_encoding(text)\n                content_doc_id = compute_mdhash_id(sanitized_text, prefix=\"doc-\")\n                existing_doc = await rag.doc_status.get_by_id(content_doc_id)\n                if existing_doc:\n                    # Content already exists, return duplicated with existing track_id\n                    status = existing_doc.get(\"status\", \"unknown\")\n                    existing_track_id = existing_doc.get(\"track_id\") or \"\"\n                    return InsertResponse(\n                        status=\"duplicated\",\n                        message=f\"Identical content already exists in document storage (doc_id: {content_doc_id}, Status: {status}).\",\n                        track_id=existing_track_id,\n                    )\n\n            # Generate track_id for texts insertion\n            track_id = generate_track_id(\"insert\")\n\n            background_tasks.add_task(\n                pipeline_index_texts,\n                rag,\n                request.texts,\n                file_sources=request.file_sources,\n                track_id=track_id,\n            )\n\n            return InsertResponse(\n                status=\"success\",\n                message=\"Texts successfully received. Processing will continue in background.\",\n                track_id=track_id,\n            )\n        except Exception as e:\n            logger.error(f\"Error /documents/texts: {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(status_code=500, detail=str(e))\n\n    @router.delete(\n        \"\", response_model=ClearDocumentsResponse, dependencies=[Depends(combined_auth)]\n    )\n    async def clear_documents():\n        \"\"\"\n        Clear all documents from the RAG system.\n\n        This endpoint deletes all documents, entities, relationships, and files from the system.\n        It uses the storage drop methods to properly clean up all data and removes all files\n        from the input directory.\n\n        Returns:\n            ClearDocumentsResponse: A response object containing the status and message.\n                - status=\"success\":           All documents and files were successfully cleared.\n                - status=\"partial_success\":   Document clear job exit with some errors.\n                - status=\"busy\":              Operation could not be completed because the pipeline is busy.\n                - status=\"fail\":              All storage drop operations failed, with message\n                - message: Detailed information about the operation results, including counts\n                  of deleted files and any errors encountered.\n\n        Raises:\n            HTTPException: Raised when a serious error occurs during the clearing process,\n                          with status code 500 and error details in the detail field.\n        \"\"\"\n        from lightrag.kg.shared_storage import (\n            get_namespace_data,\n            get_namespace_lock,\n        )\n\n        # Get pipeline status and lock\n        pipeline_status = await get_namespace_data(\n            \"pipeline_status\", workspace=rag.workspace\n        )\n        pipeline_status_lock = get_namespace_lock(\n            \"pipeline_status\", workspace=rag.workspace\n        )\n\n        # Check and set status with lock\n        async with pipeline_status_lock:\n            if pipeline_status.get(\"busy\", False):\n                return ClearDocumentsResponse(\n                    status=\"busy\",\n                    message=\"Cannot clear documents while pipeline is busy\",\n                )\n            # Set busy to true\n            pipeline_status.update(\n                {\n                    \"busy\": True,\n                    \"job_name\": \"Clearing Documents\",\n                    \"job_start\": datetime.now().isoformat(),\n                    \"docs\": 0,\n                    \"batchs\": 0,\n                    \"cur_batch\": 0,\n                    \"request_pending\": False,  # Clear any previous request\n                    \"latest_message\": \"Starting document clearing process\",\n                }\n            )\n            # Cleaning history_messages without breaking it as a shared list object\n            del pipeline_status[\"history_messages\"][:]\n            pipeline_status[\"history_messages\"].append(\n                \"Starting document clearing process\"\n            )\n\n        try:\n            # Use drop method to clear all data\n            drop_tasks = []\n            storages = [\n                rag.text_chunks,\n                rag.full_docs,\n                rag.full_entities,\n                rag.full_relations,\n                rag.entity_chunks,\n                rag.relation_chunks,\n                rag.entities_vdb,\n                rag.relationships_vdb,\n                rag.chunks_vdb,\n                rag.chunk_entity_relation_graph,\n                rag.doc_status,\n            ]\n\n            # Log storage drop start\n            if \"history_messages\" in pipeline_status:\n                pipeline_status[\"history_messages\"].append(\n                    \"Starting to drop storage components\"\n                )\n\n            for storage in storages:\n                if storage is not None:\n                    drop_tasks.append(storage.drop())\n\n            # Wait for all drop tasks to complete\n            drop_results = await asyncio.gather(*drop_tasks, return_exceptions=True)\n\n            # Check for errors and log results\n            errors = []\n            storage_success_count = 0\n            storage_error_count = 0\n\n            for i, result in enumerate(drop_results):\n                storage_name = storages[i].__class__.__name__\n                if isinstance(result, Exception):\n                    error_msg = f\"Error dropping {storage_name}: {str(result)}\"\n                    errors.append(error_msg)\n                    logger.error(error_msg)\n                    storage_error_count += 1\n                else:\n                    namespace = storages[i].namespace\n                    workspace = storages[i].workspace\n                    logger.info(\n                        f\"Successfully dropped {storage_name}: {workspace}/{namespace}\"\n                    )\n                    storage_success_count += 1\n\n            # Log storage drop results\n            if \"history_messages\" in pipeline_status:\n                if storage_error_count > 0:\n                    pipeline_status[\"history_messages\"].append(\n                        f\"Dropped {storage_success_count} storage components with {storage_error_count} errors\"\n                    )\n                else:\n                    pipeline_status[\"history_messages\"].append(\n                        f\"Successfully dropped all {storage_success_count} storage components\"\n                    )\n\n            # If all storage operations failed, return error status and don't proceed with file deletion\n            if storage_success_count == 0 and storage_error_count > 0:\n                error_message = \"All storage drop operations failed. Aborting document clearing process.\"\n                logger.error(error_message)\n                if \"history_messages\" in pipeline_status:\n                    pipeline_status[\"history_messages\"].append(error_message)\n                return ClearDocumentsResponse(status=\"fail\", message=error_message)\n\n            # Log file deletion start\n            if \"history_messages\" in pipeline_status:\n                pipeline_status[\"history_messages\"].append(\n                    \"Starting to delete files in input directory\"\n                )\n\n            # Delete only files in the current directory, preserve files in subdirectories\n            deleted_files_count = 0\n            file_errors_count = 0\n\n            for file_path in doc_manager.input_dir.glob(\"*\"):\n                if file_path.is_file():\n                    try:\n                        file_path.unlink()\n                        deleted_files_count += 1\n                    except Exception as e:\n                        logger.error(f\"Error deleting file {file_path}: {str(e)}\")\n                        file_errors_count += 1\n\n            # Log file deletion results\n            if \"history_messages\" in pipeline_status:\n                if file_errors_count > 0:\n                    pipeline_status[\"history_messages\"].append(\n                        f\"Deleted {deleted_files_count} files with {file_errors_count} errors\"\n                    )\n                    errors.append(f\"Failed to delete {file_errors_count} files\")\n                else:\n                    pipeline_status[\"history_messages\"].append(\n                        f\"Successfully deleted {deleted_files_count} files\"\n                    )\n\n            # Prepare final result message\n            final_message = \"\"\n            if errors:\n                final_message = f\"Cleared documents with some errors. Deleted {deleted_files_count} files.\"\n                status = \"partial_success\"\n            else:\n                final_message = f\"All documents cleared successfully. Deleted {deleted_files_count} files.\"\n                status = \"success\"\n\n            # Log final result\n            if \"history_messages\" in pipeline_status:\n                pipeline_status[\"history_messages\"].append(final_message)\n\n            # Return response based on results\n            return ClearDocumentsResponse(status=status, message=final_message)\n        except Exception as e:\n            error_msg = f\"Error clearing documents: {str(e)}\"\n            logger.error(error_msg)\n            logger.error(traceback.format_exc())\n            if \"history_messages\" in pipeline_status:\n                pipeline_status[\"history_messages\"].append(error_msg)\n            raise HTTPException(status_code=500, detail=str(e))\n        finally:\n            # Reset busy status after completion\n            async with pipeline_status_lock:\n                pipeline_status[\"busy\"] = False\n                completion_msg = \"Document clearing process completed\"\n                pipeline_status[\"latest_message\"] = completion_msg\n                if \"history_messages\" in pipeline_status:\n                    pipeline_status[\"history_messages\"].append(completion_msg)\n\n    @router.get(\n        \"/pipeline_status\",\n        dependencies=[Depends(combined_auth)],\n        response_model=PipelineStatusResponse,\n    )\n    async def get_pipeline_status() -> PipelineStatusResponse:\n        \"\"\"\n        Get the current status of the document indexing pipeline.\n\n        This endpoint returns information about the current state of the document processing pipeline,\n        including the processing status, progress information, and history messages.\n\n        Returns:\n            PipelineStatusResponse: A response object containing:\n                - autoscanned (bool): Whether auto-scan has started\n                - busy (bool): Whether the pipeline is currently busy\n                - job_name (str): Current job name (e.g., indexing files/indexing texts)\n                - job_start (str, optional): Job start time as ISO format string\n                - docs (int): Total number of documents to be indexed\n                - batchs (int): Number of batches for processing documents\n                - cur_batch (int): Current processing batch\n                - request_pending (bool): Flag for pending request for processing\n                - latest_message (str): Latest message from pipeline processing\n                - history_messages (List[str], optional): List of history messages (limited to latest 1000 entries,\n                  with truncation message if more than 1000 messages exist)\n\n        Raises:\n            HTTPException: If an error occurs while retrieving pipeline status (500)\n        \"\"\"\n        try:\n            from lightrag.kg.shared_storage import (\n                get_namespace_data,\n                get_namespace_lock,\n                get_all_update_flags_status,\n            )\n\n            pipeline_status = await get_namespace_data(\n                \"pipeline_status\", workspace=rag.workspace\n            )\n            pipeline_status_lock = get_namespace_lock(\n                \"pipeline_status\", workspace=rag.workspace\n            )\n\n            # Get update flags status for all namespaces\n            update_status = await get_all_update_flags_status(workspace=rag.workspace)\n\n            # Convert MutableBoolean objects to regular boolean values\n            processed_update_status = {}\n            for namespace, flags in update_status.items():\n                processed_flags = []\n                for flag in flags:\n                    # Handle both multiprocess and single process cases\n                    if hasattr(flag, \"value\"):\n                        processed_flags.append(bool(flag.value))\n                    else:\n                        processed_flags.append(bool(flag))\n                processed_update_status[namespace] = processed_flags\n\n            async with pipeline_status_lock:\n                # Convert to regular dict if it's a Manager.dict\n                status_dict = dict(pipeline_status)\n\n            # Add processed update_status to the status dictionary\n            status_dict[\"update_status\"] = processed_update_status\n\n            # Convert history_messages to a regular list if it's a Manager.list\n            # and limit to latest 1000 entries with truncation message if needed\n            if \"history_messages\" in status_dict:\n                history_list = list(status_dict[\"history_messages\"])\n                total_count = len(history_list)\n\n                if total_count > 1000:\n                    # Calculate truncated message count\n                    truncated_count = total_count - 1000\n\n                    # Take only the latest 1000 messages\n                    latest_messages = history_list[-1000:]\n\n                    # Add truncation message at the beginning\n                    truncation_message = (\n                        f\"[Truncated history messages: {truncated_count}/{total_count}]\"\n                    )\n                    status_dict[\"history_messages\"] = [\n                        truncation_message\n                    ] + latest_messages\n                else:\n                    # No truncation needed, return all messages\n                    status_dict[\"history_messages\"] = history_list\n\n            # Ensure job_start is properly formatted as a string with timezone information\n            if \"job_start\" in status_dict and status_dict[\"job_start\"]:\n                # Use format_datetime to ensure consistent formatting\n                status_dict[\"job_start\"] = format_datetime(status_dict[\"job_start\"])\n\n            return PipelineStatusResponse(**status_dict)\n        except Exception as e:\n            logger.error(f\"Error getting pipeline status: {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(status_code=500, detail=str(e))\n\n    # TODO: Deprecated, use /documents/paginated instead\n    @router.get(\n        \"\", response_model=DocsStatusesResponse, dependencies=[Depends(combined_auth)]\n    )\n    async def documents() -> DocsStatusesResponse:\n        \"\"\"\n        Get the status of all documents in the system. This endpoint is deprecated; use /documents/paginated instead.\n        To prevent excessive resource consumption, a maximum of 1,000 records is returned.\n\n        This endpoint retrieves the current status of all documents, grouped by their\n        processing status (PENDING, PROCESSING, PREPROCESSED, PROCESSED, FAILED). The results are\n        limited to 1000 total documents with fair distribution across all statuses.\n\n        Returns:\n            DocsStatusesResponse: A response object containing a dictionary where keys are\n                                DocStatus values and values are lists of DocStatusResponse\n                                objects representing documents in each status category.\n                                Maximum 1000 documents total will be returned.\n\n        Raises:\n            HTTPException: If an error occurs while retrieving document statuses (500).\n        \"\"\"\n        try:\n            statuses = (\n                DocStatus.PENDING,\n                DocStatus.PROCESSING,\n                DocStatus.PREPROCESSED,\n                DocStatus.PROCESSED,\n                DocStatus.FAILED,\n            )\n\n            tasks = [rag.get_docs_by_status(status) for status in statuses]\n            results: List[Dict[str, DocProcessingStatus]] = await asyncio.gather(*tasks)\n\n            response = DocsStatusesResponse()\n            total_documents = 0\n            max_documents = 1000\n\n            # Convert results to lists for easier processing\n            status_documents = []\n            for idx, result in enumerate(results):\n                status = statuses[idx]\n                docs_list = []\n                for doc_id, doc_status in result.items():\n                    docs_list.append((doc_id, doc_status))\n                status_documents.append((status, docs_list))\n\n            # Fair distribution: round-robin across statuses\n            status_indices = [0] * len(\n                status_documents\n            )  # Track current index for each status\n            current_status_idx = 0\n\n            while total_documents < max_documents:\n                # Check if we have any documents left to process\n                has_remaining = False\n                for status_idx, (status, docs_list) in enumerate(status_documents):\n                    if status_indices[status_idx] < len(docs_list):\n                        has_remaining = True\n                        break\n\n                if not has_remaining:\n                    break\n\n                # Try to get a document from the current status\n                status, docs_list = status_documents[current_status_idx]\n                current_index = status_indices[current_status_idx]\n\n                if current_index < len(docs_list):\n                    doc_id, doc_status = docs_list[current_index]\n\n                    if status not in response.statuses:\n                        response.statuses[status] = []\n\n                    response.statuses[status].append(\n                        DocStatusResponse(\n                            id=doc_id,\n                            content_summary=doc_status.content_summary,\n                            content_length=doc_status.content_length,\n                            status=doc_status.status,\n                            created_at=format_datetime(doc_status.created_at),\n                            updated_at=format_datetime(doc_status.updated_at),\n                            track_id=doc_status.track_id,\n                            chunks_count=doc_status.chunks_count,\n                            error_msg=doc_status.error_msg,\n                            metadata=doc_status.metadata,\n                            file_path=normalize_file_path(doc_status.file_path),\n                        )\n                    )\n\n                    status_indices[current_status_idx] += 1\n                    total_documents += 1\n\n                # Move to next status (round-robin)\n                current_status_idx = (current_status_idx + 1) % len(status_documents)\n\n            return response\n        except Exception as e:\n            logger.error(f\"Error GET /documents: {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(status_code=500, detail=str(e))\n\n    class DeleteDocByIdResponse(BaseModel):\n        \"\"\"Response model for single document deletion operation.\"\"\"\n\n        status: Literal[\"deletion_started\", \"busy\", \"not_allowed\"] = Field(\n            description=\"Status of the deletion operation\"\n        )\n        message: str = Field(description=\"Message describing the operation result\")\n        doc_id: str = Field(description=\"The ID of the document to delete\")\n\n    @router.delete(\n        \"/delete_document\",\n        response_model=DeleteDocByIdResponse,\n        dependencies=[Depends(combined_auth)],\n        summary=\"Delete a document and all its associated data by its ID.\",\n    )\n    async def delete_document(\n        delete_request: DeleteDocRequest,\n        background_tasks: BackgroundTasks,\n    ) -> DeleteDocByIdResponse:\n        \"\"\"\n        Delete documents and all their associated data by their IDs using background processing.\n\n        Deletes specific documents and all their associated data, including their status,\n        text chunks, vector embeddings, and any related graph data. When requested,\n        cached LLM extraction responses are removed after graph deletion/rebuild completes.\n        The deletion process runs in the background to avoid blocking the client connection.\n\n        This operation is irreversible and will interact with the pipeline status.\n\n        Args:\n            delete_request (DeleteDocRequest): The request containing the document IDs and deletion options.\n            background_tasks: FastAPI BackgroundTasks for async processing\n\n        Returns:\n            DeleteDocByIdResponse: The result of the deletion operation.\n                - status=\"deletion_started\": The document deletion has been initiated in the background.\n                - status=\"busy\": The pipeline is busy with another operation.\n\n        Raises:\n            HTTPException:\n              - 500: If an unexpected internal error occurs during initialization.\n        \"\"\"\n        doc_ids = delete_request.doc_ids\n\n        try:\n            from lightrag.kg.shared_storage import (\n                get_namespace_data,\n                get_namespace_lock,\n            )\n\n            pipeline_status = await get_namespace_data(\n                \"pipeline_status\", workspace=rag.workspace\n            )\n            pipeline_status_lock = get_namespace_lock(\n                \"pipeline_status\", workspace=rag.workspace\n            )\n\n            # Check if pipeline is busy with proper lock\n            async with pipeline_status_lock:\n                if pipeline_status.get(\"busy\", False):\n                    return DeleteDocByIdResponse(\n                        status=\"busy\",\n                        message=\"Cannot delete documents while pipeline is busy\",\n                        doc_id=\", \".join(doc_ids),\n                    )\n\n            # Add deletion task to background tasks\n            background_tasks.add_task(\n                background_delete_documents,\n                rag,\n                doc_manager,\n                doc_ids,\n                delete_request.delete_file,\n                delete_request.delete_llm_cache,\n            )\n\n            return DeleteDocByIdResponse(\n                status=\"deletion_started\",\n                message=f\"Document deletion for {len(doc_ids)} documents has been initiated. Processing will continue in background.\",\n                doc_id=\", \".join(doc_ids),\n            )\n\n        except Exception as e:\n            error_msg = f\"Error initiating document deletion for {delete_request.doc_ids}: {str(e)}\"\n            logger.error(error_msg)\n            logger.error(traceback.format_exc())\n            raise HTTPException(status_code=500, detail=error_msg)\n\n    @router.post(\n        \"/clear_cache\",\n        response_model=ClearCacheResponse,\n        dependencies=[Depends(combined_auth)],\n    )\n    async def clear_cache(request: ClearCacheRequest):\n        \"\"\"\n        Clear all cache data from the LLM response cache storage.\n\n        This endpoint clears all cached LLM responses regardless of mode.\n        The request body is accepted for API compatibility but is ignored.\n\n        Args:\n            request (ClearCacheRequest): The request body (ignored for compatibility).\n\n        Returns:\n            ClearCacheResponse: A response object containing the status and message.\n\n        Raises:\n            HTTPException: If an error occurs during cache clearing (500).\n        \"\"\"\n        try:\n            # Call the aclear_cache method (no modes parameter)\n            await rag.aclear_cache()\n\n            # Prepare success message\n            message = \"Successfully cleared all cache\"\n\n            return ClearCacheResponse(status=\"success\", message=message)\n        except Exception as e:\n            logger.error(f\"Error clearing cache: {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(status_code=500, detail=str(e))\n\n    @router.delete(\n        \"/delete_entity\",\n        response_model=DeletionResult,\n        dependencies=[Depends(combined_auth)],\n    )\n    async def delete_entity(request: DeleteEntityRequest):\n        \"\"\"\n        Delete an entity and all its relationships from the knowledge graph.\n\n        Args:\n            request (DeleteEntityRequest): The request body containing the entity name.\n\n        Returns:\n            DeletionResult: An object containing the outcome of the deletion process.\n\n        Raises:\n            HTTPException: If the entity is not found (404) or an error occurs (500).\n        \"\"\"\n        try:\n            result = await rag.adelete_by_entity(entity_name=request.entity_name)\n            if result.status == \"not_found\":\n                raise HTTPException(status_code=404, detail=result.message)\n            if result.status == \"fail\":\n                raise HTTPException(status_code=500, detail=result.message)\n            # Set doc_id to empty string since this is an entity operation, not document\n            result.doc_id = \"\"\n            return result\n        except HTTPException:\n            raise\n        except Exception as e:\n            error_msg = f\"Error deleting entity '{request.entity_name}': {str(e)}\"\n            logger.error(error_msg)\n            logger.error(traceback.format_exc())\n            raise HTTPException(status_code=500, detail=error_msg)\n\n    @router.delete(\n        \"/delete_relation\",\n        response_model=DeletionResult,\n        dependencies=[Depends(combined_auth)],\n    )\n    async def delete_relation(request: DeleteRelationRequest):\n        \"\"\"\n        Delete a relationship between two entities from the knowledge graph.\n\n        Args:\n            request (DeleteRelationRequest): The request body containing the source and target entity names.\n\n        Returns:\n            DeletionResult: An object containing the outcome of the deletion process.\n\n        Raises:\n            HTTPException: If the relation is not found (404) or an error occurs (500).\n        \"\"\"\n        try:\n            result = await rag.adelete_by_relation(\n                source_entity=request.source_entity,\n                target_entity=request.target_entity,\n            )\n            if result.status == \"not_found\":\n                raise HTTPException(status_code=404, detail=result.message)\n            if result.status == \"fail\":\n                raise HTTPException(status_code=500, detail=result.message)\n            # Set doc_id to empty string since this is a relation operation, not document\n            result.doc_id = \"\"\n            return result\n        except HTTPException:\n            raise\n        except Exception as e:\n            error_msg = f\"Error deleting relation from '{request.source_entity}' to '{request.target_entity}': {str(e)}\"\n            logger.error(error_msg)\n            logger.error(traceback.format_exc())\n            raise HTTPException(status_code=500, detail=error_msg)\n\n    @router.get(\n        \"/track_status/{track_id}\",\n        response_model=TrackStatusResponse,\n        dependencies=[Depends(combined_auth)],\n    )\n    async def get_track_status(track_id: str) -> TrackStatusResponse:\n        \"\"\"\n        Get the processing status of documents by tracking ID.\n\n        This endpoint retrieves all documents associated with a specific tracking ID,\n        allowing users to monitor the processing progress of their uploaded files or inserted texts.\n\n        Args:\n            track_id (str): The tracking ID returned from upload, text, or texts endpoints\n\n        Returns:\n            TrackStatusResponse: A response object containing:\n                - track_id: The tracking ID\n                - documents: List of documents associated with this track_id\n                - total_count: Total number of documents for this track_id\n\n        Raises:\n            HTTPException: If track_id is invalid (400) or an error occurs (500).\n        \"\"\"\n        try:\n            # Validate track_id\n            if not track_id or not track_id.strip():\n                raise HTTPException(status_code=400, detail=\"Track ID cannot be empty\")\n\n            track_id = track_id.strip()\n\n            # Get documents by track_id\n            docs_by_track_id = await rag.aget_docs_by_track_id(track_id)\n\n            # Convert to response format\n            documents = []\n            status_summary = {}\n\n            for doc_id, doc_status in docs_by_track_id.items():\n                documents.append(\n                    DocStatusResponse(\n                        id=doc_id,\n                        content_summary=doc_status.content_summary,\n                        content_length=doc_status.content_length,\n                        status=doc_status.status,\n                        created_at=format_datetime(doc_status.created_at),\n                        updated_at=format_datetime(doc_status.updated_at),\n                        track_id=doc_status.track_id,\n                        chunks_count=doc_status.chunks_count,\n                        error_msg=doc_status.error_msg,\n                        metadata=doc_status.metadata,\n                        file_path=normalize_file_path(doc_status.file_path),\n                    )\n                )\n\n                # Build status summary\n                # Handle both DocStatus enum and string cases for robust deserialization\n                status_key = str(doc_status.status)\n                status_summary[status_key] = status_summary.get(status_key, 0) + 1\n\n            return TrackStatusResponse(\n                track_id=track_id,\n                documents=documents,\n                total_count=len(documents),\n                status_summary=status_summary,\n            )\n\n        except HTTPException:\n            raise\n        except Exception as e:\n            logger.error(f\"Error getting track status for {track_id}: {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(status_code=500, detail=str(e))\n\n    @router.post(\n        \"/paginated\",\n        response_model=PaginatedDocsResponse,\n        dependencies=[Depends(combined_auth)],\n    )\n    async def get_documents_paginated(\n        request: DocumentsRequest,\n    ) -> PaginatedDocsResponse:\n        \"\"\"\n        Get documents with pagination support.\n\n        This endpoint retrieves documents with pagination, filtering, and sorting capabilities.\n        It provides better performance for large document collections by loading only the\n        requested page of data.\n\n        Args:\n            request (DocumentsRequest): The request body containing pagination parameters\n\n        Returns:\n            PaginatedDocsResponse: A response object containing:\n                - documents: List of documents for the current page\n                - pagination: Pagination information (page, total_count, etc.)\n                - status_counts: Count of documents by status for all documents\n\n        Raises:\n            HTTPException: If an error occurs while retrieving documents (500).\n        \"\"\"\n        try:\n            # Get paginated documents and status counts in parallel\n            docs_task = rag.doc_status.get_docs_paginated(\n                status_filter=request.status_filter,\n                page=request.page,\n                page_size=request.page_size,\n                sort_field=request.sort_field,\n                sort_direction=request.sort_direction,\n            )\n            status_counts_task = rag.doc_status.get_all_status_counts()\n\n            # Execute both queries in parallel\n            (documents_with_ids, total_count), status_counts = await asyncio.gather(\n                docs_task, status_counts_task\n            )\n\n            # Convert documents to response format\n            doc_responses = []\n            for doc_id, doc in documents_with_ids:\n                doc_responses.append(\n                    DocStatusResponse(\n                        id=doc_id,\n                        content_summary=doc.content_summary,\n                        content_length=doc.content_length,\n                        status=doc.status,\n                        created_at=format_datetime(doc.created_at),\n                        updated_at=format_datetime(doc.updated_at),\n                        track_id=doc.track_id,\n                        chunks_count=doc.chunks_count,\n                        error_msg=doc.error_msg,\n                        metadata=doc.metadata,\n                        file_path=normalize_file_path(doc.file_path),\n                    )\n                )\n\n            # Calculate pagination info\n            total_pages = (total_count + request.page_size - 1) // request.page_size\n            has_next = request.page < total_pages\n            has_prev = request.page > 1\n\n            pagination = PaginationInfo(\n                page=request.page,\n                page_size=request.page_size,\n                total_count=total_count,\n                total_pages=total_pages,\n                has_next=has_next,\n                has_prev=has_prev,\n            )\n\n            return PaginatedDocsResponse(\n                documents=doc_responses,\n                pagination=pagination,\n                status_counts=status_counts,\n            )\n\n        except Exception as e:\n            logger.error(f\"Error getting paginated documents: {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(status_code=500, detail=str(e))\n\n    @router.get(\n        \"/status_counts\",\n        response_model=StatusCountsResponse,\n        dependencies=[Depends(combined_auth)],\n    )\n    async def get_document_status_counts() -> StatusCountsResponse:\n        \"\"\"\n        Get counts of documents by status.\n\n        This endpoint retrieves the count of documents in each processing status\n        (PENDING, PROCESSING, PROCESSED, FAILED) for all documents in the system.\n\n        Returns:\n            StatusCountsResponse: A response object containing status counts\n\n        Raises:\n            HTTPException: If an error occurs while retrieving status counts (500).\n        \"\"\"\n        try:\n            status_counts = await rag.doc_status.get_all_status_counts()\n            return StatusCountsResponse(status_counts=status_counts)\n\n        except Exception as e:\n            logger.error(f\"Error getting document status counts: {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(status_code=500, detail=str(e))\n\n    @router.post(\n        \"/reprocess_failed\",\n        response_model=ReprocessResponse,\n        dependencies=[Depends(combined_auth)],\n    )\n    async def reprocess_failed_documents(background_tasks: BackgroundTasks):\n        \"\"\"\n        Reprocess failed and pending documents.\n\n        This endpoint triggers the document processing pipeline which automatically\n        picks up and reprocesses documents in the following statuses:\n        - FAILED: Documents that failed during previous processing attempts\n        - PENDING: Documents waiting to be processed\n        - PROCESSING: Documents with abnormally terminated processing (e.g., server crashes)\n\n        This is useful for recovering from server crashes, network errors, LLM service\n        outages, or other temporary failures that caused document processing to fail.\n\n        The processing happens in the background and can be monitored by checking the\n        pipeline status. The reprocessed documents retain their original track_id from\n        initial upload, so use their original track_id to monitor progress.\n\n        Returns:\n            ReprocessResponse: Response with status and message.\n                track_id is always empty string because reprocessed documents retain\n                their original track_id from initial upload.\n\n        Raises:\n            HTTPException: If an error occurs while initiating reprocessing (500).\n        \"\"\"\n        try:\n            # Start the reprocessing in the background\n            # Note: Reprocessed documents retain their original track_id from initial upload\n            background_tasks.add_task(rag.apipeline_process_enqueue_documents)\n            logger.info(\"Reprocessing of failed documents initiated\")\n\n            return ReprocessResponse(\n                status=\"reprocessing_started\",\n                message=\"Reprocessing of failed documents has been initiated in background. Documents retain their original track_id.\",\n            )\n\n        except Exception as e:\n            logger.error(f\"Error initiating reprocessing of failed documents: {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(status_code=500, detail=str(e))\n\n    @router.post(\n        \"/cancel_pipeline\",\n        response_model=CancelPipelineResponse,\n        dependencies=[Depends(combined_auth)],\n    )\n    async def cancel_pipeline():\n        \"\"\"\n        Request cancellation of the currently running pipeline.\n\n        This endpoint sets a cancellation flag in the pipeline status. The pipeline will:\n        1. Check this flag at key processing points\n        2. Stop processing new documents\n        3. Cancel all running document processing tasks\n        4. Mark all PROCESSING documents as FAILED with reason \"User cancelled\"\n\n        The cancellation is graceful and ensures data consistency. Documents that have\n        completed processing will remain in PROCESSED status.\n\n        Returns:\n            CancelPipelineResponse: Response with status and message\n                - status=\"cancellation_requested\": Cancellation flag has been set\n                - status=\"not_busy\": Pipeline is not currently running\n\n        Raises:\n            HTTPException: If an error occurs while setting cancellation flag (500).\n        \"\"\"\n        try:\n            from lightrag.kg.shared_storage import (\n                get_namespace_data,\n                get_namespace_lock,\n            )\n\n            pipeline_status = await get_namespace_data(\n                \"pipeline_status\", workspace=rag.workspace\n            )\n            pipeline_status_lock = get_namespace_lock(\n                \"pipeline_status\", workspace=rag.workspace\n            )\n\n            async with pipeline_status_lock:\n                if not pipeline_status.get(\"busy\", False):\n                    return CancelPipelineResponse(\n                        status=\"not_busy\",\n                        message=\"Pipeline is not currently running. No cancellation needed.\",\n                    )\n\n                # Set cancellation flag\n                pipeline_status[\"cancellation_requested\"] = True\n                cancel_msg = \"Pipeline cancellation requested by user\"\n                logger.info(cancel_msg)\n                pipeline_status[\"latest_message\"] = cancel_msg\n                pipeline_status[\"history_messages\"].append(cancel_msg)\n\n            return CancelPipelineResponse(\n                status=\"cancellation_requested\",\n                message=\"Pipeline cancellation has been requested. Documents will be marked as FAILED.\",\n            )\n\n        except Exception as e:\n            logger.error(f\"Error requesting pipeline cancellation: {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(status_code=500, detail=str(e))\n\n    return router\n"
  },
  {
    "path": "lightrag/api/routers/graph_routes.py",
    "content": "\"\"\"\nThis module contains all graph-related routes for the LightRAG API.\n\"\"\"\n\nfrom typing import Optional, Dict, Any\nimport traceback\nfrom fastapi import APIRouter, Depends, Query, HTTPException\nfrom pydantic import BaseModel, Field\n\nfrom lightrag.utils import logger\nfrom ..utils_api import get_combined_auth_dependency\n\nrouter = APIRouter(tags=[\"graph\"])\n\n\nclass EntityUpdateRequest(BaseModel):\n    entity_name: str\n    updated_data: Dict[str, Any]\n    allow_rename: bool = False\n    allow_merge: bool = False\n\n\nclass RelationUpdateRequest(BaseModel):\n    source_id: str\n    target_id: str\n    updated_data: Dict[str, Any]\n\n\nclass EntityMergeRequest(BaseModel):\n    entities_to_change: list[str] = Field(\n        ...,\n        description=\"List of entity names to be merged and deleted. These are typically duplicate or misspelled entities.\",\n        min_length=1,\n        examples=[[\"Elon Msk\", \"Ellon Musk\"]],\n    )\n    entity_to_change_into: str = Field(\n        ...,\n        description=\"Target entity name that will receive all relationships from the source entities. This entity will be preserved.\",\n        min_length=1,\n        examples=[\"Elon Musk\"],\n    )\n\n\nclass EntityCreateRequest(BaseModel):\n    entity_name: str = Field(\n        ...,\n        description=\"Unique name for the new entity\",\n        min_length=1,\n        examples=[\"Tesla\"],\n    )\n    entity_data: Dict[str, Any] = Field(\n        ...,\n        description=\"Dictionary containing entity properties. Common fields include 'description' and 'entity_type'.\",\n        examples=[\n            {\n                \"description\": \"Electric vehicle manufacturer\",\n                \"entity_type\": \"ORGANIZATION\",\n            }\n        ],\n    )\n\n\nclass RelationCreateRequest(BaseModel):\n    source_entity: str = Field(\n        ...,\n        description=\"Name of the source entity. This entity must already exist in the knowledge graph.\",\n        min_length=1,\n        examples=[\"Elon Musk\"],\n    )\n    target_entity: str = Field(\n        ...,\n        description=\"Name of the target entity. This entity must already exist in the knowledge graph.\",\n        min_length=1,\n        examples=[\"Tesla\"],\n    )\n    relation_data: Dict[str, Any] = Field(\n        ...,\n        description=\"Dictionary containing relationship properties. Common fields include 'description', 'keywords', and 'weight'.\",\n        examples=[\n            {\n                \"description\": \"Elon Musk is the CEO of Tesla\",\n                \"keywords\": \"CEO, founder\",\n                \"weight\": 1.0,\n            }\n        ],\n    )\n\n\ndef create_graph_routes(rag, api_key: Optional[str] = None):\n    combined_auth = get_combined_auth_dependency(api_key)\n\n    @router.get(\"/graph/label/list\", dependencies=[Depends(combined_auth)])\n    async def get_graph_labels():\n        \"\"\"\n        Get all graph labels\n\n        Returns:\n            List[str]: List of graph labels\n        \"\"\"\n        try:\n            return await rag.get_graph_labels()\n        except Exception as e:\n            logger.error(f\"Error getting graph labels: {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(\n                status_code=500, detail=f\"Error getting graph labels: {str(e)}\"\n            )\n\n    @router.get(\"/graph/label/popular\", dependencies=[Depends(combined_auth)])\n    async def get_popular_labels(\n        limit: int = Query(\n            300, description=\"Maximum number of popular labels to return\", ge=1, le=1000\n        ),\n    ):\n        \"\"\"\n        Get popular labels by node degree (most connected entities)\n\n        Args:\n            limit (int): Maximum number of labels to return (default: 300, max: 1000)\n\n        Returns:\n            List[str]: List of popular labels sorted by degree (highest first)\n        \"\"\"\n        try:\n            return await rag.chunk_entity_relation_graph.get_popular_labels(limit)\n        except Exception as e:\n            logger.error(f\"Error getting popular labels: {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(\n                status_code=500, detail=f\"Error getting popular labels: {str(e)}\"\n            )\n\n    @router.get(\"/graph/label/search\", dependencies=[Depends(combined_auth)])\n    async def search_labels(\n        q: str = Query(..., description=\"Search query string\"),\n        limit: int = Query(\n            50, description=\"Maximum number of search results to return\", ge=1, le=100\n        ),\n    ):\n        \"\"\"\n        Search labels with fuzzy matching\n\n        Args:\n            q (str): Search query string\n            limit (int): Maximum number of results to return (default: 50, max: 100)\n\n        Returns:\n            List[str]: List of matching labels sorted by relevance\n        \"\"\"\n        try:\n            return await rag.chunk_entity_relation_graph.search_labels(q, limit)\n        except Exception as e:\n            logger.error(f\"Error searching labels with query '{q}': {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(\n                status_code=500, detail=f\"Error searching labels: {str(e)}\"\n            )\n\n    @router.get(\"/graphs\", dependencies=[Depends(combined_auth)])\n    async def get_knowledge_graph(\n        label: str = Query(..., description=\"Label to get knowledge graph for\"),\n        max_depth: int = Query(3, description=\"Maximum depth of graph\", ge=1),\n        max_nodes: int = Query(1000, description=\"Maximum nodes to return\", ge=1),\n    ):\n        \"\"\"\n        Retrieve a connected subgraph of nodes where the label includes the specified label.\n        When reducing the number of nodes, the prioritization criteria are as follows:\n            1. Hops(path) to the staring node take precedence\n            2. Followed by the degree of the nodes\n\n        Args:\n            label (str): Label of the starting node\n            max_depth (int, optional): Maximum depth of the subgraph,Defaults to 3\n            max_nodes: Maxiumu nodes to return\n\n        Returns:\n            Dict[str, List[str]]: Knowledge graph for label\n        \"\"\"\n        try:\n            # Log the label parameter to check for leading spaces\n            logger.debug(\n                f\"get_knowledge_graph called with label: '{label}' (length: {len(label)}, repr: {repr(label)})\"\n            )\n\n            return await rag.get_knowledge_graph(\n                node_label=label,\n                max_depth=max_depth,\n                max_nodes=max_nodes,\n            )\n        except Exception as e:\n            logger.error(f\"Error getting knowledge graph for label '{label}': {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(\n                status_code=500, detail=f\"Error getting knowledge graph: {str(e)}\"\n            )\n\n    @router.get(\"/graph/entity/exists\", dependencies=[Depends(combined_auth)])\n    async def check_entity_exists(\n        name: str = Query(..., description=\"Entity name to check\"),\n    ):\n        \"\"\"\n        Check if an entity with the given name exists in the knowledge graph\n\n        Args:\n            name (str): Name of the entity to check\n\n        Returns:\n            Dict[str, bool]: Dictionary with 'exists' key indicating if entity exists\n        \"\"\"\n        try:\n            exists = await rag.chunk_entity_relation_graph.has_node(name)\n            return {\"exists\": exists}\n        except Exception as e:\n            logger.error(f\"Error checking entity existence for '{name}': {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(\n                status_code=500, detail=f\"Error checking entity existence: {str(e)}\"\n            )\n\n    @router.post(\"/graph/entity/edit\", dependencies=[Depends(combined_auth)])\n    async def update_entity(request: EntityUpdateRequest):\n        \"\"\"\n        Update an entity's properties in the knowledge graph\n\n        This endpoint allows updating entity properties, including renaming entities.\n        When renaming to an existing entity name, the behavior depends on allow_merge:\n\n        Args:\n            request (EntityUpdateRequest): Request containing:\n                - entity_name (str): Name of the entity to update\n                - updated_data (Dict[str, Any]): Dictionary of properties to update\n                - allow_rename (bool): Whether to allow entity renaming (default: False)\n                - allow_merge (bool): Whether to merge into existing entity when renaming\n                                     causes name conflict (default: False)\n\n        Returns:\n            Dict with the following structure:\n            {\n                \"status\": \"success\",\n                \"message\": \"Entity updated successfully\" | \"Entity merged successfully into 'target_name'\",\n                \"data\": {\n                    \"entity_name\": str,        # Final entity name\n                    \"description\": str,        # Entity description\n                    \"entity_type\": str,        # Entity type\n                    \"source_id\": str,         # Source chunk IDs\n                    ...                       # Other entity properties\n                },\n                \"operation_summary\": {\n                    \"merged\": bool,           # Whether entity was merged into another\n                    \"merge_status\": str,      # \"success\" | \"failed\" | \"not_attempted\"\n                    \"merge_error\": str | None, # Error message if merge failed\n                    \"operation_status\": str,  # \"success\" | \"partial_success\" | \"failure\"\n                    \"target_entity\": str | None, # Target entity name if renaming/merging\n                    \"final_entity\": str,      # Final entity name after operation\n                    \"renamed\": bool           # Whether entity was renamed\n                }\n            }\n\n        operation_status values explained:\n            - \"success\": All operations completed successfully\n                * For simple updates: entity properties updated\n                * For renames: entity renamed successfully\n                * For merges: non-name updates applied AND merge completed\n\n            - \"partial_success\": Update succeeded but merge failed\n                * Non-name property updates were applied successfully\n                * Merge operation failed (entity not merged)\n                * Original entity still exists with updated properties\n                * Use merge_error for failure details\n\n            - \"failure\": Operation failed completely\n                * If merge_status == \"failed\": Merge attempted but both update and merge failed\n                * If merge_status == \"not_attempted\": Regular update failed\n                * No changes were applied to the entity\n\n        merge_status values explained:\n            - \"success\": Entity successfully merged into target entity\n            - \"failed\": Merge operation was attempted but failed\n            - \"not_attempted\": No merge was attempted (normal update/rename)\n\n        Behavior when renaming to an existing entity:\n            - If allow_merge=False: Raises ValueError with 400 status (default behavior)\n            - If allow_merge=True: Automatically merges the source entity into the existing target entity,\n                                  preserving all relationships and applying non-name updates first\n\n        Example Request (simple update):\n            POST /graph/entity/edit\n            {\n                \"entity_name\": \"Tesla\",\n                \"updated_data\": {\"description\": \"Updated description\"},\n                \"allow_rename\": false,\n                \"allow_merge\": false\n            }\n\n        Example Response (simple update success):\n            {\n                \"status\": \"success\",\n                \"message\": \"Entity updated successfully\",\n                \"data\": { ... },\n                \"operation_summary\": {\n                    \"merged\": false,\n                    \"merge_status\": \"not_attempted\",\n                    \"merge_error\": null,\n                    \"operation_status\": \"success\",\n                    \"target_entity\": null,\n                    \"final_entity\": \"Tesla\",\n                    \"renamed\": false\n                }\n            }\n\n        Example Request (rename with auto-merge):\n            POST /graph/entity/edit\n            {\n                \"entity_name\": \"Elon Msk\",\n                \"updated_data\": {\n                    \"entity_name\": \"Elon Musk\",\n                    \"description\": \"Corrected description\"\n                },\n                \"allow_rename\": true,\n                \"allow_merge\": true\n            }\n\n        Example Response (merge success):\n            {\n                \"status\": \"success\",\n                \"message\": \"Entity merged successfully into 'Elon Musk'\",\n                \"data\": { ... },\n                \"operation_summary\": {\n                    \"merged\": true,\n                    \"merge_status\": \"success\",\n                    \"merge_error\": null,\n                    \"operation_status\": \"success\",\n                    \"target_entity\": \"Elon Musk\",\n                    \"final_entity\": \"Elon Musk\",\n                    \"renamed\": true\n                }\n            }\n\n        Example Response (partial success - update succeeded but merge failed):\n            {\n                \"status\": \"success\",\n                \"message\": \"Entity updated successfully\",\n                \"data\": { ... },  # Data reflects updated \"Elon Msk\" entity\n                \"operation_summary\": {\n                    \"merged\": false,\n                    \"merge_status\": \"failed\",\n                    \"merge_error\": \"Target entity locked by another operation\",\n                    \"operation_status\": \"partial_success\",\n                    \"target_entity\": \"Elon Musk\",\n                    \"final_entity\": \"Elon Msk\",  # Original entity still exists\n                    \"renamed\": true\n                }\n            }\n        \"\"\"\n        try:\n            result = await rag.aedit_entity(\n                entity_name=request.entity_name,\n                updated_data=request.updated_data,\n                allow_rename=request.allow_rename,\n                allow_merge=request.allow_merge,\n            )\n\n            # Extract operation_summary from result, with fallback for backward compatibility\n            operation_summary = result.get(\n                \"operation_summary\",\n                {\n                    \"merged\": False,\n                    \"merge_status\": \"not_attempted\",\n                    \"merge_error\": None,\n                    \"operation_status\": \"success\",\n                    \"target_entity\": None,\n                    \"final_entity\": request.updated_data.get(\n                        \"entity_name\", request.entity_name\n                    ),\n                    \"renamed\": request.updated_data.get(\n                        \"entity_name\", request.entity_name\n                    )\n                    != request.entity_name,\n                },\n            )\n\n            # Separate entity data from operation_summary for clean response\n            entity_data = dict(result)\n            entity_data.pop(\"operation_summary\", None)\n\n            # Generate appropriate response message based on merge status\n            response_message = (\n                f\"Entity merged successfully into '{operation_summary['final_entity']}'\"\n                if operation_summary.get(\"merged\")\n                else \"Entity updated successfully\"\n            )\n            return {\n                \"status\": \"success\",\n                \"message\": response_message,\n                \"data\": entity_data,\n                \"operation_summary\": operation_summary,\n            }\n        except ValueError as ve:\n            logger.error(\n                f\"Validation error updating entity '{request.entity_name}': {str(ve)}\"\n            )\n            raise HTTPException(status_code=400, detail=str(ve))\n        except Exception as e:\n            logger.error(f\"Error updating entity '{request.entity_name}': {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(\n                status_code=500, detail=f\"Error updating entity: {str(e)}\"\n            )\n\n    @router.post(\"/graph/relation/edit\", dependencies=[Depends(combined_auth)])\n    async def update_relation(request: RelationUpdateRequest):\n        \"\"\"Update a relation's properties in the knowledge graph\n\n        Args:\n            request (RelationUpdateRequest): Request containing source ID, target ID and updated data\n\n        Returns:\n            Dict: Updated relation information\n        \"\"\"\n        try:\n            result = await rag.aedit_relation(\n                source_entity=request.source_id,\n                target_entity=request.target_id,\n                updated_data=request.updated_data,\n            )\n            return {\n                \"status\": \"success\",\n                \"message\": \"Relation updated successfully\",\n                \"data\": result,\n            }\n        except ValueError as ve:\n            logger.error(\n                f\"Validation error updating relation between '{request.source_id}' and '{request.target_id}': {str(ve)}\"\n            )\n            raise HTTPException(status_code=400, detail=str(ve))\n        except Exception as e:\n            logger.error(\n                f\"Error updating relation between '{request.source_id}' and '{request.target_id}': {str(e)}\"\n            )\n            logger.error(traceback.format_exc())\n            raise HTTPException(\n                status_code=500, detail=f\"Error updating relation: {str(e)}\"\n            )\n\n    @router.post(\"/graph/entity/create\", dependencies=[Depends(combined_auth)])\n    async def create_entity(request: EntityCreateRequest):\n        \"\"\"\n        Create a new entity in the knowledge graph\n\n        This endpoint creates a new entity node in the knowledge graph with the specified\n        properties. The system automatically generates vector embeddings for the entity\n        to enable semantic search and retrieval.\n\n        Request Body:\n            entity_name (str): Unique name identifier for the entity\n            entity_data (dict): Entity properties including:\n                - description (str): Textual description of the entity\n                - entity_type (str): Category/type of the entity (e.g., PERSON, ORGANIZATION, LOCATION)\n                - source_id (str): Related chunk_id from which the description originates\n                - Additional custom properties as needed\n\n        Response Schema:\n            {\n                \"status\": \"success\",\n                \"message\": \"Entity 'Tesla' created successfully\",\n                \"data\": {\n                    \"entity_name\": \"Tesla\",\n                    \"description\": \"Electric vehicle manufacturer\",\n                    \"entity_type\": \"ORGANIZATION\",\n                    \"source_id\": \"chunk-123<SEP>chunk-456\"\n                    ... (other entity properties)\n                }\n            }\n\n        HTTP Status Codes:\n            200: Entity created successfully\n            400: Invalid request (e.g., missing required fields, duplicate entity)\n            500: Internal server error\n\n        Example Request:\n            POST /graph/entity/create\n            {\n                \"entity_name\": \"Tesla\",\n                \"entity_data\": {\n                    \"description\": \"Electric vehicle manufacturer\",\n                    \"entity_type\": \"ORGANIZATION\"\n                }\n            }\n        \"\"\"\n        try:\n            # Use the proper acreate_entity method which handles:\n            # - Graph lock for concurrency\n            # - Vector embedding creation in entities_vdb\n            # - Metadata population and defaults\n            # - Index consistency via _edit_entity_done\n            result = await rag.acreate_entity(\n                entity_name=request.entity_name,\n                entity_data=request.entity_data,\n            )\n\n            return {\n                \"status\": \"success\",\n                \"message\": f\"Entity '{request.entity_name}' created successfully\",\n                \"data\": result,\n            }\n        except ValueError as ve:\n            logger.error(\n                f\"Validation error creating entity '{request.entity_name}': {str(ve)}\"\n            )\n            raise HTTPException(status_code=400, detail=str(ve))\n        except Exception as e:\n            logger.error(f\"Error creating entity '{request.entity_name}': {str(e)}\")\n            logger.error(traceback.format_exc())\n            raise HTTPException(\n                status_code=500, detail=f\"Error creating entity: {str(e)}\"\n            )\n\n    @router.post(\"/graph/relation/create\", dependencies=[Depends(combined_auth)])\n    async def create_relation(request: RelationCreateRequest):\n        \"\"\"\n        Create a new relationship between two entities in the knowledge graph\n\n        This endpoint establishes an undirected relationship between two existing entities.\n        The provided source/target order is accepted for convenience, but the backend\n        stored edge is undirected and may be returned with the entities swapped.\n        Both entities must already exist in the knowledge graph. The system automatically\n        generates vector embeddings for the relationship to enable semantic search and graph traversal.\n\n        Prerequisites:\n            - Both source_entity and target_entity must exist in the knowledge graph\n            - Use /graph/entity/create to create entities first if they don't exist\n\n        Request Body:\n            source_entity (str): Name of the source entity (relationship origin)\n            target_entity (str): Name of the target entity (relationship destination)\n            relation_data (dict): Relationship properties including:\n                - description (str): Textual description of the relationship\n                - keywords (str): Comma-separated keywords describing the relationship type\n                - source_id (str): Related chunk_id from which the description originates\n                - weight (float): Relationship strength/importance (default: 1.0)\n                - Additional custom properties as needed\n\n        Response Schema:\n            {\n                \"status\": \"success\",\n                \"message\": \"Relation created successfully between 'Elon Musk' and 'Tesla'\",\n                \"data\": {\n                    \"src_id\": \"Elon Musk\",\n                    \"tgt_id\": \"Tesla\",\n                    \"description\": \"Elon Musk is the CEO of Tesla\",\n                    \"keywords\": \"CEO, founder\",\n                    \"source_id\": \"chunk-123<SEP>chunk-456\"\n                    \"weight\": 1.0,\n                    ... (other relationship properties)\n                }\n            }\n\n        HTTP Status Codes:\n            200: Relationship created successfully\n            400: Invalid request (e.g., missing entities, invalid data, duplicate relationship)\n            500: Internal server error\n\n        Example Request:\n            POST /graph/relation/create\n            {\n                \"source_entity\": \"Elon Musk\",\n                \"target_entity\": \"Tesla\",\n                \"relation_data\": {\n                    \"description\": \"Elon Musk is the CEO of Tesla\",\n                    \"keywords\": \"CEO, founder\",\n                    \"weight\": 1.0\n                }\n            }\n        \"\"\"\n        try:\n            # Use the proper acreate_relation method which handles:\n            # - Graph lock for concurrency\n            # - Entity existence validation\n            # - Duplicate relation checks\n            # - Vector embedding creation in relationships_vdb\n            # - Index consistency via _edit_relation_done\n            result = await rag.acreate_relation(\n                source_entity=request.source_entity,\n                target_entity=request.target_entity,\n                relation_data=request.relation_data,\n            )\n\n            return {\n                \"status\": \"success\",\n                \"message\": f\"Relation created successfully between '{request.source_entity}' and '{request.target_entity}'\",\n                \"data\": result,\n            }\n        except ValueError as ve:\n            logger.error(\n                f\"Validation error creating relation between '{request.source_entity}' and '{request.target_entity}': {str(ve)}\"\n            )\n            raise HTTPException(status_code=400, detail=str(ve))\n        except Exception as e:\n            logger.error(\n                f\"Error creating relation between '{request.source_entity}' and '{request.target_entity}': {str(e)}\"\n            )\n            logger.error(traceback.format_exc())\n            raise HTTPException(\n                status_code=500, detail=f\"Error creating relation: {str(e)}\"\n            )\n\n    @router.post(\"/graph/entities/merge\", dependencies=[Depends(combined_auth)])\n    async def merge_entities(request: EntityMergeRequest):\n        \"\"\"\n        Merge multiple entities into a single entity, preserving all relationships\n\n        This endpoint consolidates duplicate or misspelled entities while preserving the entire\n        graph structure. It's particularly useful for cleaning up knowledge graphs after document\n        processing or correcting entity name variations.\n\n        What the Merge Operation Does:\n            1. Deletes the specified source entities from the knowledge graph\n            2. Transfers all relationships from source entities to the target entity\n            3. Intelligently merges duplicate relationships (if multiple sources have the same relationship)\n            4. Updates vector embeddings for accurate retrieval and search\n            5. Preserves the complete graph structure and connectivity\n            6. Maintains relationship properties and metadata\n\n        Use Cases:\n            - Fixing spelling errors in entity names (e.g., \"Elon Msk\" -> \"Elon Musk\")\n            - Consolidating duplicate entities discovered after document processing\n            - Merging name variations (e.g., \"NY\", \"New York\", \"New York City\")\n            - Cleaning up the knowledge graph for better query performance\n            - Standardizing entity names across the knowledge base\n\n        Request Body:\n            entities_to_change (list[str]): List of entity names to be merged and deleted\n            entity_to_change_into (str): Target entity that will receive all relationships\n\n        Response Schema:\n            {\n                \"status\": \"success\",\n                \"message\": \"Successfully merged 2 entities into 'Elon Musk'\",\n                \"data\": {\n                    \"merged_entity\": \"Elon Musk\",\n                    \"deleted_entities\": [\"Elon Msk\", \"Ellon Musk\"],\n                    \"relationships_transferred\": 15,\n                    ... (merge operation details)\n                }\n            }\n\n        HTTP Status Codes:\n            200: Entities merged successfully\n            400: Invalid request (e.g., empty entity list, target entity doesn't exist)\n            500: Internal server error\n\n        Example Request:\n            POST /graph/entities/merge\n            {\n                \"entities_to_change\": [\"Elon Msk\", \"Ellon Musk\"],\n                \"entity_to_change_into\": \"Elon Musk\"\n            }\n\n        Note:\n            - The target entity (entity_to_change_into) must exist in the knowledge graph\n            - Source entities will be permanently deleted after the merge\n            - This operation cannot be undone, so verify entity names before merging\n        \"\"\"\n        try:\n            result = await rag.amerge_entities(\n                source_entities=request.entities_to_change,\n                target_entity=request.entity_to_change_into,\n            )\n            return {\n                \"status\": \"success\",\n                \"message\": f\"Successfully merged {len(request.entities_to_change)} entities into '{request.entity_to_change_into}'\",\n                \"data\": result,\n            }\n        except ValueError as ve:\n            logger.error(\n                f\"Validation error merging entities {request.entities_to_change} into '{request.entity_to_change_into}': {str(ve)}\"\n            )\n            raise HTTPException(status_code=400, detail=str(ve))\n        except Exception as e:\n            logger.error(\n                f\"Error merging entities {request.entities_to_change} into '{request.entity_to_change_into}': {str(e)}\"\n            )\n            logger.error(traceback.format_exc())\n            raise HTTPException(\n                status_code=500, detail=f\"Error merging entities: {str(e)}\"\n            )\n\n    return router\n"
  },
  {
    "path": "lightrag/api/routers/ollama_api.py",
    "content": "from fastapi import APIRouter, HTTPException, Request\nfrom pydantic import BaseModel\nfrom typing import List, Dict, Any, Optional, Type\nfrom lightrag.utils import logger\nimport time\nimport json\nimport re\nfrom enum import Enum\nfrom fastapi.responses import StreamingResponse\nimport asyncio\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.utils import TiktokenTokenizer\nfrom lightrag.api.utils_api import get_combined_auth_dependency\nfrom fastapi import Depends\n\n\n# query mode according to query prefix (bypass is not LightRAG quer mode)\nclass SearchMode(str, Enum):\n    naive = \"naive\"\n    local = \"local\"\n    global_ = \"global\"\n    hybrid = \"hybrid\"\n    mix = \"mix\"\n    bypass = \"bypass\"\n    context = \"context\"\n\n\nclass OllamaMessage(BaseModel):\n    role: str\n    content: str\n    images: Optional[List[str]] = None\n\n\nclass OllamaChatRequest(BaseModel):\n    model: str\n    messages: List[OllamaMessage]\n    stream: bool = True\n    options: Optional[Dict[str, Any]] = None\n    system: Optional[str] = None\n\n\nclass OllamaChatResponse(BaseModel):\n    model: str\n    created_at: str\n    message: OllamaMessage\n    done: bool\n\n\nclass OllamaGenerateRequest(BaseModel):\n    model: str\n    prompt: str\n    system: Optional[str] = None\n    stream: bool = False\n    options: Optional[Dict[str, Any]] = None\n\n\nclass OllamaGenerateResponse(BaseModel):\n    model: str\n    created_at: str\n    response: str\n    done: bool\n    context: Optional[List[int]]\n    total_duration: Optional[int]\n    load_duration: Optional[int]\n    prompt_eval_count: Optional[int]\n    prompt_eval_duration: Optional[int]\n    eval_count: Optional[int]\n    eval_duration: Optional[int]\n\n\nclass OllamaVersionResponse(BaseModel):\n    version: str\n\n\nclass OllamaModelDetails(BaseModel):\n    parent_model: str\n    format: str\n    family: str\n    families: List[str]\n    parameter_size: str\n    quantization_level: str\n\n\nclass OllamaModel(BaseModel):\n    name: str\n    model: str\n    size: int\n    digest: str\n    modified_at: str\n    details: OllamaModelDetails\n\n\nclass OllamaTagResponse(BaseModel):\n    models: List[OllamaModel]\n\n\nclass OllamaRunningModelDetails(BaseModel):\n    parent_model: str\n    format: str\n    family: str\n    families: List[str]\n    parameter_size: str\n    quantization_level: str\n\n\nclass OllamaRunningModel(BaseModel):\n    name: str\n    model: str\n    size: int\n    digest: str\n    details: OllamaRunningModelDetails\n    expires_at: str\n    size_vram: int\n\n\nclass OllamaPsResponse(BaseModel):\n    models: List[OllamaRunningModel]\n\n\nasync def parse_request_body(\n    request: Request, model_class: Type[BaseModel]\n) -> BaseModel:\n    \"\"\"\n    Parse request body based on Content-Type header.\n    Supports both application/json and application/octet-stream.\n\n    Args:\n        request: The FastAPI Request object\n        model_class: The Pydantic model class to parse the request into\n\n    Returns:\n        An instance of the provided model_class\n    \"\"\"\n    content_type = request.headers.get(\"content-type\", \"\").lower()\n\n    try:\n        if content_type.startswith(\"application/json\"):\n            # FastAPI already handles JSON parsing for us\n            body = await request.json()\n        elif content_type.startswith(\"application/octet-stream\"):\n            # Manually parse octet-stream as JSON\n            body_bytes = await request.body()\n            body = json.loads(body_bytes.decode(\"utf-8\"))\n        else:\n            # Try to parse as JSON for any other content type\n            body_bytes = await request.body()\n            body = json.loads(body_bytes.decode(\"utf-8\"))\n\n        # Create an instance of the model\n        return model_class(**body)\n    except json.JSONDecodeError:\n        raise HTTPException(status_code=400, detail=\"Invalid JSON in request body\")\n    except Exception as e:\n        raise HTTPException(\n            status_code=400, detail=f\"Error parsing request body: {str(e)}\"\n        )\n\n\ndef estimate_tokens(text: str) -> int:\n    \"\"\"Estimate the number of tokens in text using tiktoken\"\"\"\n    tokens = TiktokenTokenizer().encode(text)\n    return len(tokens)\n\n\ndef parse_query_mode(query: str) -> tuple[str, SearchMode, bool, Optional[str]]:\n    \"\"\"Parse query prefix to determine search mode\n    Returns tuple of (cleaned_query, search_mode, only_need_context, user_prompt)\n\n    Examples:\n    - \"/local[use mermaid format for diagrams] query string\" -> (cleaned_query, SearchMode.local, False, \"use mermaid format for diagrams\")\n    - \"/[use mermaid format for diagrams] query string\" -> (cleaned_query, SearchMode.hybrid, False, \"use mermaid format for diagrams\")\n    - \"/local  query string\" -> (cleaned_query, SearchMode.local, False, None)\n    \"\"\"\n    # Initialize user_prompt as None\n    user_prompt = None\n\n    # First check if there's a bracket format for user prompt\n    bracket_pattern = r\"^/([a-z]*)\\[(.*?)\\](.*)\"\n    bracket_match = re.match(bracket_pattern, query)\n\n    if bracket_match:\n        mode_prefix = bracket_match.group(1)\n        user_prompt = bracket_match.group(2)\n        remaining_query = bracket_match.group(3).lstrip()\n\n        # Reconstruct query, removing the bracket part\n        query = f\"/{mode_prefix} {remaining_query}\".strip()\n\n    # Unified handling of mode and only_need_context determination\n    mode_map = {\n        \"/local \": (SearchMode.local, False),\n        \"/global \": (\n            SearchMode.global_,\n            False,\n        ),  # global_ is used because 'global' is a Python keyword\n        \"/naive \": (SearchMode.naive, False),\n        \"/hybrid \": (SearchMode.hybrid, False),\n        \"/mix \": (SearchMode.mix, False),\n        \"/bypass \": (SearchMode.bypass, False),\n        \"/context\": (\n            SearchMode.mix,\n            True,\n        ),\n        \"/localcontext\": (SearchMode.local, True),\n        \"/globalcontext\": (SearchMode.global_, True),\n        \"/hybridcontext\": (SearchMode.hybrid, True),\n        \"/naivecontext\": (SearchMode.naive, True),\n        \"/mixcontext\": (SearchMode.mix, True),\n    }\n\n    for prefix, (mode, only_need_context) in mode_map.items():\n        if query.startswith(prefix):\n            # After removing prefix and leading spaces\n            cleaned_query = query[len(prefix) :].lstrip()\n            return cleaned_query, mode, only_need_context, user_prompt\n\n    return query, SearchMode.mix, False, user_prompt\n\n\nclass OllamaAPI:\n    def __init__(self, rag: LightRAG, top_k: int = 60, api_key: Optional[str] = None):\n        self.rag = rag\n        self.ollama_server_infos = rag.ollama_server_infos\n        self.top_k = top_k\n        self.api_key = api_key\n        self.router = APIRouter(tags=[\"ollama\"])\n        self.setup_routes()\n\n    def setup_routes(self):\n        # Create combined auth dependency for Ollama API routes\n        combined_auth = get_combined_auth_dependency(self.api_key)\n\n        @self.router.get(\"/version\", dependencies=[Depends(combined_auth)])\n        async def get_version():\n            \"\"\"Get Ollama version information\"\"\"\n            return OllamaVersionResponse(version=\"0.9.3\")\n\n        @self.router.get(\"/tags\", dependencies=[Depends(combined_auth)])\n        async def get_tags():\n            \"\"\"Return available models acting as an Ollama server\"\"\"\n            return OllamaTagResponse(\n                models=[\n                    {\n                        \"name\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                        \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                        \"modified_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                        \"size\": self.ollama_server_infos.LIGHTRAG_SIZE,\n                        \"digest\": self.ollama_server_infos.LIGHTRAG_DIGEST,\n                        \"details\": {\n                            \"parent_model\": \"\",\n                            \"format\": \"gguf\",\n                            \"family\": self.ollama_server_infos.LIGHTRAG_NAME,\n                            \"families\": [self.ollama_server_infos.LIGHTRAG_NAME],\n                            \"parameter_size\": \"13B\",\n                            \"quantization_level\": \"Q4_0\",\n                        },\n                    }\n                ]\n            )\n\n        @self.router.get(\"/ps\", dependencies=[Depends(combined_auth)])\n        async def get_running_models():\n            \"\"\"List Running Models - returns currently running models\"\"\"\n            return OllamaPsResponse(\n                models=[\n                    {\n                        \"name\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                        \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                        \"size\": self.ollama_server_infos.LIGHTRAG_SIZE,\n                        \"digest\": self.ollama_server_infos.LIGHTRAG_DIGEST,\n                        \"details\": {\n                            \"parent_model\": \"\",\n                            \"format\": \"gguf\",\n                            \"family\": \"llama\",\n                            \"families\": [\"llama\"],\n                            \"parameter_size\": \"7.2B\",\n                            \"quantization_level\": \"Q4_0\",\n                        },\n                        \"expires_at\": \"2050-12-31T14:38:31.83753-07:00\",\n                        \"size_vram\": self.ollama_server_infos.LIGHTRAG_SIZE,\n                    }\n                ]\n            )\n\n        @self.router.post(\n            \"/generate\", dependencies=[Depends(combined_auth)], include_in_schema=True\n        )\n        async def generate(raw_request: Request):\n            \"\"\"Handle generate completion requests acting as an Ollama model\n            For compatibility purpose, the request is not processed by LightRAG,\n            and will be handled by underlying LLM model.\n            Supports both application/json and application/octet-stream Content-Types.\n            \"\"\"\n            try:\n                # Parse the request body manually\n                request = await parse_request_body(raw_request, OllamaGenerateRequest)\n\n                query = request.prompt\n                start_time = time.time_ns()\n                prompt_tokens = estimate_tokens(query)\n\n                if request.system:\n                    self.rag.llm_model_kwargs[\"system_prompt\"] = request.system\n\n                if request.stream:\n                    response = await self.rag.llm_model_func(\n                        query, stream=True, **self.rag.llm_model_kwargs\n                    )\n\n                    async def stream_generator():\n                        first_chunk_time = None\n                        last_chunk_time = time.time_ns()\n                        total_response = \"\"\n\n                        # Ensure response is an async generator\n                        if isinstance(response, str):\n                            # If it's a string, send in two parts\n                            first_chunk_time = start_time\n                            last_chunk_time = time.time_ns()\n                            total_response = response\n\n                            data = {\n                                \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                                \"created_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                                \"response\": response,\n                                \"done\": False,\n                            }\n                            yield f\"{json.dumps(data, ensure_ascii=False)}\\n\"\n\n                            completion_tokens = estimate_tokens(total_response)\n                            total_time = last_chunk_time - start_time\n                            prompt_eval_time = first_chunk_time - start_time\n                            eval_time = last_chunk_time - first_chunk_time\n\n                            data = {\n                                \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                                \"created_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                                \"response\": \"\",\n                                \"done\": True,\n                                \"done_reason\": \"stop\",\n                                \"context\": [],\n                                \"total_duration\": total_time,\n                                \"load_duration\": 0,\n                                \"prompt_eval_count\": prompt_tokens,\n                                \"prompt_eval_duration\": prompt_eval_time,\n                                \"eval_count\": completion_tokens,\n                                \"eval_duration\": eval_time,\n                            }\n                            yield f\"{json.dumps(data, ensure_ascii=False)}\\n\"\n                        else:\n                            try:\n                                async for chunk in response:\n                                    if chunk:\n                                        if first_chunk_time is None:\n                                            first_chunk_time = time.time_ns()\n\n                                        last_chunk_time = time.time_ns()\n\n                                        total_response += chunk\n                                        data = {\n                                            \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                                            \"created_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                                            \"response\": chunk,\n                                            \"done\": False,\n                                        }\n                                        yield f\"{json.dumps(data, ensure_ascii=False)}\\n\"\n                            except (asyncio.CancelledError, Exception) as e:\n                                error_msg = str(e)\n                                if isinstance(e, asyncio.CancelledError):\n                                    error_msg = \"Stream was cancelled by server\"\n                                else:\n                                    error_msg = f\"Provider error: {error_msg}\"\n\n                                logger.error(f\"Stream error: {error_msg}\")\n\n                                # Send error message to client\n                                error_data = {\n                                    \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                                    \"created_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                                    \"response\": f\"\\n\\nError: {error_msg}\",\n                                    \"error\": f\"\\n\\nError: {error_msg}\",\n                                    \"done\": False,\n                                }\n                                yield f\"{json.dumps(error_data, ensure_ascii=False)}\\n\"\n\n                                # Send final message to close the stream\n                                final_data = {\n                                    \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                                    \"created_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                                    \"response\": \"\",\n                                    \"done\": True,\n                                }\n                                yield f\"{json.dumps(final_data, ensure_ascii=False)}\\n\"\n                                return\n                            if first_chunk_time is None:\n                                first_chunk_time = start_time\n                            completion_tokens = estimate_tokens(total_response)\n                            total_time = last_chunk_time - start_time\n                            prompt_eval_time = first_chunk_time - start_time\n                            eval_time = last_chunk_time - first_chunk_time\n\n                            data = {\n                                \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                                \"created_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                                \"response\": \"\",\n                                \"done\": True,\n                                \"done_reason\": \"stop\",\n                                \"context\": [],\n                                \"total_duration\": total_time,\n                                \"load_duration\": 0,\n                                \"prompt_eval_count\": prompt_tokens,\n                                \"prompt_eval_duration\": prompt_eval_time,\n                                \"eval_count\": completion_tokens,\n                                \"eval_duration\": eval_time,\n                            }\n                            yield f\"{json.dumps(data, ensure_ascii=False)}\\n\"\n                            return\n\n                    return StreamingResponse(\n                        stream_generator(),\n                        media_type=\"application/x-ndjson\",\n                        headers={\n                            \"Cache-Control\": \"no-cache\",\n                            \"Connection\": \"keep-alive\",\n                            \"Content-Type\": \"application/x-ndjson\",\n                            \"X-Accel-Buffering\": \"no\",  # Ensure proper handling of streaming responses in Nginx proxy\n                        },\n                    )\n                else:\n                    first_chunk_time = time.time_ns()\n                    response_text = await self.rag.llm_model_func(\n                        query, stream=False, **self.rag.llm_model_kwargs\n                    )\n                    last_chunk_time = time.time_ns()\n\n                    if not response_text:\n                        response_text = \"No response generated\"\n\n                    completion_tokens = estimate_tokens(str(response_text))\n                    total_time = last_chunk_time - start_time\n                    prompt_eval_time = first_chunk_time - start_time\n                    eval_time = last_chunk_time - first_chunk_time\n\n                    return {\n                        \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                        \"created_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                        \"response\": str(response_text),\n                        \"done\": True,\n                        \"done_reason\": \"stop\",\n                        \"context\": [],\n                        \"total_duration\": total_time,\n                        \"load_duration\": 0,\n                        \"prompt_eval_count\": prompt_tokens,\n                        \"prompt_eval_duration\": prompt_eval_time,\n                        \"eval_count\": completion_tokens,\n                        \"eval_duration\": eval_time,\n                    }\n            except Exception as e:\n                logger.error(f\"Ollama generate error: {str(e)}\", exc_info=True)\n                raise HTTPException(status_code=500, detail=str(e))\n\n        @self.router.post(\n            \"/chat\", dependencies=[Depends(combined_auth)], include_in_schema=True\n        )\n        async def chat(raw_request: Request):\n            \"\"\"Process chat completion requests by acting as an Ollama model.\n            Routes user queries through LightRAG by selecting query mode based on query prefix.\n            Detects and forwards OpenWebUI session-related requests (for meta data generation task) directly to LLM.\n            Supports both application/json and application/octet-stream Content-Types.\n            \"\"\"\n            try:\n                # Parse the request body manually\n                request = await parse_request_body(raw_request, OllamaChatRequest)\n\n                # Get all messages\n                messages = request.messages\n                if not messages:\n                    raise HTTPException(status_code=400, detail=\"No messages provided\")\n\n                # Validate that the last message is from a user\n                if messages[-1].role != \"user\":\n                    raise HTTPException(\n                        status_code=400, detail=\"Last message must be from user role\"\n                    )\n\n                # Get the last message as query and previous messages as history\n                query = messages[-1].content\n                # Convert OllamaMessage objects to dictionaries\n                conversation_history = [\n                    {\"role\": msg.role, \"content\": msg.content} for msg in messages[:-1]\n                ]\n\n                # Check for query prefix\n                cleaned_query, mode, only_need_context, user_prompt = parse_query_mode(\n                    query\n                )\n\n                start_time = time.time_ns()\n                prompt_tokens = estimate_tokens(cleaned_query)\n\n                param_dict = {\n                    \"mode\": mode.value,\n                    \"stream\": request.stream,\n                    \"only_need_context\": only_need_context,\n                    \"conversation_history\": conversation_history,\n                    \"top_k\": self.top_k,\n                }\n\n                # Add user_prompt to param_dict\n                if user_prompt is not None:\n                    param_dict[\"user_prompt\"] = user_prompt\n\n                query_param = QueryParam(**param_dict)\n\n                if request.stream:\n                    # Determine if the request is prefix with \"/bypass\"\n                    if mode == SearchMode.bypass:\n                        if request.system:\n                            self.rag.llm_model_kwargs[\"system_prompt\"] = request.system\n                        response = await self.rag.llm_model_func(\n                            cleaned_query,\n                            stream=True,\n                            history_messages=conversation_history,\n                            **self.rag.llm_model_kwargs,\n                        )\n                    else:\n                        response = await self.rag.aquery(\n                            cleaned_query, param=query_param\n                        )\n\n                    async def stream_generator():\n                        first_chunk_time = None\n                        last_chunk_time = time.time_ns()\n                        total_response = \"\"\n\n                        # Ensure response is an async generator\n                        if isinstance(response, str):\n                            # If it's a string, send in two parts\n                            first_chunk_time = start_time\n                            last_chunk_time = time.time_ns()\n                            total_response = response\n\n                            data = {\n                                \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                                \"created_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                                \"message\": {\n                                    \"role\": \"assistant\",\n                                    \"content\": response,\n                                    \"images\": None,\n                                },\n                                \"done\": False,\n                            }\n                            yield f\"{json.dumps(data, ensure_ascii=False)}\\n\"\n\n                            completion_tokens = estimate_tokens(total_response)\n                            total_time = last_chunk_time - start_time\n                            prompt_eval_time = first_chunk_time - start_time\n                            eval_time = last_chunk_time - first_chunk_time\n\n                            data = {\n                                \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                                \"created_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                                \"message\": {\n                                    \"role\": \"assistant\",\n                                    \"content\": \"\",\n                                    \"images\": None,\n                                },\n                                \"done_reason\": \"stop\",\n                                \"done\": True,\n                                \"total_duration\": total_time,\n                                \"load_duration\": 0,\n                                \"prompt_eval_count\": prompt_tokens,\n                                \"prompt_eval_duration\": prompt_eval_time,\n                                \"eval_count\": completion_tokens,\n                                \"eval_duration\": eval_time,\n                            }\n                            yield f\"{json.dumps(data, ensure_ascii=False)}\\n\"\n                        else:\n                            try:\n                                async for chunk in response:\n                                    if chunk:\n                                        if first_chunk_time is None:\n                                            first_chunk_time = time.time_ns()\n\n                                        last_chunk_time = time.time_ns()\n\n                                        total_response += chunk\n                                        data = {\n                                            \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                                            \"created_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                                            \"message\": {\n                                                \"role\": \"assistant\",\n                                                \"content\": chunk,\n                                                \"images\": None,\n                                            },\n                                            \"done\": False,\n                                        }\n                                        yield f\"{json.dumps(data, ensure_ascii=False)}\\n\"\n                            except (asyncio.CancelledError, Exception) as e:\n                                error_msg = str(e)\n                                if isinstance(e, asyncio.CancelledError):\n                                    error_msg = \"Stream was cancelled by server\"\n                                else:\n                                    error_msg = f\"Provider error: {error_msg}\"\n\n                                logger.error(f\"Stream error: {error_msg}\")\n\n                                # Send error message to client\n                                error_data = {\n                                    \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                                    \"created_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                                    \"message\": {\n                                        \"role\": \"assistant\",\n                                        \"content\": f\"\\n\\nError: {error_msg}\",\n                                        \"images\": None,\n                                    },\n                                    \"error\": f\"\\n\\nError: {error_msg}\",\n                                    \"done\": False,\n                                }\n                                yield f\"{json.dumps(error_data, ensure_ascii=False)}\\n\"\n\n                                # Send final message to close the stream\n                                final_data = {\n                                    \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                                    \"created_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                                    \"message\": {\n                                        \"role\": \"assistant\",\n                                        \"content\": \"\",\n                                        \"images\": None,\n                                    },\n                                    \"done\": True,\n                                }\n                                yield f\"{json.dumps(final_data, ensure_ascii=False)}\\n\"\n                                return\n\n                            if first_chunk_time is None:\n                                first_chunk_time = start_time\n                            completion_tokens = estimate_tokens(total_response)\n                            total_time = last_chunk_time - start_time\n                            prompt_eval_time = first_chunk_time - start_time\n                            eval_time = last_chunk_time - first_chunk_time\n\n                            data = {\n                                \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                                \"created_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                                \"message\": {\n                                    \"role\": \"assistant\",\n                                    \"content\": \"\",\n                                    \"images\": None,\n                                },\n                                \"done_reason\": \"stop\",\n                                \"done\": True,\n                                \"total_duration\": total_time,\n                                \"load_duration\": 0,\n                                \"prompt_eval_count\": prompt_tokens,\n                                \"prompt_eval_duration\": prompt_eval_time,\n                                \"eval_count\": completion_tokens,\n                                \"eval_duration\": eval_time,\n                            }\n                            yield f\"{json.dumps(data, ensure_ascii=False)}\\n\"\n\n                    return StreamingResponse(\n                        stream_generator(),\n                        media_type=\"application/x-ndjson\",\n                        headers={\n                            \"Cache-Control\": \"no-cache\",\n                            \"Connection\": \"keep-alive\",\n                            \"Content-Type\": \"application/x-ndjson\",\n                            \"X-Accel-Buffering\": \"no\",  # Ensure proper handling of streaming responses in Nginx proxy\n                        },\n                    )\n                else:\n                    first_chunk_time = time.time_ns()\n\n                    # Determine if the request is prefix with \"/bypass\" or from Open WebUI's session title and session keyword generation task\n                    match_result = re.search(\n                        r\"\\n<chat_history>\\nUSER:\", cleaned_query, re.MULTILINE\n                    )\n                    if match_result or mode == SearchMode.bypass:\n                        if request.system:\n                            self.rag.llm_model_kwargs[\"system_prompt\"] = request.system\n\n                        response_text = await self.rag.llm_model_func(\n                            cleaned_query,\n                            stream=False,\n                            history_messages=conversation_history,\n                            **self.rag.llm_model_kwargs,\n                        )\n                    else:\n                        response_text = await self.rag.aquery(\n                            cleaned_query, param=query_param\n                        )\n\n                    last_chunk_time = time.time_ns()\n\n                    if not response_text:\n                        response_text = \"No response generated\"\n\n                    completion_tokens = estimate_tokens(str(response_text))\n                    total_time = last_chunk_time - start_time\n                    prompt_eval_time = first_chunk_time - start_time\n                    eval_time = last_chunk_time - first_chunk_time\n\n                    return {\n                        \"model\": self.ollama_server_infos.LIGHTRAG_MODEL,\n                        \"created_at\": self.ollama_server_infos.LIGHTRAG_CREATED_AT,\n                        \"message\": {\n                            \"role\": \"assistant\",\n                            \"content\": str(response_text),\n                            \"images\": None,\n                        },\n                        \"done_reason\": \"stop\",\n                        \"done\": True,\n                        \"total_duration\": total_time,\n                        \"load_duration\": 0,\n                        \"prompt_eval_count\": prompt_tokens,\n                        \"prompt_eval_duration\": prompt_eval_time,\n                        \"eval_count\": completion_tokens,\n                        \"eval_duration\": eval_time,\n                    }\n            except Exception as e:\n                logger.error(f\"Ollama chat error: {str(e)}\", exc_info=True)\n                raise HTTPException(status_code=500, detail=str(e))\n"
  },
  {
    "path": "lightrag/api/routers/query_routes.py",
    "content": "\"\"\"\nThis module contains all query-related routes for the LightRAG API.\n\"\"\"\n\nimport json\nfrom typing import Any, Dict, List, Literal, Optional\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom lightrag.base import QueryParam\nfrom lightrag.api.utils_api import get_combined_auth_dependency\nfrom lightrag.utils import logger\nfrom pydantic import BaseModel, Field, field_validator\n\nrouter = APIRouter(tags=[\"query\"])\n\n\nclass QueryRequest(BaseModel):\n    query: str = Field(\n        min_length=3,\n        description=\"The query text\",\n    )\n\n    mode: Literal[\"local\", \"global\", \"hybrid\", \"naive\", \"mix\", \"bypass\"] = Field(\n        default=\"mix\",\n        description=\"Query mode\",\n    )\n\n    only_need_context: Optional[bool] = Field(\n        default=None,\n        description=\"If True, only returns the retrieved context without generating a response.\",\n    )\n\n    only_need_prompt: Optional[bool] = Field(\n        default=None,\n        description=\"If True, only returns the generated prompt without producing a response.\",\n    )\n\n    response_type: Optional[str] = Field(\n        min_length=1,\n        default=None,\n        description=\"Defines the response format. Examples: 'Multiple Paragraphs', 'Single Paragraph', 'Bullet Points'.\",\n    )\n\n    top_k: Optional[int] = Field(\n        ge=1,\n        default=None,\n        description=\"Number of top items to retrieve. Represents entities in 'local' mode and relationships in 'global' mode.\",\n    )\n\n    chunk_top_k: Optional[int] = Field(\n        ge=1,\n        default=None,\n        description=\"Number of text chunks to retrieve initially from vector search and keep after reranking.\",\n    )\n\n    max_entity_tokens: Optional[int] = Field(\n        default=None,\n        description=\"Maximum number of tokens allocated for entity context in unified token control system.\",\n        ge=1,\n    )\n\n    max_relation_tokens: Optional[int] = Field(\n        default=None,\n        description=\"Maximum number of tokens allocated for relationship context in unified token control system.\",\n        ge=1,\n    )\n\n    max_total_tokens: Optional[int] = Field(\n        default=None,\n        description=\"Maximum total tokens budget for the entire query context (entities + relations + chunks + system prompt).\",\n        ge=1,\n    )\n\n    hl_keywords: list[str] = Field(\n        default_factory=list,\n        description=\"List of high-level keywords to prioritize in retrieval. Leave empty to use the LLM to generate the keywords.\",\n    )\n\n    ll_keywords: list[str] = Field(\n        default_factory=list,\n        description=\"List of low-level keywords to refine retrieval focus. Leave empty to use the LLM to generate the keywords.\",\n    )\n\n    conversation_history: Optional[List[Dict[str, Any]]] = Field(\n        default=None,\n        description=\"History messages are only sent to LLM for context, not used for retrieval. Format: [{'role': 'user/assistant', 'content': 'message'}].\",\n    )\n\n    user_prompt: Optional[str] = Field(\n        default=None,\n        description=\"User-provided prompt for the query. If provided, this will be used instead of the default value from prompt template.\",\n    )\n\n    enable_rerank: Optional[bool] = Field(\n        default=None,\n        description=\"Enable reranking for retrieved text chunks. If True but no rerank model is configured, a warning will be issued. Default is True.\",\n    )\n\n    include_references: Optional[bool] = Field(\n        default=True,\n        description=\"If True, includes reference list in responses. Affects /query and /query/stream endpoints. /query/data always includes references.\",\n    )\n\n    include_chunk_content: Optional[bool] = Field(\n        default=False,\n        description=\"If True, includes actual chunk text content in references. Only applies when include_references=True. Useful for evaluation and debugging.\",\n    )\n\n    stream: Optional[bool] = Field(\n        default=True,\n        description=\"If True, enables streaming output for real-time responses. Only affects /query/stream endpoint.\",\n    )\n\n    @field_validator(\"query\", mode=\"after\")\n    @classmethod\n    def query_strip_after(cls, query: str) -> str:\n        return query.strip()\n\n    @field_validator(\"conversation_history\", mode=\"after\")\n    @classmethod\n    def conversation_history_role_check(\n        cls, conversation_history: List[Dict[str, Any]] | None\n    ) -> List[Dict[str, Any]] | None:\n        if conversation_history is None:\n            return None\n        for msg in conversation_history:\n            if \"role\" not in msg:\n                raise ValueError(\"Each message must have a 'role' key.\")\n            if not isinstance(msg[\"role\"], str) or not msg[\"role\"].strip():\n                raise ValueError(\"Each message 'role' must be a non-empty string.\")\n        return conversation_history\n\n    def to_query_params(self, is_stream: bool) -> \"QueryParam\":\n        \"\"\"Converts a QueryRequest instance into a QueryParam instance.\"\"\"\n        # Use Pydantic's `.model_dump(exclude_none=True)` to remove None values automatically\n        # Exclude API-level parameters that don't belong in QueryParam\n        request_data = self.model_dump(\n            exclude_none=True, exclude={\"query\", \"include_chunk_content\"}\n        )\n\n        # Ensure `mode` and `stream` are set explicitly\n        param = QueryParam(**request_data)\n        param.stream = is_stream\n        return param\n\n\nclass ReferenceItem(BaseModel):\n    \"\"\"A single reference item in query responses.\"\"\"\n\n    reference_id: str = Field(description=\"Unique reference identifier\")\n    file_path: str = Field(description=\"Path to the source file\")\n    content: Optional[List[str]] = Field(\n        default=None,\n        description=\"List of chunk contents from this file (only present when include_chunk_content=True)\",\n    )\n\n\nclass QueryResponse(BaseModel):\n    response: str = Field(\n        description=\"The generated response\",\n    )\n    references: Optional[List[ReferenceItem]] = Field(\n        default=None,\n        description=\"Reference list (Disabled when include_references=False, /query/data always includes references.)\",\n    )\n\n\nclass QueryDataResponse(BaseModel):\n    status: str = Field(description=\"Query execution status\")\n    message: str = Field(description=\"Status message\")\n    data: Dict[str, Any] = Field(\n        description=\"Query result data containing entities, relationships, chunks, and references\"\n    )\n    metadata: Dict[str, Any] = Field(\n        description=\"Query metadata including mode, keywords, and processing information\"\n    )\n\n\nclass StreamChunkResponse(BaseModel):\n    \"\"\"Response model for streaming chunks in NDJSON format\"\"\"\n\n    references: Optional[List[Dict[str, str]]] = Field(\n        default=None,\n        description=\"Reference list (only in first chunk when include_references=True)\",\n    )\n    response: Optional[str] = Field(\n        default=None, description=\"Response content chunk or complete response\"\n    )\n    error: Optional[str] = Field(\n        default=None, description=\"Error message if processing fails\"\n    )\n\n\ndef create_query_routes(rag, api_key: Optional[str] = None, top_k: int = 60):\n    combined_auth = get_combined_auth_dependency(api_key)\n\n    @router.post(\n        \"/query\",\n        response_model=QueryResponse,\n        dependencies=[Depends(combined_auth)],\n        responses={\n            200: {\n                \"description\": \"Successful RAG query response\",\n                \"content\": {\n                    \"application/json\": {\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                                \"response\": {\n                                    \"type\": \"string\",\n                                    \"description\": \"The generated response from the RAG system\",\n                                },\n                                \"references\": {\n                                    \"type\": \"array\",\n                                    \"items\": {\n                                        \"type\": \"object\",\n                                        \"properties\": {\n                                            \"reference_id\": {\"type\": \"string\"},\n                                            \"file_path\": {\"type\": \"string\"},\n                                            \"content\": {\n                                                \"type\": \"array\",\n                                                \"items\": {\"type\": \"string\"},\n                                                \"description\": \"List of chunk contents from this file (only included when include_chunk_content=True)\",\n                                            },\n                                        },\n                                    },\n                                    \"description\": \"Reference list (only included when include_references=True)\",\n                                },\n                            },\n                            \"required\": [\"response\"],\n                        },\n                        \"examples\": {\n                            \"with_references\": {\n                                \"summary\": \"Response with references\",\n                                \"description\": \"Example response when include_references=True\",\n                                \"value\": {\n                                    \"response\": \"Artificial Intelligence (AI) is a branch of computer science that aims to create intelligent machines capable of performing tasks that typically require human intelligence, such as learning, reasoning, and problem-solving.\",\n                                    \"references\": [\n                                        {\n                                            \"reference_id\": \"1\",\n                                            \"file_path\": \"/documents/ai_overview.pdf\",\n                                        },\n                                        {\n                                            \"reference_id\": \"2\",\n                                            \"file_path\": \"/documents/machine_learning.txt\",\n                                        },\n                                    ],\n                                },\n                            },\n                            \"with_chunk_content\": {\n                                \"summary\": \"Response with chunk content\",\n                                \"description\": \"Example response when include_references=True and include_chunk_content=True. Note: content is an array of chunks from the same file.\",\n                                \"value\": {\n                                    \"response\": \"Artificial Intelligence (AI) is a branch of computer science that aims to create intelligent machines capable of performing tasks that typically require human intelligence, such as learning, reasoning, and problem-solving.\",\n                                    \"references\": [\n                                        {\n                                            \"reference_id\": \"1\",\n                                            \"file_path\": \"/documents/ai_overview.pdf\",\n                                            \"content\": [\n                                                \"Artificial Intelligence (AI) represents a transformative field in computer science focused on creating systems that can perform tasks requiring human-like intelligence. These tasks include learning from experience, understanding natural language, recognizing patterns, and making decisions.\",\n                                                \"AI systems can be categorized into narrow AI, which is designed for specific tasks, and general AI, which aims to match human cognitive abilities across a wide range of domains.\",\n                                            ],\n                                        },\n                                        {\n                                            \"reference_id\": \"2\",\n                                            \"file_path\": \"/documents/machine_learning.txt\",\n                                            \"content\": [\n                                                \"Machine learning is a subset of AI that enables computers to learn and improve from experience without being explicitly programmed. It focuses on the development of algorithms that can access data and use it to learn for themselves.\"\n                                            ],\n                                        },\n                                    ],\n                                },\n                            },\n                            \"without_references\": {\n                                \"summary\": \"Response without references\",\n                                \"description\": \"Example response when include_references=False\",\n                                \"value\": {\n                                    \"response\": \"Artificial Intelligence (AI) is a branch of computer science that aims to create intelligent machines capable of performing tasks that typically require human intelligence, such as learning, reasoning, and problem-solving.\"\n                                },\n                            },\n                            \"different_modes\": {\n                                \"summary\": \"Different query modes\",\n                                \"description\": \"Examples of responses from different query modes\",\n                                \"value\": {\n                                    \"local_mode\": \"Focuses on specific entities and their relationships\",\n                                    \"global_mode\": \"Provides broader context from relationship patterns\",\n                                    \"hybrid_mode\": \"Combines local and global approaches\",\n                                    \"naive_mode\": \"Simple vector similarity search\",\n                                    \"mix_mode\": \"Integrates knowledge graph and vector retrieval\",\n                                },\n                            },\n                        },\n                    }\n                },\n            },\n            400: {\n                \"description\": \"Bad Request - Invalid input parameters\",\n                \"content\": {\n                    \"application/json\": {\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\"detail\": {\"type\": \"string\"}},\n                        },\n                        \"example\": {\n                            \"detail\": \"Query text must be at least 3 characters long\"\n                        },\n                    }\n                },\n            },\n            500: {\n                \"description\": \"Internal Server Error - Query processing failed\",\n                \"content\": {\n                    \"application/json\": {\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\"detail\": {\"type\": \"string\"}},\n                        },\n                        \"example\": {\n                            \"detail\": \"Failed to process query: LLM service unavailable\"\n                        },\n                    }\n                },\n            },\n        },\n    )\n    async def query_text(request: QueryRequest):\n        \"\"\"\n        Comprehensive RAG query endpoint with non-streaming response. Parameter \"stream\" is ignored.\n\n        This endpoint performs Retrieval-Augmented Generation (RAG) queries using various modes\n        to provide intelligent responses based on your knowledge base.\n\n        **Query Modes:**\n        - **local**: Focuses on specific entities and their direct relationships\n        - **global**: Analyzes broader patterns and relationships across the knowledge graph\n        - **hybrid**: Combines local and global approaches for comprehensive results\n        - **naive**: Simple vector similarity search without knowledge graph\n        - **mix**: Integrates knowledge graph retrieval with vector search (recommended)\n        - **bypass**: Direct LLM query without knowledge retrieval\n\n        conversation_history parameteris sent to LLM only, does not affect retrieval results.\n\n        **Usage Examples:**\n\n        Basic query:\n        ```json\n        {\n            \"query\": \"What is machine learning?\",\n            \"mode\": \"mix\"\n        }\n        ```\n\n        Bypass initial LLM call by providing high-level and low-level keywords:\n        ```json\n        {\n            \"query\": \"What is Retrieval-Augmented-Generation?\",\n            \"hl_keywords\": [\"machine learning\", \"information retrieval\", \"natural language processing\"],\n            \"ll_keywords\": [\"retrieval augmented generation\", \"RAG\", \"knowledge base\"],\n            \"mode\": \"mix\"\n        }\n        ```\n\n        Advanced query with references:\n        ```json\n        {\n            \"query\": \"Explain neural networks\",\n            \"mode\": \"hybrid\",\n            \"include_references\": true,\n            \"response_type\": \"Multiple Paragraphs\",\n            \"top_k\": 10\n        }\n        ```\n\n        Conversation with history:\n        ```json\n        {\n            \"query\": \"Can you give me more details?\",\n            \"conversation_history\": [\n                {\"role\": \"user\", \"content\": \"What is AI?\"},\n                {\"role\": \"assistant\", \"content\": \"AI is artificial intelligence...\"}\n            ]\n        }\n        ```\n\n        Args:\n            request (QueryRequest): The request object containing query parameters:\n                - **query**: The question or prompt to process (min 3 characters)\n                - **mode**: Query strategy - \"mix\" recommended for best results\n                - **include_references**: Whether to include source citations\n                - **response_type**: Format preference (e.g., \"Multiple Paragraphs\")\n                - **top_k**: Number of top entities/relations to retrieve\n                - **conversation_history**: Previous dialogue context\n                - **max_total_tokens**: Token budget for the entire response\n\n        Returns:\n            QueryResponse: JSON response containing:\n                - **response**: The generated answer to your query\n                - **references**: Source citations (if include_references=True)\n\n        Raises:\n            HTTPException:\n                - 400: Invalid input parameters (e.g., query too short)\n                - 500: Internal processing error (e.g., LLM service unavailable)\n        \"\"\"\n        try:\n            param = request.to_query_params(\n                False\n            )  # Ensure stream=False for non-streaming endpoint\n            # Force stream=False for /query endpoint regardless of include_references setting\n            param.stream = False\n\n            # Unified approach: always use aquery_llm for both cases\n            result = await rag.aquery_llm(request.query, param=param)\n\n            # Extract LLM response and references from unified result\n            llm_response = result.get(\"llm_response\", {})\n            data = result.get(\"data\", {})\n            references = data.get(\"references\", [])\n\n            # Get the non-streaming response content\n            response_content = llm_response.get(\"content\", \"\")\n            if not response_content:\n                response_content = \"No relevant context found for the query.\"\n\n            # Enrich references with chunk content if requested\n            if request.include_references and request.include_chunk_content:\n                chunks = data.get(\"chunks\", [])\n                # Create a mapping from reference_id to chunk content\n                ref_id_to_content = {}\n                for chunk in chunks:\n                    ref_id = chunk.get(\"reference_id\", \"\")\n                    content = chunk.get(\"content\", \"\")\n                    if ref_id and content:\n                        # Collect chunk content; join later to avoid quadratic string concatenation\n                        ref_id_to_content.setdefault(ref_id, []).append(content)\n\n                # Add content to references\n                enriched_references = []\n                for ref in references:\n                    ref_copy = ref.copy()\n                    ref_id = ref.get(\"reference_id\", \"\")\n                    if ref_id in ref_id_to_content:\n                        # Keep content as a list of chunks (one file may have multiple chunks)\n                        ref_copy[\"content\"] = ref_id_to_content[ref_id]\n                    enriched_references.append(ref_copy)\n                references = enriched_references\n\n            # Return response with or without references based on request\n            if request.include_references:\n                return QueryResponse(response=response_content, references=references)\n            else:\n                return QueryResponse(response=response_content, references=None)\n        except Exception as e:\n            logger.error(f\"Error processing query: {str(e)}\", exc_info=True)\n            raise HTTPException(status_code=500, detail=str(e))\n\n    @router.post(\n        \"/query/stream\",\n        dependencies=[Depends(combined_auth)],\n        responses={\n            200: {\n                \"description\": \"Flexible RAG query response - format depends on stream parameter\",\n                \"content\": {\n                    \"application/x-ndjson\": {\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"ndjson\",\n                            \"description\": \"Newline-delimited JSON (NDJSON) format used for both streaming and non-streaming responses. For streaming: multiple lines with separate JSON objects. For non-streaming: single line with complete JSON object.\",\n                            \"example\": '{\"references\": [{\"reference_id\": \"1\", \"file_path\": \"/documents/ai.pdf\"}]}\\n{\"response\": \"Artificial Intelligence is\"}\\n{\"response\": \" a field of computer science\"}\\n{\"response\": \" that focuses on creating intelligent machines.\"}',\n                        },\n                        \"examples\": {\n                            \"streaming_with_references\": {\n                                \"summary\": \"Streaming mode with references (stream=true)\",\n                                \"description\": \"Multiple NDJSON lines when stream=True and include_references=True. First line contains references, subsequent lines contain response chunks.\",\n                                \"value\": '{\"references\": [{\"reference_id\": \"1\", \"file_path\": \"/documents/ai_overview.pdf\"}, {\"reference_id\": \"2\", \"file_path\": \"/documents/ml_basics.txt\"}]}\\n{\"response\": \"Artificial Intelligence (AI) is a branch of computer science\"}\\n{\"response\": \" that aims to create intelligent machines capable of performing\"}\\n{\"response\": \" tasks that typically require human intelligence, such as learning,\"}\\n{\"response\": \" reasoning, and problem-solving.\"}',\n                            },\n                            \"streaming_with_chunk_content\": {\n                                \"summary\": \"Streaming mode with chunk content (stream=true, include_chunk_content=true)\",\n                                \"description\": \"Multiple NDJSON lines when stream=True, include_references=True, and include_chunk_content=True. First line contains references with content arrays (one file may have multiple chunks), subsequent lines contain response chunks.\",\n                                \"value\": '{\"references\": [{\"reference_id\": \"1\", \"file_path\": \"/documents/ai_overview.pdf\", \"content\": [\"Artificial Intelligence (AI) represents a transformative field...\", \"AI systems can be categorized into narrow AI and general AI...\"]}, {\"reference_id\": \"2\", \"file_path\": \"/documents/ml_basics.txt\", \"content\": [\"Machine learning is a subset of AI that enables computers to learn...\"]}]}\\n{\"response\": \"Artificial Intelligence (AI) is a branch of computer science\"}\\n{\"response\": \" that aims to create intelligent machines capable of performing\"}\\n{\"response\": \" tasks that typically require human intelligence.\"}',\n                            },\n                            \"streaming_without_references\": {\n                                \"summary\": \"Streaming mode without references (stream=true)\",\n                                \"description\": \"Multiple NDJSON lines when stream=True and include_references=False. Only response chunks are sent.\",\n                                \"value\": '{\"response\": \"Machine learning is a subset of artificial intelligence\"}\\n{\"response\": \" that enables computers to learn and improve from experience\"}\\n{\"response\": \" without being explicitly programmed for every task.\"}',\n                            },\n                            \"non_streaming_with_references\": {\n                                \"summary\": \"Non-streaming mode with references (stream=false)\",\n                                \"description\": \"Single NDJSON line when stream=False and include_references=True. Complete response with references in one message.\",\n                                \"value\": '{\"references\": [{\"reference_id\": \"1\", \"file_path\": \"/documents/neural_networks.pdf\"}], \"response\": \"Neural networks are computational models inspired by biological neural networks that consist of interconnected nodes (neurons) organized in layers. They are fundamental to deep learning and can learn complex patterns from data through training processes.\"}',\n                            },\n                            \"non_streaming_without_references\": {\n                                \"summary\": \"Non-streaming mode without references (stream=false)\",\n                                \"description\": \"Single NDJSON line when stream=False and include_references=False. Complete response only.\",\n                                \"value\": '{\"response\": \"Deep learning is a subset of machine learning that uses neural networks with multiple layers (hence deep) to model and understand complex patterns in data. It has revolutionized fields like computer vision, natural language processing, and speech recognition.\"}',\n                            },\n                            \"error_response\": {\n                                \"summary\": \"Error during streaming\",\n                                \"description\": \"Error handling in NDJSON format when an error occurs during processing.\",\n                                \"value\": '{\"references\": [{\"reference_id\": \"1\", \"file_path\": \"/documents/ai.pdf\"}]}\\n{\"response\": \"Artificial Intelligence is\"}\\n{\"error\": \"LLM service temporarily unavailable\"}',\n                            },\n                        },\n                    }\n                },\n            },\n            400: {\n                \"description\": \"Bad Request - Invalid input parameters\",\n                \"content\": {\n                    \"application/json\": {\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\"detail\": {\"type\": \"string\"}},\n                        },\n                        \"example\": {\n                            \"detail\": \"Query text must be at least 3 characters long\"\n                        },\n                    }\n                },\n            },\n            500: {\n                \"description\": \"Internal Server Error - Query processing failed\",\n                \"content\": {\n                    \"application/json\": {\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\"detail\": {\"type\": \"string\"}},\n                        },\n                        \"example\": {\n                            \"detail\": \"Failed to process streaming query: Knowledge graph unavailable\"\n                        },\n                    }\n                },\n            },\n        },\n    )\n    async def query_text_stream(request: QueryRequest):\n        \"\"\"\n        Advanced RAG query endpoint with flexible streaming response.\n\n        This endpoint provides the most flexible querying experience, supporting both real-time streaming\n        and complete response delivery based on your integration needs.\n\n        **Response Modes:**\n        - Real-time response delivery as content is generated\n        - NDJSON format: each line is a separate JSON object\n        - First line: `{\"references\": [...]}` (if include_references=True)\n        - Subsequent lines: `{\"response\": \"content chunk\"}`\n        - Error handling: `{\"error\": \"error message\"}`\n\n        > If stream parameter is False, or the query hit LLM cache, complete response delivered in a single streaming message.\n\n        **Response Format Details**\n        - **Content-Type**: `application/x-ndjson` (Newline-Delimited JSON)\n        - **Structure**: Each line is an independent, valid JSON object\n        - **Parsing**: Process line-by-line, each line is self-contained\n        - **Headers**: Includes cache control and connection management\n\n        **Query Modes (same as /query endpoint)**\n        - **local**: Entity-focused retrieval with direct relationships\n        - **global**: Pattern analysis across the knowledge graph\n        - **hybrid**: Combined local and global strategies\n        - **naive**: Vector similarity search only\n        - **mix**: Integrated knowledge graph + vector retrieval (recommended)\n        - **bypass**: Direct LLM query without knowledge retrieval\n\n        conversation_history parameteris sent to LLM only, does not affect retrieval results.\n\n        **Usage Examples**\n\n        Real-time streaming query:\n        ```json\n        {\n            \"query\": \"Explain machine learning algorithms\",\n            \"mode\": \"mix\",\n            \"stream\": true,\n            \"include_references\": true\n        }\n        ```\n\n        Bypass initial LLM call by providing high-level and low-level keywords:\n        ```json\n        {\n            \"query\": \"What is Retrieval-Augmented-Generation?\",\n            \"hl_keywords\": [\"machine learning\", \"information retrieval\", \"natural language processing\"],\n            \"ll_keywords\": [\"retrieval augmented generation\", \"RAG\", \"knowledge base\"],\n            \"mode\": \"mix\"\n        }\n        ```\n\n        Complete response query:\n        ```json\n        {\n            \"query\": \"What is deep learning?\",\n            \"mode\": \"hybrid\",\n            \"stream\": false,\n            \"response_type\": \"Multiple Paragraphs\"\n        }\n        ```\n\n        Conversation with context:\n        ```json\n        {\n            \"query\": \"Can you elaborate on that?\",\n            \"stream\": true,\n            \"conversation_history\": [\n                {\"role\": \"user\", \"content\": \"What is neural network?\"},\n                {\"role\": \"assistant\", \"content\": \"A neural network is...\"}\n            ]\n        }\n        ```\n\n        **Response Processing:**\n\n        ```python\n        async for line in response.iter_lines():\n            data = json.loads(line)\n            if \"references\" in data:\n                # Handle references (first message)\n                references = data[\"references\"]\n            if \"response\" in data:\n                # Handle content chunk\n                content_chunk = data[\"response\"]\n            if \"error\" in data:\n                # Handle error\n                error_message = data[\"error\"]\n        ```\n\n        **Error Handling:**\n        - Streaming errors are delivered as `{\"error\": \"message\"}` lines\n        - Non-streaming errors raise HTTP exceptions\n        - Partial responses may be delivered before errors in streaming mode\n        - Always check for error objects when processing streaming responses\n\n        Args:\n            request (QueryRequest): The request object containing query parameters:\n                - **query**: The question or prompt to process (min 3 characters)\n                - **mode**: Query strategy - \"mix\" recommended for best results\n                - **stream**: Enable streaming (True) or complete response (False)\n                - **include_references**: Whether to include source citations\n                - **response_type**: Format preference (e.g., \"Multiple Paragraphs\")\n                - **top_k**: Number of top entities/relations to retrieve\n                - **conversation_history**: Previous dialogue context for multi-turn conversations\n                - **max_total_tokens**: Token budget for the entire response\n\n        Returns:\n            StreamingResponse: NDJSON streaming response containing:\n                - **Streaming mode**: Multiple JSON objects, one per line\n                  - References object (if requested): `{\"references\": [...]}`\n                  - Content chunks: `{\"response\": \"chunk content\"}`\n                  - Error objects: `{\"error\": \"error message\"}`\n                - **Non-streaming mode**: Single JSON object\n                  - Complete response: `{\"references\": [...], \"response\": \"complete content\"}`\n\n        Raises:\n            HTTPException:\n                - 400: Invalid input parameters (e.g., query too short, invalid mode)\n                - 500: Internal processing error (e.g., LLM service unavailable)\n\n        Note:\n            This endpoint is ideal for applications requiring flexible response delivery.\n            Use streaming mode for real-time interfaces and non-streaming for batch processing.\n        \"\"\"\n        try:\n            # Use the stream parameter from the request, defaulting to True if not specified\n            stream_mode = request.stream if request.stream is not None else True\n            param = request.to_query_params(stream_mode)\n\n            from fastapi.responses import StreamingResponse\n\n            # Unified approach: always use aquery_llm for all cases\n            result = await rag.aquery_llm(request.query, param=param)\n\n            async def stream_generator():\n                # Extract references and LLM response from unified result\n                references = result.get(\"data\", {}).get(\"references\", [])\n                llm_response = result.get(\"llm_response\", {})\n\n                # Enrich references with chunk content if requested\n                if request.include_references and request.include_chunk_content:\n                    data = result.get(\"data\", {})\n                    chunks = data.get(\"chunks\", [])\n                    # Create a mapping from reference_id to chunk content\n                    ref_id_to_content = {}\n                    for chunk in chunks:\n                        ref_id = chunk.get(\"reference_id\", \"\")\n                        content = chunk.get(\"content\", \"\")\n                        if ref_id and content:\n                            # Collect chunk content\n                            ref_id_to_content.setdefault(ref_id, []).append(content)\n\n                    # Add content to references\n                    enriched_references = []\n                    for ref in references:\n                        ref_copy = ref.copy()\n                        ref_id = ref.get(\"reference_id\", \"\")\n                        if ref_id in ref_id_to_content:\n                            # Keep content as a list of chunks (one file may have multiple chunks)\n                            ref_copy[\"content\"] = ref_id_to_content[ref_id]\n                        enriched_references.append(ref_copy)\n                    references = enriched_references\n\n                if llm_response.get(\"is_streaming\"):\n                    # Streaming mode: send references first, then stream response chunks\n                    if request.include_references:\n                        yield f\"{json.dumps({'references': references})}\\n\"\n\n                    response_stream = llm_response.get(\"response_iterator\")\n                    if response_stream:\n                        try:\n                            async for chunk in response_stream:\n                                if chunk:  # Only send non-empty content\n                                    yield f\"{json.dumps({'response': chunk})}\\n\"\n                        except Exception as e:\n                            logger.error(f\"Streaming error: {str(e)}\")\n                            yield f\"{json.dumps({'error': str(e)})}\\n\"\n                else:\n                    # Non-streaming mode: send complete response in one message\n                    response_content = llm_response.get(\"content\", \"\")\n                    if not response_content:\n                        response_content = \"No relevant context found for the query.\"\n\n                    # Create complete response object\n                    complete_response = {\"response\": response_content}\n                    if request.include_references:\n                        complete_response[\"references\"] = references\n\n                    yield f\"{json.dumps(complete_response)}\\n\"\n\n            return StreamingResponse(\n                stream_generator(),\n                media_type=\"application/x-ndjson\",\n                headers={\n                    \"Cache-Control\": \"no-cache\",\n                    \"Connection\": \"keep-alive\",\n                    \"Content-Type\": \"application/x-ndjson\",\n                    \"X-Accel-Buffering\": \"no\",  # Ensure proper handling of streaming response when proxied by Nginx\n                },\n            )\n        except Exception as e:\n            logger.error(f\"Error processing streaming query: {str(e)}\", exc_info=True)\n            raise HTTPException(status_code=500, detail=str(e))\n\n    @router.post(\n        \"/query/data\",\n        response_model=QueryDataResponse,\n        dependencies=[Depends(combined_auth)],\n        responses={\n            200: {\n                \"description\": \"Successful data retrieval response with structured RAG data\",\n                \"content\": {\n                    \"application/json\": {\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                                \"status\": {\n                                    \"type\": \"string\",\n                                    \"enum\": [\"success\", \"failure\"],\n                                    \"description\": \"Query execution status\",\n                                },\n                                \"message\": {\n                                    \"type\": \"string\",\n                                    \"description\": \"Status message describing the result\",\n                                },\n                                \"data\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"entities\": {\n                                            \"type\": \"array\",\n                                            \"items\": {\n                                                \"type\": \"object\",\n                                                \"properties\": {\n                                                    \"entity_name\": {\"type\": \"string\"},\n                                                    \"entity_type\": {\"type\": \"string\"},\n                                                    \"description\": {\"type\": \"string\"},\n                                                    \"source_id\": {\"type\": \"string\"},\n                                                    \"file_path\": {\"type\": \"string\"},\n                                                    \"reference_id\": {\"type\": \"string\"},\n                                                },\n                                            },\n                                            \"description\": \"Retrieved entities from knowledge graph\",\n                                        },\n                                        \"relationships\": {\n                                            \"type\": \"array\",\n                                            \"items\": {\n                                                \"type\": \"object\",\n                                                \"properties\": {\n                                                    \"src_id\": {\"type\": \"string\"},\n                                                    \"tgt_id\": {\"type\": \"string\"},\n                                                    \"description\": {\"type\": \"string\"},\n                                                    \"keywords\": {\"type\": \"string\"},\n                                                    \"weight\": {\"type\": \"number\"},\n                                                    \"source_id\": {\"type\": \"string\"},\n                                                    \"file_path\": {\"type\": \"string\"},\n                                                    \"reference_id\": {\"type\": \"string\"},\n                                                },\n                                            },\n                                            \"description\": \"Retrieved relationships from knowledge graph\",\n                                        },\n                                        \"chunks\": {\n                                            \"type\": \"array\",\n                                            \"items\": {\n                                                \"type\": \"object\",\n                                                \"properties\": {\n                                                    \"content\": {\"type\": \"string\"},\n                                                    \"file_path\": {\"type\": \"string\"},\n                                                    \"chunk_id\": {\"type\": \"string\"},\n                                                    \"reference_id\": {\"type\": \"string\"},\n                                                },\n                                            },\n                                            \"description\": \"Retrieved text chunks from vector database\",\n                                        },\n                                        \"references\": {\n                                            \"type\": \"array\",\n                                            \"items\": {\n                                                \"type\": \"object\",\n                                                \"properties\": {\n                                                    \"reference_id\": {\"type\": \"string\"},\n                                                    \"file_path\": {\"type\": \"string\"},\n                                                },\n                                            },\n                                            \"description\": \"Reference list for citation purposes\",\n                                        },\n                                    },\n                                    \"description\": \"Structured retrieval data containing entities, relationships, chunks, and references\",\n                                },\n                                \"metadata\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"query_mode\": {\"type\": \"string\"},\n                                        \"keywords\": {\n                                            \"type\": \"object\",\n                                            \"properties\": {\n                                                \"high_level\": {\n                                                    \"type\": \"array\",\n                                                    \"items\": {\"type\": \"string\"},\n                                                },\n                                                \"low_level\": {\n                                                    \"type\": \"array\",\n                                                    \"items\": {\"type\": \"string\"},\n                                                },\n                                            },\n                                        },\n                                        \"processing_info\": {\n                                            \"type\": \"object\",\n                                            \"properties\": {\n                                                \"total_entities_found\": {\n                                                    \"type\": \"integer\"\n                                                },\n                                                \"total_relations_found\": {\n                                                    \"type\": \"integer\"\n                                                },\n                                                \"entities_after_truncation\": {\n                                                    \"type\": \"integer\"\n                                                },\n                                                \"relations_after_truncation\": {\n                                                    \"type\": \"integer\"\n                                                },\n                                                \"final_chunks_count\": {\n                                                    \"type\": \"integer\"\n                                                },\n                                            },\n                                        },\n                                    },\n                                    \"description\": \"Query metadata including mode, keywords, and processing information\",\n                                },\n                            },\n                            \"required\": [\"status\", \"message\", \"data\", \"metadata\"],\n                        },\n                        \"examples\": {\n                            \"successful_local_mode\": {\n                                \"summary\": \"Local mode data retrieval\",\n                                \"description\": \"Example of structured data from local mode query focusing on specific entities\",\n                                \"value\": {\n                                    \"status\": \"success\",\n                                    \"message\": \"Query executed successfully\",\n                                    \"data\": {\n                                        \"entities\": [\n                                            {\n                                                \"entity_name\": \"Neural Networks\",\n                                                \"entity_type\": \"CONCEPT\",\n                                                \"description\": \"Computational models inspired by biological neural networks\",\n                                                \"source_id\": \"chunk-123\",\n                                                \"file_path\": \"/documents/ai_basics.pdf\",\n                                                \"reference_id\": \"1\",\n                                            }\n                                        ],\n                                        \"relationships\": [\n                                            {\n                                                \"src_id\": \"Neural Networks\",\n                                                \"tgt_id\": \"Machine Learning\",\n                                                \"description\": \"Neural networks are a subset of machine learning algorithms\",\n                                                \"keywords\": \"subset, algorithm, learning\",\n                                                \"weight\": 0.85,\n                                                \"source_id\": \"chunk-123\",\n                                                \"file_path\": \"/documents/ai_basics.pdf\",\n                                                \"reference_id\": \"1\",\n                                            }\n                                        ],\n                                        \"chunks\": [\n                                            {\n                                                \"content\": \"Neural networks are computational models that mimic the way biological neural networks work...\",\n                                                \"file_path\": \"/documents/ai_basics.pdf\",\n                                                \"chunk_id\": \"chunk-123\",\n                                                \"reference_id\": \"1\",\n                                            }\n                                        ],\n                                        \"references\": [\n                                            {\n                                                \"reference_id\": \"1\",\n                                                \"file_path\": \"/documents/ai_basics.pdf\",\n                                            }\n                                        ],\n                                    },\n                                    \"metadata\": {\n                                        \"query_mode\": \"local\",\n                                        \"keywords\": {\n                                            \"high_level\": [\"neural\", \"networks\"],\n                                            \"low_level\": [\n                                                \"computation\",\n                                                \"model\",\n                                                \"algorithm\",\n                                            ],\n                                        },\n                                        \"processing_info\": {\n                                            \"total_entities_found\": 5,\n                                            \"total_relations_found\": 3,\n                                            \"entities_after_truncation\": 1,\n                                            \"relations_after_truncation\": 1,\n                                            \"final_chunks_count\": 1,\n                                        },\n                                    },\n                                },\n                            },\n                            \"global_mode\": {\n                                \"summary\": \"Global mode data retrieval\",\n                                \"description\": \"Example of structured data from global mode query analyzing broader patterns\",\n                                \"value\": {\n                                    \"status\": \"success\",\n                                    \"message\": \"Query executed successfully\",\n                                    \"data\": {\n                                        \"entities\": [],\n                                        \"relationships\": [\n                                            {\n                                                \"src_id\": \"Artificial Intelligence\",\n                                                \"tgt_id\": \"Machine Learning\",\n                                                \"description\": \"AI encompasses machine learning as a core component\",\n                                                \"keywords\": \"encompasses, component, field\",\n                                                \"weight\": 0.92,\n                                                \"source_id\": \"chunk-456\",\n                                                \"file_path\": \"/documents/ai_overview.pdf\",\n                                                \"reference_id\": \"2\",\n                                            }\n                                        ],\n                                        \"chunks\": [],\n                                        \"references\": [\n                                            {\n                                                \"reference_id\": \"2\",\n                                                \"file_path\": \"/documents/ai_overview.pdf\",\n                                            }\n                                        ],\n                                    },\n                                    \"metadata\": {\n                                        \"query_mode\": \"global\",\n                                        \"keywords\": {\n                                            \"high_level\": [\n                                                \"artificial\",\n                                                \"intelligence\",\n                                                \"overview\",\n                                            ],\n                                            \"low_level\": [],\n                                        },\n                                    },\n                                },\n                            },\n                            \"naive_mode\": {\n                                \"summary\": \"Naive mode data retrieval\",\n                                \"description\": \"Example of structured data from naive mode using only vector search\",\n                                \"value\": {\n                                    \"status\": \"success\",\n                                    \"message\": \"Query executed successfully\",\n                                    \"data\": {\n                                        \"entities\": [],\n                                        \"relationships\": [],\n                                        \"chunks\": [\n                                            {\n                                                \"content\": \"Deep learning is a subset of machine learning that uses neural networks with multiple layers...\",\n                                                \"file_path\": \"/documents/deep_learning.pdf\",\n                                                \"chunk_id\": \"chunk-789\",\n                                                \"reference_id\": \"3\",\n                                            }\n                                        ],\n                                        \"references\": [\n                                            {\n                                                \"reference_id\": \"3\",\n                                                \"file_path\": \"/documents/deep_learning.pdf\",\n                                            }\n                                        ],\n                                    },\n                                    \"metadata\": {\n                                        \"query_mode\": \"naive\",\n                                        \"keywords\": {\"high_level\": [], \"low_level\": []},\n                                    },\n                                },\n                            },\n                        },\n                    }\n                },\n            },\n            400: {\n                \"description\": \"Bad Request - Invalid input parameters\",\n                \"content\": {\n                    \"application/json\": {\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\"detail\": {\"type\": \"string\"}},\n                        },\n                        \"example\": {\n                            \"detail\": \"Query text must be at least 3 characters long\"\n                        },\n                    }\n                },\n            },\n            500: {\n                \"description\": \"Internal Server Error - Data retrieval failed\",\n                \"content\": {\n                    \"application/json\": {\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\"detail\": {\"type\": \"string\"}},\n                        },\n                        \"example\": {\n                            \"detail\": \"Failed to retrieve data: Knowledge graph unavailable\"\n                        },\n                    }\n                },\n            },\n        },\n    )\n    async def query_data(request: QueryRequest):\n        \"\"\"\n        Advanced data retrieval endpoint for structured RAG analysis.\n\n        This endpoint provides raw retrieval results without LLM generation, perfect for:\n        - **Data Analysis**: Examine what information would be used for RAG\n        - **System Integration**: Get structured data for custom processing\n        - **Debugging**: Understand retrieval behavior and quality\n        - **Research**: Analyze knowledge graph structure and relationships\n\n        **Key Features:**\n        - No LLM generation - pure data retrieval\n        - Complete structured output with entities, relationships, and chunks\n        - Always includes references for citation\n        - Detailed metadata about processing and keywords\n        - Compatible with all query modes and parameters\n\n        **Query Mode Behaviors:**\n        - **local**: Returns entities and their direct relationships + related chunks\n        - **global**: Returns relationship patterns across the knowledge graph\n        - **hybrid**: Combines local and global retrieval strategies\n        - **naive**: Returns only vector-retrieved text chunks (no knowledge graph)\n        - **mix**: Integrates knowledge graph data with vector-retrieved chunks\n        - **bypass**: Returns empty data arrays (used for direct LLM queries)\n\n        **Data Structure:**\n        - **entities**: Knowledge graph entities with descriptions and metadata\n        - **relationships**: Connections between entities with weights and descriptions\n        - **chunks**: Text segments from documents with source information\n        - **references**: Citation information mapping reference IDs to file paths\n        - **metadata**: Processing information, keywords, and query statistics\n\n        **Usage Examples:**\n\n        Analyze entity relationships:\n        ```json\n        {\n            \"query\": \"machine learning algorithms\",\n            \"mode\": \"local\",\n            \"top_k\": 10\n        }\n        ```\n\n        Explore global patterns:\n        ```json\n        {\n            \"query\": \"artificial intelligence trends\",\n            \"mode\": \"global\",\n            \"max_relation_tokens\": 2000\n        }\n        ```\n\n        Vector similarity search:\n        ```json\n        {\n            \"query\": \"neural network architectures\",\n            \"mode\": \"naive\",\n            \"chunk_top_k\": 5\n        }\n        ```\n\n        Bypass initial LLM call by providing high-level and low-level keywords:\n        ```json\n        {\n            \"query\": \"What is Retrieval-Augmented-Generation?\",\n            \"hl_keywords\": [\"machine learning\", \"information retrieval\", \"natural language processing\"],\n            \"ll_keywords\": [\"retrieval augmented generation\", \"RAG\", \"knowledge base\"],\n            \"mode\": \"mix\"\n        }\n        ```\n\n        **Response Analysis:**\n        - **Empty arrays**: Normal for certain modes (e.g., naive mode has no entities/relationships)\n        - **Processing info**: Shows retrieval statistics and token usage\n        - **Keywords**: High-level and low-level keywords extracted from query\n        - **Reference mapping**: Links all data back to source documents\n\n        Args:\n            request (QueryRequest): The request object containing query parameters:\n                - **query**: The search query to analyze (min 3 characters)\n                - **mode**: Retrieval strategy affecting data types returned\n                - **top_k**: Number of top entities/relationships to retrieve\n                - **chunk_top_k**: Number of text chunks to retrieve\n                - **max_entity_tokens**: Token limit for entity context\n                - **max_relation_tokens**: Token limit for relationship context\n                - **max_total_tokens**: Overall token budget for retrieval\n\n        Returns:\n            QueryDataResponse: Structured JSON response containing:\n                - **status**: \"success\" or \"failure\"\n                - **message**: Human-readable status description\n                - **data**: Complete retrieval results with entities, relationships, chunks, references\n                - **metadata**: Query processing information and statistics\n\n        Raises:\n            HTTPException:\n                - 400: Invalid input parameters (e.g., query too short, invalid mode)\n                - 500: Internal processing error (e.g., knowledge graph unavailable)\n\n        Note:\n            This endpoint always includes references regardless of the include_references parameter,\n            as structured data analysis typically requires source attribution.\n        \"\"\"\n        try:\n            param = request.to_query_params(False)  # No streaming for data endpoint\n            response = await rag.aquery_data(request.query, param=param)\n\n            # aquery_data returns the new format with status, message, data, and metadata\n            if isinstance(response, dict):\n                return QueryDataResponse(**response)\n            else:\n                # Handle unexpected response format\n                return QueryDataResponse(\n                    status=\"failure\",\n                    message=\"Invalid response type\",\n                    data={},\n                )\n        except Exception as e:\n            logger.error(f\"Error processing data query: {str(e)}\", exc_info=True)\n            raise HTTPException(status_code=500, detail=str(e))\n\n    return router\n"
  },
  {
    "path": "lightrag/api/run_with_gunicorn.py",
    "content": "#!/usr/bin/env python\n\"\"\"\nStart LightRAG server with Gunicorn\n\"\"\"\n\nimport os\nimport sys\nimport platform\nimport pipmaster as pm\nfrom lightrag.api.utils_api import display_splash_screen, check_env_file\nfrom lightrag.api.config import global_args\nfrom lightrag.utils import get_env_value\nfrom lightrag.kg.shared_storage import initialize_share_data\n\nfrom lightrag.constants import (\n    DEFAULT_WOKERS,\n    DEFAULT_TIMEOUT,\n)\n\n\ndef check_and_install_dependencies():\n    \"\"\"Check and install required dependencies\"\"\"\n    required_packages = [\n        \"gunicorn\",\n        \"tiktoken\",\n        \"psutil\",\n        # Add other required packages here\n    ]\n\n    for package in required_packages:\n        if not pm.is_installed(package):\n            print(f\"Installing {package}...\")\n            pm.install(package)\n            print(f\"{package} installed successfully\")\n\n\ndef main():\n    # Explicitly initialize configuration for Gunicorn mode\n    from lightrag.api.config import initialize_config\n\n    initialize_config()\n\n    # Set Gunicorn mode flag for lifespan cleanup detection\n    os.environ[\"LIGHTRAG_GUNICORN_MODE\"] = \"1\"\n\n    # Check .env file\n    if not check_env_file():\n        sys.exit(1)\n\n    # Check DOCLING compatibility with Gunicorn multi-worker mode on macOS\n    if (\n        platform.system() == \"Darwin\"\n        and global_args.document_loading_engine == \"DOCLING\"\n        and global_args.workers > 1\n    ):\n        print(\"\\n\" + \"=\" * 80)\n        print(\"❌ ERROR: Incompatible configuration detected!\")\n        print(\"=\" * 80)\n        print(\n            \"\\nDOCLING engine with Gunicorn multi-worker mode is not supported on macOS\"\n        )\n        print(\"\\nReason:\")\n        print(\"  PyTorch (required by DOCLING) has known compatibility issues with\")\n        print(\"  fork-based multiprocessing on macOS, which can cause crashes or\")\n        print(\"  unexpected behavior when using Gunicorn with multiple workers.\")\n        print(\"\\nCurrent configuration:\")\n        print(\"  - Operating System: macOS (Darwin)\")\n        print(f\"  - Document Engine: {global_args.document_loading_engine}\")\n        print(f\"  - Workers: {global_args.workers}\")\n        print(\"\\nPossible solutions:\")\n        print(\"  1. Use single worker mode:\")\n        print(\"     --workers 1\")\n        print(\"\\n  2. Change document loading engine in .env:\")\n        print(\"     DOCUMENT_LOADING_ENGINE=DEFAULT\")\n        print(\"\\n  3. Deploy on Linux where multi-worker mode is fully supported\")\n        print(\"=\" * 80 + \"\\n\")\n        sys.exit(1)\n\n    # Check macOS fork safety environment variable for multi-worker mode\n    if (\n        platform.system() == \"Darwin\"\n        and global_args.workers > 1\n        and os.environ.get(\"OBJC_DISABLE_INITIALIZE_FORK_SAFETY\") != \"YES\"\n    ):\n        print(\"\\n\" + \"=\" * 80)\n        print(\"❌ ERROR: Missing required environment variable on macOS!\")\n        print(\"=\" * 80)\n        print(\"\\nmacOS with Gunicorn multi-worker mode requires:\")\n        print(\"  OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES\")\n        print(\"\\nReason:\")\n        print(\"  NumPy uses macOS's Accelerate framework (Objective-C based) for\")\n        print(\"  vector computations. The Objective-C runtime has fork safety checks\")\n        print(\"  that will crash worker processes when embedding functions are called.\")\n        print(\"\\nCurrent configuration:\")\n        print(\"  - Operating System: macOS (Darwin)\")\n        print(f\"  - Workers: {global_args.workers}\")\n        print(\n            f\"  - Environment Variable: {os.environ.get('OBJC_DISABLE_INITIALIZE_FORK_SAFETY', 'NOT SET')}\"\n        )\n        print(\"\\nHow to fix:\")\n        print(\"  Option 1 - Set environment variable before starting (recommended):\")\n        print(\"     export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES\")\n        print(\"     lightrag-gunicorn --workers 2\")\n        print(\"\\n  Option 2 - Add to your shell profile (~/.zshrc or ~/.bash_profile):\")\n        print(\"     echo 'export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES' >> ~/.zshrc\")\n        print(\"     source ~/.zshrc\")\n        print(\"\\n  Option 3 - Use single worker mode (no multiprocessing):\")\n        print(\"     lightrag-server --workers 1\")\n        print(\"=\" * 80 + \"\\n\")\n        sys.exit(1)\n\n    # Check and install dependencies\n    check_and_install_dependencies()\n\n    # Note: Signal handlers are NOT registered here because:\n    # - Master cleanup already handled by gunicorn_config.on_exit()\n\n    # Display startup information\n    display_splash_screen(global_args)\n\n    print(\"🚀 Starting LightRAG with Gunicorn\")\n    print(f\"🔄 Worker management: Gunicorn (workers={global_args.workers})\")\n    print(\"🔍 Preloading app: Enabled\")\n    print(\"📝 Note: Using Gunicorn's preload feature for shared data initialization\")\n    print(\"\\n\\n\" + \"=\" * 80)\n    print(\"MAIN PROCESS INITIALIZATION\")\n    print(f\"Process ID: {os.getpid()}\")\n    print(f\"Workers setting: {global_args.workers}\")\n    print(\"=\" * 80 + \"\\n\")\n\n    # Import Gunicorn's StandaloneApplication\n    from gunicorn.app.base import BaseApplication\n\n    # Define a custom application class that loads our config\n    class GunicornApp(BaseApplication):\n        def __init__(self, app, options=None):\n            self.options = options or {}\n            self.application = app\n            super().__init__()\n\n        def load_config(self):\n            # Define valid Gunicorn configuration options\n            valid_options = {\n                \"bind\",\n                \"workers\",\n                \"worker_class\",\n                \"timeout\",\n                \"keepalive\",\n                \"preload_app\",\n                \"errorlog\",\n                \"accesslog\",\n                \"loglevel\",\n                \"certfile\",\n                \"keyfile\",\n                \"limit_request_line\",\n                \"limit_request_fields\",\n                \"limit_request_field_size\",\n                \"graceful_timeout\",\n                \"max_requests\",\n                \"max_requests_jitter\",\n            }\n\n            # Special hooks that need to be set separately\n            special_hooks = {\n                \"on_starting\",\n                \"on_reload\",\n                \"on_exit\",\n                \"pre_fork\",\n                \"post_fork\",\n                \"pre_exec\",\n                \"pre_request\",\n                \"post_request\",\n                \"worker_init\",\n                \"worker_exit\",\n                \"nworkers_changed\",\n                \"child_exit\",\n            }\n\n            # Import and configure the gunicorn_config module\n            from lightrag.api import gunicorn_config\n\n            # Set configuration variables in gunicorn_config, prioritizing command line arguments\n            gunicorn_config.workers = (\n                global_args.workers\n                if global_args.workers\n                else get_env_value(\"WORKERS\", DEFAULT_WOKERS, int)\n            )\n\n            # Bind configuration prioritizes command line arguments\n            host = (\n                global_args.host\n                if global_args.host != \"0.0.0.0\"\n                else os.getenv(\"HOST\", \"0.0.0.0\")\n            )\n            port = (\n                global_args.port\n                if global_args.port != 9621\n                else get_env_value(\"PORT\", 9621, int)\n            )\n            gunicorn_config.bind = f\"{host}:{port}\"\n\n            # Log level configuration prioritizes command line arguments\n            gunicorn_config.loglevel = (\n                global_args.log_level.lower()\n                if global_args.log_level\n                else os.getenv(\"LOG_LEVEL\", \"info\")\n            )\n\n            # Timeout configuration prioritizes command line arguments\n            gunicorn_config.timeout = (\n                global_args.timeout + 30\n                if global_args.timeout is not None\n                else get_env_value(\n                    \"TIMEOUT\", DEFAULT_TIMEOUT + 30, int, special_none=True\n                )\n            )\n\n            # Keepalive configuration\n            gunicorn_config.keepalive = get_env_value(\"KEEPALIVE\", 5, int)\n\n            # SSL configuration prioritizes command line arguments\n            if global_args.ssl or os.getenv(\"SSL\", \"\").lower() in (\n                \"true\",\n                \"1\",\n                \"yes\",\n                \"t\",\n                \"on\",\n            ):\n                gunicorn_config.certfile = (\n                    global_args.ssl_certfile\n                    if global_args.ssl_certfile\n                    else os.getenv(\"SSL_CERTFILE\")\n                )\n                gunicorn_config.keyfile = (\n                    global_args.ssl_keyfile\n                    if global_args.ssl_keyfile\n                    else os.getenv(\"SSL_KEYFILE\")\n                )\n\n            # Set configuration options from the module\n            for key in dir(gunicorn_config):\n                if key in valid_options:\n                    value = getattr(gunicorn_config, key)\n                    # Skip functions like on_starting and None values\n                    if not callable(value) and value is not None:\n                        self.cfg.set(key, value)\n                # Set special hooks\n                elif key in special_hooks:\n                    value = getattr(gunicorn_config, key)\n                    if callable(value):\n                        self.cfg.set(key, value)\n\n            if hasattr(gunicorn_config, \"logconfig_dict\"):\n                self.cfg.set(\n                    \"logconfig_dict\", getattr(gunicorn_config, \"logconfig_dict\")\n                )\n\n        def load(self):\n            # Import the application\n            from lightrag.api.lightrag_server import get_application\n\n            return get_application(global_args)\n\n    # Create the application\n    app = GunicornApp(\"\")\n\n    # Force workers to be an integer and greater than 1 for multi-process mode\n    workers_count = global_args.workers\n    if workers_count > 1:\n        # Set a flag to indicate we're in the main process\n        os.environ[\"LIGHTRAG_MAIN_PROCESS\"] = \"1\"\n        initialize_share_data(workers_count)\n    else:\n        initialize_share_data(1)\n\n    # Run the application\n    print(\"\\nStarting Gunicorn with direct Python API...\")\n    app.run()\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "lightrag/api/runtime_validation.py",
    "content": "\"\"\"Helpers for validating startup runtime expectations from `.env`.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nfrom dotenv import dotenv_values\n\n_CONTAINER_RUNTIME_TARGETS = {\"compose\", \"docker\"}\n\n\n@dataclass(frozen=True)\nclass RuntimeEnvironment:\n    \"\"\"Describes whether the current process is running in a container runtime.\"\"\"\n\n    in_container: bool\n    in_docker: bool\n    in_kubernetes: bool\n\n    @property\n    def label(self) -> str:\n        if self.in_kubernetes:\n            return \"Kubernetes\"\n        if self.in_docker:\n            return \"Docker\"\n        return \"host\"\n\n\ndef _read_cgroup_content() -> str:\n    \"\"\"Best-effort read of cgroup metadata for container detection.\"\"\"\n\n    for candidate in (\"/proc/1/cgroup\", \"/proc/self/cgroup\"):\n        try:\n            return Path(candidate).read_text(encoding=\"utf-8\")\n        except OSError:\n            continue\n    return \"\"\n\n\ndef detect_runtime_environment(\n    environ: dict[str, str] | None = None,\n) -> RuntimeEnvironment:\n    \"\"\"Detect whether the current process is running on host, Docker, or Kubernetes.\"\"\"\n\n    environ = environ or os.environ\n    cgroup_content = _read_cgroup_content().lower()\n\n    in_kubernetes = bool(\n        environ.get(\"KUBERNETES_SERVICE_HOST\")\n        or Path(\"/var/run/secrets/kubernetes.io/serviceaccount\").exists()\n        or \"kubepods\" in cgroup_content\n        or \"kubernetes\" in cgroup_content\n    )\n    in_docker = bool(\n        Path(\"/.dockerenv\").exists()\n        or Path(\"/run/.containerenv\").exists()\n        or any(\n            marker in cgroup_content\n            for marker in (\"docker\", \"containerd\", \"libpod\", \"podman\")\n        )\n    )\n\n    return RuntimeEnvironment(\n        in_container=in_kubernetes or in_docker,\n        in_docker=in_docker,\n        in_kubernetes=in_kubernetes,\n    )\n\n\ndef load_runtime_target_from_env_file(env_path: str | Path = \".env\") -> str | None:\n    \"\"\"Return the raw LIGHTRAG_RUNTIME_TARGET value from the `.env` file, if present.\"\"\"\n\n    env_values = dotenv_values(str(env_path))\n    runtime_target = env_values.get(\"LIGHTRAG_RUNTIME_TARGET\")\n    if runtime_target is None:\n        return None\n    return runtime_target.strip()\n\n\ndef validate_runtime_target(\n    runtime_target: str | None,\n    runtime_environment: RuntimeEnvironment | None = None,\n) -> tuple[bool, str | None]:\n    \"\"\"Validate `.env` runtime target against the current runtime environment.\"\"\"\n\n    if runtime_target is None:\n        return True, None\n\n    normalized_target = runtime_target.strip().lower()\n    runtime_environment = runtime_environment or detect_runtime_environment()\n\n    if normalized_target == \"host\":\n        if runtime_environment.in_container:\n            return (\n                False,\n                \"Configuration error in .env: LIGHTRAG_RUNTIME_TARGET=host.\\n\"\n                \"This value from .env requires the server process to run on the host, \"\n                f\"but the current process is running inside {runtime_environment.label}.\",\n            )\n        return True, None\n\n    if normalized_target in _CONTAINER_RUNTIME_TARGETS:\n        if runtime_environment.in_container:\n            return True, None\n        return (\n            False,\n            f\"Configuration error in .env: LIGHTRAG_RUNTIME_TARGET={runtime_target}.\\n\"\n            \"This value from .env requires the server process to run inside Docker or \"\n            \"Kubernetes, but the current process is running on the host.\",\n        )\n\n    return (\n        False,\n        f\"Configuration error in .env: LIGHTRAG_RUNTIME_TARGET={runtime_target!r}.\\n\"\n        \"This value from .env must be 'host' or 'compose' (alias: 'docker').\",\n    )\n\n\ndef validate_runtime_target_from_env_file(\n    env_path: str | Path = \".env\",\n    runtime_environment: RuntimeEnvironment | None = None,\n) -> tuple[bool, str | None]:\n    \"\"\"Load LIGHTRAG_RUNTIME_TARGET from `.env` and validate it if declared.\"\"\"\n\n    runtime_target = load_runtime_target_from_env_file(env_path)\n    return validate_runtime_target(runtime_target, runtime_environment)\n"
  },
  {
    "path": "lightrag/api/static/swagger-ui/swagger-ui-bundle.js",
    "content": "/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */\n!function webpackUniversalModuleDefinition(s,o){\"object\"==typeof exports&&\"object\"==typeof module?module.exports=o():\"function\"==typeof define&&define.amd?define([],o):\"object\"==typeof exports?exports.SwaggerUIBundle=o():s.SwaggerUIBundle=o()}(this,(()=>(()=>{var s={251:(s,o)=>{o.read=function(s,o,i,a,u){var _,w,x=8*u-a-1,C=(1<<x)-1,j=C>>1,L=-7,B=i?u-1:0,$=i?-1:1,U=s[o+B];for(B+=$,_=U&(1<<-L)-1,U>>=-L,L+=x;L>0;_=256*_+s[o+B],B+=$,L-=8);for(w=_&(1<<-L)-1,_>>=-L,L+=a;L>0;w=256*w+s[o+B],B+=$,L-=8);if(0===_)_=1-j;else{if(_===C)return w?NaN:1/0*(U?-1:1);w+=Math.pow(2,a),_-=j}return(U?-1:1)*w*Math.pow(2,_-a)},o.write=function(s,o,i,a,u,_){var w,x,C,j=8*_-u-1,L=(1<<j)-1,B=L>>1,$=23===u?Math.pow(2,-24)-Math.pow(2,-77):0,U=a?0:_-1,V=a?1:-1,z=o<0||0===o&&1/o<0?1:0;for(o=Math.abs(o),isNaN(o)||o===1/0?(x=isNaN(o)?1:0,w=L):(w=Math.floor(Math.log(o)/Math.LN2),o*(C=Math.pow(2,-w))<1&&(w--,C*=2),(o+=w+B>=1?$/C:$*Math.pow(2,1-B))*C>=2&&(w++,C/=2),w+B>=L?(x=0,w=L):w+B>=1?(x=(o*C-1)*Math.pow(2,u),w+=B):(x=o*Math.pow(2,B-1)*Math.pow(2,u),w=0));u>=8;s[i+U]=255&x,U+=V,x/=256,u-=8);for(w=w<<u|x,j+=u;j>0;s[i+U]=255&w,U+=V,w/=256,j-=8);s[i+U-V]|=128*z}},462:(s,o,i)=>{\"use strict\";var a=i(40975);s.exports=a},659:(s,o,i)=>{var a=i(51873),u=Object.prototype,_=u.hasOwnProperty,w=u.toString,x=a?a.toStringTag:void 0;s.exports=function getRawTag(s){var o=_.call(s,x),i=s[x];try{s[x]=void 0;var a=!0}catch(s){}var u=w.call(s);return a&&(o?s[x]=i:delete s[x]),u}},694:(s,o,i)=>{\"use strict\";i(91599);var a=i(37257);i(12560),s.exports=a},953:(s,o,i)=>{\"use strict\";s.exports=i(53375)},1733:s=>{var o=/[^\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\x7f]+/g;s.exports=function asciiWords(s){return s.match(o)||[]}},1882:(s,o,i)=>{var a=i(72552),u=i(23805);s.exports=function isFunction(s){if(!u(s))return!1;var o=a(s);return\"[object Function]\"==o||\"[object GeneratorFunction]\"==o||\"[object AsyncFunction]\"==o||\"[object Proxy]\"==o}},1907:(s,o,i)=>{\"use strict\";var a=i(41505),u=Function.prototype,_=u.call,w=a&&u.bind.bind(_,_);s.exports=a?w:function(s){return function(){return _.apply(s,arguments)}}},2205:function(s,o,i){var a;a=void 0!==i.g?i.g:this,s.exports=function(s){if(s.CSS&&s.CSS.escape)return s.CSS.escape;var cssEscape=function(s){if(0==arguments.length)throw new TypeError(\"`CSS.escape` requires an argument.\");for(var o,i=String(s),a=i.length,u=-1,_=\"\",w=i.charCodeAt(0);++u<a;)0!=(o=i.charCodeAt(u))?_+=o>=1&&o<=31||127==o||0==u&&o>=48&&o<=57||1==u&&o>=48&&o<=57&&45==w?\"\\\\\"+o.toString(16)+\" \":0==u&&1==a&&45==o||!(o>=128||45==o||95==o||o>=48&&o<=57||o>=65&&o<=90||o>=97&&o<=122)?\"\\\\\"+i.charAt(u):i.charAt(u):_+=\"�\";return _};return s.CSS||(s.CSS={}),s.CSS.escape=cssEscape,cssEscape}(a)},2209:(s,o,i)=>{\"use strict\";var a,u=i(9404),_=function productionTypeChecker(){invariant(!1,\"ImmutablePropTypes type checking code is stripped in production.\")};_.isRequired=_;var w=function getProductionTypeChecker(){return _};function getPropType(s){var o=typeof s;return Array.isArray(s)?\"array\":s instanceof RegExp?\"object\":s instanceof u.Iterable?\"Immutable.\"+s.toSource().split(\" \")[0]:o}function createChainableTypeChecker(s){function checkType(o,i,a,u,_,w){for(var x=arguments.length,C=Array(x>6?x-6:0),j=6;j<x;j++)C[j-6]=arguments[j];return w=w||a,u=u||\"<<anonymous>>\",null!=i[a]?s.apply(void 0,[i,a,u,_,w].concat(C)):o?new Error(\"Required \"+_+\" `\"+w+\"` was not specified in `\"+u+\"`.\"):void 0}var o=checkType.bind(null,!1);return o.isRequired=checkType.bind(null,!0),o}function createIterableSubclassTypeChecker(s,o){return function createImmutableTypeChecker(s,o){return createChainableTypeChecker((function validate(i,a,u,_,w){var x=i[a];if(!o(x)){var C=getPropType(x);return new Error(\"Invalid \"+_+\" `\"+w+\"` of type `\"+C+\"` supplied to `\"+u+\"`, expected `\"+s+\"`.\")}return null}))}(\"Iterable.\"+s,(function(s){return u.Iterable.isIterable(s)&&o(s)}))}(a={listOf:w,mapOf:w,orderedMapOf:w,setOf:w,orderedSetOf:w,stackOf:w,iterableOf:w,recordOf:w,shape:w,contains:w,mapContains:w,orderedMapContains:w,list:_,map:_,orderedMap:_,set:_,orderedSet:_,stack:_,seq:_,record:_,iterable:_}).iterable.indexed=createIterableSubclassTypeChecker(\"Indexed\",u.Iterable.isIndexed),a.iterable.keyed=createIterableSubclassTypeChecker(\"Keyed\",u.Iterable.isKeyed),s.exports=a},2404:(s,o,i)=>{var a=i(60270);s.exports=function isEqual(s,o){return a(s,o)}},2523:s=>{s.exports=function baseFindIndex(s,o,i,a){for(var u=s.length,_=i+(a?1:-1);a?_--:++_<u;)if(o(s[_],_,s))return _;return-1}},2532:(s,o,i)=>{\"use strict\";var a=i(45951),u=Object.defineProperty;s.exports=function(s,o){try{u(a,s,{value:o,configurable:!0,writable:!0})}catch(i){a[s]=o}return o}},2694:(s,o,i)=>{\"use strict\";var a=i(6925);function emptyFunction(){}function emptyFunctionWithReset(){}emptyFunctionWithReset.resetWarningCache=emptyFunction,s.exports=function(){function shim(s,o,i,u,_,w){if(w!==a){var x=new Error(\"Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types\");throw x.name=\"Invariant Violation\",x}}function getShim(){return shim}shim.isRequired=shim;var s={array:shim,bigint:shim,bool:shim,func:shim,number:shim,object:shim,string:shim,symbol:shim,any:shim,arrayOf:getShim,element:shim,elementType:shim,instanceOf:getShim,node:shim,objectOf:getShim,oneOf:getShim,oneOfType:getShim,shape:getShim,exact:getShim,checkPropTypes:emptyFunctionWithReset,resetWarningCache:emptyFunction};return s.PropTypes=s,s}},2874:s=>{s.exports={}},2875:(s,o,i)=>{\"use strict\";var a=i(23045),u=i(80376);s.exports=Object.keys||function keys(s){return a(s,u)}},2955:(s,o,i)=>{\"use strict\";var a,u=i(65606);function _defineProperty(s,o,i){return(o=function _toPropertyKey(s){var o=function _toPrimitive(s,o){if(\"object\"!=typeof s||null===s)return s;var i=s[Symbol.toPrimitive];if(void 0!==i){var a=i.call(s,o||\"default\");if(\"object\"!=typeof a)return a;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(\"string\"===o?String:Number)(s)}(s,\"string\");return\"symbol\"==typeof o?o:String(o)}(o))in s?Object.defineProperty(s,o,{value:i,enumerable:!0,configurable:!0,writable:!0}):s[o]=i,s}var _=i(86238),w=Symbol(\"lastResolve\"),x=Symbol(\"lastReject\"),C=Symbol(\"error\"),j=Symbol(\"ended\"),L=Symbol(\"lastPromise\"),B=Symbol(\"handlePromise\"),$=Symbol(\"stream\");function createIterResult(s,o){return{value:s,done:o}}function readAndResolve(s){var o=s[w];if(null!==o){var i=s[$].read();null!==i&&(s[L]=null,s[w]=null,s[x]=null,o(createIterResult(i,!1)))}}function onReadable(s){u.nextTick(readAndResolve,s)}var U=Object.getPrototypeOf((function(){})),V=Object.setPrototypeOf((_defineProperty(a={get stream(){return this[$]},next:function next(){var s=this,o=this[C];if(null!==o)return Promise.reject(o);if(this[j])return Promise.resolve(createIterResult(void 0,!0));if(this[$].destroyed)return new Promise((function(o,i){u.nextTick((function(){s[C]?i(s[C]):o(createIterResult(void 0,!0))}))}));var i,a=this[L];if(a)i=new Promise(function wrapForNext(s,o){return function(i,a){s.then((function(){o[j]?i(createIterResult(void 0,!0)):o[B](i,a)}),a)}}(a,this));else{var _=this[$].read();if(null!==_)return Promise.resolve(createIterResult(_,!1));i=new Promise(this[B])}return this[L]=i,i}},Symbol.asyncIterator,(function(){return this})),_defineProperty(a,\"return\",(function _return(){var s=this;return new Promise((function(o,i){s[$].destroy(null,(function(s){s?i(s):o(createIterResult(void 0,!0))}))}))})),a),U);s.exports=function createReadableStreamAsyncIterator(s){var o,i=Object.create(V,(_defineProperty(o={},$,{value:s,writable:!0}),_defineProperty(o,w,{value:null,writable:!0}),_defineProperty(o,x,{value:null,writable:!0}),_defineProperty(o,C,{value:null,writable:!0}),_defineProperty(o,j,{value:s._readableState.endEmitted,writable:!0}),_defineProperty(o,B,{value:function value(s,o){var a=i[$].read();a?(i[L]=null,i[w]=null,i[x]=null,s(createIterResult(a,!1))):(i[w]=s,i[x]=o)},writable:!0}),o));return i[L]=null,_(s,(function(s){if(s&&\"ERR_STREAM_PREMATURE_CLOSE\"!==s.code){var o=i[x];return null!==o&&(i[L]=null,i[w]=null,i[x]=null,o(s)),void(i[C]=s)}var a=i[w];null!==a&&(i[L]=null,i[w]=null,i[x]=null,a(createIterResult(void 0,!0))),i[j]=!0})),s.on(\"readable\",onReadable.bind(null,i)),i}},3110:(s,o,i)=>{const a=i(5187),u=i(85015),_=i(98023),w=i(53812),x=i(23805),C=i(85105),j=i(86804);class Namespace{constructor(s){this.elementMap={},this.elementDetection=[],this.Element=j.Element,this.KeyValuePair=j.KeyValuePair,s&&s.noDefault||this.useDefault(),this._attributeElementKeys=[],this._attributeElementArrayKeys=[]}use(s){return s.namespace&&s.namespace({base:this}),s.load&&s.load({base:this}),this}useDefault(){return this.register(\"null\",j.NullElement).register(\"string\",j.StringElement).register(\"number\",j.NumberElement).register(\"boolean\",j.BooleanElement).register(\"array\",j.ArrayElement).register(\"object\",j.ObjectElement).register(\"member\",j.MemberElement).register(\"ref\",j.RefElement).register(\"link\",j.LinkElement),this.detect(a,j.NullElement,!1).detect(u,j.StringElement,!1).detect(_,j.NumberElement,!1).detect(w,j.BooleanElement,!1).detect(Array.isArray,j.ArrayElement,!1).detect(x,j.ObjectElement,!1),this}register(s,o){return this._elements=void 0,this.elementMap[s]=o,this}unregister(s){return this._elements=void 0,delete this.elementMap[s],this}detect(s,o,i){return void 0===i||i?this.elementDetection.unshift([s,o]):this.elementDetection.push([s,o]),this}toElement(s){if(s instanceof this.Element)return s;let o;for(let i=0;i<this.elementDetection.length;i+=1){const a=this.elementDetection[i][0],u=this.elementDetection[i][1];if(a(s)){o=new u(s);break}}return o}getElementClass(s){const o=this.elementMap[s];return void 0===o?this.Element:o}fromRefract(s){return this.serialiser.deserialise(s)}toRefract(s){return this.serialiser.serialise(s)}get elements(){return void 0===this._elements&&(this._elements={Element:this.Element},Object.keys(this.elementMap).forEach((s=>{const o=s[0].toUpperCase()+s.substr(1);this._elements[o]=this.elementMap[s]}))),this._elements}get serialiser(){return new C(this)}}C.prototype.Namespace=Namespace,s.exports=Namespace},3121:(s,o,i)=>{\"use strict\";var a=i(65482),u=Math.min;s.exports=function(s){var o=a(s);return o>0?u(o,9007199254740991):0}},3209:(s,o,i)=>{var a=i(91596),u=i(53320),_=i(36306),w=\"__lodash_placeholder__\",x=128,C=Math.min;s.exports=function mergeData(s,o){var i=s[1],j=o[1],L=i|j,B=L<131,$=j==x&&8==i||j==x&&256==i&&s[7].length<=o[8]||384==j&&o[7].length<=o[8]&&8==i;if(!B&&!$)return s;1&j&&(s[2]=o[2],L|=1&i?0:4);var U=o[3];if(U){var V=s[3];s[3]=V?a(V,U,o[4]):U,s[4]=V?_(s[3],w):o[4]}return(U=o[5])&&(V=s[5],s[5]=V?u(V,U,o[6]):U,s[6]=V?_(s[5],w):o[6]),(U=o[7])&&(s[7]=U),j&x&&(s[8]=null==s[8]?o[8]:C(s[8],o[8])),null==s[9]&&(s[9]=o[9]),s[0]=o[0],s[1]=L,s}},3650:(s,o,i)=>{var a=i(74335)(Object.keys,Object);s.exports=a},3656:(s,o,i)=>{s=i.nmd(s);var a=i(9325),u=i(89935),_=o&&!o.nodeType&&o,w=_&&s&&!s.nodeType&&s,x=w&&w.exports===_?a.Buffer:void 0,C=(x?x.isBuffer:void 0)||u;s.exports=C},4509:(s,o,i)=>{var a=i(12651);s.exports=function mapCacheHas(s){return a(this,s).has(s)}},4640:s=>{\"use strict\";var o=String;s.exports=function(s){try{return o(s)}catch(s){return\"Object\"}}},4664:(s,o,i)=>{var a=i(79770),u=i(63345),_=Object.prototype.propertyIsEnumerable,w=Object.getOwnPropertySymbols,x=w?function(s){return null==s?[]:(s=Object(s),a(w(s),(function(o){return _.call(s,o)})))}:u;s.exports=x},4901:(s,o,i)=>{var a=i(72552),u=i(30294),_=i(40346),w={};w[\"[object Float32Array]\"]=w[\"[object Float64Array]\"]=w[\"[object Int8Array]\"]=w[\"[object Int16Array]\"]=w[\"[object Int32Array]\"]=w[\"[object Uint8Array]\"]=w[\"[object Uint8ClampedArray]\"]=w[\"[object Uint16Array]\"]=w[\"[object Uint32Array]\"]=!0,w[\"[object Arguments]\"]=w[\"[object Array]\"]=w[\"[object ArrayBuffer]\"]=w[\"[object Boolean]\"]=w[\"[object DataView]\"]=w[\"[object Date]\"]=w[\"[object Error]\"]=w[\"[object Function]\"]=w[\"[object Map]\"]=w[\"[object Number]\"]=w[\"[object Object]\"]=w[\"[object RegExp]\"]=w[\"[object Set]\"]=w[\"[object String]\"]=w[\"[object WeakMap]\"]=!1,s.exports=function baseIsTypedArray(s){return _(s)&&u(s.length)&&!!w[a(s)]}},4993:(s,o,i)=>{\"use strict\";var a=i(16946),u=i(74239);s.exports=function(s){return a(u(s))}},5187:s=>{s.exports=function isNull(s){return null===s}},5419:s=>{s.exports=function(s,o,i,a){var u=new Blob(void 0!==a?[a,s]:[s],{type:i||\"application/octet-stream\"});if(void 0!==window.navigator.msSaveBlob)window.navigator.msSaveBlob(u,o);else{var _=window.URL&&window.URL.createObjectURL?window.URL.createObjectURL(u):window.webkitURL.createObjectURL(u),w=document.createElement(\"a\");w.style.display=\"none\",w.href=_,w.setAttribute(\"download\",o),void 0===w.download&&w.setAttribute(\"target\",\"_blank\"),document.body.appendChild(w),w.click(),setTimeout((function(){document.body.removeChild(w),window.URL.revokeObjectURL(_)}),200)}}},5556:(s,o,i)=>{s.exports=i(2694)()},5861:(s,o,i)=>{var a=i(55580),u=i(68223),_=i(32804),w=i(76545),x=i(28303),C=i(72552),j=i(47473),L=\"[object Map]\",B=\"[object Promise]\",$=\"[object Set]\",U=\"[object WeakMap]\",V=\"[object DataView]\",z=j(a),Y=j(u),Z=j(_),ee=j(w),ie=j(x),ae=C;(a&&ae(new a(new ArrayBuffer(1)))!=V||u&&ae(new u)!=L||_&&ae(_.resolve())!=B||w&&ae(new w)!=$||x&&ae(new x)!=U)&&(ae=function(s){var o=C(s),i=\"[object Object]\"==o?s.constructor:void 0,a=i?j(i):\"\";if(a)switch(a){case z:return V;case Y:return L;case Z:return B;case ee:return $;case ie:return U}return o}),s.exports=ae},6048:s=>{s.exports=function negate(s){if(\"function\"!=typeof s)throw new TypeError(\"Expected a function\");return function(){var o=arguments;switch(o.length){case 0:return!s.call(this);case 1:return!s.call(this,o[0]);case 2:return!s.call(this,o[0],o[1]);case 3:return!s.call(this,o[0],o[1],o[2])}return!s.apply(this,o)}}},6188:s=>{\"use strict\";s.exports=Math.max},6205:s=>{s.exports={ROOT:0,GROUP:1,POSITION:2,SET:3,RANGE:4,REPETITION:5,REFERENCE:6,CHAR:7}},6233:(s,o,i)=>{const a=i(6048),u=i(10316),_=i(92340);class ArrayElement extends u{constructor(s,o,i){super(s||[],o,i),this.element=\"array\"}primitive(){return\"array\"}get(s){return this.content[s]}getValue(s){const o=this.get(s);if(o)return o.toValue()}getIndex(s){return this.content[s]}set(s,o){return this.content[s]=this.refract(o),this}remove(s){const o=this.content.splice(s,1);return o.length?o[0]:null}map(s,o){return this.content.map(s,o)}flatMap(s,o){return this.map(s,o).reduce(((s,o)=>s.concat(o)),[])}compactMap(s,o){const i=[];return this.forEach((a=>{const u=s.bind(o)(a);u&&i.push(u)})),i}filter(s,o){return new _(this.content.filter(s,o))}reject(s,o){return this.filter(a(s),o)}reduce(s,o){let i,a;void 0!==o?(i=0,a=this.refract(o)):(i=1,a=\"object\"===this.primitive()?this.first.value:this.first);for(let o=i;o<this.length;o+=1){const i=this.content[o];a=\"object\"===this.primitive()?this.refract(s(a,i.value,i.key,i,this)):this.refract(s(a,i,o,this))}return a}forEach(s,o){this.content.forEach(((i,a)=>{s.bind(o)(i,this.refract(a))}))}shift(){return this.content.shift()}unshift(s){this.content.unshift(this.refract(s))}push(s){return this.content.push(this.refract(s)),this}add(s){this.push(s)}findElements(s,o){const i=o||{},a=!!i.recursive,u=void 0===i.results?[]:i.results;return this.forEach(((o,i,_)=>{a&&void 0!==o.findElements&&o.findElements(s,{results:u,recursive:a}),s(o,i,_)&&u.push(o)})),u}find(s){return new _(this.findElements(s,{recursive:!0}))}findByElement(s){return this.find((o=>o.element===s))}findByClass(s){return this.find((o=>o.classes.includes(s)))}getById(s){return this.find((o=>o.id.toValue()===s)).first}includes(s){return this.content.some((o=>o.equals(s)))}contains(s){return this.includes(s)}empty(){return new this.constructor([])}\"fantasy-land/empty\"(){return this.empty()}concat(s){return new this.constructor(this.content.concat(s.content))}\"fantasy-land/concat\"(s){return this.concat(s)}\"fantasy-land/map\"(s){return new this.constructor(this.map(s))}\"fantasy-land/chain\"(s){return this.map((o=>s(o)),this).reduce(((s,o)=>s.concat(o)),this.empty())}\"fantasy-land/filter\"(s){return new this.constructor(this.content.filter(s))}\"fantasy-land/reduce\"(s,o){return this.content.reduce(s,o)}get length(){return this.content.length}get isEmpty(){return 0===this.content.length}get first(){return this.getIndex(0)}get second(){return this.getIndex(1)}get last(){return this.getIndex(this.length-1)}}ArrayElement.empty=function empty(){return new this},ArrayElement[\"fantasy-land/empty\"]=ArrayElement.empty,\"undefined\"!=typeof Symbol&&(ArrayElement.prototype[Symbol.iterator]=function symbol(){return this.content[Symbol.iterator]()}),s.exports=ArrayElement},6499:(s,o,i)=>{\"use strict\";var a=i(1907),u=0,_=Math.random(),w=a(1..toString);s.exports=function(s){return\"Symbol(\"+(void 0===s?\"\":s)+\")_\"+w(++u+_,36)}},6549:s=>{\"use strict\";s.exports=Object.getOwnPropertyDescriptor},6925:s=>{\"use strict\";s.exports=\"SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED\"},7057:(s,o,i)=>{\"use strict\";var a=i(11470).charAt,u=i(90160),_=i(64932),w=i(60183),x=i(59550),C=\"String Iterator\",j=_.set,L=_.getterFor(C);w(String,\"String\",(function(s){j(this,{type:C,string:u(s),index:0})}),(function next(){var s,o=L(this),i=o.string,u=o.index;return u>=i.length?x(void 0,!0):(s=a(i,u),o.index+=s.length,x(s,!1))}))},7176:(s,o,i)=>{\"use strict\";var a,u=i(73126),_=i(75795);try{a=[].__proto__===Array.prototype}catch(s){if(!s||\"object\"!=typeof s||!(\"code\"in s)||\"ERR_PROTO_ACCESS\"!==s.code)throw s}var w=!!a&&_&&_(Object.prototype,\"__proto__\"),x=Object,C=x.getPrototypeOf;s.exports=w&&\"function\"==typeof w.get?u([w.get]):\"function\"==typeof C&&function getDunder(s){return C(null==s?s:x(s))}},7309:(s,o,i)=>{var a=i(62006)(i(24713));s.exports=a},7376:s=>{\"use strict\";s.exports=!0},7463:(s,o,i)=>{\"use strict\";var a=i(98828),u=i(62250),_=/#|\\.prototype\\./,isForced=function(s,o){var i=x[w(s)];return i===j||i!==C&&(u(o)?a(o):!!o)},w=isForced.normalize=function(s){return String(s).replace(_,\".\").toLowerCase()},x=isForced.data={},C=isForced.NATIVE=\"N\",j=isForced.POLYFILL=\"P\";s.exports=isForced},7666:(s,o,i)=>{var a=i(84851),u=i(953);function _extends(){var o;return s.exports=_extends=a?u(o=a).call(o):function(s){for(var o=1;o<arguments.length;o++){var i=arguments[o];for(var a in i)({}).hasOwnProperty.call(i,a)&&(s[a]=i[a])}return s},s.exports.__esModule=!0,s.exports.default=s.exports,_extends.apply(null,arguments)}s.exports=_extends,s.exports.__esModule=!0,s.exports.default=s.exports},8048:(s,o,i)=>{const a=i(6205);o.wordBoundary=()=>({type:a.POSITION,value:\"b\"}),o.nonWordBoundary=()=>({type:a.POSITION,value:\"B\"}),o.begin=()=>({type:a.POSITION,value:\"^\"}),o.end=()=>({type:a.POSITION,value:\"$\"})},8068:s=>{\"use strict\";var o=(()=>{var s=Object.defineProperty,o=Object.getOwnPropertyDescriptor,i=Object.getOwnPropertyNames,a=Object.getOwnPropertySymbols,u=Object.prototype.hasOwnProperty,_=Object.prototype.propertyIsEnumerable,__defNormalProp=(o,i,a)=>i in o?s(o,i,{enumerable:!0,configurable:!0,writable:!0,value:a}):o[i]=a,__spreadValues=(s,o)=>{for(var i in o||(o={}))u.call(o,i)&&__defNormalProp(s,i,o[i]);if(a)for(var i of a(o))_.call(o,i)&&__defNormalProp(s,i,o[i]);return s},__publicField=(s,o,i)=>__defNormalProp(s,\"symbol\"!=typeof o?o+\"\":o,i),w={};((o,i)=>{for(var a in i)s(o,a,{get:i[a],enumerable:!0})})(w,{DEFAULT_OPTIONS:()=>C,DEFAULT_UUID_LENGTH:()=>x,default:()=>B});var x=6,C={dictionary:\"alphanum\",shuffle:!0,debug:!1,length:x,counter:0},j=class _ShortUniqueId{constructor(s={}){__publicField(this,\"counter\"),__publicField(this,\"debug\"),__publicField(this,\"dict\"),__publicField(this,\"version\"),__publicField(this,\"dictIndex\",0),__publicField(this,\"dictRange\",[]),__publicField(this,\"lowerBound\",0),__publicField(this,\"upperBound\",0),__publicField(this,\"dictLength\",0),__publicField(this,\"uuidLength\"),__publicField(this,\"_digit_first_ascii\",48),__publicField(this,\"_digit_last_ascii\",58),__publicField(this,\"_alpha_lower_first_ascii\",97),__publicField(this,\"_alpha_lower_last_ascii\",123),__publicField(this,\"_hex_last_ascii\",103),__publicField(this,\"_alpha_upper_first_ascii\",65),__publicField(this,\"_alpha_upper_last_ascii\",91),__publicField(this,\"_number_dict_ranges\",{digits:[this._digit_first_ascii,this._digit_last_ascii]}),__publicField(this,\"_alpha_dict_ranges\",{lowerCase:[this._alpha_lower_first_ascii,this._alpha_lower_last_ascii],upperCase:[this._alpha_upper_first_ascii,this._alpha_upper_last_ascii]}),__publicField(this,\"_alpha_lower_dict_ranges\",{lowerCase:[this._alpha_lower_first_ascii,this._alpha_lower_last_ascii]}),__publicField(this,\"_alpha_upper_dict_ranges\",{upperCase:[this._alpha_upper_first_ascii,this._alpha_upper_last_ascii]}),__publicField(this,\"_alphanum_dict_ranges\",{digits:[this._digit_first_ascii,this._digit_last_ascii],lowerCase:[this._alpha_lower_first_ascii,this._alpha_lower_last_ascii],upperCase:[this._alpha_upper_first_ascii,this._alpha_upper_last_ascii]}),__publicField(this,\"_alphanum_lower_dict_ranges\",{digits:[this._digit_first_ascii,this._digit_last_ascii],lowerCase:[this._alpha_lower_first_ascii,this._alpha_lower_last_ascii]}),__publicField(this,\"_alphanum_upper_dict_ranges\",{digits:[this._digit_first_ascii,this._digit_last_ascii],upperCase:[this._alpha_upper_first_ascii,this._alpha_upper_last_ascii]}),__publicField(this,\"_hex_dict_ranges\",{decDigits:[this._digit_first_ascii,this._digit_last_ascii],alphaDigits:[this._alpha_lower_first_ascii,this._hex_last_ascii]}),__publicField(this,\"_dict_ranges\",{_number_dict_ranges:this._number_dict_ranges,_alpha_dict_ranges:this._alpha_dict_ranges,_alpha_lower_dict_ranges:this._alpha_lower_dict_ranges,_alpha_upper_dict_ranges:this._alpha_upper_dict_ranges,_alphanum_dict_ranges:this._alphanum_dict_ranges,_alphanum_lower_dict_ranges:this._alphanum_lower_dict_ranges,_alphanum_upper_dict_ranges:this._alphanum_upper_dict_ranges,_hex_dict_ranges:this._hex_dict_ranges}),__publicField(this,\"log\",((...s)=>{const o=[...s];o[0]=\"[short-unique-id] \".concat(s[0]),!0!==this.debug||\"undefined\"==typeof console||null===console||console.log(...o)})),__publicField(this,\"_normalizeDictionary\",((s,o)=>{let i;if(s&&Array.isArray(s)&&s.length>1)i=s;else{i=[],this.dictIndex=0;const o=\"_\".concat(s,\"_dict_ranges\"),a=this._dict_ranges[o];let u=0;for(const[,s]of Object.entries(a)){const[o,i]=s;u+=Math.abs(i-o)}i=new Array(u);let _=0;for(const[,s]of Object.entries(a)){this.dictRange=s,this.lowerBound=this.dictRange[0],this.upperBound=this.dictRange[1];const o=this.lowerBound<=this.upperBound,a=this.lowerBound,u=this.upperBound;if(o)for(let s=a;s<u;s++)i[_++]=String.fromCharCode(s),this.dictIndex=s;else for(let s=a;s>u;s--)i[_++]=String.fromCharCode(s),this.dictIndex=s}i.length=_}if(o){for(let s=i.length-1;s>0;s--){const o=Math.floor(Math.random()*(s+1));[i[s],i[o]]=[i[o],i[s]]}}return i})),__publicField(this,\"setDictionary\",((s,o)=>{this.dict=this._normalizeDictionary(s,o),this.dictLength=this.dict.length,this.setCounter(0)})),__publicField(this,\"seq\",(()=>this.sequentialUUID())),__publicField(this,\"sequentialUUID\",(()=>{const s=this.dictLength,o=this.dict;let i=this.counter;const a=[];do{const u=i%s;i=Math.trunc(i/s),a.push(o[u])}while(0!==i);const u=a.join(\"\");return this.counter+=1,u})),__publicField(this,\"rnd\",((s=this.uuidLength||x)=>this.randomUUID(s))),__publicField(this,\"randomUUID\",((s=this.uuidLength||x)=>{if(null==s||s<1)throw new Error(\"Invalid UUID Length Provided\");const o=new Array(s),i=this.dictLength,a=this.dict;for(let u=0;u<s;u++){const s=Math.floor(Math.random()*i);o[u]=a[s]}return o.join(\"\")})),__publicField(this,\"fmt\",((s,o)=>this.formattedUUID(s,o))),__publicField(this,\"formattedUUID\",((s,o)=>{const i={$r:this.randomUUID,$s:this.sequentialUUID,$t:this.stamp};return s.replace(/\\$[rs]\\d{0,}|\\$t0|\\$t[1-9]\\d{1,}/g,(s=>{const a=s.slice(0,2),u=Number.parseInt(s.slice(2),10);return\"$s\"===a?i[a]().padStart(u,\"0\"):\"$t\"===a&&o?i[a](u,o):i[a](u)}))})),__publicField(this,\"availableUUIDs\",((s=this.uuidLength)=>Number.parseFloat(([...new Set(this.dict)].length**s).toFixed(0)))),__publicField(this,\"_collisionCache\",new Map),__publicField(this,\"approxMaxBeforeCollision\",((s=this.availableUUIDs(this.uuidLength))=>{const o=s,i=this._collisionCache.get(o);if(void 0!==i)return i;const a=Number.parseFloat(Math.sqrt(Math.PI/2*s).toFixed(20));return this._collisionCache.set(o,a),a})),__publicField(this,\"collisionProbability\",((s=this.availableUUIDs(this.uuidLength),o=this.uuidLength)=>Number.parseFloat((this.approxMaxBeforeCollision(s)/this.availableUUIDs(o)).toFixed(20)))),__publicField(this,\"uniqueness\",((s=this.availableUUIDs(this.uuidLength))=>{const o=Number.parseFloat((1-this.approxMaxBeforeCollision(s)/s).toFixed(20));return o>1?1:o<0?0:o})),__publicField(this,\"getVersion\",(()=>this.version)),__publicField(this,\"stamp\",((s,o)=>{const i=Math.floor(+(o||new Date)/1e3).toString(16);if(\"number\"==typeof s&&0===s)return i;if(\"number\"!=typeof s||s<10)throw new Error([\"Param finalLength must be a number greater than or equal to 10,\",\"or 0 if you want the raw hexadecimal timestamp\"].join(\"\\n\"));const a=s-9,u=Math.round(Math.random()*(a>15?15:a)),_=this.randomUUID(a);return\"\".concat(_.substring(0,u)).concat(i).concat(_.substring(u)).concat(u.toString(16))})),__publicField(this,\"parseStamp\",((s,o)=>{if(o&&!/t0|t[1-9]\\d{1,}/.test(o))throw new Error(\"Cannot extract date from a formated UUID with no timestamp in the format\");const i=o?o.replace(/\\$[rs]\\d{0,}|\\$t0|\\$t[1-9]\\d{1,}/g,(s=>{const o={$r:s=>[...Array(s)].map((()=>\"r\")).join(\"\"),$s:s=>[...Array(s)].map((()=>\"s\")).join(\"\"),$t:s=>[...Array(s)].map((()=>\"t\")).join(\"\")},i=s.slice(0,2),a=Number.parseInt(s.slice(2),10);return o[i](a)})).replace(/^(.*?)(t{8,})(.*)$/g,((o,i,a)=>s.substring(i.length,i.length+a.length))):s;if(8===i.length)return new Date(1e3*Number.parseInt(i,16));if(i.length<10)throw new Error(\"Stamp length invalid\");const a=Number.parseInt(i.substring(i.length-1),16);return new Date(1e3*Number.parseInt(i.substring(a,a+8),16))})),__publicField(this,\"setCounter\",(s=>{this.counter=s})),__publicField(this,\"validate\",((s,o)=>{const i=o?this._normalizeDictionary(o):this.dict;return s.split(\"\").every((s=>i.includes(s)))}));const o=__spreadValues(__spreadValues({},C),s);this.counter=0,this.debug=!1,this.dict=[],this.version=\"5.3.2\";const{dictionary:i,shuffle:a,length:u,counter:_}=o;this.uuidLength=u,this.setDictionary(i,a),this.setCounter(_),this.debug=o.debug,this.log(this.dict),this.log(\"Generator instantiated with Dictionary Size \".concat(this.dictLength,\" and counter set to \").concat(this.counter)),this.log=this.log.bind(this),this.setDictionary=this.setDictionary.bind(this),this.setCounter=this.setCounter.bind(this),this.seq=this.seq.bind(this),this.sequentialUUID=this.sequentialUUID.bind(this),this.rnd=this.rnd.bind(this),this.randomUUID=this.randomUUID.bind(this),this.fmt=this.fmt.bind(this),this.formattedUUID=this.formattedUUID.bind(this),this.availableUUIDs=this.availableUUIDs.bind(this),this.approxMaxBeforeCollision=this.approxMaxBeforeCollision.bind(this),this.collisionProbability=this.collisionProbability.bind(this),this.uniqueness=this.uniqueness.bind(this),this.getVersion=this.getVersion.bind(this),this.stamp=this.stamp.bind(this),this.parseStamp=this.parseStamp.bind(this)}};__publicField(j,\"default\",j);var L,B=j;return L=w,((a,_,w,x)=>{if(_&&\"object\"==typeof _||\"function\"==typeof _)for(let C of i(_))u.call(a,C)||C===w||s(a,C,{get:()=>_[C],enumerable:!(x=o(_,C))||x.enumerable});return a})(s({},\"__esModule\",{value:!0}),L)})();s.exports=o.default,\"undefined\"!=typeof window&&(o=o.default)},9325:(s,o,i)=>{var a=i(34840),u=\"object\"==typeof self&&self&&self.Object===Object&&self,_=a||u||Function(\"return this\")();s.exports=_},9404:function(s){s.exports=function(){\"use strict\";var s=Array.prototype.slice;function createClass(s,o){o&&(s.prototype=Object.create(o.prototype)),s.prototype.constructor=s}function Iterable(s){return isIterable(s)?s:Seq(s)}function KeyedIterable(s){return isKeyed(s)?s:KeyedSeq(s)}function IndexedIterable(s){return isIndexed(s)?s:IndexedSeq(s)}function SetIterable(s){return isIterable(s)&&!isAssociative(s)?s:SetSeq(s)}function isIterable(s){return!(!s||!s[o])}function isKeyed(s){return!(!s||!s[i])}function isIndexed(s){return!(!s||!s[a])}function isAssociative(s){return isKeyed(s)||isIndexed(s)}function isOrdered(s){return!(!s||!s[u])}createClass(KeyedIterable,Iterable),createClass(IndexedIterable,Iterable),createClass(SetIterable,Iterable),Iterable.isIterable=isIterable,Iterable.isKeyed=isKeyed,Iterable.isIndexed=isIndexed,Iterable.isAssociative=isAssociative,Iterable.isOrdered=isOrdered,Iterable.Keyed=KeyedIterable,Iterable.Indexed=IndexedIterable,Iterable.Set=SetIterable;var o=\"@@__IMMUTABLE_ITERABLE__@@\",i=\"@@__IMMUTABLE_KEYED__@@\",a=\"@@__IMMUTABLE_INDEXED__@@\",u=\"@@__IMMUTABLE_ORDERED__@@\",_=\"delete\",w=5,x=1<<w,C=x-1,j={},L={value:!1},B={value:!1};function MakeRef(s){return s.value=!1,s}function SetRef(s){s&&(s.value=!0)}function OwnerID(){}function arrCopy(s,o){o=o||0;for(var i=Math.max(0,s.length-o),a=new Array(i),u=0;u<i;u++)a[u]=s[u+o];return a}function ensureSize(s){return void 0===s.size&&(s.size=s.__iterate(returnTrue)),s.size}function wrapIndex(s,o){if(\"number\"!=typeof o){var i=o>>>0;if(\"\"+i!==o||4294967295===i)return NaN;o=i}return o<0?ensureSize(s)+o:o}function returnTrue(){return!0}function wholeSlice(s,o,i){return(0===s||void 0!==i&&s<=-i)&&(void 0===o||void 0!==i&&o>=i)}function resolveBegin(s,o){return resolveIndex(s,o,0)}function resolveEnd(s,o){return resolveIndex(s,o,o)}function resolveIndex(s,o,i){return void 0===s?i:s<0?Math.max(0,o+s):void 0===o?s:Math.min(o,s)}var $=0,U=1,V=2,z=\"function\"==typeof Symbol&&Symbol.iterator,Y=\"@@iterator\",Z=z||Y;function Iterator(s){this.next=s}function iteratorValue(s,o,i,a){var u=0===s?o:1===s?i:[o,i];return a?a.value=u:a={value:u,done:!1},a}function iteratorDone(){return{value:void 0,done:!0}}function hasIterator(s){return!!getIteratorFn(s)}function isIterator(s){return s&&\"function\"==typeof s.next}function getIterator(s){var o=getIteratorFn(s);return o&&o.call(s)}function getIteratorFn(s){var o=s&&(z&&s[z]||s[Y]);if(\"function\"==typeof o)return o}function isArrayLike(s){return s&&\"number\"==typeof s.length}function Seq(s){return null==s?emptySequence():isIterable(s)?s.toSeq():seqFromValue(s)}function KeyedSeq(s){return null==s?emptySequence().toKeyedSeq():isIterable(s)?isKeyed(s)?s.toSeq():s.fromEntrySeq():keyedSeqFromValue(s)}function IndexedSeq(s){return null==s?emptySequence():isIterable(s)?isKeyed(s)?s.entrySeq():s.toIndexedSeq():indexedSeqFromValue(s)}function SetSeq(s){return(null==s?emptySequence():isIterable(s)?isKeyed(s)?s.entrySeq():s:indexedSeqFromValue(s)).toSetSeq()}Iterator.prototype.toString=function(){return\"[Iterator]\"},Iterator.KEYS=$,Iterator.VALUES=U,Iterator.ENTRIES=V,Iterator.prototype.inspect=Iterator.prototype.toSource=function(){return this.toString()},Iterator.prototype[Z]=function(){return this},createClass(Seq,Iterable),Seq.of=function(){return Seq(arguments)},Seq.prototype.toSeq=function(){return this},Seq.prototype.toString=function(){return this.__toString(\"Seq {\",\"}\")},Seq.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().toArray(),this.size=this._cache.length),this},Seq.prototype.__iterate=function(s,o){return seqIterate(this,s,o,!0)},Seq.prototype.__iterator=function(s,o){return seqIterator(this,s,o,!0)},createClass(KeyedSeq,Seq),KeyedSeq.prototype.toKeyedSeq=function(){return this},createClass(IndexedSeq,Seq),IndexedSeq.of=function(){return IndexedSeq(arguments)},IndexedSeq.prototype.toIndexedSeq=function(){return this},IndexedSeq.prototype.toString=function(){return this.__toString(\"Seq [\",\"]\")},IndexedSeq.prototype.__iterate=function(s,o){return seqIterate(this,s,o,!1)},IndexedSeq.prototype.__iterator=function(s,o){return seqIterator(this,s,o,!1)},createClass(SetSeq,Seq),SetSeq.of=function(){return SetSeq(arguments)},SetSeq.prototype.toSetSeq=function(){return this},Seq.isSeq=isSeq,Seq.Keyed=KeyedSeq,Seq.Set=SetSeq,Seq.Indexed=IndexedSeq;var ee,ie,ae,ce=\"@@__IMMUTABLE_SEQ__@@\";function ArraySeq(s){this._array=s,this.size=s.length}function ObjectSeq(s){var o=Object.keys(s);this._object=s,this._keys=o,this.size=o.length}function IterableSeq(s){this._iterable=s,this.size=s.length||s.size}function IteratorSeq(s){this._iterator=s,this._iteratorCache=[]}function isSeq(s){return!(!s||!s[ce])}function emptySequence(){return ee||(ee=new ArraySeq([]))}function keyedSeqFromValue(s){var o=Array.isArray(s)?new ArraySeq(s).fromEntrySeq():isIterator(s)?new IteratorSeq(s).fromEntrySeq():hasIterator(s)?new IterableSeq(s).fromEntrySeq():\"object\"==typeof s?new ObjectSeq(s):void 0;if(!o)throw new TypeError(\"Expected Array or iterable object of [k, v] entries, or keyed object: \"+s);return o}function indexedSeqFromValue(s){var o=maybeIndexedSeqFromValue(s);if(!o)throw new TypeError(\"Expected Array or iterable object of values: \"+s);return o}function seqFromValue(s){var o=maybeIndexedSeqFromValue(s)||\"object\"==typeof s&&new ObjectSeq(s);if(!o)throw new TypeError(\"Expected Array or iterable object of values, or keyed object: \"+s);return o}function maybeIndexedSeqFromValue(s){return isArrayLike(s)?new ArraySeq(s):isIterator(s)?new IteratorSeq(s):hasIterator(s)?new IterableSeq(s):void 0}function seqIterate(s,o,i,a){var u=s._cache;if(u){for(var _=u.length-1,w=0;w<=_;w++){var x=u[i?_-w:w];if(!1===o(x[1],a?x[0]:w,s))return w+1}return w}return s.__iterateUncached(o,i)}function seqIterator(s,o,i,a){var u=s._cache;if(u){var _=u.length-1,w=0;return new Iterator((function(){var s=u[i?_-w:w];return w++>_?iteratorDone():iteratorValue(o,a?s[0]:w-1,s[1])}))}return s.__iteratorUncached(o,i)}function fromJS(s,o){return o?fromJSWith(o,s,\"\",{\"\":s}):fromJSDefault(s)}function fromJSWith(s,o,i,a){return Array.isArray(o)?s.call(a,i,IndexedSeq(o).map((function(i,a){return fromJSWith(s,i,a,o)}))):isPlainObj(o)?s.call(a,i,KeyedSeq(o).map((function(i,a){return fromJSWith(s,i,a,o)}))):o}function fromJSDefault(s){return Array.isArray(s)?IndexedSeq(s).map(fromJSDefault).toList():isPlainObj(s)?KeyedSeq(s).map(fromJSDefault).toMap():s}function isPlainObj(s){return s&&(s.constructor===Object||void 0===s.constructor)}function is(s,o){if(s===o||s!=s&&o!=o)return!0;if(!s||!o)return!1;if(\"function\"==typeof s.valueOf&&\"function\"==typeof o.valueOf){if((s=s.valueOf())===(o=o.valueOf())||s!=s&&o!=o)return!0;if(!s||!o)return!1}return!(\"function\"!=typeof s.equals||\"function\"!=typeof o.equals||!s.equals(o))}function deepEqual(s,o){if(s===o)return!0;if(!isIterable(o)||void 0!==s.size&&void 0!==o.size&&s.size!==o.size||void 0!==s.__hash&&void 0!==o.__hash&&s.__hash!==o.__hash||isKeyed(s)!==isKeyed(o)||isIndexed(s)!==isIndexed(o)||isOrdered(s)!==isOrdered(o))return!1;if(0===s.size&&0===o.size)return!0;var i=!isAssociative(s);if(isOrdered(s)){var a=s.entries();return o.every((function(s,o){var u=a.next().value;return u&&is(u[1],s)&&(i||is(u[0],o))}))&&a.next().done}var u=!1;if(void 0===s.size)if(void 0===o.size)\"function\"==typeof s.cacheResult&&s.cacheResult();else{u=!0;var _=s;s=o,o=_}var w=!0,x=o.__iterate((function(o,a){if(i?!s.has(o):u?!is(o,s.get(a,j)):!is(s.get(a,j),o))return w=!1,!1}));return w&&s.size===x}function Repeat(s,o){if(!(this instanceof Repeat))return new Repeat(s,o);if(this._value=s,this.size=void 0===o?1/0:Math.max(0,o),0===this.size){if(ie)return ie;ie=this}}function invariant(s,o){if(!s)throw new Error(o)}function Range(s,o,i){if(!(this instanceof Range))return new Range(s,o,i);if(invariant(0!==i,\"Cannot step a Range by 0\"),s=s||0,void 0===o&&(o=1/0),i=void 0===i?1:Math.abs(i),o<s&&(i=-i),this._start=s,this._end=o,this._step=i,this.size=Math.max(0,Math.ceil((o-s)/i-1)+1),0===this.size){if(ae)return ae;ae=this}}function Collection(){throw TypeError(\"Abstract\")}function KeyedCollection(){}function IndexedCollection(){}function SetCollection(){}Seq.prototype[ce]=!0,createClass(ArraySeq,IndexedSeq),ArraySeq.prototype.get=function(s,o){return this.has(s)?this._array[wrapIndex(this,s)]:o},ArraySeq.prototype.__iterate=function(s,o){for(var i=this._array,a=i.length-1,u=0;u<=a;u++)if(!1===s(i[o?a-u:u],u,this))return u+1;return u},ArraySeq.prototype.__iterator=function(s,o){var i=this._array,a=i.length-1,u=0;return new Iterator((function(){return u>a?iteratorDone():iteratorValue(s,u,i[o?a-u++:u++])}))},createClass(ObjectSeq,KeyedSeq),ObjectSeq.prototype.get=function(s,o){return void 0===o||this.has(s)?this._object[s]:o},ObjectSeq.prototype.has=function(s){return this._object.hasOwnProperty(s)},ObjectSeq.prototype.__iterate=function(s,o){for(var i=this._object,a=this._keys,u=a.length-1,_=0;_<=u;_++){var w=a[o?u-_:_];if(!1===s(i[w],w,this))return _+1}return _},ObjectSeq.prototype.__iterator=function(s,o){var i=this._object,a=this._keys,u=a.length-1,_=0;return new Iterator((function(){var w=a[o?u-_:_];return _++>u?iteratorDone():iteratorValue(s,w,i[w])}))},ObjectSeq.prototype[u]=!0,createClass(IterableSeq,IndexedSeq),IterableSeq.prototype.__iterateUncached=function(s,o){if(o)return this.cacheResult().__iterate(s,o);var i=getIterator(this._iterable),a=0;if(isIterator(i))for(var u;!(u=i.next()).done&&!1!==s(u.value,a++,this););return a},IterableSeq.prototype.__iteratorUncached=function(s,o){if(o)return this.cacheResult().__iterator(s,o);var i=getIterator(this._iterable);if(!isIterator(i))return new Iterator(iteratorDone);var a=0;return new Iterator((function(){var o=i.next();return o.done?o:iteratorValue(s,a++,o.value)}))},createClass(IteratorSeq,IndexedSeq),IteratorSeq.prototype.__iterateUncached=function(s,o){if(o)return this.cacheResult().__iterate(s,o);for(var i,a=this._iterator,u=this._iteratorCache,_=0;_<u.length;)if(!1===s(u[_],_++,this))return _;for(;!(i=a.next()).done;){var w=i.value;if(u[_]=w,!1===s(w,_++,this))break}return _},IteratorSeq.prototype.__iteratorUncached=function(s,o){if(o)return this.cacheResult().__iterator(s,o);var i=this._iterator,a=this._iteratorCache,u=0;return new Iterator((function(){if(u>=a.length){var o=i.next();if(o.done)return o;a[u]=o.value}return iteratorValue(s,u,a[u++])}))},createClass(Repeat,IndexedSeq),Repeat.prototype.toString=function(){return 0===this.size?\"Repeat []\":\"Repeat [ \"+this._value+\" \"+this.size+\" times ]\"},Repeat.prototype.get=function(s,o){return this.has(s)?this._value:o},Repeat.prototype.includes=function(s){return is(this._value,s)},Repeat.prototype.slice=function(s,o){var i=this.size;return wholeSlice(s,o,i)?this:new Repeat(this._value,resolveEnd(o,i)-resolveBegin(s,i))},Repeat.prototype.reverse=function(){return this},Repeat.prototype.indexOf=function(s){return is(this._value,s)?0:-1},Repeat.prototype.lastIndexOf=function(s){return is(this._value,s)?this.size:-1},Repeat.prototype.__iterate=function(s,o){for(var i=0;i<this.size;i++)if(!1===s(this._value,i,this))return i+1;return i},Repeat.prototype.__iterator=function(s,o){var i=this,a=0;return new Iterator((function(){return a<i.size?iteratorValue(s,a++,i._value):iteratorDone()}))},Repeat.prototype.equals=function(s){return s instanceof Repeat?is(this._value,s._value):deepEqual(s)},createClass(Range,IndexedSeq),Range.prototype.toString=function(){return 0===this.size?\"Range []\":\"Range [ \"+this._start+\"...\"+this._end+(1!==this._step?\" by \"+this._step:\"\")+\" ]\"},Range.prototype.get=function(s,o){return this.has(s)?this._start+wrapIndex(this,s)*this._step:o},Range.prototype.includes=function(s){var o=(s-this._start)/this._step;return o>=0&&o<this.size&&o===Math.floor(o)},Range.prototype.slice=function(s,o){return wholeSlice(s,o,this.size)?this:(s=resolveBegin(s,this.size),(o=resolveEnd(o,this.size))<=s?new Range(0,0):new Range(this.get(s,this._end),this.get(o,this._end),this._step))},Range.prototype.indexOf=function(s){var o=s-this._start;if(o%this._step==0){var i=o/this._step;if(i>=0&&i<this.size)return i}return-1},Range.prototype.lastIndexOf=function(s){return this.indexOf(s)},Range.prototype.__iterate=function(s,o){for(var i=this.size-1,a=this._step,u=o?this._start+i*a:this._start,_=0;_<=i;_++){if(!1===s(u,_,this))return _+1;u+=o?-a:a}return _},Range.prototype.__iterator=function(s,o){var i=this.size-1,a=this._step,u=o?this._start+i*a:this._start,_=0;return new Iterator((function(){var w=u;return u+=o?-a:a,_>i?iteratorDone():iteratorValue(s,_++,w)}))},Range.prototype.equals=function(s){return s instanceof Range?this._start===s._start&&this._end===s._end&&this._step===s._step:deepEqual(this,s)},createClass(Collection,Iterable),createClass(KeyedCollection,Collection),createClass(IndexedCollection,Collection),createClass(SetCollection,Collection),Collection.Keyed=KeyedCollection,Collection.Indexed=IndexedCollection,Collection.Set=SetCollection;var le=\"function\"==typeof Math.imul&&-2===Math.imul(4294967295,2)?Math.imul:function imul(s,o){var i=65535&(s|=0),a=65535&(o|=0);return i*a+((s>>>16)*a+i*(o>>>16)<<16>>>0)|0};function smi(s){return s>>>1&1073741824|3221225471&s}function hash(s){if(!1===s||null==s)return 0;if(\"function\"==typeof s.valueOf&&(!1===(s=s.valueOf())||null==s))return 0;if(!0===s)return 1;var o=typeof s;if(\"number\"===o){if(s!=s||s===1/0)return 0;var i=0|s;for(i!==s&&(i^=4294967295*s);s>4294967295;)i^=s/=4294967295;return smi(i)}if(\"string\"===o)return s.length>Se?cachedHashString(s):hashString(s);if(\"function\"==typeof s.hashCode)return s.hashCode();if(\"object\"===o)return hashJSObj(s);if(\"function\"==typeof s.toString)return hashString(s.toString());throw new Error(\"Value type \"+o+\" cannot be hashed.\")}function cachedHashString(s){var o=Pe[s];return void 0===o&&(o=hashString(s),xe===we&&(xe=0,Pe={}),xe++,Pe[s]=o),o}function hashString(s){for(var o=0,i=0;i<s.length;i++)o=31*o+s.charCodeAt(i)|0;return smi(o)}function hashJSObj(s){var o;if(ye&&void 0!==(o=fe.get(s)))return o;if(void 0!==(o=s[_e]))return o;if(!de){if(void 0!==(o=s.propertyIsEnumerable&&s.propertyIsEnumerable[_e]))return o;if(void 0!==(o=getIENodeHash(s)))return o}if(o=++be,1073741824&be&&(be=0),ye)fe.set(s,o);else{if(void 0!==pe&&!1===pe(s))throw new Error(\"Non-extensible objects are not allowed as keys.\");if(de)Object.defineProperty(s,_e,{enumerable:!1,configurable:!1,writable:!1,value:o});else if(void 0!==s.propertyIsEnumerable&&s.propertyIsEnumerable===s.constructor.prototype.propertyIsEnumerable)s.propertyIsEnumerable=function(){return this.constructor.prototype.propertyIsEnumerable.apply(this,arguments)},s.propertyIsEnumerable[_e]=o;else{if(void 0===s.nodeType)throw new Error(\"Unable to set a non-enumerable property on object.\");s[_e]=o}}return o}var pe=Object.isExtensible,de=function(){try{return Object.defineProperty({},\"@\",{}),!0}catch(s){return!1}}();function getIENodeHash(s){if(s&&s.nodeType>0)switch(s.nodeType){case 1:return s.uniqueID;case 9:return s.documentElement&&s.documentElement.uniqueID}}var fe,ye=\"function\"==typeof WeakMap;ye&&(fe=new WeakMap);var be=0,_e=\"__immutablehash__\";\"function\"==typeof Symbol&&(_e=Symbol(_e));var Se=16,we=255,xe=0,Pe={};function assertNotInfinite(s){invariant(s!==1/0,\"Cannot perform this action with an infinite size.\")}function Map(s){return null==s?emptyMap():isMap(s)&&!isOrdered(s)?s:emptyMap().withMutations((function(o){var i=KeyedIterable(s);assertNotInfinite(i.size),i.forEach((function(s,i){return o.set(i,s)}))}))}function isMap(s){return!(!s||!s[Re])}createClass(Map,KeyedCollection),Map.of=function(){var o=s.call(arguments,0);return emptyMap().withMutations((function(s){for(var i=0;i<o.length;i+=2){if(i+1>=o.length)throw new Error(\"Missing value for key: \"+o[i]);s.set(o[i],o[i+1])}}))},Map.prototype.toString=function(){return this.__toString(\"Map {\",\"}\")},Map.prototype.get=function(s,o){return this._root?this._root.get(0,void 0,s,o):o},Map.prototype.set=function(s,o){return updateMap(this,s,o)},Map.prototype.setIn=function(s,o){return this.updateIn(s,j,(function(){return o}))},Map.prototype.remove=function(s){return updateMap(this,s,j)},Map.prototype.deleteIn=function(s){return this.updateIn(s,(function(){return j}))},Map.prototype.update=function(s,o,i){return 1===arguments.length?s(this):this.updateIn([s],o,i)},Map.prototype.updateIn=function(s,o,i){i||(i=o,o=void 0);var a=updateInDeepMap(this,forceIterator(s),o,i);return a===j?void 0:a},Map.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):emptyMap()},Map.prototype.merge=function(){return mergeIntoMapWith(this,void 0,arguments)},Map.prototype.mergeWith=function(o){return mergeIntoMapWith(this,o,s.call(arguments,1))},Map.prototype.mergeIn=function(o){var i=s.call(arguments,1);return this.updateIn(o,emptyMap(),(function(s){return\"function\"==typeof s.merge?s.merge.apply(s,i):i[i.length-1]}))},Map.prototype.mergeDeep=function(){return mergeIntoMapWith(this,deepMerger,arguments)},Map.prototype.mergeDeepWith=function(o){var i=s.call(arguments,1);return mergeIntoMapWith(this,deepMergerWith(o),i)},Map.prototype.mergeDeepIn=function(o){var i=s.call(arguments,1);return this.updateIn(o,emptyMap(),(function(s){return\"function\"==typeof s.mergeDeep?s.mergeDeep.apply(s,i):i[i.length-1]}))},Map.prototype.sort=function(s){return OrderedMap(sortFactory(this,s))},Map.prototype.sortBy=function(s,o){return OrderedMap(sortFactory(this,o,s))},Map.prototype.withMutations=function(s){var o=this.asMutable();return s(o),o.wasAltered()?o.__ensureOwner(this.__ownerID):this},Map.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new OwnerID)},Map.prototype.asImmutable=function(){return this.__ensureOwner()},Map.prototype.wasAltered=function(){return this.__altered},Map.prototype.__iterator=function(s,o){return new MapIterator(this,s,o)},Map.prototype.__iterate=function(s,o){var i=this,a=0;return this._root&&this._root.iterate((function(o){return a++,s(o[1],o[0],i)}),o),a},Map.prototype.__ensureOwner=function(s){return s===this.__ownerID?this:s?makeMap(this.size,this._root,s,this.__hash):(this.__ownerID=s,this.__altered=!1,this)},Map.isMap=isMap;var Te,Re=\"@@__IMMUTABLE_MAP__@@\",$e=Map.prototype;function ArrayMapNode(s,o){this.ownerID=s,this.entries=o}function BitmapIndexedNode(s,o,i){this.ownerID=s,this.bitmap=o,this.nodes=i}function HashArrayMapNode(s,o,i){this.ownerID=s,this.count=o,this.nodes=i}function HashCollisionNode(s,o,i){this.ownerID=s,this.keyHash=o,this.entries=i}function ValueNode(s,o,i){this.ownerID=s,this.keyHash=o,this.entry=i}function MapIterator(s,o,i){this._type=o,this._reverse=i,this._stack=s._root&&mapIteratorFrame(s._root)}function mapIteratorValue(s,o){return iteratorValue(s,o[0],o[1])}function mapIteratorFrame(s,o){return{node:s,index:0,__prev:o}}function makeMap(s,o,i,a){var u=Object.create($e);return u.size=s,u._root=o,u.__ownerID=i,u.__hash=a,u.__altered=!1,u}function emptyMap(){return Te||(Te=makeMap(0))}function updateMap(s,o,i){var a,u;if(s._root){var _=MakeRef(L),w=MakeRef(B);if(a=updateNode(s._root,s.__ownerID,0,void 0,o,i,_,w),!w.value)return s;u=s.size+(_.value?i===j?-1:1:0)}else{if(i===j)return s;u=1,a=new ArrayMapNode(s.__ownerID,[[o,i]])}return s.__ownerID?(s.size=u,s._root=a,s.__hash=void 0,s.__altered=!0,s):a?makeMap(u,a):emptyMap()}function updateNode(s,o,i,a,u,_,w,x){return s?s.update(o,i,a,u,_,w,x):_===j?s:(SetRef(x),SetRef(w),new ValueNode(o,a,[u,_]))}function isLeafNode(s){return s.constructor===ValueNode||s.constructor===HashCollisionNode}function mergeIntoNode(s,o,i,a,u){if(s.keyHash===a)return new HashCollisionNode(o,a,[s.entry,u]);var _,x=(0===i?s.keyHash:s.keyHash>>>i)&C,j=(0===i?a:a>>>i)&C;return new BitmapIndexedNode(o,1<<x|1<<j,x===j?[mergeIntoNode(s,o,i+w,a,u)]:(_=new ValueNode(o,a,u),x<j?[s,_]:[_,s]))}function createNodes(s,o,i,a){s||(s=new OwnerID);for(var u=new ValueNode(s,hash(i),[i,a]),_=0;_<o.length;_++){var w=o[_];u=u.update(s,0,void 0,w[0],w[1])}return u}function packNodes(s,o,i,a){for(var u=0,_=0,w=new Array(i),x=0,C=1,j=o.length;x<j;x++,C<<=1){var L=o[x];void 0!==L&&x!==a&&(u|=C,w[_++]=L)}return new BitmapIndexedNode(s,u,w)}function expandNodes(s,o,i,a,u){for(var _=0,w=new Array(x),C=0;0!==i;C++,i>>>=1)w[C]=1&i?o[_++]:void 0;return w[a]=u,new HashArrayMapNode(s,_+1,w)}function mergeIntoMapWith(s,o,i){for(var a=[],u=0;u<i.length;u++){var _=i[u],w=KeyedIterable(_);isIterable(_)||(w=w.map((function(s){return fromJS(s)}))),a.push(w)}return mergeIntoCollectionWith(s,o,a)}function deepMerger(s,o,i){return s&&s.mergeDeep&&isIterable(o)?s.mergeDeep(o):is(s,o)?s:o}function deepMergerWith(s){return function(o,i,a){if(o&&o.mergeDeepWith&&isIterable(i))return o.mergeDeepWith(s,i);var u=s(o,i,a);return is(o,u)?o:u}}function mergeIntoCollectionWith(s,o,i){return 0===(i=i.filter((function(s){return 0!==s.size}))).length?s:0!==s.size||s.__ownerID||1!==i.length?s.withMutations((function(s){for(var a=o?function(i,a){s.update(a,j,(function(s){return s===j?i:o(s,i,a)}))}:function(o,i){s.set(i,o)},u=0;u<i.length;u++)i[u].forEach(a)})):s.constructor(i[0])}function updateInDeepMap(s,o,i,a){var u=s===j,_=o.next();if(_.done){var w=u?i:s,x=a(w);return x===w?s:x}invariant(u||s&&s.set,\"invalid keyPath\");var C=_.value,L=u?j:s.get(C,j),B=updateInDeepMap(L,o,i,a);return B===L?s:B===j?s.remove(C):(u?emptyMap():s).set(C,B)}function popCount(s){return s=(s=(858993459&(s-=s>>1&1431655765))+(s>>2&858993459))+(s>>4)&252645135,s+=s>>8,127&(s+=s>>16)}function setIn(s,o,i,a){var u=a?s:arrCopy(s);return u[o]=i,u}function spliceIn(s,o,i,a){var u=s.length+1;if(a&&o+1===u)return s[o]=i,s;for(var _=new Array(u),w=0,x=0;x<u;x++)x===o?(_[x]=i,w=-1):_[x]=s[x+w];return _}function spliceOut(s,o,i){var a=s.length-1;if(i&&o===a)return s.pop(),s;for(var u=new Array(a),_=0,w=0;w<a;w++)w===o&&(_=1),u[w]=s[w+_];return u}$e[Re]=!0,$e[_]=$e.remove,$e.removeIn=$e.deleteIn,ArrayMapNode.prototype.get=function(s,o,i,a){for(var u=this.entries,_=0,w=u.length;_<w;_++)if(is(i,u[_][0]))return u[_][1];return a},ArrayMapNode.prototype.update=function(s,o,i,a,u,_,w){for(var x=u===j,C=this.entries,L=0,B=C.length;L<B&&!is(a,C[L][0]);L++);var $=L<B;if($?C[L][1]===u:x)return this;if(SetRef(w),(x||!$)&&SetRef(_),!x||1!==C.length){if(!$&&!x&&C.length>=qe)return createNodes(s,C,a,u);var U=s&&s===this.ownerID,V=U?C:arrCopy(C);return $?x?L===B-1?V.pop():V[L]=V.pop():V[L]=[a,u]:V.push([a,u]),U?(this.entries=V,this):new ArrayMapNode(s,V)}},BitmapIndexedNode.prototype.get=function(s,o,i,a){void 0===o&&(o=hash(i));var u=1<<((0===s?o:o>>>s)&C),_=this.bitmap;return _&u?this.nodes[popCount(_&u-1)].get(s+w,o,i,a):a},BitmapIndexedNode.prototype.update=function(s,o,i,a,u,_,x){void 0===i&&(i=hash(a));var L=(0===o?i:i>>>o)&C,B=1<<L,$=this.bitmap,U=!!($&B);if(!U&&u===j)return this;var V=popCount($&B-1),z=this.nodes,Y=U?z[V]:void 0,Z=updateNode(Y,s,o+w,i,a,u,_,x);if(Z===Y)return this;if(!U&&Z&&z.length>=ze)return expandNodes(s,z,$,L,Z);if(U&&!Z&&2===z.length&&isLeafNode(z[1^V]))return z[1^V];if(U&&Z&&1===z.length&&isLeafNode(Z))return Z;var ee=s&&s===this.ownerID,ie=U?Z?$:$^B:$|B,ae=U?Z?setIn(z,V,Z,ee):spliceOut(z,V,ee):spliceIn(z,V,Z,ee);return ee?(this.bitmap=ie,this.nodes=ae,this):new BitmapIndexedNode(s,ie,ae)},HashArrayMapNode.prototype.get=function(s,o,i,a){void 0===o&&(o=hash(i));var u=(0===s?o:o>>>s)&C,_=this.nodes[u];return _?_.get(s+w,o,i,a):a},HashArrayMapNode.prototype.update=function(s,o,i,a,u,_,x){void 0===i&&(i=hash(a));var L=(0===o?i:i>>>o)&C,B=u===j,$=this.nodes,U=$[L];if(B&&!U)return this;var V=updateNode(U,s,o+w,i,a,u,_,x);if(V===U)return this;var z=this.count;if(U){if(!V&&--z<We)return packNodes(s,$,z,L)}else z++;var Y=s&&s===this.ownerID,Z=setIn($,L,V,Y);return Y?(this.count=z,this.nodes=Z,this):new HashArrayMapNode(s,z,Z)},HashCollisionNode.prototype.get=function(s,o,i,a){for(var u=this.entries,_=0,w=u.length;_<w;_++)if(is(i,u[_][0]))return u[_][1];return a},HashCollisionNode.prototype.update=function(s,o,i,a,u,_,w){void 0===i&&(i=hash(a));var x=u===j;if(i!==this.keyHash)return x?this:(SetRef(w),SetRef(_),mergeIntoNode(this,s,o,i,[a,u]));for(var C=this.entries,L=0,B=C.length;L<B&&!is(a,C[L][0]);L++);var $=L<B;if($?C[L][1]===u:x)return this;if(SetRef(w),(x||!$)&&SetRef(_),x&&2===B)return new ValueNode(s,this.keyHash,C[1^L]);var U=s&&s===this.ownerID,V=U?C:arrCopy(C);return $?x?L===B-1?V.pop():V[L]=V.pop():V[L]=[a,u]:V.push([a,u]),U?(this.entries=V,this):new HashCollisionNode(s,this.keyHash,V)},ValueNode.prototype.get=function(s,o,i,a){return is(i,this.entry[0])?this.entry[1]:a},ValueNode.prototype.update=function(s,o,i,a,u,_,w){var x=u===j,C=is(a,this.entry[0]);return(C?u===this.entry[1]:x)?this:(SetRef(w),x?void SetRef(_):C?s&&s===this.ownerID?(this.entry[1]=u,this):new ValueNode(s,this.keyHash,[a,u]):(SetRef(_),mergeIntoNode(this,s,o,hash(a),[a,u])))},ArrayMapNode.prototype.iterate=HashCollisionNode.prototype.iterate=function(s,o){for(var i=this.entries,a=0,u=i.length-1;a<=u;a++)if(!1===s(i[o?u-a:a]))return!1},BitmapIndexedNode.prototype.iterate=HashArrayMapNode.prototype.iterate=function(s,o){for(var i=this.nodes,a=0,u=i.length-1;a<=u;a++){var _=i[o?u-a:a];if(_&&!1===_.iterate(s,o))return!1}},ValueNode.prototype.iterate=function(s,o){return s(this.entry)},createClass(MapIterator,Iterator),MapIterator.prototype.next=function(){for(var s=this._type,o=this._stack;o;){var i,a=o.node,u=o.index++;if(a.entry){if(0===u)return mapIteratorValue(s,a.entry)}else if(a.entries){if(u<=(i=a.entries.length-1))return mapIteratorValue(s,a.entries[this._reverse?i-u:u])}else if(u<=(i=a.nodes.length-1)){var _=a.nodes[this._reverse?i-u:u];if(_){if(_.entry)return mapIteratorValue(s,_.entry);o=this._stack=mapIteratorFrame(_,o)}continue}o=this._stack=this._stack.__prev}return iteratorDone()};var qe=x/4,ze=x/2,We=x/4;function List(s){var o=emptyList();if(null==s)return o;if(isList(s))return s;var i=IndexedIterable(s),a=i.size;return 0===a?o:(assertNotInfinite(a),a>0&&a<x?makeList(0,a,w,null,new VNode(i.toArray())):o.withMutations((function(s){s.setSize(a),i.forEach((function(o,i){return s.set(i,o)}))})))}function isList(s){return!(!s||!s[He])}createClass(List,IndexedCollection),List.of=function(){return this(arguments)},List.prototype.toString=function(){return this.__toString(\"List [\",\"]\")},List.prototype.get=function(s,o){if((s=wrapIndex(this,s))>=0&&s<this.size){var i=listNodeFor(this,s+=this._origin);return i&&i.array[s&C]}return o},List.prototype.set=function(s,o){return updateList(this,s,o)},List.prototype.remove=function(s){return this.has(s)?0===s?this.shift():s===this.size-1?this.pop():this.splice(s,1):this},List.prototype.insert=function(s,o){return this.splice(s,0,o)},List.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=this._origin=this._capacity=0,this._level=w,this._root=this._tail=null,this.__hash=void 0,this.__altered=!0,this):emptyList()},List.prototype.push=function(){var s=arguments,o=this.size;return this.withMutations((function(i){setListBounds(i,0,o+s.length);for(var a=0;a<s.length;a++)i.set(o+a,s[a])}))},List.prototype.pop=function(){return setListBounds(this,0,-1)},List.prototype.unshift=function(){var s=arguments;return this.withMutations((function(o){setListBounds(o,-s.length);for(var i=0;i<s.length;i++)o.set(i,s[i])}))},List.prototype.shift=function(){return setListBounds(this,1)},List.prototype.merge=function(){return mergeIntoListWith(this,void 0,arguments)},List.prototype.mergeWith=function(o){return mergeIntoListWith(this,o,s.call(arguments,1))},List.prototype.mergeDeep=function(){return mergeIntoListWith(this,deepMerger,arguments)},List.prototype.mergeDeepWith=function(o){var i=s.call(arguments,1);return mergeIntoListWith(this,deepMergerWith(o),i)},List.prototype.setSize=function(s){return setListBounds(this,0,s)},List.prototype.slice=function(s,o){var i=this.size;return wholeSlice(s,o,i)?this:setListBounds(this,resolveBegin(s,i),resolveEnd(o,i))},List.prototype.__iterator=function(s,o){var i=0,a=iterateList(this,o);return new Iterator((function(){var o=a();return o===et?iteratorDone():iteratorValue(s,i++,o)}))},List.prototype.__iterate=function(s,o){for(var i,a=0,u=iterateList(this,o);(i=u())!==et&&!1!==s(i,a++,this););return a},List.prototype.__ensureOwner=function(s){return s===this.__ownerID?this:s?makeList(this._origin,this._capacity,this._level,this._root,this._tail,s,this.__hash):(this.__ownerID=s,this)},List.isList=isList;var He=\"@@__IMMUTABLE_LIST__@@\",Ye=List.prototype;function VNode(s,o){this.array=s,this.ownerID=o}Ye[He]=!0,Ye[_]=Ye.remove,Ye.setIn=$e.setIn,Ye.deleteIn=Ye.removeIn=$e.removeIn,Ye.update=$e.update,Ye.updateIn=$e.updateIn,Ye.mergeIn=$e.mergeIn,Ye.mergeDeepIn=$e.mergeDeepIn,Ye.withMutations=$e.withMutations,Ye.asMutable=$e.asMutable,Ye.asImmutable=$e.asImmutable,Ye.wasAltered=$e.wasAltered,VNode.prototype.removeBefore=function(s,o,i){if(i===o?1<<o:0===this.array.length)return this;var a=i>>>o&C;if(a>=this.array.length)return new VNode([],s);var u,_=0===a;if(o>0){var x=this.array[a];if((u=x&&x.removeBefore(s,o-w,i))===x&&_)return this}if(_&&!u)return this;var j=editableVNode(this,s);if(!_)for(var L=0;L<a;L++)j.array[L]=void 0;return u&&(j.array[a]=u),j},VNode.prototype.removeAfter=function(s,o,i){if(i===(o?1<<o:0)||0===this.array.length)return this;var a,u=i-1>>>o&C;if(u>=this.array.length)return this;if(o>0){var _=this.array[u];if((a=_&&_.removeAfter(s,o-w,i))===_&&u===this.array.length-1)return this}var x=editableVNode(this,s);return x.array.splice(u+1),a&&(x.array[u]=a),x};var Xe,Qe,et={};function iterateList(s,o){var i=s._origin,a=s._capacity,u=getTailOffset(a),_=s._tail;return iterateNodeOrLeaf(s._root,s._level,0);function iterateNodeOrLeaf(s,o,i){return 0===o?iterateLeaf(s,i):iterateNode(s,o,i)}function iterateLeaf(s,w){var C=w===u?_&&_.array:s&&s.array,j=w>i?0:i-w,L=a-w;return L>x&&(L=x),function(){if(j===L)return et;var s=o?--L:j++;return C&&C[s]}}function iterateNode(s,u,_){var C,j=s&&s.array,L=_>i?0:i-_>>u,B=1+(a-_>>u);return B>x&&(B=x),function(){for(;;){if(C){var s=C();if(s!==et)return s;C=null}if(L===B)return et;var i=o?--B:L++;C=iterateNodeOrLeaf(j&&j[i],u-w,_+(i<<u))}}}}function makeList(s,o,i,a,u,_,w){var x=Object.create(Ye);return x.size=o-s,x._origin=s,x._capacity=o,x._level=i,x._root=a,x._tail=u,x.__ownerID=_,x.__hash=w,x.__altered=!1,x}function emptyList(){return Xe||(Xe=makeList(0,0,w))}function updateList(s,o,i){if((o=wrapIndex(s,o))!=o)return s;if(o>=s.size||o<0)return s.withMutations((function(s){o<0?setListBounds(s,o).set(0,i):setListBounds(s,0,o+1).set(o,i)}));o+=s._origin;var a=s._tail,u=s._root,_=MakeRef(B);return o>=getTailOffset(s._capacity)?a=updateVNode(a,s.__ownerID,0,o,i,_):u=updateVNode(u,s.__ownerID,s._level,o,i,_),_.value?s.__ownerID?(s._root=u,s._tail=a,s.__hash=void 0,s.__altered=!0,s):makeList(s._origin,s._capacity,s._level,u,a):s}function updateVNode(s,o,i,a,u,_){var x,j=a>>>i&C,L=s&&j<s.array.length;if(!L&&void 0===u)return s;if(i>0){var B=s&&s.array[j],$=updateVNode(B,o,i-w,a,u,_);return $===B?s:((x=editableVNode(s,o)).array[j]=$,x)}return L&&s.array[j]===u?s:(SetRef(_),x=editableVNode(s,o),void 0===u&&j===x.array.length-1?x.array.pop():x.array[j]=u,x)}function editableVNode(s,o){return o&&s&&o===s.ownerID?s:new VNode(s?s.array.slice():[],o)}function listNodeFor(s,o){if(o>=getTailOffset(s._capacity))return s._tail;if(o<1<<s._level+w){for(var i=s._root,a=s._level;i&&a>0;)i=i.array[o>>>a&C],a-=w;return i}}function setListBounds(s,o,i){void 0!==o&&(o|=0),void 0!==i&&(i|=0);var a=s.__ownerID||new OwnerID,u=s._origin,_=s._capacity,x=u+o,j=void 0===i?_:i<0?_+i:u+i;if(x===u&&j===_)return s;if(x>=j)return s.clear();for(var L=s._level,B=s._root,$=0;x+$<0;)B=new VNode(B&&B.array.length?[void 0,B]:[],a),$+=1<<(L+=w);$&&(x+=$,u+=$,j+=$,_+=$);for(var U=getTailOffset(_),V=getTailOffset(j);V>=1<<L+w;)B=new VNode(B&&B.array.length?[B]:[],a),L+=w;var z=s._tail,Y=V<U?listNodeFor(s,j-1):V>U?new VNode([],a):z;if(z&&V>U&&x<_&&z.array.length){for(var Z=B=editableVNode(B,a),ee=L;ee>w;ee-=w){var ie=U>>>ee&C;Z=Z.array[ie]=editableVNode(Z.array[ie],a)}Z.array[U>>>w&C]=z}if(j<_&&(Y=Y&&Y.removeAfter(a,0,j)),x>=V)x-=V,j-=V,L=w,B=null,Y=Y&&Y.removeBefore(a,0,x);else if(x>u||V<U){for($=0;B;){var ae=x>>>L&C;if(ae!==V>>>L&C)break;ae&&($+=(1<<L)*ae),L-=w,B=B.array[ae]}B&&x>u&&(B=B.removeBefore(a,L,x-$)),B&&V<U&&(B=B.removeAfter(a,L,V-$)),$&&(x-=$,j-=$)}return s.__ownerID?(s.size=j-x,s._origin=x,s._capacity=j,s._level=L,s._root=B,s._tail=Y,s.__hash=void 0,s.__altered=!0,s):makeList(x,j,L,B,Y)}function mergeIntoListWith(s,o,i){for(var a=[],u=0,_=0;_<i.length;_++){var w=i[_],x=IndexedIterable(w);x.size>u&&(u=x.size),isIterable(w)||(x=x.map((function(s){return fromJS(s)}))),a.push(x)}return u>s.size&&(s=s.setSize(u)),mergeIntoCollectionWith(s,o,a)}function getTailOffset(s){return s<x?0:s-1>>>w<<w}function OrderedMap(s){return null==s?emptyOrderedMap():isOrderedMap(s)?s:emptyOrderedMap().withMutations((function(o){var i=KeyedIterable(s);assertNotInfinite(i.size),i.forEach((function(s,i){return o.set(i,s)}))}))}function isOrderedMap(s){return isMap(s)&&isOrdered(s)}function makeOrderedMap(s,o,i,a){var u=Object.create(OrderedMap.prototype);return u.size=s?s.size:0,u._map=s,u._list=o,u.__ownerID=i,u.__hash=a,u}function emptyOrderedMap(){return Qe||(Qe=makeOrderedMap(emptyMap(),emptyList()))}function updateOrderedMap(s,o,i){var a,u,_=s._map,w=s._list,C=_.get(o),L=void 0!==C;if(i===j){if(!L)return s;w.size>=x&&w.size>=2*_.size?(a=(u=w.filter((function(s,o){return void 0!==s&&C!==o}))).toKeyedSeq().map((function(s){return s[0]})).flip().toMap(),s.__ownerID&&(a.__ownerID=u.__ownerID=s.__ownerID)):(a=_.remove(o),u=C===w.size-1?w.pop():w.set(C,void 0))}else if(L){if(i===w.get(C)[1])return s;a=_,u=w.set(C,[o,i])}else a=_.set(o,w.size),u=w.set(w.size,[o,i]);return s.__ownerID?(s.size=a.size,s._map=a,s._list=u,s.__hash=void 0,s):makeOrderedMap(a,u)}function ToKeyedSequence(s,o){this._iter=s,this._useKeys=o,this.size=s.size}function ToIndexedSequence(s){this._iter=s,this.size=s.size}function ToSetSequence(s){this._iter=s,this.size=s.size}function FromEntriesSequence(s){this._iter=s,this.size=s.size}function flipFactory(s){var o=makeSequence(s);return o._iter=s,o.size=s.size,o.flip=function(){return s},o.reverse=function(){var o=s.reverse.apply(this);return o.flip=function(){return s.reverse()},o},o.has=function(o){return s.includes(o)},o.includes=function(o){return s.has(o)},o.cacheResult=cacheResultThrough,o.__iterateUncached=function(o,i){var a=this;return s.__iterate((function(s,i){return!1!==o(i,s,a)}),i)},o.__iteratorUncached=function(o,i){if(o===V){var a=s.__iterator(o,i);return new Iterator((function(){var s=a.next();if(!s.done){var o=s.value[0];s.value[0]=s.value[1],s.value[1]=o}return s}))}return s.__iterator(o===U?$:U,i)},o}function mapFactory(s,o,i){var a=makeSequence(s);return a.size=s.size,a.has=function(o){return s.has(o)},a.get=function(a,u){var _=s.get(a,j);return _===j?u:o.call(i,_,a,s)},a.__iterateUncached=function(a,u){var _=this;return s.__iterate((function(s,u,w){return!1!==a(o.call(i,s,u,w),u,_)}),u)},a.__iteratorUncached=function(a,u){var _=s.__iterator(V,u);return new Iterator((function(){var u=_.next();if(u.done)return u;var w=u.value,x=w[0];return iteratorValue(a,x,o.call(i,w[1],x,s),u)}))},a}function reverseFactory(s,o){var i=makeSequence(s);return i._iter=s,i.size=s.size,i.reverse=function(){return s},s.flip&&(i.flip=function(){var o=flipFactory(s);return o.reverse=function(){return s.flip()},o}),i.get=function(i,a){return s.get(o?i:-1-i,a)},i.has=function(i){return s.has(o?i:-1-i)},i.includes=function(o){return s.includes(o)},i.cacheResult=cacheResultThrough,i.__iterate=function(o,i){var a=this;return s.__iterate((function(s,i){return o(s,i,a)}),!i)},i.__iterator=function(o,i){return s.__iterator(o,!i)},i}function filterFactory(s,o,i,a){var u=makeSequence(s);return a&&(u.has=function(a){var u=s.get(a,j);return u!==j&&!!o.call(i,u,a,s)},u.get=function(a,u){var _=s.get(a,j);return _!==j&&o.call(i,_,a,s)?_:u}),u.__iterateUncached=function(u,_){var w=this,x=0;return s.__iterate((function(s,_,C){if(o.call(i,s,_,C))return x++,u(s,a?_:x-1,w)}),_),x},u.__iteratorUncached=function(u,_){var w=s.__iterator(V,_),x=0;return new Iterator((function(){for(;;){var _=w.next();if(_.done)return _;var C=_.value,j=C[0],L=C[1];if(o.call(i,L,j,s))return iteratorValue(u,a?j:x++,L,_)}}))},u}function countByFactory(s,o,i){var a=Map().asMutable();return s.__iterate((function(u,_){a.update(o.call(i,u,_,s),0,(function(s){return s+1}))})),a.asImmutable()}function groupByFactory(s,o,i){var a=isKeyed(s),u=(isOrdered(s)?OrderedMap():Map()).asMutable();s.__iterate((function(_,w){u.update(o.call(i,_,w,s),(function(s){return(s=s||[]).push(a?[w,_]:_),s}))}));var _=iterableClass(s);return u.map((function(o){return reify(s,_(o))}))}function sliceFactory(s,o,i,a){var u=s.size;if(void 0!==o&&(o|=0),void 0!==i&&(i===1/0?i=u:i|=0),wholeSlice(o,i,u))return s;var _=resolveBegin(o,u),w=resolveEnd(i,u);if(_!=_||w!=w)return sliceFactory(s.toSeq().cacheResult(),o,i,a);var x,C=w-_;C==C&&(x=C<0?0:C);var j=makeSequence(s);return j.size=0===x?x:s.size&&x||void 0,!a&&isSeq(s)&&x>=0&&(j.get=function(o,i){return(o=wrapIndex(this,o))>=0&&o<x?s.get(o+_,i):i}),j.__iterateUncached=function(o,i){var u=this;if(0===x)return 0;if(i)return this.cacheResult().__iterate(o,i);var w=0,C=!0,j=0;return s.__iterate((function(s,i){if(!C||!(C=w++<_))return j++,!1!==o(s,a?i:j-1,u)&&j!==x})),j},j.__iteratorUncached=function(o,i){if(0!==x&&i)return this.cacheResult().__iterator(o,i);var u=0!==x&&s.__iterator(o,i),w=0,C=0;return new Iterator((function(){for(;w++<_;)u.next();if(++C>x)return iteratorDone();var s=u.next();return a||o===U?s:iteratorValue(o,C-1,o===$?void 0:s.value[1],s)}))},j}function takeWhileFactory(s,o,i){var a=makeSequence(s);return a.__iterateUncached=function(a,u){var _=this;if(u)return this.cacheResult().__iterate(a,u);var w=0;return s.__iterate((function(s,u,x){return o.call(i,s,u,x)&&++w&&a(s,u,_)})),w},a.__iteratorUncached=function(a,u){var _=this;if(u)return this.cacheResult().__iterator(a,u);var w=s.__iterator(V,u),x=!0;return new Iterator((function(){if(!x)return iteratorDone();var s=w.next();if(s.done)return s;var u=s.value,C=u[0],j=u[1];return o.call(i,j,C,_)?a===V?s:iteratorValue(a,C,j,s):(x=!1,iteratorDone())}))},a}function skipWhileFactory(s,o,i,a){var u=makeSequence(s);return u.__iterateUncached=function(u,_){var w=this;if(_)return this.cacheResult().__iterate(u,_);var x=!0,C=0;return s.__iterate((function(s,_,j){if(!x||!(x=o.call(i,s,_,j)))return C++,u(s,a?_:C-1,w)})),C},u.__iteratorUncached=function(u,_){var w=this;if(_)return this.cacheResult().__iterator(u,_);var x=s.__iterator(V,_),C=!0,j=0;return new Iterator((function(){var s,_,L;do{if((s=x.next()).done)return a||u===U?s:iteratorValue(u,j++,u===$?void 0:s.value[1],s);var B=s.value;_=B[0],L=B[1],C&&(C=o.call(i,L,_,w))}while(C);return u===V?s:iteratorValue(u,_,L,s)}))},u}function concatFactory(s,o){var i=isKeyed(s),a=[s].concat(o).map((function(s){return isIterable(s)?i&&(s=KeyedIterable(s)):s=i?keyedSeqFromValue(s):indexedSeqFromValue(Array.isArray(s)?s:[s]),s})).filter((function(s){return 0!==s.size}));if(0===a.length)return s;if(1===a.length){var u=a[0];if(u===s||i&&isKeyed(u)||isIndexed(s)&&isIndexed(u))return u}var _=new ArraySeq(a);return i?_=_.toKeyedSeq():isIndexed(s)||(_=_.toSetSeq()),(_=_.flatten(!0)).size=a.reduce((function(s,o){if(void 0!==s){var i=o.size;if(void 0!==i)return s+i}}),0),_}function flattenFactory(s,o,i){var a=makeSequence(s);return a.__iterateUncached=function(a,u){var _=0,w=!1;function flatDeep(s,x){var C=this;s.__iterate((function(s,u){return(!o||x<o)&&isIterable(s)?flatDeep(s,x+1):!1===a(s,i?u:_++,C)&&(w=!0),!w}),u)}return flatDeep(s,0),_},a.__iteratorUncached=function(a,u){var _=s.__iterator(a,u),w=[],x=0;return new Iterator((function(){for(;_;){var s=_.next();if(!1===s.done){var C=s.value;if(a===V&&(C=C[1]),o&&!(w.length<o)||!isIterable(C))return i?s:iteratorValue(a,x++,C,s);w.push(_),_=C.__iterator(a,u)}else _=w.pop()}return iteratorDone()}))},a}function flatMapFactory(s,o,i){var a=iterableClass(s);return s.toSeq().map((function(u,_){return a(o.call(i,u,_,s))})).flatten(!0)}function interposeFactory(s,o){var i=makeSequence(s);return i.size=s.size&&2*s.size-1,i.__iterateUncached=function(i,a){var u=this,_=0;return s.__iterate((function(s,a){return(!_||!1!==i(o,_++,u))&&!1!==i(s,_++,u)}),a),_},i.__iteratorUncached=function(i,a){var u,_=s.__iterator(U,a),w=0;return new Iterator((function(){return(!u||w%2)&&(u=_.next()).done?u:w%2?iteratorValue(i,w++,o):iteratorValue(i,w++,u.value,u)}))},i}function sortFactory(s,o,i){o||(o=defaultComparator);var a=isKeyed(s),u=0,_=s.toSeq().map((function(o,a){return[a,o,u++,i?i(o,a,s):o]})).toArray();return _.sort((function(s,i){return o(s[3],i[3])||s[2]-i[2]})).forEach(a?function(s,o){_[o].length=2}:function(s,o){_[o]=s[1]}),a?KeyedSeq(_):isIndexed(s)?IndexedSeq(_):SetSeq(_)}function maxFactory(s,o,i){if(o||(o=defaultComparator),i){var a=s.toSeq().map((function(o,a){return[o,i(o,a,s)]})).reduce((function(s,i){return maxCompare(o,s[1],i[1])?i:s}));return a&&a[0]}return s.reduce((function(s,i){return maxCompare(o,s,i)?i:s}))}function maxCompare(s,o,i){var a=s(i,o);return 0===a&&i!==o&&(null==i||i!=i)||a>0}function zipWithFactory(s,o,i){var a=makeSequence(s);return a.size=new ArraySeq(i).map((function(s){return s.size})).min(),a.__iterate=function(s,o){for(var i,a=this.__iterator(U,o),u=0;!(i=a.next()).done&&!1!==s(i.value,u++,this););return u},a.__iteratorUncached=function(s,a){var u=i.map((function(s){return s=Iterable(s),getIterator(a?s.reverse():s)})),_=0,w=!1;return new Iterator((function(){var i;return w||(i=u.map((function(s){return s.next()})),w=i.some((function(s){return s.done}))),w?iteratorDone():iteratorValue(s,_++,o.apply(null,i.map((function(s){return s.value}))))}))},a}function reify(s,o){return isSeq(s)?o:s.constructor(o)}function validateEntry(s){if(s!==Object(s))throw new TypeError(\"Expected [K, V] tuple: \"+s)}function resolveSize(s){return assertNotInfinite(s.size),ensureSize(s)}function iterableClass(s){return isKeyed(s)?KeyedIterable:isIndexed(s)?IndexedIterable:SetIterable}function makeSequence(s){return Object.create((isKeyed(s)?KeyedSeq:isIndexed(s)?IndexedSeq:SetSeq).prototype)}function cacheResultThrough(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):Seq.prototype.cacheResult.call(this)}function defaultComparator(s,o){return s>o?1:s<o?-1:0}function forceIterator(s){var o=getIterator(s);if(!o){if(!isArrayLike(s))throw new TypeError(\"Expected iterable or array-like: \"+s);o=getIterator(Iterable(s))}return o}function Record(s,o){var i,a=function Record(_){if(_ instanceof a)return _;if(!(this instanceof a))return new a(_);if(!i){i=!0;var w=Object.keys(s);setProps(u,w),u.size=w.length,u._name=o,u._keys=w,u._defaultValues=s}this._map=Map(_)},u=a.prototype=Object.create(tt);return u.constructor=a,a}createClass(OrderedMap,Map),OrderedMap.of=function(){return this(arguments)},OrderedMap.prototype.toString=function(){return this.__toString(\"OrderedMap {\",\"}\")},OrderedMap.prototype.get=function(s,o){var i=this._map.get(s);return void 0!==i?this._list.get(i)[1]:o},OrderedMap.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._map.clear(),this._list.clear(),this):emptyOrderedMap()},OrderedMap.prototype.set=function(s,o){return updateOrderedMap(this,s,o)},OrderedMap.prototype.remove=function(s){return updateOrderedMap(this,s,j)},OrderedMap.prototype.wasAltered=function(){return this._map.wasAltered()||this._list.wasAltered()},OrderedMap.prototype.__iterate=function(s,o){var i=this;return this._list.__iterate((function(o){return o&&s(o[1],o[0],i)}),o)},OrderedMap.prototype.__iterator=function(s,o){return this._list.fromEntrySeq().__iterator(s,o)},OrderedMap.prototype.__ensureOwner=function(s){if(s===this.__ownerID)return this;var o=this._map.__ensureOwner(s),i=this._list.__ensureOwner(s);return s?makeOrderedMap(o,i,s,this.__hash):(this.__ownerID=s,this._map=o,this._list=i,this)},OrderedMap.isOrderedMap=isOrderedMap,OrderedMap.prototype[u]=!0,OrderedMap.prototype[_]=OrderedMap.prototype.remove,createClass(ToKeyedSequence,KeyedSeq),ToKeyedSequence.prototype.get=function(s,o){return this._iter.get(s,o)},ToKeyedSequence.prototype.has=function(s){return this._iter.has(s)},ToKeyedSequence.prototype.valueSeq=function(){return this._iter.valueSeq()},ToKeyedSequence.prototype.reverse=function(){var s=this,o=reverseFactory(this,!0);return this._useKeys||(o.valueSeq=function(){return s._iter.toSeq().reverse()}),o},ToKeyedSequence.prototype.map=function(s,o){var i=this,a=mapFactory(this,s,o);return this._useKeys||(a.valueSeq=function(){return i._iter.toSeq().map(s,o)}),a},ToKeyedSequence.prototype.__iterate=function(s,o){var i,a=this;return this._iter.__iterate(this._useKeys?function(o,i){return s(o,i,a)}:(i=o?resolveSize(this):0,function(u){return s(u,o?--i:i++,a)}),o)},ToKeyedSequence.prototype.__iterator=function(s,o){if(this._useKeys)return this._iter.__iterator(s,o);var i=this._iter.__iterator(U,o),a=o?resolveSize(this):0;return new Iterator((function(){var u=i.next();return u.done?u:iteratorValue(s,o?--a:a++,u.value,u)}))},ToKeyedSequence.prototype[u]=!0,createClass(ToIndexedSequence,IndexedSeq),ToIndexedSequence.prototype.includes=function(s){return this._iter.includes(s)},ToIndexedSequence.prototype.__iterate=function(s,o){var i=this,a=0;return this._iter.__iterate((function(o){return s(o,a++,i)}),o)},ToIndexedSequence.prototype.__iterator=function(s,o){var i=this._iter.__iterator(U,o),a=0;return new Iterator((function(){var o=i.next();return o.done?o:iteratorValue(s,a++,o.value,o)}))},createClass(ToSetSequence,SetSeq),ToSetSequence.prototype.has=function(s){return this._iter.includes(s)},ToSetSequence.prototype.__iterate=function(s,o){var i=this;return this._iter.__iterate((function(o){return s(o,o,i)}),o)},ToSetSequence.prototype.__iterator=function(s,o){var i=this._iter.__iterator(U,o);return new Iterator((function(){var o=i.next();return o.done?o:iteratorValue(s,o.value,o.value,o)}))},createClass(FromEntriesSequence,KeyedSeq),FromEntriesSequence.prototype.entrySeq=function(){return this._iter.toSeq()},FromEntriesSequence.prototype.__iterate=function(s,o){var i=this;return this._iter.__iterate((function(o){if(o){validateEntry(o);var a=isIterable(o);return s(a?o.get(1):o[1],a?o.get(0):o[0],i)}}),o)},FromEntriesSequence.prototype.__iterator=function(s,o){var i=this._iter.__iterator(U,o);return new Iterator((function(){for(;;){var o=i.next();if(o.done)return o;var a=o.value;if(a){validateEntry(a);var u=isIterable(a);return iteratorValue(s,u?a.get(0):a[0],u?a.get(1):a[1],o)}}}))},ToIndexedSequence.prototype.cacheResult=ToKeyedSequence.prototype.cacheResult=ToSetSequence.prototype.cacheResult=FromEntriesSequence.prototype.cacheResult=cacheResultThrough,createClass(Record,KeyedCollection),Record.prototype.toString=function(){return this.__toString(recordName(this)+\" {\",\"}\")},Record.prototype.has=function(s){return this._defaultValues.hasOwnProperty(s)},Record.prototype.get=function(s,o){if(!this.has(s))return o;var i=this._defaultValues[s];return this._map?this._map.get(s,i):i},Record.prototype.clear=function(){if(this.__ownerID)return this._map&&this._map.clear(),this;var s=this.constructor;return s._empty||(s._empty=makeRecord(this,emptyMap()))},Record.prototype.set=function(s,o){if(!this.has(s))throw new Error('Cannot set unknown key \"'+s+'\" on '+recordName(this));if(this._map&&!this._map.has(s)&&o===this._defaultValues[s])return this;var i=this._map&&this._map.set(s,o);return this.__ownerID||i===this._map?this:makeRecord(this,i)},Record.prototype.remove=function(s){if(!this.has(s))return this;var o=this._map&&this._map.remove(s);return this.__ownerID||o===this._map?this:makeRecord(this,o)},Record.prototype.wasAltered=function(){return this._map.wasAltered()},Record.prototype.__iterator=function(s,o){var i=this;return KeyedIterable(this._defaultValues).map((function(s,o){return i.get(o)})).__iterator(s,o)},Record.prototype.__iterate=function(s,o){var i=this;return KeyedIterable(this._defaultValues).map((function(s,o){return i.get(o)})).__iterate(s,o)},Record.prototype.__ensureOwner=function(s){if(s===this.__ownerID)return this;var o=this._map&&this._map.__ensureOwner(s);return s?makeRecord(this,o,s):(this.__ownerID=s,this._map=o,this)};var tt=Record.prototype;function makeRecord(s,o,i){var a=Object.create(Object.getPrototypeOf(s));return a._map=o,a.__ownerID=i,a}function recordName(s){return s._name||s.constructor.name||\"Record\"}function setProps(s,o){try{o.forEach(setProp.bind(void 0,s))}catch(s){}}function setProp(s,o){Object.defineProperty(s,o,{get:function(){return this.get(o)},set:function(s){invariant(this.__ownerID,\"Cannot set on an immutable record.\"),this.set(o,s)}})}function Set(s){return null==s?emptySet():isSet(s)&&!isOrdered(s)?s:emptySet().withMutations((function(o){var i=SetIterable(s);assertNotInfinite(i.size),i.forEach((function(s){return o.add(s)}))}))}function isSet(s){return!(!s||!s[nt])}tt[_]=tt.remove,tt.deleteIn=tt.removeIn=$e.removeIn,tt.merge=$e.merge,tt.mergeWith=$e.mergeWith,tt.mergeIn=$e.mergeIn,tt.mergeDeep=$e.mergeDeep,tt.mergeDeepWith=$e.mergeDeepWith,tt.mergeDeepIn=$e.mergeDeepIn,tt.setIn=$e.setIn,tt.update=$e.update,tt.updateIn=$e.updateIn,tt.withMutations=$e.withMutations,tt.asMutable=$e.asMutable,tt.asImmutable=$e.asImmutable,createClass(Set,SetCollection),Set.of=function(){return this(arguments)},Set.fromKeys=function(s){return this(KeyedIterable(s).keySeq())},Set.prototype.toString=function(){return this.__toString(\"Set {\",\"}\")},Set.prototype.has=function(s){return this._map.has(s)},Set.prototype.add=function(s){return updateSet(this,this._map.set(s,!0))},Set.prototype.remove=function(s){return updateSet(this,this._map.remove(s))},Set.prototype.clear=function(){return updateSet(this,this._map.clear())},Set.prototype.union=function(){var o=s.call(arguments,0);return 0===(o=o.filter((function(s){return 0!==s.size}))).length?this:0!==this.size||this.__ownerID||1!==o.length?this.withMutations((function(s){for(var i=0;i<o.length;i++)SetIterable(o[i]).forEach((function(o){return s.add(o)}))})):this.constructor(o[0])},Set.prototype.intersect=function(){var o=s.call(arguments,0);if(0===o.length)return this;o=o.map((function(s){return SetIterable(s)}));var i=this;return this.withMutations((function(s){i.forEach((function(i){o.every((function(s){return s.includes(i)}))||s.remove(i)}))}))},Set.prototype.subtract=function(){var o=s.call(arguments,0);if(0===o.length)return this;o=o.map((function(s){return SetIterable(s)}));var i=this;return this.withMutations((function(s){i.forEach((function(i){o.some((function(s){return s.includes(i)}))&&s.remove(i)}))}))},Set.prototype.merge=function(){return this.union.apply(this,arguments)},Set.prototype.mergeWith=function(o){var i=s.call(arguments,1);return this.union.apply(this,i)},Set.prototype.sort=function(s){return OrderedSet(sortFactory(this,s))},Set.prototype.sortBy=function(s,o){return OrderedSet(sortFactory(this,o,s))},Set.prototype.wasAltered=function(){return this._map.wasAltered()},Set.prototype.__iterate=function(s,o){var i=this;return this._map.__iterate((function(o,a){return s(a,a,i)}),o)},Set.prototype.__iterator=function(s,o){return this._map.map((function(s,o){return o})).__iterator(s,o)},Set.prototype.__ensureOwner=function(s){if(s===this.__ownerID)return this;var o=this._map.__ensureOwner(s);return s?this.__make(o,s):(this.__ownerID=s,this._map=o,this)},Set.isSet=isSet;var rt,nt=\"@@__IMMUTABLE_SET__@@\",st=Set.prototype;function updateSet(s,o){return s.__ownerID?(s.size=o.size,s._map=o,s):o===s._map?s:0===o.size?s.__empty():s.__make(o)}function makeSet(s,o){var i=Object.create(st);return i.size=s?s.size:0,i._map=s,i.__ownerID=o,i}function emptySet(){return rt||(rt=makeSet(emptyMap()))}function OrderedSet(s){return null==s?emptyOrderedSet():isOrderedSet(s)?s:emptyOrderedSet().withMutations((function(o){var i=SetIterable(s);assertNotInfinite(i.size),i.forEach((function(s){return o.add(s)}))}))}function isOrderedSet(s){return isSet(s)&&isOrdered(s)}st[nt]=!0,st[_]=st.remove,st.mergeDeep=st.merge,st.mergeDeepWith=st.mergeWith,st.withMutations=$e.withMutations,st.asMutable=$e.asMutable,st.asImmutable=$e.asImmutable,st.__empty=emptySet,st.__make=makeSet,createClass(OrderedSet,Set),OrderedSet.of=function(){return this(arguments)},OrderedSet.fromKeys=function(s){return this(KeyedIterable(s).keySeq())},OrderedSet.prototype.toString=function(){return this.__toString(\"OrderedSet {\",\"}\")},OrderedSet.isOrderedSet=isOrderedSet;var ot,it=OrderedSet.prototype;function makeOrderedSet(s,o){var i=Object.create(it);return i.size=s?s.size:0,i._map=s,i.__ownerID=o,i}function emptyOrderedSet(){return ot||(ot=makeOrderedSet(emptyOrderedMap()))}function Stack(s){return null==s?emptyStack():isStack(s)?s:emptyStack().unshiftAll(s)}function isStack(s){return!(!s||!s[ct])}it[u]=!0,it.__empty=emptyOrderedSet,it.__make=makeOrderedSet,createClass(Stack,IndexedCollection),Stack.of=function(){return this(arguments)},Stack.prototype.toString=function(){return this.__toString(\"Stack [\",\"]\")},Stack.prototype.get=function(s,o){var i=this._head;for(s=wrapIndex(this,s);i&&s--;)i=i.next;return i?i.value:o},Stack.prototype.peek=function(){return this._head&&this._head.value},Stack.prototype.push=function(){if(0===arguments.length)return this;for(var s=this.size+arguments.length,o=this._head,i=arguments.length-1;i>=0;i--)o={value:arguments[i],next:o};return this.__ownerID?(this.size=s,this._head=o,this.__hash=void 0,this.__altered=!0,this):makeStack(s,o)},Stack.prototype.pushAll=function(s){if(0===(s=IndexedIterable(s)).size)return this;assertNotInfinite(s.size);var o=this.size,i=this._head;return s.reverse().forEach((function(s){o++,i={value:s,next:i}})),this.__ownerID?(this.size=o,this._head=i,this.__hash=void 0,this.__altered=!0,this):makeStack(o,i)},Stack.prototype.pop=function(){return this.slice(1)},Stack.prototype.unshift=function(){return this.push.apply(this,arguments)},Stack.prototype.unshiftAll=function(s){return this.pushAll(s)},Stack.prototype.shift=function(){return this.pop.apply(this,arguments)},Stack.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):emptyStack()},Stack.prototype.slice=function(s,o){if(wholeSlice(s,o,this.size))return this;var i=resolveBegin(s,this.size);if(resolveEnd(o,this.size)!==this.size)return IndexedCollection.prototype.slice.call(this,s,o);for(var a=this.size-i,u=this._head;i--;)u=u.next;return this.__ownerID?(this.size=a,this._head=u,this.__hash=void 0,this.__altered=!0,this):makeStack(a,u)},Stack.prototype.__ensureOwner=function(s){return s===this.__ownerID?this:s?makeStack(this.size,this._head,s,this.__hash):(this.__ownerID=s,this.__altered=!1,this)},Stack.prototype.__iterate=function(s,o){if(o)return this.reverse().__iterate(s);for(var i=0,a=this._head;a&&!1!==s(a.value,i++,this);)a=a.next;return i},Stack.prototype.__iterator=function(s,o){if(o)return this.reverse().__iterator(s);var i=0,a=this._head;return new Iterator((function(){if(a){var o=a.value;return a=a.next,iteratorValue(s,i++,o)}return iteratorDone()}))},Stack.isStack=isStack;var at,ct=\"@@__IMMUTABLE_STACK__@@\",lt=Stack.prototype;function makeStack(s,o,i,a){var u=Object.create(lt);return u.size=s,u._head=o,u.__ownerID=i,u.__hash=a,u.__altered=!1,u}function emptyStack(){return at||(at=makeStack(0))}function mixin(s,o){var keyCopier=function(i){s.prototype[i]=o[i]};return Object.keys(o).forEach(keyCopier),Object.getOwnPropertySymbols&&Object.getOwnPropertySymbols(o).forEach(keyCopier),s}lt[ct]=!0,lt.withMutations=$e.withMutations,lt.asMutable=$e.asMutable,lt.asImmutable=$e.asImmutable,lt.wasAltered=$e.wasAltered,Iterable.Iterator=Iterator,mixin(Iterable,{toArray:function(){assertNotInfinite(this.size);var s=new Array(this.size||0);return this.valueSeq().__iterate((function(o,i){s[i]=o})),s},toIndexedSeq:function(){return new ToIndexedSequence(this)},toJS:function(){return this.toSeq().map((function(s){return s&&\"function\"==typeof s.toJS?s.toJS():s})).__toJS()},toJSON:function(){return this.toSeq().map((function(s){return s&&\"function\"==typeof s.toJSON?s.toJSON():s})).__toJS()},toKeyedSeq:function(){return new ToKeyedSequence(this,!0)},toMap:function(){return Map(this.toKeyedSeq())},toObject:function(){assertNotInfinite(this.size);var s={};return this.__iterate((function(o,i){s[i]=o})),s},toOrderedMap:function(){return OrderedMap(this.toKeyedSeq())},toOrderedSet:function(){return OrderedSet(isKeyed(this)?this.valueSeq():this)},toSet:function(){return Set(isKeyed(this)?this.valueSeq():this)},toSetSeq:function(){return new ToSetSequence(this)},toSeq:function(){return isIndexed(this)?this.toIndexedSeq():isKeyed(this)?this.toKeyedSeq():this.toSetSeq()},toStack:function(){return Stack(isKeyed(this)?this.valueSeq():this)},toList:function(){return List(isKeyed(this)?this.valueSeq():this)},toString:function(){return\"[Iterable]\"},__toString:function(s,o){return 0===this.size?s+o:s+\" \"+this.toSeq().map(this.__toStringMapper).join(\", \")+\" \"+o},concat:function(){return reify(this,concatFactory(this,s.call(arguments,0)))},includes:function(s){return this.some((function(o){return is(o,s)}))},entries:function(){return this.__iterator(V)},every:function(s,o){assertNotInfinite(this.size);var i=!0;return this.__iterate((function(a,u,_){if(!s.call(o,a,u,_))return i=!1,!1})),i},filter:function(s,o){return reify(this,filterFactory(this,s,o,!0))},find:function(s,o,i){var a=this.findEntry(s,o);return a?a[1]:i},forEach:function(s,o){return assertNotInfinite(this.size),this.__iterate(o?s.bind(o):s)},join:function(s){assertNotInfinite(this.size),s=void 0!==s?\"\"+s:\",\";var o=\"\",i=!0;return this.__iterate((function(a){i?i=!1:o+=s,o+=null!=a?a.toString():\"\"})),o},keys:function(){return this.__iterator($)},map:function(s,o){return reify(this,mapFactory(this,s,o))},reduce:function(s,o,i){var a,u;return assertNotInfinite(this.size),arguments.length<2?u=!0:a=o,this.__iterate((function(o,_,w){u?(u=!1,a=o):a=s.call(i,a,o,_,w)})),a},reduceRight:function(s,o,i){var a=this.toKeyedSeq().reverse();return a.reduce.apply(a,arguments)},reverse:function(){return reify(this,reverseFactory(this,!0))},slice:function(s,o){return reify(this,sliceFactory(this,s,o,!0))},some:function(s,o){return!this.every(not(s),o)},sort:function(s){return reify(this,sortFactory(this,s))},values:function(){return this.__iterator(U)},butLast:function(){return this.slice(0,-1)},isEmpty:function(){return void 0!==this.size?0===this.size:!this.some((function(){return!0}))},count:function(s,o){return ensureSize(s?this.toSeq().filter(s,o):this)},countBy:function(s,o){return countByFactory(this,s,o)},equals:function(s){return deepEqual(this,s)},entrySeq:function(){var s=this;if(s._cache)return new ArraySeq(s._cache);var o=s.toSeq().map(entryMapper).toIndexedSeq();return o.fromEntrySeq=function(){return s.toSeq()},o},filterNot:function(s,o){return this.filter(not(s),o)},findEntry:function(s,o,i){var a=i;return this.__iterate((function(i,u,_){if(s.call(o,i,u,_))return a=[u,i],!1})),a},findKey:function(s,o){var i=this.findEntry(s,o);return i&&i[0]},findLast:function(s,o,i){return this.toKeyedSeq().reverse().find(s,o,i)},findLastEntry:function(s,o,i){return this.toKeyedSeq().reverse().findEntry(s,o,i)},findLastKey:function(s,o){return this.toKeyedSeq().reverse().findKey(s,o)},first:function(){return this.find(returnTrue)},flatMap:function(s,o){return reify(this,flatMapFactory(this,s,o))},flatten:function(s){return reify(this,flattenFactory(this,s,!0))},fromEntrySeq:function(){return new FromEntriesSequence(this)},get:function(s,o){return this.find((function(o,i){return is(i,s)}),void 0,o)},getIn:function(s,o){for(var i,a=this,u=forceIterator(s);!(i=u.next()).done;){var _=i.value;if((a=a&&a.get?a.get(_,j):j)===j)return o}return a},groupBy:function(s,o){return groupByFactory(this,s,o)},has:function(s){return this.get(s,j)!==j},hasIn:function(s){return this.getIn(s,j)!==j},isSubset:function(s){return s=\"function\"==typeof s.includes?s:Iterable(s),this.every((function(o){return s.includes(o)}))},isSuperset:function(s){return(s=\"function\"==typeof s.isSubset?s:Iterable(s)).isSubset(this)},keyOf:function(s){return this.findKey((function(o){return is(o,s)}))},keySeq:function(){return this.toSeq().map(keyMapper).toIndexedSeq()},last:function(){return this.toSeq().reverse().first()},lastKeyOf:function(s){return this.toKeyedSeq().reverse().keyOf(s)},max:function(s){return maxFactory(this,s)},maxBy:function(s,o){return maxFactory(this,o,s)},min:function(s){return maxFactory(this,s?neg(s):defaultNegComparator)},minBy:function(s,o){return maxFactory(this,o?neg(o):defaultNegComparator,s)},rest:function(){return this.slice(1)},skip:function(s){return this.slice(Math.max(0,s))},skipLast:function(s){return reify(this,this.toSeq().reverse().skip(s).reverse())},skipWhile:function(s,o){return reify(this,skipWhileFactory(this,s,o,!0))},skipUntil:function(s,o){return this.skipWhile(not(s),o)},sortBy:function(s,o){return reify(this,sortFactory(this,o,s))},take:function(s){return this.slice(0,Math.max(0,s))},takeLast:function(s){return reify(this,this.toSeq().reverse().take(s).reverse())},takeWhile:function(s,o){return reify(this,takeWhileFactory(this,s,o))},takeUntil:function(s,o){return this.takeWhile(not(s),o)},valueSeq:function(){return this.toIndexedSeq()},hashCode:function(){return this.__hash||(this.__hash=hashIterable(this))}});var ut=Iterable.prototype;ut[o]=!0,ut[Z]=ut.values,ut.__toJS=ut.toArray,ut.__toStringMapper=quoteString,ut.inspect=ut.toSource=function(){return this.toString()},ut.chain=ut.flatMap,ut.contains=ut.includes,mixin(KeyedIterable,{flip:function(){return reify(this,flipFactory(this))},mapEntries:function(s,o){var i=this,a=0;return reify(this,this.toSeq().map((function(u,_){return s.call(o,[_,u],a++,i)})).fromEntrySeq())},mapKeys:function(s,o){var i=this;return reify(this,this.toSeq().flip().map((function(a,u){return s.call(o,a,u,i)})).flip())}});var pt=KeyedIterable.prototype;function keyMapper(s,o){return o}function entryMapper(s,o){return[o,s]}function not(s){return function(){return!s.apply(this,arguments)}}function neg(s){return function(){return-s.apply(this,arguments)}}function quoteString(s){return\"string\"==typeof s?JSON.stringify(s):String(s)}function defaultZipper(){return arrCopy(arguments)}function defaultNegComparator(s,o){return s<o?1:s>o?-1:0}function hashIterable(s){if(s.size===1/0)return 0;var o=isOrdered(s),i=isKeyed(s),a=o?1:0;return murmurHashOfSize(s.__iterate(i?o?function(s,o){a=31*a+hashMerge(hash(s),hash(o))|0}:function(s,o){a=a+hashMerge(hash(s),hash(o))|0}:o?function(s){a=31*a+hash(s)|0}:function(s){a=a+hash(s)|0}),a)}function murmurHashOfSize(s,o){return o=le(o,3432918353),o=le(o<<15|o>>>-15,461845907),o=le(o<<13|o>>>-13,5),o=le((o=o+3864292196^s)^o>>>16,2246822507),o=smi((o=le(o^o>>>13,3266489909))^o>>>16)}function hashMerge(s,o){return s^o+2654435769+(s<<6)+(s>>2)}return pt[i]=!0,pt[Z]=ut.entries,pt.__toJS=ut.toObject,pt.__toStringMapper=function(s,o){return JSON.stringify(o)+\": \"+quoteString(s)},mixin(IndexedIterable,{toKeyedSeq:function(){return new ToKeyedSequence(this,!1)},filter:function(s,o){return reify(this,filterFactory(this,s,o,!1))},findIndex:function(s,o){var i=this.findEntry(s,o);return i?i[0]:-1},indexOf:function(s){var o=this.keyOf(s);return void 0===o?-1:o},lastIndexOf:function(s){var o=this.lastKeyOf(s);return void 0===o?-1:o},reverse:function(){return reify(this,reverseFactory(this,!1))},slice:function(s,o){return reify(this,sliceFactory(this,s,o,!1))},splice:function(s,o){var i=arguments.length;if(o=Math.max(0|o,0),0===i||2===i&&!o)return this;s=resolveBegin(s,s<0?this.count():this.size);var a=this.slice(0,s);return reify(this,1===i?a:a.concat(arrCopy(arguments,2),this.slice(s+o)))},findLastIndex:function(s,o){var i=this.findLastEntry(s,o);return i?i[0]:-1},first:function(){return this.get(0)},flatten:function(s){return reify(this,flattenFactory(this,s,!1))},get:function(s,o){return(s=wrapIndex(this,s))<0||this.size===1/0||void 0!==this.size&&s>this.size?o:this.find((function(o,i){return i===s}),void 0,o)},has:function(s){return(s=wrapIndex(this,s))>=0&&(void 0!==this.size?this.size===1/0||s<this.size:-1!==this.indexOf(s))},interpose:function(s){return reify(this,interposeFactory(this,s))},interleave:function(){var s=[this].concat(arrCopy(arguments)),o=zipWithFactory(this.toSeq(),IndexedSeq.of,s),i=o.flatten(!0);return o.size&&(i.size=o.size*s.length),reify(this,i)},keySeq:function(){return Range(0,this.size)},last:function(){return this.get(-1)},skipWhile:function(s,o){return reify(this,skipWhileFactory(this,s,o,!1))},zip:function(){return reify(this,zipWithFactory(this,defaultZipper,[this].concat(arrCopy(arguments))))},zipWith:function(s){var o=arrCopy(arguments);return o[0]=this,reify(this,zipWithFactory(this,s,o))}}),IndexedIterable.prototype[a]=!0,IndexedIterable.prototype[u]=!0,mixin(SetIterable,{get:function(s,o){return this.has(s)?s:o},includes:function(s){return this.has(s)},keySeq:function(){return this.valueSeq()}}),SetIterable.prototype.has=ut.includes,SetIterable.prototype.contains=SetIterable.prototype.includes,mixin(KeyedSeq,KeyedIterable.prototype),mixin(IndexedSeq,IndexedIterable.prototype),mixin(SetSeq,SetIterable.prototype),mixin(KeyedCollection,KeyedIterable.prototype),mixin(IndexedCollection,IndexedIterable.prototype),mixin(SetCollection,SetIterable.prototype),{Iterable,Seq,Collection,Map,OrderedMap,List,Stack,Set,OrderedSet,Record,Range,Repeat,is,fromJS}}()},9748:(s,o,i)=>{\"use strict\";i(71340);var a=i(92046);s.exports=a.Object.assign},9957:(s,o,i)=>{\"use strict\";var a=Function.prototype.call,u=Object.prototype.hasOwnProperty,_=i(66743);s.exports=_.call(a,u)},9999:(s,o,i)=>{var a=i(37217),u=i(83729),_=i(16547),w=i(74733),x=i(43838),C=i(93290),j=i(23007),L=i(92271),B=i(48948),$=i(50002),U=i(83349),V=i(5861),z=i(76189),Y=i(77199),Z=i(35529),ee=i(56449),ie=i(3656),ae=i(87730),ce=i(23805),le=i(38440),pe=i(95950),de=i(37241),fe=\"[object Arguments]\",ye=\"[object Function]\",be=\"[object Object]\",_e={};_e[fe]=_e[\"[object Array]\"]=_e[\"[object ArrayBuffer]\"]=_e[\"[object DataView]\"]=_e[\"[object Boolean]\"]=_e[\"[object Date]\"]=_e[\"[object Float32Array]\"]=_e[\"[object Float64Array]\"]=_e[\"[object Int8Array]\"]=_e[\"[object Int16Array]\"]=_e[\"[object Int32Array]\"]=_e[\"[object Map]\"]=_e[\"[object Number]\"]=_e[be]=_e[\"[object RegExp]\"]=_e[\"[object Set]\"]=_e[\"[object String]\"]=_e[\"[object Symbol]\"]=_e[\"[object Uint8Array]\"]=_e[\"[object Uint8ClampedArray]\"]=_e[\"[object Uint16Array]\"]=_e[\"[object Uint32Array]\"]=!0,_e[\"[object Error]\"]=_e[ye]=_e[\"[object WeakMap]\"]=!1,s.exports=function baseClone(s,o,i,Se,we,xe){var Pe,Te=1&o,Re=2&o,$e=4&o;if(i&&(Pe=we?i(s,Se,we,xe):i(s)),void 0!==Pe)return Pe;if(!ce(s))return s;var qe=ee(s);if(qe){if(Pe=z(s),!Te)return j(s,Pe)}else{var ze=V(s),We=ze==ye||\"[object GeneratorFunction]\"==ze;if(ie(s))return C(s,Te);if(ze==be||ze==fe||We&&!we){if(Pe=Re||We?{}:Z(s),!Te)return Re?B(s,x(Pe,s)):L(s,w(Pe,s))}else{if(!_e[ze])return we?s:{};Pe=Y(s,ze,Te)}}xe||(xe=new a);var He=xe.get(s);if(He)return He;xe.set(s,Pe),le(s)?s.forEach((function(a){Pe.add(baseClone(a,o,i,a,s,xe))})):ae(s)&&s.forEach((function(a,u){Pe.set(u,baseClone(a,o,i,u,s,xe))}));var Ye=qe?void 0:($e?Re?U:$:Re?de:pe)(s);return u(Ye||s,(function(a,u){Ye&&(a=s[u=a]),_(Pe,u,baseClone(a,o,i,u,s,xe))})),Pe}},10023:(s,o,i)=>{const a=i(6205),INTS=()=>[{type:a.RANGE,from:48,to:57}],WORDS=()=>[{type:a.CHAR,value:95},{type:a.RANGE,from:97,to:122},{type:a.RANGE,from:65,to:90}].concat(INTS()),WHITESPACE=()=>[{type:a.CHAR,value:9},{type:a.CHAR,value:10},{type:a.CHAR,value:11},{type:a.CHAR,value:12},{type:a.CHAR,value:13},{type:a.CHAR,value:32},{type:a.CHAR,value:160},{type:a.CHAR,value:5760},{type:a.RANGE,from:8192,to:8202},{type:a.CHAR,value:8232},{type:a.CHAR,value:8233},{type:a.CHAR,value:8239},{type:a.CHAR,value:8287},{type:a.CHAR,value:12288},{type:a.CHAR,value:65279}];o.words=()=>({type:a.SET,set:WORDS(),not:!1}),o.notWords=()=>({type:a.SET,set:WORDS(),not:!0}),o.ints=()=>({type:a.SET,set:INTS(),not:!1}),o.notInts=()=>({type:a.SET,set:INTS(),not:!0}),o.whitespace=()=>({type:a.SET,set:WHITESPACE(),not:!1}),o.notWhitespace=()=>({type:a.SET,set:WHITESPACE(),not:!0}),o.anyChar=()=>({type:a.SET,set:[{type:a.CHAR,value:10},{type:a.CHAR,value:13},{type:a.CHAR,value:8232},{type:a.CHAR,value:8233}],not:!0})},10043:(s,o,i)=>{\"use strict\";var a=i(54018),u=String,_=TypeError;s.exports=function(s){if(a(s))return s;throw new _(\"Can't set \"+u(s)+\" as a prototype\")}},10076:s=>{\"use strict\";s.exports=Function.prototype.call},10124:(s,o,i)=>{var a=i(9325);s.exports=function(){return a.Date.now()}},10300:(s,o,i)=>{\"use strict\";var a=i(13930),u=i(82159),_=i(36624),w=i(4640),x=i(73448),C=TypeError;s.exports=function(s,o){var i=arguments.length<2?x(s):o;if(u(i))return _(a(i,s));throw new C(w(s)+\" is not iterable\")}},10316:(s,o,i)=>{const a=i(2404),u=i(55973),_=i(92340);class Element{constructor(s,o,i){o&&(this.meta=o),i&&(this.attributes=i),this.content=s}freeze(){Object.isFrozen(this)||(this._meta&&(this.meta.parent=this,this.meta.freeze()),this._attributes&&(this.attributes.parent=this,this.attributes.freeze()),this.children.forEach((s=>{s.parent=this,s.freeze()}),this),this.content&&Array.isArray(this.content)&&Object.freeze(this.content),Object.freeze(this))}primitive(){}clone(){const s=new this.constructor;return s.element=this.element,this.meta.length&&(s._meta=this.meta.clone()),this.attributes.length&&(s._attributes=this.attributes.clone()),this.content?this.content.clone?s.content=this.content.clone():Array.isArray(this.content)?s.content=this.content.map((s=>s.clone())):s.content=this.content:s.content=this.content,s}toValue(){return this.content instanceof Element?this.content.toValue():this.content instanceof u?{key:this.content.key.toValue(),value:this.content.value?this.content.value.toValue():void 0}:this.content&&this.content.map?this.content.map((s=>s.toValue()),this):this.content}toRef(s){if(\"\"===this.id.toValue())throw Error(\"Cannot create reference to an element that does not contain an ID\");const o=new this.RefElement(this.id.toValue());return s&&(o.path=s),o}findRecursive(...s){if(arguments.length>1&&!this.isFrozen)throw new Error(\"Cannot find recursive with multiple element names without first freezing the element. Call `element.freeze()`\");const o=s.pop();let i=new _;const append=(s,o)=>(s.push(o),s),checkElement=(s,i)=>{i.element===o&&s.push(i);const a=i.findRecursive(o);return a&&a.reduce(append,s),i.content instanceof u&&(i.content.key&&checkElement(s,i.content.key),i.content.value&&checkElement(s,i.content.value)),s};return this.content&&(this.content.element&&checkElement(i,this.content),Array.isArray(this.content)&&this.content.reduce(checkElement,i)),s.isEmpty||(i=i.filter((o=>{let i=o.parents.map((s=>s.element));for(const o in s){const a=s[o],u=i.indexOf(a);if(-1===u)return!1;i=i.splice(0,u)}return!0}))),i}set(s){return this.content=s,this}equals(s){return a(this.toValue(),s)}getMetaProperty(s,o){if(!this.meta.hasKey(s)){if(this.isFrozen){const s=this.refract(o);return s.freeze(),s}this.meta.set(s,o)}return this.meta.get(s)}setMetaProperty(s,o){this.meta.set(s,o)}get element(){return this._storedElement||\"element\"}set element(s){this._storedElement=s}get content(){return this._content}set content(s){if(s instanceof Element)this._content=s;else if(s instanceof _)this.content=s.elements;else if(\"string\"==typeof s||\"number\"==typeof s||\"boolean\"==typeof s||\"null\"===s||null==s)this._content=s;else if(s instanceof u)this._content=s;else if(Array.isArray(s))this._content=s.map(this.refract);else{if(\"object\"!=typeof s)throw new Error(\"Cannot set content to given value\");this._content=Object.keys(s).map((o=>new this.MemberElement(o,s[o])))}}get meta(){if(!this._meta){if(this.isFrozen){const s=new this.ObjectElement;return s.freeze(),s}this._meta=new this.ObjectElement}return this._meta}set meta(s){s instanceof this.ObjectElement?this._meta=s:this.meta.set(s||{})}get attributes(){if(!this._attributes){if(this.isFrozen){const s=new this.ObjectElement;return s.freeze(),s}this._attributes=new this.ObjectElement}return this._attributes}set attributes(s){s instanceof this.ObjectElement?this._attributes=s:this.attributes.set(s||{})}get id(){return this.getMetaProperty(\"id\",\"\")}set id(s){this.setMetaProperty(\"id\",s)}get classes(){return this.getMetaProperty(\"classes\",[])}set classes(s){this.setMetaProperty(\"classes\",s)}get title(){return this.getMetaProperty(\"title\",\"\")}set title(s){this.setMetaProperty(\"title\",s)}get description(){return this.getMetaProperty(\"description\",\"\")}set description(s){this.setMetaProperty(\"description\",s)}get links(){return this.getMetaProperty(\"links\",[])}set links(s){this.setMetaProperty(\"links\",s)}get isFrozen(){return Object.isFrozen(this)}get parents(){let{parent:s}=this;const o=new _;for(;s;)o.push(s),s=s.parent;return o}get children(){if(Array.isArray(this.content))return new _(this.content);if(this.content instanceof u){const s=new _([this.content.key]);return this.content.value&&s.push(this.content.value),s}return this.content instanceof Element?new _([this.content]):new _}get recursiveChildren(){const s=new _;return this.children.forEach((o=>{s.push(o),o.recursiveChildren.forEach((o=>{s.push(o)}))})),s}}s.exports=Element},10392:s=>{s.exports=function getValue(s,o){return null==s?void 0:s[o]}},10487:(s,o,i)=>{\"use strict\";var a=i(96897),u=i(30655),_=i(73126),w=i(12205);s.exports=function callBind(s){var o=_(arguments),i=s.length-(arguments.length-1);return a(o,1+(i>0?i:0),!0)},u?u(s.exports,\"apply\",{value:w}):s.exports.apply=w},10776:(s,o,i)=>{var a=i(30756),u=i(95950);s.exports=function getMatchData(s){for(var o=u(s),i=o.length;i--;){var _=o[i],w=s[_];o[i]=[_,w,a(w)]}return o}},10866:(s,o,i)=>{const a=i(6048),u=i(92340);class ObjectSlice extends u{map(s,o){return this.elements.map((i=>s.bind(o)(i.value,i.key,i)))}filter(s,o){return new ObjectSlice(this.elements.filter((i=>s.bind(o)(i.value,i.key,i))))}reject(s,o){return this.filter(a(s.bind(o)))}forEach(s,o){return this.elements.forEach(((i,a)=>{s.bind(o)(i.value,i.key,i,a)}))}keys(){return this.map(((s,o)=>o.toValue()))}values(){return this.map((s=>s.toValue()))}}s.exports=ObjectSlice},11002:s=>{\"use strict\";s.exports=Function.prototype.apply},11042:(s,o,i)=>{\"use strict\";var a=i(85582),u=i(1907),_=i(24443),w=i(87170),x=i(36624),C=u([].concat);s.exports=a(\"Reflect\",\"ownKeys\")||function ownKeys(s){var o=_.f(x(s)),i=w.f;return i?C(o,i(s)):o}},11091:(s,o,i)=>{\"use strict\";var a=i(45951),u=i(76024),_=i(92361),w=i(62250),x=i(13846).f,C=i(7463),j=i(92046),L=i(28311),B=i(61626),$=i(49724);i(36128);var wrapConstructor=function(s){var Wrapper=function(o,i,a){if(this instanceof Wrapper){switch(arguments.length){case 0:return new s;case 1:return new s(o);case 2:return new s(o,i)}return new s(o,i,a)}return u(s,this,arguments)};return Wrapper.prototype=s.prototype,Wrapper};s.exports=function(s,o){var i,u,U,V,z,Y,Z,ee,ie,ae=s.target,ce=s.global,le=s.stat,pe=s.proto,de=ce?a:le?a[ae]:a[ae]&&a[ae].prototype,fe=ce?j:j[ae]||B(j,ae,{})[ae],ye=fe.prototype;for(V in o)u=!(i=C(ce?V:ae+(le?\".\":\"#\")+V,s.forced))&&de&&$(de,V),Y=fe[V],u&&(Z=s.dontCallGetSet?(ie=x(de,V))&&ie.value:de[V]),z=u&&Z?Z:o[V],(i||pe||typeof Y!=typeof z)&&(ee=s.bind&&u?L(z,a):s.wrap&&u?wrapConstructor(z):pe&&w(z)?_(z):z,(s.sham||z&&z.sham||Y&&Y.sham)&&B(ee,\"sham\",!0),B(fe,V,ee),pe&&($(j,U=ae+\"Prototype\")||B(j,U,{}),B(j[U],V,z),s.real&&ye&&(i||!ye[V])&&B(ye,V,z)))}},11287:s=>{s.exports=function getHolder(s){return s.placeholder}},11331:(s,o,i)=>{var a=i(72552),u=i(28879),_=i(40346),w=Function.prototype,x=Object.prototype,C=w.toString,j=x.hasOwnProperty,L=C.call(Object);s.exports=function isPlainObject(s){if(!_(s)||\"[object Object]\"!=a(s))return!1;var o=u(s);if(null===o)return!0;var i=j.call(o,\"constructor\")&&o.constructor;return\"function\"==typeof i&&i instanceof i&&C.call(i)==L}},11470:(s,o,i)=>{\"use strict\";var a=i(1907),u=i(65482),_=i(90160),w=i(74239),x=a(\"\".charAt),C=a(\"\".charCodeAt),j=a(\"\".slice),createMethod=function(s){return function(o,i){var a,L,B=_(w(o)),$=u(i),U=B.length;return $<0||$>=U?s?\"\":void 0:(a=C(B,$))<55296||a>56319||$+1===U||(L=C(B,$+1))<56320||L>57343?s?x(B,$):a:s?j(B,$,$+2):L-56320+(a-55296<<10)+65536}};s.exports={codeAt:createMethod(!1),charAt:createMethod(!0)}},11842:(s,o,i)=>{var a=i(82819),u=i(9325);s.exports=function createBind(s,o,i){var _=1&o,w=a(s);return function wrapper(){return(this&&this!==u&&this instanceof wrapper?w:s).apply(_?i:this,arguments)}}},12205:(s,o,i)=>{\"use strict\";var a=i(66743),u=i(11002),_=i(13144);s.exports=function applyBind(){return _(a,u,arguments)}},12242:(s,o,i)=>{const a=i(10316);s.exports=class BooleanElement extends a{constructor(s,o,i){super(s,o,i),this.element=\"boolean\"}primitive(){return\"boolean\"}}},12507:(s,o,i)=>{var a=i(28754),u=i(49698),_=i(63912),w=i(13222);s.exports=function createCaseFirst(s){return function(o){o=w(o);var i=u(o)?_(o):void 0,x=i?i[0]:o.charAt(0),C=i?a(i,1).join(\"\"):o.slice(1);return x[s]()+C}}},12560:(s,o,i)=>{\"use strict\";i(99363);var a=i(19287),u=i(45951),_=i(14840),w=i(93742);for(var x in a)_(u[x],x),w[x]=w.Array},12651:(s,o,i)=>{var a=i(74218);s.exports=function getMapData(s,o){var i=s.__data__;return a(o)?i[\"string\"==typeof o?\"string\":\"hash\"]:i.map}},12749:(s,o,i)=>{var a=i(81042),u=Object.prototype.hasOwnProperty;s.exports=function hashHas(s){var o=this.__data__;return a?void 0!==o[s]:u.call(o,s)}},13144:(s,o,i)=>{\"use strict\";var a=i(66743),u=i(11002),_=i(10076),w=i(47119);s.exports=w||a.call(_,u)},13222:(s,o,i)=>{var a=i(77556);s.exports=function toString(s){return null==s?\"\":a(s)}},13846:(s,o,i)=>{\"use strict\";var a=i(39447),u=i(13930),_=i(22574),w=i(75817),x=i(4993),C=i(70470),j=i(49724),L=i(73648),B=Object.getOwnPropertyDescriptor;o.f=a?B:function getOwnPropertyDescriptor(s,o){if(s=x(s),o=C(o),L)try{return B(s,o)}catch(s){}if(j(s,o))return w(!u(_.f,s,o),s[o])}},13930:(s,o,i)=>{\"use strict\";var a=i(41505),u=Function.prototype.call;s.exports=a?u.bind(u):function(){return u.apply(u,arguments)}},14248:s=>{s.exports=function arraySome(s,o){for(var i=-1,a=null==s?0:s.length;++i<a;)if(o(s[i],i,s))return!0;return!1}},14528:s=>{s.exports=function arrayPush(s,o){for(var i=-1,a=o.length,u=s.length;++i<a;)s[u+i]=o[i];return s}},14540:(s,o,i)=>{const a=i(10316);s.exports=class RefElement extends a{constructor(s,o,i){super(s||[],o,i),this.element=\"ref\",this.path||(this.path=\"element\")}get path(){return this.attributes.get(\"path\")}set path(s){this.attributes.set(\"path\",s)}}},14744:s=>{\"use strict\";var o=function isMergeableObject(s){return function isNonNullObject(s){return!!s&&\"object\"==typeof s}(s)&&!function isSpecial(s){var o=Object.prototype.toString.call(s);return\"[object RegExp]\"===o||\"[object Date]\"===o||function isReactElement(s){return s.$$typeof===i}(s)}(s)};var i=\"function\"==typeof Symbol&&Symbol.for?Symbol.for(\"react.element\"):60103;function cloneUnlessOtherwiseSpecified(s,o){return!1!==o.clone&&o.isMergeableObject(s)?deepmerge(function emptyTarget(s){return Array.isArray(s)?[]:{}}(s),s,o):s}function defaultArrayMerge(s,o,i){return s.concat(o).map((function(s){return cloneUnlessOtherwiseSpecified(s,i)}))}function getKeys(s){return Object.keys(s).concat(function getEnumerableOwnPropertySymbols(s){return Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(s).filter((function(o){return Object.propertyIsEnumerable.call(s,o)})):[]}(s))}function propertyIsOnObject(s,o){try{return o in s}catch(s){return!1}}function mergeObject(s,o,i){var a={};return i.isMergeableObject(s)&&getKeys(s).forEach((function(o){a[o]=cloneUnlessOtherwiseSpecified(s[o],i)})),getKeys(o).forEach((function(u){(function propertyIsUnsafe(s,o){return propertyIsOnObject(s,o)&&!(Object.hasOwnProperty.call(s,o)&&Object.propertyIsEnumerable.call(s,o))})(s,u)||(propertyIsOnObject(s,u)&&i.isMergeableObject(o[u])?a[u]=function getMergeFunction(s,o){if(!o.customMerge)return deepmerge;var i=o.customMerge(s);return\"function\"==typeof i?i:deepmerge}(u,i)(s[u],o[u],i):a[u]=cloneUnlessOtherwiseSpecified(o[u],i))})),a}function deepmerge(s,i,a){(a=a||{}).arrayMerge=a.arrayMerge||defaultArrayMerge,a.isMergeableObject=a.isMergeableObject||o,a.cloneUnlessOtherwiseSpecified=cloneUnlessOtherwiseSpecified;var u=Array.isArray(i);return u===Array.isArray(s)?u?a.arrayMerge(s,i,a):mergeObject(s,i,a):cloneUnlessOtherwiseSpecified(i,a)}deepmerge.all=function deepmergeAll(s,o){if(!Array.isArray(s))throw new Error(\"first argument should be an array\");return s.reduce((function(s,i){return deepmerge(s,i,o)}),{})};var a=deepmerge;s.exports=a},14792:(s,o,i)=>{var a=i(13222),u=i(55808);s.exports=function capitalize(s){return u(a(s).toLowerCase())}},14840:(s,o,i)=>{\"use strict\";var a=i(52623),u=i(74284).f,_=i(61626),w=i(49724),x=i(54878),C=i(76264)(\"toStringTag\");s.exports=function(s,o,i,j){var L=i?s:s&&s.prototype;L&&(w(L,C)||u(L,C,{configurable:!0,value:o}),j&&!a&&_(L,\"toString\",x))}},14974:s=>{s.exports=function safeGet(s,o){if((\"constructor\"!==o||\"function\"!=typeof s[o])&&\"__proto__\"!=o)return s[o]}},15287:(s,o)=>{\"use strict\";var i=Symbol.for(\"react.element\"),a=Symbol.for(\"react.portal\"),u=Symbol.for(\"react.fragment\"),_=Symbol.for(\"react.strict_mode\"),w=Symbol.for(\"react.profiler\"),x=Symbol.for(\"react.provider\"),C=Symbol.for(\"react.context\"),j=Symbol.for(\"react.forward_ref\"),L=Symbol.for(\"react.suspense\"),B=Symbol.for(\"react.memo\"),$=Symbol.for(\"react.lazy\"),U=Symbol.iterator;var V={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},z=Object.assign,Y={};function E(s,o,i){this.props=s,this.context=o,this.refs=Y,this.updater=i||V}function F(){}function G(s,o,i){this.props=s,this.context=o,this.refs=Y,this.updater=i||V}E.prototype.isReactComponent={},E.prototype.setState=function(s,o){if(\"object\"!=typeof s&&\"function\"!=typeof s&&null!=s)throw Error(\"setState(...): takes an object of state variables to update or a function which returns an object of state variables.\");this.updater.enqueueSetState(this,s,o,\"setState\")},E.prototype.forceUpdate=function(s){this.updater.enqueueForceUpdate(this,s,\"forceUpdate\")},F.prototype=E.prototype;var Z=G.prototype=new F;Z.constructor=G,z(Z,E.prototype),Z.isPureReactComponent=!0;var ee=Array.isArray,ie=Object.prototype.hasOwnProperty,ae={current:null},ce={key:!0,ref:!0,__self:!0,__source:!0};function M(s,o,a){var u,_={},w=null,x=null;if(null!=o)for(u in void 0!==o.ref&&(x=o.ref),void 0!==o.key&&(w=\"\"+o.key),o)ie.call(o,u)&&!ce.hasOwnProperty(u)&&(_[u]=o[u]);var C=arguments.length-2;if(1===C)_.children=a;else if(1<C){for(var j=Array(C),L=0;L<C;L++)j[L]=arguments[L+2];_.children=j}if(s&&s.defaultProps)for(u in C=s.defaultProps)void 0===_[u]&&(_[u]=C[u]);return{$$typeof:i,type:s,key:w,ref:x,props:_,_owner:ae.current}}function O(s){return\"object\"==typeof s&&null!==s&&s.$$typeof===i}var le=/\\/+/g;function Q(s,o){return\"object\"==typeof s&&null!==s&&null!=s.key?function escape(s){var o={\"=\":\"=0\",\":\":\"=2\"};return\"$\"+s.replace(/[=:]/g,(function(s){return o[s]}))}(\"\"+s.key):o.toString(36)}function R(s,o,u,_,w){var x=typeof s;\"undefined\"!==x&&\"boolean\"!==x||(s=null);var C=!1;if(null===s)C=!0;else switch(x){case\"string\":case\"number\":C=!0;break;case\"object\":switch(s.$$typeof){case i:case a:C=!0}}if(C)return w=w(C=s),s=\"\"===_?\".\"+Q(C,0):_,ee(w)?(u=\"\",null!=s&&(u=s.replace(le,\"$&/\")+\"/\"),R(w,o,u,\"\",(function(s){return s}))):null!=w&&(O(w)&&(w=function N(s,o){return{$$typeof:i,type:s.type,key:o,ref:s.ref,props:s.props,_owner:s._owner}}(w,u+(!w.key||C&&C.key===w.key?\"\":(\"\"+w.key).replace(le,\"$&/\")+\"/\")+s)),o.push(w)),1;if(C=0,_=\"\"===_?\".\":_+\":\",ee(s))for(var j=0;j<s.length;j++){var L=_+Q(x=s[j],j);C+=R(x,o,u,L,w)}else if(L=function A(s){return null===s||\"object\"!=typeof s?null:\"function\"==typeof(s=U&&s[U]||s[\"@@iterator\"])?s:null}(s),\"function\"==typeof L)for(s=L.call(s),j=0;!(x=s.next()).done;)C+=R(x=x.value,o,u,L=_+Q(x,j++),w);else if(\"object\"===x)throw o=String(s),Error(\"Objects are not valid as a React child (found: \"+(\"[object Object]\"===o?\"object with keys {\"+Object.keys(s).join(\", \")+\"}\":o)+\"). If you meant to render a collection of children, use an array instead.\");return C}function S(s,o,i){if(null==s)return s;var a=[],u=0;return R(s,a,\"\",\"\",(function(s){return o.call(i,s,u++)})),a}function T(s){if(-1===s._status){var o=s._result;(o=o()).then((function(o){0!==s._status&&-1!==s._status||(s._status=1,s._result=o)}),(function(o){0!==s._status&&-1!==s._status||(s._status=2,s._result=o)})),-1===s._status&&(s._status=0,s._result=o)}if(1===s._status)return s._result.default;throw s._result}var pe={current:null},de={transition:null},fe={ReactCurrentDispatcher:pe,ReactCurrentBatchConfig:de,ReactCurrentOwner:ae};function X(){throw Error(\"act(...) is not supported in production builds of React.\")}o.Children={map:S,forEach:function(s,o,i){S(s,(function(){o.apply(this,arguments)}),i)},count:function(s){var o=0;return S(s,(function(){o++})),o},toArray:function(s){return S(s,(function(s){return s}))||[]},only:function(s){if(!O(s))throw Error(\"React.Children.only expected to receive a single React element child.\");return s}},o.Component=E,o.Fragment=u,o.Profiler=w,o.PureComponent=G,o.StrictMode=_,o.Suspense=L,o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=fe,o.act=X,o.cloneElement=function(s,o,a){if(null==s)throw Error(\"React.cloneElement(...): The argument must be a React element, but you passed \"+s+\".\");var u=z({},s.props),_=s.key,w=s.ref,x=s._owner;if(null!=o){if(void 0!==o.ref&&(w=o.ref,x=ae.current),void 0!==o.key&&(_=\"\"+o.key),s.type&&s.type.defaultProps)var C=s.type.defaultProps;for(j in o)ie.call(o,j)&&!ce.hasOwnProperty(j)&&(u[j]=void 0===o[j]&&void 0!==C?C[j]:o[j])}var j=arguments.length-2;if(1===j)u.children=a;else if(1<j){C=Array(j);for(var L=0;L<j;L++)C[L]=arguments[L+2];u.children=C}return{$$typeof:i,type:s.type,key:_,ref:w,props:u,_owner:x}},o.createContext=function(s){return(s={$$typeof:C,_currentValue:s,_currentValue2:s,_threadCount:0,Provider:null,Consumer:null,_defaultValue:null,_globalName:null}).Provider={$$typeof:x,_context:s},s.Consumer=s},o.createElement=M,o.createFactory=function(s){var o=M.bind(null,s);return o.type=s,o},o.createRef=function(){return{current:null}},o.forwardRef=function(s){return{$$typeof:j,render:s}},o.isValidElement=O,o.lazy=function(s){return{$$typeof:$,_payload:{_status:-1,_result:s},_init:T}},o.memo=function(s,o){return{$$typeof:B,type:s,compare:void 0===o?null:o}},o.startTransition=function(s){var o=de.transition;de.transition={};try{s()}finally{de.transition=o}},o.unstable_act=X,o.useCallback=function(s,o){return pe.current.useCallback(s,o)},o.useContext=function(s){return pe.current.useContext(s)},o.useDebugValue=function(){},o.useDeferredValue=function(s){return pe.current.useDeferredValue(s)},o.useEffect=function(s,o){return pe.current.useEffect(s,o)},o.useId=function(){return pe.current.useId()},o.useImperativeHandle=function(s,o,i){return pe.current.useImperativeHandle(s,o,i)},o.useInsertionEffect=function(s,o){return pe.current.useInsertionEffect(s,o)},o.useLayoutEffect=function(s,o){return pe.current.useLayoutEffect(s,o)},o.useMemo=function(s,o){return pe.current.useMemo(s,o)},o.useReducer=function(s,o,i){return pe.current.useReducer(s,o,i)},o.useRef=function(s){return pe.current.useRef(s)},o.useState=function(s){return pe.current.useState(s)},o.useSyncExternalStore=function(s,o,i){return pe.current.useSyncExternalStore(s,o,i)},o.useTransition=function(){return pe.current.useTransition()},o.version=\"18.3.1\"},15325:(s,o,i)=>{var a=i(96131);s.exports=function arrayIncludes(s,o){return!!(null==s?0:s.length)&&a(s,o,0)>-1}},15340:()=>{},15377:(s,o,i)=>{\"use strict\";var a=i(92861).Buffer,u=i(64634),_=i(74372),w=ArrayBuffer.isView||function isView(s){try{return _(s),!0}catch(s){return!1}},x=\"undefined\"!=typeof Uint8Array,C=\"undefined\"!=typeof ArrayBuffer&&\"undefined\"!=typeof Uint8Array,j=C&&(a.prototype instanceof Uint8Array||a.TYPED_ARRAY_SUPPORT);s.exports=function toBuffer(s,o){if(s instanceof a)return s;if(\"string\"==typeof s)return a.from(s,o);if(C&&w(s)){if(0===s.byteLength)return a.alloc(0);if(j){var i=a.from(s.buffer,s.byteOffset,s.byteLength);if(i.byteLength===s.byteLength)return i}var _=s instanceof Uint8Array?s:new Uint8Array(s.buffer,s.byteOffset,s.byteLength),L=a.from(_);if(L.length===s.byteLength)return L}if(x&&s instanceof Uint8Array)return a.from(s);var B=u(s);if(B)for(var $=0;$<s.length;$+=1){var U=s[$];if(\"number\"!=typeof U||U<0||U>255||~~U!==U)throw new RangeError(\"Array items must be numbers in the range 0-255.\")}if(B||a.isBuffer(s)&&s.constructor&&\"function\"==typeof s.constructor.isBuffer&&s.constructor.isBuffer(s))return a.from(s);throw new TypeError('The \"data\" argument must be a string, an Array, a Buffer, a Uint8Array, or a DataView.')}},15389:(s,o,i)=>{var a=i(93663),u=i(87978),_=i(83488),w=i(56449),x=i(50583);s.exports=function baseIteratee(s){return\"function\"==typeof s?s:null==s?_:\"object\"==typeof s?w(s)?u(s[0],s[1]):a(s):x(s)}},15972:(s,o,i)=>{\"use strict\";var a=i(49724),u=i(62250),_=i(39298),w=i(92522),x=i(57382),C=w(\"IE_PROTO\"),j=Object,L=j.prototype;s.exports=x?j.getPrototypeOf:function(s){var o=_(s);if(a(o,C))return o[C];var i=o.constructor;return u(i)&&o instanceof i?i.prototype:o instanceof j?L:null}},16038:(s,o,i)=>{var a=i(5861),u=i(40346);s.exports=function baseIsSet(s){return u(s)&&\"[object Set]\"==a(s)}},16426:s=>{s.exports=function(){var s=document.getSelection();if(!s.rangeCount)return function(){};for(var o=document.activeElement,i=[],a=0;a<s.rangeCount;a++)i.push(s.getRangeAt(a));switch(o.tagName.toUpperCase()){case\"INPUT\":case\"TEXTAREA\":o.blur();break;default:o=null}return s.removeAllRanges(),function(){\"Caret\"===s.type&&s.removeAllRanges(),s.rangeCount||i.forEach((function(o){s.addRange(o)})),o&&o.focus()}}},16547:(s,o,i)=>{var a=i(43360),u=i(75288),_=Object.prototype.hasOwnProperty;s.exports=function assignValue(s,o,i){var w=s[o];_.call(s,o)&&u(w,i)&&(void 0!==i||o in s)||a(s,o,i)}},16708:(s,o,i)=>{\"use strict\";var a,u=i(65606);function CorkedRequest(s){var o=this;this.next=null,this.entry=null,this.finish=function(){!function onCorkedFinish(s,o,i){var a=s.entry;s.entry=null;for(;a;){var u=a.callback;o.pendingcb--,u(i),a=a.next}o.corkedRequestsFree.next=s}(o,s)}}s.exports=Writable,Writable.WritableState=WritableState;var _={deprecate:i(94643)},w=i(40345),x=i(48287).Buffer,C=(void 0!==i.g?i.g:\"undefined\"!=typeof window?window:\"undefined\"!=typeof self?self:{}).Uint8Array||function(){};var j,L=i(75896),B=i(65291).getHighWaterMark,$=i(86048).F,U=$.ERR_INVALID_ARG_TYPE,V=$.ERR_METHOD_NOT_IMPLEMENTED,z=$.ERR_MULTIPLE_CALLBACK,Y=$.ERR_STREAM_CANNOT_PIPE,Z=$.ERR_STREAM_DESTROYED,ee=$.ERR_STREAM_NULL_VALUES,ie=$.ERR_STREAM_WRITE_AFTER_END,ae=$.ERR_UNKNOWN_ENCODING,ce=L.errorOrDestroy;function nop(){}function WritableState(s,o,_){a=a||i(25382),s=s||{},\"boolean\"!=typeof _&&(_=o instanceof a),this.objectMode=!!s.objectMode,_&&(this.objectMode=this.objectMode||!!s.writableObjectMode),this.highWaterMark=B(this,s,\"writableHighWaterMark\",_),this.finalCalled=!1,this.needDrain=!1,this.ending=!1,this.ended=!1,this.finished=!1,this.destroyed=!1;var w=!1===s.decodeStrings;this.decodeStrings=!w,this.defaultEncoding=s.defaultEncoding||\"utf8\",this.length=0,this.writing=!1,this.corked=0,this.sync=!0,this.bufferProcessing=!1,this.onwrite=function(s){!function onwrite(s,o){var i=s._writableState,a=i.sync,_=i.writecb;if(\"function\"!=typeof _)throw new z;if(function onwriteStateUpdate(s){s.writing=!1,s.writecb=null,s.length-=s.writelen,s.writelen=0}(i),o)!function onwriteError(s,o,i,a,_){--o.pendingcb,i?(u.nextTick(_,a),u.nextTick(finishMaybe,s,o),s._writableState.errorEmitted=!0,ce(s,a)):(_(a),s._writableState.errorEmitted=!0,ce(s,a),finishMaybe(s,o))}(s,i,a,o,_);else{var w=needFinish(i)||s.destroyed;w||i.corked||i.bufferProcessing||!i.bufferedRequest||clearBuffer(s,i),a?u.nextTick(afterWrite,s,i,w,_):afterWrite(s,i,w,_)}}(o,s)},this.writecb=null,this.writelen=0,this.bufferedRequest=null,this.lastBufferedRequest=null,this.pendingcb=0,this.prefinished=!1,this.errorEmitted=!1,this.emitClose=!1!==s.emitClose,this.autoDestroy=!!s.autoDestroy,this.bufferedRequestCount=0,this.corkedRequestsFree=new CorkedRequest(this)}function Writable(s){var o=this instanceof(a=a||i(25382));if(!o&&!j.call(Writable,this))return new Writable(s);this._writableState=new WritableState(s,this,o),this.writable=!0,s&&(\"function\"==typeof s.write&&(this._write=s.write),\"function\"==typeof s.writev&&(this._writev=s.writev),\"function\"==typeof s.destroy&&(this._destroy=s.destroy),\"function\"==typeof s.final&&(this._final=s.final)),w.call(this)}function doWrite(s,o,i,a,u,_,w){o.writelen=a,o.writecb=w,o.writing=!0,o.sync=!0,o.destroyed?o.onwrite(new Z(\"write\")):i?s._writev(u,o.onwrite):s._write(u,_,o.onwrite),o.sync=!1}function afterWrite(s,o,i,a){i||function onwriteDrain(s,o){0===o.length&&o.needDrain&&(o.needDrain=!1,s.emit(\"drain\"))}(s,o),o.pendingcb--,a(),finishMaybe(s,o)}function clearBuffer(s,o){o.bufferProcessing=!0;var i=o.bufferedRequest;if(s._writev&&i&&i.next){var a=o.bufferedRequestCount,u=new Array(a),_=o.corkedRequestsFree;_.entry=i;for(var w=0,x=!0;i;)u[w]=i,i.isBuf||(x=!1),i=i.next,w+=1;u.allBuffers=x,doWrite(s,o,!0,o.length,u,\"\",_.finish),o.pendingcb++,o.lastBufferedRequest=null,_.next?(o.corkedRequestsFree=_.next,_.next=null):o.corkedRequestsFree=new CorkedRequest(o),o.bufferedRequestCount=0}else{for(;i;){var C=i.chunk,j=i.encoding,L=i.callback;if(doWrite(s,o,!1,o.objectMode?1:C.length,C,j,L),i=i.next,o.bufferedRequestCount--,o.writing)break}null===i&&(o.lastBufferedRequest=null)}o.bufferedRequest=i,o.bufferProcessing=!1}function needFinish(s){return s.ending&&0===s.length&&null===s.bufferedRequest&&!s.finished&&!s.writing}function callFinal(s,o){s._final((function(i){o.pendingcb--,i&&ce(s,i),o.prefinished=!0,s.emit(\"prefinish\"),finishMaybe(s,o)}))}function finishMaybe(s,o){var i=needFinish(o);if(i&&(function prefinish(s,o){o.prefinished||o.finalCalled||(\"function\"!=typeof s._final||o.destroyed?(o.prefinished=!0,s.emit(\"prefinish\")):(o.pendingcb++,o.finalCalled=!0,u.nextTick(callFinal,s,o)))}(s,o),0===o.pendingcb&&(o.finished=!0,s.emit(\"finish\"),o.autoDestroy))){var a=s._readableState;(!a||a.autoDestroy&&a.endEmitted)&&s.destroy()}return i}i(56698)(Writable,w),WritableState.prototype.getBuffer=function getBuffer(){for(var s=this.bufferedRequest,o=[];s;)o.push(s),s=s.next;return o},function(){try{Object.defineProperty(WritableState.prototype,\"buffer\",{get:_.deprecate((function writableStateBufferGetter(){return this.getBuffer()}),\"_writableState.buffer is deprecated. Use _writableState.getBuffer instead.\",\"DEP0003\")})}catch(s){}}(),\"function\"==typeof Symbol&&Symbol.hasInstance&&\"function\"==typeof Function.prototype[Symbol.hasInstance]?(j=Function.prototype[Symbol.hasInstance],Object.defineProperty(Writable,Symbol.hasInstance,{value:function value(s){return!!j.call(this,s)||this===Writable&&(s&&s._writableState instanceof WritableState)}})):j=function realHasInstance(s){return s instanceof this},Writable.prototype.pipe=function(){ce(this,new Y)},Writable.prototype.write=function(s,o,i){var a=this._writableState,_=!1,w=!a.objectMode&&function _isUint8Array(s){return x.isBuffer(s)||s instanceof C}(s);return w&&!x.isBuffer(s)&&(s=function _uint8ArrayToBuffer(s){return x.from(s)}(s)),\"function\"==typeof o&&(i=o,o=null),w?o=\"buffer\":o||(o=a.defaultEncoding),\"function\"!=typeof i&&(i=nop),a.ending?function writeAfterEnd(s,o){var i=new ie;ce(s,i),u.nextTick(o,i)}(this,i):(w||function validChunk(s,o,i,a){var _;return null===i?_=new ee:\"string\"==typeof i||o.objectMode||(_=new U(\"chunk\",[\"string\",\"Buffer\"],i)),!_||(ce(s,_),u.nextTick(a,_),!1)}(this,a,s,i))&&(a.pendingcb++,_=function writeOrBuffer(s,o,i,a,u,_){if(!i){var w=function decodeChunk(s,o,i){s.objectMode||!1===s.decodeStrings||\"string\"!=typeof o||(o=x.from(o,i));return o}(o,a,u);a!==w&&(i=!0,u=\"buffer\",a=w)}var C=o.objectMode?1:a.length;o.length+=C;var j=o.length<o.highWaterMark;j||(o.needDrain=!0);if(o.writing||o.corked){var L=o.lastBufferedRequest;o.lastBufferedRequest={chunk:a,encoding:u,isBuf:i,callback:_,next:null},L?L.next=o.lastBufferedRequest:o.bufferedRequest=o.lastBufferedRequest,o.bufferedRequestCount+=1}else doWrite(s,o,!1,C,a,u,_);return j}(this,a,w,s,o,i)),_},Writable.prototype.cork=function(){this._writableState.corked++},Writable.prototype.uncork=function(){var s=this._writableState;s.corked&&(s.corked--,s.writing||s.corked||s.bufferProcessing||!s.bufferedRequest||clearBuffer(this,s))},Writable.prototype.setDefaultEncoding=function setDefaultEncoding(s){if(\"string\"==typeof s&&(s=s.toLowerCase()),!([\"hex\",\"utf8\",\"utf-8\",\"ascii\",\"binary\",\"base64\",\"ucs2\",\"ucs-2\",\"utf16le\",\"utf-16le\",\"raw\"].indexOf((s+\"\").toLowerCase())>-1))throw new ae(s);return this._writableState.defaultEncoding=s,this},Object.defineProperty(Writable.prototype,\"writableBuffer\",{enumerable:!1,get:function get(){return this._writableState&&this._writableState.getBuffer()}}),Object.defineProperty(Writable.prototype,\"writableHighWaterMark\",{enumerable:!1,get:function get(){return this._writableState.highWaterMark}}),Writable.prototype._write=function(s,o,i){i(new V(\"_write()\"))},Writable.prototype._writev=null,Writable.prototype.end=function(s,o,i){var a=this._writableState;return\"function\"==typeof s?(i=s,s=null,o=null):\"function\"==typeof o&&(i=o,o=null),null!=s&&this.write(s,o),a.corked&&(a.corked=1,this.uncork()),a.ending||function endWritable(s,o,i){o.ending=!0,finishMaybe(s,o),i&&(o.finished?u.nextTick(i):s.once(\"finish\",i));o.ended=!0,s.writable=!1}(this,a,i),this},Object.defineProperty(Writable.prototype,\"writableLength\",{enumerable:!1,get:function get(){return this._writableState.length}}),Object.defineProperty(Writable.prototype,\"destroyed\",{enumerable:!1,get:function get(){return void 0!==this._writableState&&this._writableState.destroyed},set:function set(s){this._writableState&&(this._writableState.destroyed=s)}}),Writable.prototype.destroy=L.destroy,Writable.prototype._undestroy=L.undestroy,Writable.prototype._destroy=function(s,o){o(s)}},16946:(s,o,i)=>{\"use strict\";var a=i(1907),u=i(98828),_=i(45807),w=Object,x=a(\"\".split);s.exports=u((function(){return!w(\"z\").propertyIsEnumerable(0)}))?function(s){return\"String\"===_(s)?x(s,\"\"):w(s)}:w},16962:(s,o)=>{o.aliasToReal={each:\"forEach\",eachRight:\"forEachRight\",entries:\"toPairs\",entriesIn:\"toPairsIn\",extend:\"assignIn\",extendAll:\"assignInAll\",extendAllWith:\"assignInAllWith\",extendWith:\"assignInWith\",first:\"head\",conforms:\"conformsTo\",matches:\"isMatch\",property:\"get\",__:\"placeholder\",F:\"stubFalse\",T:\"stubTrue\",all:\"every\",allPass:\"overEvery\",always:\"constant\",any:\"some\",anyPass:\"overSome\",apply:\"spread\",assoc:\"set\",assocPath:\"set\",complement:\"negate\",compose:\"flowRight\",contains:\"includes\",dissoc:\"unset\",dissocPath:\"unset\",dropLast:\"dropRight\",dropLastWhile:\"dropRightWhile\",equals:\"isEqual\",identical:\"eq\",indexBy:\"keyBy\",init:\"initial\",invertObj:\"invert\",juxt:\"over\",omitAll:\"omit\",nAry:\"ary\",path:\"get\",pathEq:\"matchesProperty\",pathOr:\"getOr\",paths:\"at\",pickAll:\"pick\",pipe:\"flow\",pluck:\"map\",prop:\"get\",propEq:\"matchesProperty\",propOr:\"getOr\",props:\"at\",symmetricDifference:\"xor\",symmetricDifferenceBy:\"xorBy\",symmetricDifferenceWith:\"xorWith\",takeLast:\"takeRight\",takeLastWhile:\"takeRightWhile\",unapply:\"rest\",unnest:\"flatten\",useWith:\"overArgs\",where:\"conformsTo\",whereEq:\"isMatch\",zipObj:\"zipObject\"},o.aryMethod={1:[\"assignAll\",\"assignInAll\",\"attempt\",\"castArray\",\"ceil\",\"create\",\"curry\",\"curryRight\",\"defaultsAll\",\"defaultsDeepAll\",\"floor\",\"flow\",\"flowRight\",\"fromPairs\",\"invert\",\"iteratee\",\"memoize\",\"method\",\"mergeAll\",\"methodOf\",\"mixin\",\"nthArg\",\"over\",\"overEvery\",\"overSome\",\"rest\",\"reverse\",\"round\",\"runInContext\",\"spread\",\"template\",\"trim\",\"trimEnd\",\"trimStart\",\"uniqueId\",\"words\",\"zipAll\"],2:[\"add\",\"after\",\"ary\",\"assign\",\"assignAllWith\",\"assignIn\",\"assignInAllWith\",\"at\",\"before\",\"bind\",\"bindAll\",\"bindKey\",\"chunk\",\"cloneDeepWith\",\"cloneWith\",\"concat\",\"conformsTo\",\"countBy\",\"curryN\",\"curryRightN\",\"debounce\",\"defaults\",\"defaultsDeep\",\"defaultTo\",\"delay\",\"difference\",\"divide\",\"drop\",\"dropRight\",\"dropRightWhile\",\"dropWhile\",\"endsWith\",\"eq\",\"every\",\"filter\",\"find\",\"findIndex\",\"findKey\",\"findLast\",\"findLastIndex\",\"findLastKey\",\"flatMap\",\"flatMapDeep\",\"flattenDepth\",\"forEach\",\"forEachRight\",\"forIn\",\"forInRight\",\"forOwn\",\"forOwnRight\",\"get\",\"groupBy\",\"gt\",\"gte\",\"has\",\"hasIn\",\"includes\",\"indexOf\",\"intersection\",\"invertBy\",\"invoke\",\"invokeMap\",\"isEqual\",\"isMatch\",\"join\",\"keyBy\",\"lastIndexOf\",\"lt\",\"lte\",\"map\",\"mapKeys\",\"mapValues\",\"matchesProperty\",\"maxBy\",\"meanBy\",\"merge\",\"mergeAllWith\",\"minBy\",\"multiply\",\"nth\",\"omit\",\"omitBy\",\"overArgs\",\"pad\",\"padEnd\",\"padStart\",\"parseInt\",\"partial\",\"partialRight\",\"partition\",\"pick\",\"pickBy\",\"propertyOf\",\"pull\",\"pullAll\",\"pullAt\",\"random\",\"range\",\"rangeRight\",\"rearg\",\"reject\",\"remove\",\"repeat\",\"restFrom\",\"result\",\"sampleSize\",\"some\",\"sortBy\",\"sortedIndex\",\"sortedIndexOf\",\"sortedLastIndex\",\"sortedLastIndexOf\",\"sortedUniqBy\",\"split\",\"spreadFrom\",\"startsWith\",\"subtract\",\"sumBy\",\"take\",\"takeRight\",\"takeRightWhile\",\"takeWhile\",\"tap\",\"throttle\",\"thru\",\"times\",\"trimChars\",\"trimCharsEnd\",\"trimCharsStart\",\"truncate\",\"union\",\"uniqBy\",\"uniqWith\",\"unset\",\"unzipWith\",\"without\",\"wrap\",\"xor\",\"zip\",\"zipObject\",\"zipObjectDeep\"],3:[\"assignInWith\",\"assignWith\",\"clamp\",\"differenceBy\",\"differenceWith\",\"findFrom\",\"findIndexFrom\",\"findLastFrom\",\"findLastIndexFrom\",\"getOr\",\"includesFrom\",\"indexOfFrom\",\"inRange\",\"intersectionBy\",\"intersectionWith\",\"invokeArgs\",\"invokeArgsMap\",\"isEqualWith\",\"isMatchWith\",\"flatMapDepth\",\"lastIndexOfFrom\",\"mergeWith\",\"orderBy\",\"padChars\",\"padCharsEnd\",\"padCharsStart\",\"pullAllBy\",\"pullAllWith\",\"rangeStep\",\"rangeStepRight\",\"reduce\",\"reduceRight\",\"replace\",\"set\",\"slice\",\"sortedIndexBy\",\"sortedLastIndexBy\",\"transform\",\"unionBy\",\"unionWith\",\"update\",\"xorBy\",\"xorWith\",\"zipWith\"],4:[\"fill\",\"setWith\",\"updateWith\"]},o.aryRearg={2:[1,0],3:[2,0,1],4:[3,2,0,1]},o.iterateeAry={dropRightWhile:1,dropWhile:1,every:1,filter:1,find:1,findFrom:1,findIndex:1,findIndexFrom:1,findKey:1,findLast:1,findLastFrom:1,findLastIndex:1,findLastIndexFrom:1,findLastKey:1,flatMap:1,flatMapDeep:1,flatMapDepth:1,forEach:1,forEachRight:1,forIn:1,forInRight:1,forOwn:1,forOwnRight:1,map:1,mapKeys:1,mapValues:1,partition:1,reduce:2,reduceRight:2,reject:1,remove:1,some:1,takeRightWhile:1,takeWhile:1,times:1,transform:2},o.iterateeRearg={mapKeys:[1],reduceRight:[1,0]},o.methodRearg={assignInAllWith:[1,0],assignInWith:[1,2,0],assignAllWith:[1,0],assignWith:[1,2,0],differenceBy:[1,2,0],differenceWith:[1,2,0],getOr:[2,1,0],intersectionBy:[1,2,0],intersectionWith:[1,2,0],isEqualWith:[1,2,0],isMatchWith:[2,1,0],mergeAllWith:[1,0],mergeWith:[1,2,0],padChars:[2,1,0],padCharsEnd:[2,1,0],padCharsStart:[2,1,0],pullAllBy:[2,1,0],pullAllWith:[2,1,0],rangeStep:[1,2,0],rangeStepRight:[1,2,0],setWith:[3,1,2,0],sortedIndexBy:[2,1,0],sortedLastIndexBy:[2,1,0],unionBy:[1,2,0],unionWith:[1,2,0],updateWith:[3,1,2,0],xorBy:[1,2,0],xorWith:[1,2,0],zipWith:[1,2,0]},o.methodSpread={assignAll:{start:0},assignAllWith:{start:0},assignInAll:{start:0},assignInAllWith:{start:0},defaultsAll:{start:0},defaultsDeepAll:{start:0},invokeArgs:{start:2},invokeArgsMap:{start:2},mergeAll:{start:0},mergeAllWith:{start:0},partial:{start:1},partialRight:{start:1},without:{start:1},zipAll:{start:0}},o.mutate={array:{fill:!0,pull:!0,pullAll:!0,pullAllBy:!0,pullAllWith:!0,pullAt:!0,remove:!0,reverse:!0},object:{assign:!0,assignAll:!0,assignAllWith:!0,assignIn:!0,assignInAll:!0,assignInAllWith:!0,assignInWith:!0,assignWith:!0,defaults:!0,defaultsAll:!0,defaultsDeep:!0,defaultsDeepAll:!0,merge:!0,mergeAll:!0,mergeAllWith:!0,mergeWith:!0},set:{set:!0,setWith:!0,unset:!0,update:!0,updateWith:!0}},o.realToAlias=function(){var s=Object.prototype.hasOwnProperty,i=o.aliasToReal,a={};for(var u in i){var _=i[u];s.call(a,_)?a[_].push(u):a[_]=[u]}return a}(),o.remap={assignAll:\"assign\",assignAllWith:\"assignWith\",assignInAll:\"assignIn\",assignInAllWith:\"assignInWith\",curryN:\"curry\",curryRightN:\"curryRight\",defaultsAll:\"defaults\",defaultsDeepAll:\"defaultsDeep\",findFrom:\"find\",findIndexFrom:\"findIndex\",findLastFrom:\"findLast\",findLastIndexFrom:\"findLastIndex\",getOr:\"get\",includesFrom:\"includes\",indexOfFrom:\"indexOf\",invokeArgs:\"invoke\",invokeArgsMap:\"invokeMap\",lastIndexOfFrom:\"lastIndexOf\",mergeAll:\"merge\",mergeAllWith:\"mergeWith\",padChars:\"pad\",padCharsEnd:\"padEnd\",padCharsStart:\"padStart\",propertyOf:\"get\",rangeStep:\"range\",rangeStepRight:\"rangeRight\",restFrom:\"rest\",spreadFrom:\"spread\",trimChars:\"trim\",trimCharsEnd:\"trimEnd\",trimCharsStart:\"trimStart\",zipAll:\"zip\"},o.skipFixed={castArray:!0,flow:!0,flowRight:!0,iteratee:!0,mixin:!0,rearg:!0,runInContext:!0},o.skipRearg={add:!0,assign:!0,assignIn:!0,bind:!0,bindKey:!0,concat:!0,difference:!0,divide:!0,eq:!0,gt:!0,gte:!0,isEqual:!0,lt:!0,lte:!0,matchesProperty:!0,merge:!0,multiply:!0,overArgs:!0,partial:!0,partialRight:!0,propertyOf:!0,random:!0,range:!0,rangeRight:!0,subtract:!0,zip:!0,zipObject:!0,zipObjectDeep:!0}},17255:(s,o,i)=>{var a=i(47422);s.exports=function basePropertyDeep(s){return function(o){return a(o,s)}}},17285:s=>{function source(s){return s?\"string\"==typeof s?s:s.source:null}function lookahead(s){return concat(\"(?=\",s,\")\")}function concat(...s){return s.map((s=>source(s))).join(\"\")}function either(...s){return\"(\"+s.map((s=>source(s))).join(\"|\")+\")\"}s.exports=function xml(s){const o=concat(/[A-Z_]/,function optional(s){return concat(\"(\",s,\")?\")}(/[A-Z0-9_.-]*:/),/[A-Z0-9_.-]*/),i={className:\"symbol\",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},a={begin:/\\s/,contains:[{className:\"meta-keyword\",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\\n/}]},u=s.inherit(a,{begin:/\\(/,end:/\\)/}),_=s.inherit(s.APOS_STRING_MODE,{className:\"meta-string\"}),w=s.inherit(s.QUOTE_STRING_MODE,{className:\"meta-string\"}),x={endsWithParent:!0,illegal:/</,relevance:0,contains:[{className:\"attr\",begin:/[A-Za-z0-9._:-]+/,relevance:0},{begin:/=\\s*/,relevance:0,contains:[{className:\"string\",endsParent:!0,variants:[{begin:/\"/,end:/\"/,contains:[i]},{begin:/'/,end:/'/,contains:[i]},{begin:/[^\\s\"'=<>`]+/}]}]}]};return{name:\"HTML, XML\",aliases:[\"html\",\"xhtml\",\"rss\",\"atom\",\"xjb\",\"xsd\",\"xsl\",\"plist\",\"wsf\",\"svg\"],case_insensitive:!0,contains:[{className:\"meta\",begin:/<![a-z]/,end:/>/,relevance:10,contains:[a,w,_,u,{begin:/\\[/,end:/\\]/,contains:[{className:\"meta\",begin:/<![a-z]/,end:/>/,contains:[a,u,w,_]}]}]},s.COMMENT(/<!--/,/-->/,{relevance:10}),{begin:/<!\\[CDATA\\[/,end:/\\]\\]>/,relevance:10},i,{className:\"meta\",begin:/<\\?xml/,end:/\\?>/,relevance:10},{className:\"tag\",begin:/<style(?=\\s|>)/,end:/>/,keywords:{name:\"style\"},contains:[x],starts:{end:/<\\/style>/,returnEnd:!0,subLanguage:[\"css\",\"xml\"]}},{className:\"tag\",begin:/<script(?=\\s|>)/,end:/>/,keywords:{name:\"script\"},contains:[x],starts:{end:/<\\/script>/,returnEnd:!0,subLanguage:[\"javascript\",\"handlebars\",\"xml\"]}},{className:\"tag\",begin:/<>|<\\/>/},{className:\"tag\",begin:concat(/</,lookahead(concat(o,either(/\\/>/,/>/,/\\s/)))),end:/\\/?>/,contains:[{className:\"name\",begin:o,relevance:0,starts:x}]},{className:\"tag\",begin:concat(/<\\//,lookahead(concat(o,/>/))),contains:[{className:\"name\",begin:o,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]}}},17400:(s,o,i)=>{var a=i(99374),u=1/0;s.exports=function toFinite(s){return s?(s=a(s))===u||s===-1/0?17976931348623157e292*(s<0?-1:1):s==s?s:0:0===s?s:0}},17533:s=>{s.exports=function yaml(s){var o=\"true false yes no null\",i=\"[\\\\w#;/?:@&=+$,.~*'()[\\\\]]+\",a={className:\"string\",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/\"/,end:/\"/},{begin:/\\S+/}],contains:[s.BACKSLASH_ESCAPE,{className:\"template-variable\",variants:[{begin:/\\{\\{/,end:/\\}\\}/},{begin:/%\\{/,end:/\\}/}]}]},u=s.inherit(a,{variants:[{begin:/'/,end:/'/},{begin:/\"/,end:/\"/},{begin:/[^\\s,{}[\\]]+/}]}),_={className:\"number\",begin:\"\\\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\\\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\\\.[0-9]*)?([ \\\\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\\\b\"},w={end:\",\",endsWithParent:!0,excludeEnd:!0,keywords:o,relevance:0},x={begin:/\\{/,end:/\\}/,contains:[w],illegal:\"\\\\n\",relevance:0},C={begin:\"\\\\[\",end:\"\\\\]\",contains:[w],illegal:\"\\\\n\",relevance:0},j=[{className:\"attr\",variants:[{begin:\"\\\\w[\\\\w :\\\\/.-]*:(?=[ \\t]|$)\"},{begin:'\"\\\\w[\\\\w :\\\\/.-]*\":(?=[ \\t]|$)'},{begin:\"'\\\\w[\\\\w :\\\\/.-]*':(?=[ \\t]|$)\"}]},{className:\"meta\",begin:\"^---\\\\s*$\",relevance:10},{className:\"string\",begin:\"[\\\\|>]([1-9]?[+-])?[ ]*\\\\n( +)[^ ][^\\\\n]*\\\\n(\\\\2[^\\\\n]+\\\\n?)*\"},{begin:\"<%[%=-]?\",end:\"[%-]?%>\",subLanguage:\"ruby\",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:\"type\",begin:\"!\\\\w+!\"+i},{className:\"type\",begin:\"!<\"+i+\">\"},{className:\"type\",begin:\"!\"+i},{className:\"type\",begin:\"!!\"+i},{className:\"meta\",begin:\"&\"+s.UNDERSCORE_IDENT_RE+\"$\"},{className:\"meta\",begin:\"\\\\*\"+s.UNDERSCORE_IDENT_RE+\"$\"},{className:\"bullet\",begin:\"-(?=[ ]|$)\",relevance:0},s.HASH_COMMENT_MODE,{beginKeywords:o,keywords:{literal:o}},_,{className:\"number\",begin:s.C_NUMBER_RE+\"\\\\b\",relevance:0},x,C,a],L=[...j];return L.pop(),L.push(u),w.contains=L,{name:\"YAML\",case_insensitive:!0,aliases:[\"yml\"],contains:j}}},17670:(s,o,i)=>{var a=i(12651);s.exports=function mapCacheDelete(s){var o=a(this,s).delete(s);return this.size-=o?1:0,o}},17965:(s,o,i)=>{\"use strict\";var a=i(16426),u={\"text/plain\":\"Text\",\"text/html\":\"Url\",default:\"Text\"};s.exports=function copy(s,o){var i,_,w,x,C,j,L=!1;o||(o={}),i=o.debug||!1;try{if(w=a(),x=document.createRange(),C=document.getSelection(),(j=document.createElement(\"span\")).textContent=s,j.ariaHidden=\"true\",j.style.all=\"unset\",j.style.position=\"fixed\",j.style.top=0,j.style.clip=\"rect(0, 0, 0, 0)\",j.style.whiteSpace=\"pre\",j.style.webkitUserSelect=\"text\",j.style.MozUserSelect=\"text\",j.style.msUserSelect=\"text\",j.style.userSelect=\"text\",j.addEventListener(\"copy\",(function(a){if(a.stopPropagation(),o.format)if(a.preventDefault(),void 0===a.clipboardData){i&&console.warn(\"unable to use e.clipboardData\"),i&&console.warn(\"trying IE specific stuff\"),window.clipboardData.clearData();var _=u[o.format]||u.default;window.clipboardData.setData(_,s)}else a.clipboardData.clearData(),a.clipboardData.setData(o.format,s);o.onCopy&&(a.preventDefault(),o.onCopy(a.clipboardData))})),document.body.appendChild(j),x.selectNodeContents(j),C.addRange(x),!document.execCommand(\"copy\"))throw new Error(\"copy command was unsuccessful\");L=!0}catch(a){i&&console.error(\"unable to copy using execCommand: \",a),i&&console.warn(\"trying IE specific stuff\");try{window.clipboardData.setData(o.format||\"text\",s),o.onCopy&&o.onCopy(window.clipboardData),L=!0}catch(a){i&&console.error(\"unable to copy using clipboardData: \",a),i&&console.error(\"falling back to prompt\"),_=function format(s){var o=(/mac os x/i.test(navigator.userAgent)?\"⌘\":\"Ctrl\")+\"+C\";return s.replace(/#{\\s*key\\s*}/g,o)}(\"message\"in o?o.message:\"Copy to clipboard: #{key}, Enter\"),window.prompt(_,s)}}finally{C&&(\"function\"==typeof C.removeRange?C.removeRange(x):C.removeAllRanges()),j&&document.body.removeChild(j),w()}return L}},18073:(s,o,i)=>{var a=i(85087),u=i(54641),_=i(70981);s.exports=function createRecurry(s,o,i,w,x,C,j,L,B,$){var U=8&o;o|=U?32:64,4&(o&=~(U?64:32))||(o&=-4);var V=[s,o,x,U?C:void 0,U?j:void 0,U?void 0:C,U?void 0:j,L,B,$],z=i.apply(void 0,V);return a(s)&&u(z,V),z.placeholder=w,_(z,s,o)}},19123:(s,o,i)=>{var a=i(65606),u=i(31499),_=i(88310).Stream;function resolve(s,o,i){var a,_=function create_indent(s,o){return new Array(o||0).join(s||\"\")}(o,i=i||0),w=s;if(\"object\"==typeof s&&((w=s[a=Object.keys(s)[0]])&&w._elem))return w._elem.name=a,w._elem.icount=i,w._elem.indent=o,w._elem.indents=_,w._elem.interrupt=w,w._elem;var x,C=[],j=[];function get_attributes(s){Object.keys(s).forEach((function(o){C.push(function attribute(s,o){return s+'=\"'+u(o)+'\"'}(o,s[o]))}))}switch(typeof w){case\"object\":if(null===w)break;w._attr&&get_attributes(w._attr),w._cdata&&j.push((\"<![CDATA[\"+w._cdata).replace(/\\]\\]>/g,\"]]]]><![CDATA[>\")+\"]]>\"),w.forEach&&(x=!1,j.push(\"\"),w.forEach((function(s){\"object\"==typeof s?\"_attr\"==Object.keys(s)[0]?get_attributes(s._attr):j.push(resolve(s,o,i+1)):(j.pop(),x=!0,j.push(u(s)))})),x||j.push(\"\"));break;default:j.push(u(w))}return{name:a,interrupt:!1,attributes:C,content:j,icount:i,indents:_,indent:o}}function format(s,o,i){if(\"object\"!=typeof o)return s(!1,o);var a=o.interrupt?1:o.content.length;function proceed(){for(;o.content.length;){var u=o.content.shift();if(void 0!==u){if(interrupt(u))return;format(s,u)}}s(!1,(a>1?o.indents:\"\")+(o.name?\"</\"+o.name+\">\":\"\")+(o.indent&&!i?\"\\n\":\"\")),i&&i()}function interrupt(o){return!!o.interrupt&&(o.interrupt.append=s,o.interrupt.end=proceed,o.interrupt=!1,s(!0),!0)}if(s(!1,o.indents+(o.name?\"<\"+o.name:\"\")+(o.attributes.length?\" \"+o.attributes.join(\" \"):\"\")+(a?o.name?\">\":\"\":o.name?\"/>\":\"\")+(o.indent&&a>1?\"\\n\":\"\")),!a)return s(!1,o.indent?\"\\n\":\"\");interrupt(o)||proceed()}s.exports=function xml(s,o){\"object\"!=typeof o&&(o={indent:o});var i=o.stream?new _:null,u=\"\",w=!1,x=o.indent?!0===o.indent?\"    \":o.indent:\"\",C=!0;function delay(s){C?a.nextTick(s):s()}function append(s,o){if(void 0!==o&&(u+=o),s&&!w&&(i=i||new _,w=!0),s&&w){var a=u;delay((function(){i.emit(\"data\",a)})),u=\"\"}}function add(s,o){format(append,resolve(s,x,x?1:0),o)}function end(){if(i){var s=u;delay((function(){i.emit(\"data\",s),i.emit(\"end\"),i.readable=!1,i.emit(\"close\")}))}}return delay((function(){C=!1})),o.declaration&&function addXmlDeclaration(s){var o={version:\"1.0\",encoding:s.encoding||\"UTF-8\"};s.standalone&&(o.standalone=s.standalone),add({\"?xml\":{_attr:o}}),u=u.replace(\"/>\",\"?>\")}(o.declaration),s&&s.forEach?s.forEach((function(o,i){var a;i+1===s.length&&(a=end),add(o,a)})):add(s,end),i?(i.readable=!0,i):u},s.exports.element=s.exports.Element=function element(){var s={_elem:resolve(Array.prototype.slice.call(arguments)),push:function(s){if(!this.append)throw new Error(\"not assigned to a parent!\");var o=this,i=this._elem.indent;format(this.append,resolve(s,i,this._elem.icount+(i?1:0)),(function(){o.append(!0)}))},close:function(s){void 0!==s&&this.push(s),this.end&&this.end()}};return s}},19219:s=>{s.exports=function cacheHas(s,o){return s.has(o)}},19287:s=>{\"use strict\";s.exports={CSSRuleList:0,CSSStyleDeclaration:0,CSSValueList:0,ClientRectList:0,DOMRectList:0,DOMStringList:0,DOMTokenList:1,DataTransferItemList:0,FileList:0,HTMLAllCollection:0,HTMLCollection:0,HTMLFormElement:0,HTMLSelectElement:0,MediaList:0,MimeTypeArray:0,NamedNodeMap:0,NodeList:1,PaintRequestList:0,Plugin:0,PluginArray:0,SVGLengthList:0,SVGNumberList:0,SVGPathSegList:0,SVGPointList:0,SVGStringList:0,SVGTransformList:0,SourceBufferList:0,StyleSheetList:0,TextTrackCueList:0,TextTrackList:0,TouchList:0}},19358:(s,o,i)=>{\"use strict\";var a=i(85582),u=i(49724),_=i(61626),w=i(88280),x=i(79192),C=i(19595),j=i(54829),L=i(34084),B=i(32096),$=i(39259),U=i(85884),V=i(39447),z=i(7376);s.exports=function(s,o,i,Y){var Z=\"stackTraceLimit\",ee=Y?2:1,ie=s.split(\".\"),ae=ie[ie.length-1],ce=a.apply(null,ie);if(ce){var le=ce.prototype;if(!z&&u(le,\"cause\")&&delete le.cause,!i)return ce;var pe=a(\"Error\"),de=o((function(s,o){var i=B(Y?o:s,void 0),a=Y?new ce(s):new ce;return void 0!==i&&_(a,\"message\",i),U(a,de,a.stack,2),this&&w(le,this)&&L(a,this,de),arguments.length>ee&&$(a,arguments[ee]),a}));if(de.prototype=le,\"Error\"!==ae?x?x(de,pe):C(de,pe,{name:!0}):V&&Z in ce&&(j(de,ce,Z),j(de,ce,\"prepareStackTrace\")),C(de,ce),!z)try{le.name!==ae&&_(le,\"name\",ae),le.constructor=de}catch(s){}return de}}},19570:(s,o,i)=>{var a=i(37334),u=i(93243),_=i(83488),w=u?function(s,o){return u(s,\"toString\",{configurable:!0,enumerable:!1,value:a(o),writable:!0})}:_;s.exports=w},19595:(s,o,i)=>{\"use strict\";var a=i(49724),u=i(11042),_=i(13846),w=i(74284);s.exports=function(s,o,i){for(var x=u(o),C=w.f,j=_.f,L=0;L<x.length;L++){var B=x[L];a(s,B)||i&&a(i,B)||C(s,B,j(o,B))}}},19709:(s,o,i)=>{\"use strict\";var a=i(23034);s.exports=a},19846:(s,o,i)=>{\"use strict\";var a=i(20798),u=i(98828),_=i(45951).String;s.exports=!!Object.getOwnPropertySymbols&&!u((function(){var s=Symbol(\"symbol detection\");return!_(s)||!(Object(s)instanceof Symbol)||!Symbol.sham&&a&&a<41}))},19931:(s,o,i)=>{var a=i(31769),u=i(68090),_=i(68969),w=i(77797);s.exports=function baseUnset(s,o){return o=a(o,s),null==(s=_(s,o))||delete s[w(u(o))]}},20181:(s,o,i)=>{var a=/^\\s+|\\s+$/g,u=/^[-+]0x[0-9a-f]+$/i,_=/^0b[01]+$/i,w=/^0o[0-7]+$/i,x=parseInt,C=\"object\"==typeof i.g&&i.g&&i.g.Object===Object&&i.g,j=\"object\"==typeof self&&self&&self.Object===Object&&self,L=C||j||Function(\"return this\")(),B=Object.prototype.toString,$=Math.max,U=Math.min,now=function(){return L.Date.now()};function isObject(s){var o=typeof s;return!!s&&(\"object\"==o||\"function\"==o)}function toNumber(s){if(\"number\"==typeof s)return s;if(function isSymbol(s){return\"symbol\"==typeof s||function isObjectLike(s){return!!s&&\"object\"==typeof s}(s)&&\"[object Symbol]\"==B.call(s)}(s))return NaN;if(isObject(s)){var o=\"function\"==typeof s.valueOf?s.valueOf():s;s=isObject(o)?o+\"\":o}if(\"string\"!=typeof s)return 0===s?s:+s;s=s.replace(a,\"\");var i=_.test(s);return i||w.test(s)?x(s.slice(2),i?2:8):u.test(s)?NaN:+s}s.exports=function debounce(s,o,i){var a,u,_,w,x,C,j=0,L=!1,B=!1,V=!0;if(\"function\"!=typeof s)throw new TypeError(\"Expected a function\");function invokeFunc(o){var i=a,_=u;return a=u=void 0,j=o,w=s.apply(_,i)}function shouldInvoke(s){var i=s-C;return void 0===C||i>=o||i<0||B&&s-j>=_}function timerExpired(){var s=now();if(shouldInvoke(s))return trailingEdge(s);x=setTimeout(timerExpired,function remainingWait(s){var i=o-(s-C);return B?U(i,_-(s-j)):i}(s))}function trailingEdge(s){return x=void 0,V&&a?invokeFunc(s):(a=u=void 0,w)}function debounced(){var s=now(),i=shouldInvoke(s);if(a=arguments,u=this,C=s,i){if(void 0===x)return function leadingEdge(s){return j=s,x=setTimeout(timerExpired,o),L?invokeFunc(s):w}(C);if(B)return x=setTimeout(timerExpired,o),invokeFunc(C)}return void 0===x&&(x=setTimeout(timerExpired,o)),w}return o=toNumber(o)||0,isObject(i)&&(L=!!i.leading,_=(B=\"maxWait\"in i)?$(toNumber(i.maxWait)||0,o):_,V=\"trailing\"in i?!!i.trailing:V),debounced.cancel=function cancel(){void 0!==x&&clearTimeout(x),j=0,a=C=u=x=void 0},debounced.flush=function flush(){return void 0===x?w:trailingEdge(now())},debounced}},20317:s=>{s.exports=function mapToArray(s){var o=-1,i=Array(s.size);return s.forEach((function(s,a){i[++o]=[a,s]})),i}},20334:(s,o,i)=>{\"use strict\";var a=i(48287).Buffer;class NonError extends Error{constructor(s){super(NonError._prepareSuperMessage(s)),Object.defineProperty(this,\"name\",{value:\"NonError\",configurable:!0,writable:!0}),Error.captureStackTrace&&Error.captureStackTrace(this,NonError)}static _prepareSuperMessage(s){try{return JSON.stringify(s)}catch{return String(s)}}}const u=[{property:\"name\",enumerable:!1},{property:\"message\",enumerable:!1},{property:\"stack\",enumerable:!1},{property:\"code\",enumerable:!0}],_=Symbol(\".toJSON called\"),destroyCircular=({from:s,seen:o,to_:i,forceEnumerable:w,maxDepth:x,depth:C})=>{const j=i||(Array.isArray(s)?[]:{});if(o.push(s),C>=x)return j;if(\"function\"==typeof s.toJSON&&!0!==s[_])return(s=>{s[_]=!0;const o=s.toJSON();return delete s[_],o})(s);for(const[i,u]of Object.entries(s))\"function\"==typeof a&&a.isBuffer(u)?j[i]=\"[object Buffer]\":\"function\"!=typeof u&&(u&&\"object\"==typeof u?o.includes(s[i])?j[i]=\"[Circular]\":(C++,j[i]=destroyCircular({from:s[i],seen:o.slice(),forceEnumerable:w,maxDepth:x,depth:C})):j[i]=u);for(const{property:o,enumerable:i}of u)\"string\"==typeof s[o]&&Object.defineProperty(j,o,{value:s[o],enumerable:!!w||i,configurable:!0,writable:!0});return j};s.exports={serializeError:(s,o={})=>{const{maxDepth:i=Number.POSITIVE_INFINITY}=o;return\"object\"==typeof s&&null!==s?destroyCircular({from:s,seen:[],forceEnumerable:!0,maxDepth:i,depth:0}):\"function\"==typeof s?`[Function: ${s.name||\"anonymous\"}]`:s},deserializeError:(s,o={})=>{const{maxDepth:i=Number.POSITIVE_INFINITY}=o;if(s instanceof Error)return s;if(\"object\"==typeof s&&null!==s&&!Array.isArray(s)){const o=new Error;return destroyCircular({from:s,seen:[],to_:o,maxDepth:i,depth:0}),o}return new NonError(s)}}},20426:s=>{var o=Object.prototype.hasOwnProperty;s.exports=function baseHas(s,i){return null!=s&&o.call(s,i)}},20575:(s,o,i)=>{\"use strict\";var a=i(3121);s.exports=function(s){return a(s.length)}},20798:(s,o,i)=>{\"use strict\";var a,u,_=i(45951),w=i(96794),x=_.process,C=_.Deno,j=x&&x.versions||C&&C.version,L=j&&j.v8;L&&(u=(a=L.split(\".\"))[0]>0&&a[0]<4?1:+(a[0]+a[1])),!u&&w&&(!(a=w.match(/Edge\\/(\\d+)/))||a[1]>=74)&&(a=w.match(/Chrome\\/(\\d+)/))&&(u=+a[1]),s.exports=u},20850:(s,o,i)=>{\"use strict\";s.exports=i(46076)},20999:(s,o,i)=>{var a=i(69302),u=i(36800);s.exports=function createAssigner(s){return a((function(o,i){var a=-1,_=i.length,w=_>1?i[_-1]:void 0,x=_>2?i[2]:void 0;for(w=s.length>3&&\"function\"==typeof w?(_--,w):void 0,x&&u(i[0],i[1],x)&&(w=_<3?void 0:w,_=1),o=Object(o);++a<_;){var C=i[a];C&&s(o,C,a,w)}return o}))}},21549:(s,o,i)=>{var a=i(22032),u=i(63862),_=i(66721),w=i(12749),x=i(35749);function Hash(s){var o=-1,i=null==s?0:s.length;for(this.clear();++o<i;){var a=s[o];this.set(a[0],a[1])}}Hash.prototype.clear=a,Hash.prototype.delete=u,Hash.prototype.get=_,Hash.prototype.has=w,Hash.prototype.set=x,s.exports=Hash},21791:(s,o,i)=>{var a=i(16547),u=i(43360);s.exports=function copyObject(s,o,i,_){var w=!i;i||(i={});for(var x=-1,C=o.length;++x<C;){var j=o[x],L=_?_(i[j],s[j],j,i,s):void 0;void 0===L&&(L=s[j]),w?u(i,j,L):a(i,j,L)}return i}},21986:(s,o,i)=>{var a=i(51873),u=i(37828),_=i(75288),w=i(25911),x=i(20317),C=i(84247),j=a?a.prototype:void 0,L=j?j.valueOf:void 0;s.exports=function equalByTag(s,o,i,a,j,B,$){switch(i){case\"[object DataView]\":if(s.byteLength!=o.byteLength||s.byteOffset!=o.byteOffset)return!1;s=s.buffer,o=o.buffer;case\"[object ArrayBuffer]\":return!(s.byteLength!=o.byteLength||!B(new u(s),new u(o)));case\"[object Boolean]\":case\"[object Date]\":case\"[object Number]\":return _(+s,+o);case\"[object Error]\":return s.name==o.name&&s.message==o.message;case\"[object RegExp]\":case\"[object String]\":return s==o+\"\";case\"[object Map]\":var U=x;case\"[object Set]\":var V=1&a;if(U||(U=C),s.size!=o.size&&!V)return!1;var z=$.get(s);if(z)return z==o;a|=2,$.set(s,o);var Y=w(U(s),U(o),a,j,B,$);return $.delete(s),Y;case\"[object Symbol]\":if(L)return L.call(s)==L.call(o)}return!1}},22032:(s,o,i)=>{var a=i(81042);s.exports=function hashClear(){this.__data__=a?a(null):{},this.size=0}},22225:s=>{var o=\"\\\\ud800-\\\\udfff\",i=\"\\\\u2700-\\\\u27bf\",a=\"a-z\\\\xdf-\\\\xf6\\\\xf8-\\\\xff\",u=\"A-Z\\\\xc0-\\\\xd6\\\\xd8-\\\\xde\",_=\"\\\\xac\\\\xb1\\\\xd7\\\\xf7\\\\x00-\\\\x2f\\\\x3a-\\\\x40\\\\x5b-\\\\x60\\\\x7b-\\\\xbf\\\\u2000-\\\\u206f \\\\t\\\\x0b\\\\f\\\\xa0\\\\ufeff\\\\n\\\\r\\\\u2028\\\\u2029\\\\u1680\\\\u180e\\\\u2000\\\\u2001\\\\u2002\\\\u2003\\\\u2004\\\\u2005\\\\u2006\\\\u2007\\\\u2008\\\\u2009\\\\u200a\\\\u202f\\\\u205f\\\\u3000\",w=\"[\"+_+\"]\",x=\"\\\\d+\",C=\"[\"+i+\"]\",j=\"[\"+a+\"]\",L=\"[^\"+o+_+x+i+a+u+\"]\",B=\"(?:\\\\ud83c[\\\\udde6-\\\\uddff]){2}\",$=\"[\\\\ud800-\\\\udbff][\\\\udc00-\\\\udfff]\",U=\"[\"+u+\"]\",V=\"(?:\"+j+\"|\"+L+\")\",z=\"(?:\"+U+\"|\"+L+\")\",Y=\"(?:['’](?:d|ll|m|re|s|t|ve))?\",Z=\"(?:['’](?:D|LL|M|RE|S|T|VE))?\",ee=\"(?:[\\\\u0300-\\\\u036f\\\\ufe20-\\\\ufe2f\\\\u20d0-\\\\u20ff]|\\\\ud83c[\\\\udffb-\\\\udfff])?\",ie=\"[\\\\ufe0e\\\\ufe0f]?\",ae=ie+ee+(\"(?:\\\\u200d(?:\"+[\"[^\"+o+\"]\",B,$].join(\"|\")+\")\"+ie+ee+\")*\"),ce=\"(?:\"+[C,B,$].join(\"|\")+\")\"+ae,le=RegExp([U+\"?\"+j+\"+\"+Y+\"(?=\"+[w,U,\"$\"].join(\"|\")+\")\",z+\"+\"+Z+\"(?=\"+[w,U+V,\"$\"].join(\"|\")+\")\",U+\"?\"+V+\"+\"+Y,U+\"+\"+Z,\"\\\\d*(?:1ST|2ND|3RD|(?![123])\\\\dTH)(?=\\\\b|[a-z_])\",\"\\\\d*(?:1st|2nd|3rd|(?![123])\\\\dth)(?=\\\\b|[A-Z_])\",x,ce].join(\"|\"),\"g\");s.exports=function unicodeWords(s){return s.match(le)||[]}},22551:(s,o,i)=>{\"use strict\";var a=i(96540),u=i(69982);function p(s){for(var o=\"https://reactjs.org/docs/error-decoder.html?invariant=\"+s,i=1;i<arguments.length;i++)o+=\"&args[]=\"+encodeURIComponent(arguments[i]);return\"Minified React error #\"+s+\"; visit \"+o+\" for the full message or use the non-minified dev environment for full errors and additional helpful warnings.\"}var _=new Set,w={};function fa(s,o){ha(s,o),ha(s+\"Capture\",o)}function ha(s,o){for(w[s]=o,s=0;s<o.length;s++)_.add(o[s])}var x=!(\"undefined\"==typeof window||void 0===window.document||void 0===window.document.createElement),C=Object.prototype.hasOwnProperty,j=/^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD][:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040]*$/,L={},B={};function v(s,o,i,a,u,_,w){this.acceptsBooleans=2===o||3===o||4===o,this.attributeName=a,this.attributeNamespace=u,this.mustUseProperty=i,this.propertyName=s,this.type=o,this.sanitizeURL=_,this.removeEmptyString=w}var $={};\"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style\".split(\" \").forEach((function(s){$[s]=new v(s,0,!1,s,null,!1,!1)})),[[\"acceptCharset\",\"accept-charset\"],[\"className\",\"class\"],[\"htmlFor\",\"for\"],[\"httpEquiv\",\"http-equiv\"]].forEach((function(s){var o=s[0];$[o]=new v(o,1,!1,s[1],null,!1,!1)})),[\"contentEditable\",\"draggable\",\"spellCheck\",\"value\"].forEach((function(s){$[s]=new v(s,2,!1,s.toLowerCase(),null,!1,!1)})),[\"autoReverse\",\"externalResourcesRequired\",\"focusable\",\"preserveAlpha\"].forEach((function(s){$[s]=new v(s,2,!1,s,null,!1,!1)})),\"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope\".split(\" \").forEach((function(s){$[s]=new v(s,3,!1,s.toLowerCase(),null,!1,!1)})),[\"checked\",\"multiple\",\"muted\",\"selected\"].forEach((function(s){$[s]=new v(s,3,!0,s,null,!1,!1)})),[\"capture\",\"download\"].forEach((function(s){$[s]=new v(s,4,!1,s,null,!1,!1)})),[\"cols\",\"rows\",\"size\",\"span\"].forEach((function(s){$[s]=new v(s,6,!1,s,null,!1,!1)})),[\"rowSpan\",\"start\"].forEach((function(s){$[s]=new v(s,5,!1,s.toLowerCase(),null,!1,!1)}));var U=/[\\-:]([a-z])/g;function sa(s){return s[1].toUpperCase()}function ta(s,o,i,a){var u=$.hasOwnProperty(o)?$[o]:null;(null!==u?0!==u.type:a||!(2<o.length)||\"o\"!==o[0]&&\"O\"!==o[0]||\"n\"!==o[1]&&\"N\"!==o[1])&&(function qa(s,o,i,a){if(null==o||function pa(s,o,i,a){if(null!==i&&0===i.type)return!1;switch(typeof o){case\"function\":case\"symbol\":return!0;case\"boolean\":return!a&&(null!==i?!i.acceptsBooleans:\"data-\"!==(s=s.toLowerCase().slice(0,5))&&\"aria-\"!==s);default:return!1}}(s,o,i,a))return!0;if(a)return!1;if(null!==i)switch(i.type){case 3:return!o;case 4:return!1===o;case 5:return isNaN(o);case 6:return isNaN(o)||1>o}return!1}(o,i,u,a)&&(i=null),a||null===u?function oa(s){return!!C.call(B,s)||!C.call(L,s)&&(j.test(s)?B[s]=!0:(L[s]=!0,!1))}(o)&&(null===i?s.removeAttribute(o):s.setAttribute(o,\"\"+i)):u.mustUseProperty?s[u.propertyName]=null===i?3!==u.type&&\"\":i:(o=u.attributeName,a=u.attributeNamespace,null===i?s.removeAttribute(o):(i=3===(u=u.type)||4===u&&!0===i?\"\":\"\"+i,a?s.setAttributeNS(a,o,i):s.setAttribute(o,i))))}\"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height\".split(\" \").forEach((function(s){var o=s.replace(U,sa);$[o]=new v(o,1,!1,s,null,!1,!1)})),\"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type\".split(\" \").forEach((function(s){var o=s.replace(U,sa);$[o]=new v(o,1,!1,s,\"http://www.w3.org/1999/xlink\",!1,!1)})),[\"xml:base\",\"xml:lang\",\"xml:space\"].forEach((function(s){var o=s.replace(U,sa);$[o]=new v(o,1,!1,s,\"http://www.w3.org/XML/1998/namespace\",!1,!1)})),[\"tabIndex\",\"crossOrigin\"].forEach((function(s){$[s]=new v(s,1,!1,s.toLowerCase(),null,!1,!1)})),$.xlinkHref=new v(\"xlinkHref\",1,!1,\"xlink:href\",\"http://www.w3.org/1999/xlink\",!0,!1),[\"src\",\"href\",\"action\",\"formAction\"].forEach((function(s){$[s]=new v(s,1,!1,s.toLowerCase(),null,!0,!0)}));var V=a.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,z=Symbol.for(\"react.element\"),Y=Symbol.for(\"react.portal\"),Z=Symbol.for(\"react.fragment\"),ee=Symbol.for(\"react.strict_mode\"),ie=Symbol.for(\"react.profiler\"),ae=Symbol.for(\"react.provider\"),ce=Symbol.for(\"react.context\"),le=Symbol.for(\"react.forward_ref\"),pe=Symbol.for(\"react.suspense\"),de=Symbol.for(\"react.suspense_list\"),fe=Symbol.for(\"react.memo\"),ye=Symbol.for(\"react.lazy\");Symbol.for(\"react.scope\"),Symbol.for(\"react.debug_trace_mode\");var be=Symbol.for(\"react.offscreen\");Symbol.for(\"react.legacy_hidden\"),Symbol.for(\"react.cache\"),Symbol.for(\"react.tracing_marker\");var _e=Symbol.iterator;function Ka(s){return null===s||\"object\"!=typeof s?null:\"function\"==typeof(s=_e&&s[_e]||s[\"@@iterator\"])?s:null}var Se,we=Object.assign;function Ma(s){if(void 0===Se)try{throw Error()}catch(s){var o=s.stack.trim().match(/\\n( *(at )?)/);Se=o&&o[1]||\"\"}return\"\\n\"+Se+s}var xe=!1;function Oa(s,o){if(!s||xe)return\"\";xe=!0;var i=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{if(o)if(o=function(){throw Error()},Object.defineProperty(o.prototype,\"props\",{set:function(){throw Error()}}),\"object\"==typeof Reflect&&Reflect.construct){try{Reflect.construct(o,[])}catch(s){var a=s}Reflect.construct(s,[],o)}else{try{o.call()}catch(s){a=s}s.call(o.prototype)}else{try{throw Error()}catch(s){a=s}s()}}catch(o){if(o&&a&&\"string\"==typeof o.stack){for(var u=o.stack.split(\"\\n\"),_=a.stack.split(\"\\n\"),w=u.length-1,x=_.length-1;1<=w&&0<=x&&u[w]!==_[x];)x--;for(;1<=w&&0<=x;w--,x--)if(u[w]!==_[x]){if(1!==w||1!==x)do{if(w--,0>--x||u[w]!==_[x]){var C=\"\\n\"+u[w].replace(\" at new \",\" at \");return s.displayName&&C.includes(\"<anonymous>\")&&(C=C.replace(\"<anonymous>\",s.displayName)),C}}while(1<=w&&0<=x);break}}}finally{xe=!1,Error.prepareStackTrace=i}return(s=s?s.displayName||s.name:\"\")?Ma(s):\"\"}function Pa(s){switch(s.tag){case 5:return Ma(s.type);case 16:return Ma(\"Lazy\");case 13:return Ma(\"Suspense\");case 19:return Ma(\"SuspenseList\");case 0:case 2:case 15:return s=Oa(s.type,!1);case 11:return s=Oa(s.type.render,!1);case 1:return s=Oa(s.type,!0);default:return\"\"}}function Qa(s){if(null==s)return null;if(\"function\"==typeof s)return s.displayName||s.name||null;if(\"string\"==typeof s)return s;switch(s){case Z:return\"Fragment\";case Y:return\"Portal\";case ie:return\"Profiler\";case ee:return\"StrictMode\";case pe:return\"Suspense\";case de:return\"SuspenseList\"}if(\"object\"==typeof s)switch(s.$$typeof){case ce:return(s.displayName||\"Context\")+\".Consumer\";case ae:return(s._context.displayName||\"Context\")+\".Provider\";case le:var o=s.render;return(s=s.displayName)||(s=\"\"!==(s=o.displayName||o.name||\"\")?\"ForwardRef(\"+s+\")\":\"ForwardRef\"),s;case fe:return null!==(o=s.displayName||null)?o:Qa(s.type)||\"Memo\";case ye:o=s._payload,s=s._init;try{return Qa(s(o))}catch(s){}}return null}function Ra(s){var o=s.type;switch(s.tag){case 24:return\"Cache\";case 9:return(o.displayName||\"Context\")+\".Consumer\";case 10:return(o._context.displayName||\"Context\")+\".Provider\";case 18:return\"DehydratedFragment\";case 11:return s=(s=o.render).displayName||s.name||\"\",o.displayName||(\"\"!==s?\"ForwardRef(\"+s+\")\":\"ForwardRef\");case 7:return\"Fragment\";case 5:return o;case 4:return\"Portal\";case 3:return\"Root\";case 6:return\"Text\";case 16:return Qa(o);case 8:return o===ee?\"StrictMode\":\"Mode\";case 22:return\"Offscreen\";case 12:return\"Profiler\";case 21:return\"Scope\";case 13:return\"Suspense\";case 19:return\"SuspenseList\";case 25:return\"TracingMarker\";case 1:case 0:case 17:case 2:case 14:case 15:if(\"function\"==typeof o)return o.displayName||o.name||null;if(\"string\"==typeof o)return o}return null}function Sa(s){switch(typeof s){case\"boolean\":case\"number\":case\"string\":case\"undefined\":case\"object\":return s;default:return\"\"}}function Ta(s){var o=s.type;return(s=s.nodeName)&&\"input\"===s.toLowerCase()&&(\"checkbox\"===o||\"radio\"===o)}function Va(s){s._valueTracker||(s._valueTracker=function Ua(s){var o=Ta(s)?\"checked\":\"value\",i=Object.getOwnPropertyDescriptor(s.constructor.prototype,o),a=\"\"+s[o];if(!s.hasOwnProperty(o)&&void 0!==i&&\"function\"==typeof i.get&&\"function\"==typeof i.set){var u=i.get,_=i.set;return Object.defineProperty(s,o,{configurable:!0,get:function(){return u.call(this)},set:function(s){a=\"\"+s,_.call(this,s)}}),Object.defineProperty(s,o,{enumerable:i.enumerable}),{getValue:function(){return a},setValue:function(s){a=\"\"+s},stopTracking:function(){s._valueTracker=null,delete s[o]}}}}(s))}function Wa(s){if(!s)return!1;var o=s._valueTracker;if(!o)return!0;var i=o.getValue(),a=\"\";return s&&(a=Ta(s)?s.checked?\"true\":\"false\":s.value),(s=a)!==i&&(o.setValue(s),!0)}function Xa(s){if(void 0===(s=s||(\"undefined\"!=typeof document?document:void 0)))return null;try{return s.activeElement||s.body}catch(o){return s.body}}function Ya(s,o){var i=o.checked;return we({},o,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:null!=i?i:s._wrapperState.initialChecked})}function Za(s,o){var i=null==o.defaultValue?\"\":o.defaultValue,a=null!=o.checked?o.checked:o.defaultChecked;i=Sa(null!=o.value?o.value:i),s._wrapperState={initialChecked:a,initialValue:i,controlled:\"checkbox\"===o.type||\"radio\"===o.type?null!=o.checked:null!=o.value}}function ab(s,o){null!=(o=o.checked)&&ta(s,\"checked\",o,!1)}function bb(s,o){ab(s,o);var i=Sa(o.value),a=o.type;if(null!=i)\"number\"===a?(0===i&&\"\"===s.value||s.value!=i)&&(s.value=\"\"+i):s.value!==\"\"+i&&(s.value=\"\"+i);else if(\"submit\"===a||\"reset\"===a)return void s.removeAttribute(\"value\");o.hasOwnProperty(\"value\")?cb(s,o.type,i):o.hasOwnProperty(\"defaultValue\")&&cb(s,o.type,Sa(o.defaultValue)),null==o.checked&&null!=o.defaultChecked&&(s.defaultChecked=!!o.defaultChecked)}function db(s,o,i){if(o.hasOwnProperty(\"value\")||o.hasOwnProperty(\"defaultValue\")){var a=o.type;if(!(\"submit\"!==a&&\"reset\"!==a||void 0!==o.value&&null!==o.value))return;o=\"\"+s._wrapperState.initialValue,i||o===s.value||(s.value=o),s.defaultValue=o}\"\"!==(i=s.name)&&(s.name=\"\"),s.defaultChecked=!!s._wrapperState.initialChecked,\"\"!==i&&(s.name=i)}function cb(s,o,i){\"number\"===o&&Xa(s.ownerDocument)===s||(null==i?s.defaultValue=\"\"+s._wrapperState.initialValue:s.defaultValue!==\"\"+i&&(s.defaultValue=\"\"+i))}var Pe=Array.isArray;function fb(s,o,i,a){if(s=s.options,o){o={};for(var u=0;u<i.length;u++)o[\"$\"+i[u]]=!0;for(i=0;i<s.length;i++)u=o.hasOwnProperty(\"$\"+s[i].value),s[i].selected!==u&&(s[i].selected=u),u&&a&&(s[i].defaultSelected=!0)}else{for(i=\"\"+Sa(i),o=null,u=0;u<s.length;u++){if(s[u].value===i)return s[u].selected=!0,void(a&&(s[u].defaultSelected=!0));null!==o||s[u].disabled||(o=s[u])}null!==o&&(o.selected=!0)}}function gb(s,o){if(null!=o.dangerouslySetInnerHTML)throw Error(p(91));return we({},o,{value:void 0,defaultValue:void 0,children:\"\"+s._wrapperState.initialValue})}function hb(s,o){var i=o.value;if(null==i){if(i=o.children,o=o.defaultValue,null!=i){if(null!=o)throw Error(p(92));if(Pe(i)){if(1<i.length)throw Error(p(93));i=i[0]}o=i}null==o&&(o=\"\"),i=o}s._wrapperState={initialValue:Sa(i)}}function ib(s,o){var i=Sa(o.value),a=Sa(o.defaultValue);null!=i&&((i=\"\"+i)!==s.value&&(s.value=i),null==o.defaultValue&&s.defaultValue!==i&&(s.defaultValue=i)),null!=a&&(s.defaultValue=\"\"+a)}function jb(s){var o=s.textContent;o===s._wrapperState.initialValue&&\"\"!==o&&null!==o&&(s.value=o)}function kb(s){switch(s){case\"svg\":return\"http://www.w3.org/2000/svg\";case\"math\":return\"http://www.w3.org/1998/Math/MathML\";default:return\"http://www.w3.org/1999/xhtml\"}}function lb(s,o){return null==s||\"http://www.w3.org/1999/xhtml\"===s?kb(o):\"http://www.w3.org/2000/svg\"===s&&\"foreignObject\"===o?\"http://www.w3.org/1999/xhtml\":s}var Te,Re,$e=(Re=function(s,o){if(\"http://www.w3.org/2000/svg\"!==s.namespaceURI||\"innerHTML\"in s)s.innerHTML=o;else{for((Te=Te||document.createElement(\"div\")).innerHTML=\"<svg>\"+o.valueOf().toString()+\"</svg>\",o=Te.firstChild;s.firstChild;)s.removeChild(s.firstChild);for(;o.firstChild;)s.appendChild(o.firstChild)}},\"undefined\"!=typeof MSApp&&MSApp.execUnsafeLocalFunction?function(s,o,i,a){MSApp.execUnsafeLocalFunction((function(){return Re(s,o)}))}:Re);function ob(s,o){if(o){var i=s.firstChild;if(i&&i===s.lastChild&&3===i.nodeType)return void(i.nodeValue=o)}s.textContent=o}var qe={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},ze=[\"Webkit\",\"ms\",\"Moz\",\"O\"];function rb(s,o,i){return null==o||\"boolean\"==typeof o||\"\"===o?\"\":i||\"number\"!=typeof o||0===o||qe.hasOwnProperty(s)&&qe[s]?(\"\"+o).trim():o+\"px\"}function sb(s,o){for(var i in s=s.style,o)if(o.hasOwnProperty(i)){var a=0===i.indexOf(\"--\"),u=rb(i,o[i],a);\"float\"===i&&(i=\"cssFloat\"),a?s.setProperty(i,u):s[i]=u}}Object.keys(qe).forEach((function(s){ze.forEach((function(o){o=o+s.charAt(0).toUpperCase()+s.substring(1),qe[o]=qe[s]}))}));var We=we({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function ub(s,o){if(o){if(We[s]&&(null!=o.children||null!=o.dangerouslySetInnerHTML))throw Error(p(137,s));if(null!=o.dangerouslySetInnerHTML){if(null!=o.children)throw Error(p(60));if(\"object\"!=typeof o.dangerouslySetInnerHTML||!(\"__html\"in o.dangerouslySetInnerHTML))throw Error(p(61))}if(null!=o.style&&\"object\"!=typeof o.style)throw Error(p(62))}}function vb(s,o){if(-1===s.indexOf(\"-\"))return\"string\"==typeof o.is;switch(s){case\"annotation-xml\":case\"color-profile\":case\"font-face\":case\"font-face-src\":case\"font-face-uri\":case\"font-face-format\":case\"font-face-name\":case\"missing-glyph\":return!1;default:return!0}}var He=null;function xb(s){return(s=s.target||s.srcElement||window).correspondingUseElement&&(s=s.correspondingUseElement),3===s.nodeType?s.parentNode:s}var Ye=null,Xe=null,Qe=null;function Bb(s){if(s=Cb(s)){if(\"function\"!=typeof Ye)throw Error(p(280));var o=s.stateNode;o&&(o=Db(o),Ye(s.stateNode,s.type,o))}}function Eb(s){Xe?Qe?Qe.push(s):Qe=[s]:Xe=s}function Fb(){if(Xe){var s=Xe,o=Qe;if(Qe=Xe=null,Bb(s),o)for(s=0;s<o.length;s++)Bb(o[s])}}function Gb(s,o){return s(o)}function Hb(){}var et=!1;function Jb(s,o,i){if(et)return s(o,i);et=!0;try{return Gb(s,o,i)}finally{et=!1,(null!==Xe||null!==Qe)&&(Hb(),Fb())}}function Kb(s,o){var i=s.stateNode;if(null===i)return null;var a=Db(i);if(null===a)return null;i=a[o];e:switch(o){case\"onClick\":case\"onClickCapture\":case\"onDoubleClick\":case\"onDoubleClickCapture\":case\"onMouseDown\":case\"onMouseDownCapture\":case\"onMouseMove\":case\"onMouseMoveCapture\":case\"onMouseUp\":case\"onMouseUpCapture\":case\"onMouseEnter\":(a=!a.disabled)||(a=!(\"button\"===(s=s.type)||\"input\"===s||\"select\"===s||\"textarea\"===s)),s=!a;break e;default:s=!1}if(s)return null;if(i&&\"function\"!=typeof i)throw Error(p(231,o,typeof i));return i}var tt=!1;if(x)try{var rt={};Object.defineProperty(rt,\"passive\",{get:function(){tt=!0}}),window.addEventListener(\"test\",rt,rt),window.removeEventListener(\"test\",rt,rt)}catch(Re){tt=!1}function Nb(s,o,i,a,u,_,w,x,C){var j=Array.prototype.slice.call(arguments,3);try{o.apply(i,j)}catch(s){this.onError(s)}}var nt=!1,st=null,ot=!1,it=null,at={onError:function(s){nt=!0,st=s}};function Tb(s,o,i,a,u,_,w,x,C){nt=!1,st=null,Nb.apply(at,arguments)}function Vb(s){var o=s,i=s;if(s.alternate)for(;o.return;)o=o.return;else{s=o;do{!!(4098&(o=s).flags)&&(i=o.return),s=o.return}while(s)}return 3===o.tag?i:null}function Wb(s){if(13===s.tag){var o=s.memoizedState;if(null===o&&(null!==(s=s.alternate)&&(o=s.memoizedState)),null!==o)return o.dehydrated}return null}function Xb(s){if(Vb(s)!==s)throw Error(p(188))}function Zb(s){return null!==(s=function Yb(s){var o=s.alternate;if(!o){if(null===(o=Vb(s)))throw Error(p(188));return o!==s?null:s}for(var i=s,a=o;;){var u=i.return;if(null===u)break;var _=u.alternate;if(null===_){if(null!==(a=u.return)){i=a;continue}break}if(u.child===_.child){for(_=u.child;_;){if(_===i)return Xb(u),s;if(_===a)return Xb(u),o;_=_.sibling}throw Error(p(188))}if(i.return!==a.return)i=u,a=_;else{for(var w=!1,x=u.child;x;){if(x===i){w=!0,i=u,a=_;break}if(x===a){w=!0,a=u,i=_;break}x=x.sibling}if(!w){for(x=_.child;x;){if(x===i){w=!0,i=_,a=u;break}if(x===a){w=!0,a=_,i=u;break}x=x.sibling}if(!w)throw Error(p(189))}}if(i.alternate!==a)throw Error(p(190))}if(3!==i.tag)throw Error(p(188));return i.stateNode.current===i?s:o}(s))?$b(s):null}function $b(s){if(5===s.tag||6===s.tag)return s;for(s=s.child;null!==s;){var o=$b(s);if(null!==o)return o;s=s.sibling}return null}var ct=u.unstable_scheduleCallback,lt=u.unstable_cancelCallback,ut=u.unstable_shouldYield,pt=u.unstable_requestPaint,ht=u.unstable_now,dt=u.unstable_getCurrentPriorityLevel,mt=u.unstable_ImmediatePriority,gt=u.unstable_UserBlockingPriority,yt=u.unstable_NormalPriority,vt=u.unstable_LowPriority,bt=u.unstable_IdlePriority,_t=null,St=null;var Et=Math.clz32?Math.clz32:function nc(s){return s>>>=0,0===s?32:31-(wt(s)/xt|0)|0},wt=Math.log,xt=Math.LN2;var kt=64,Ot=4194304;function tc(s){switch(s&-s){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return 4194240&s;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return 130023424&s;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return s}}function uc(s,o){var i=s.pendingLanes;if(0===i)return 0;var a=0,u=s.suspendedLanes,_=s.pingedLanes,w=268435455&i;if(0!==w){var x=w&~u;0!==x?a=tc(x):0!==(_&=w)&&(a=tc(_))}else 0!==(w=i&~u)?a=tc(w):0!==_&&(a=tc(_));if(0===a)return 0;if(0!==o&&o!==a&&!(o&u)&&((u=a&-a)>=(_=o&-o)||16===u&&4194240&_))return o;if(4&a&&(a|=16&i),0!==(o=s.entangledLanes))for(s=s.entanglements,o&=a;0<o;)u=1<<(i=31-Et(o)),a|=s[i],o&=~u;return a}function vc(s,o){switch(s){case 1:case 2:case 4:return o+250;case 8:case 16:case 32:case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return o+5e3;default:return-1}}function xc(s){return 0!==(s=-1073741825&s.pendingLanes)?s:1073741824&s?1073741824:0}function yc(){var s=kt;return!(4194240&(kt<<=1))&&(kt=64),s}function zc(s){for(var o=[],i=0;31>i;i++)o.push(s);return o}function Ac(s,o,i){s.pendingLanes|=o,536870912!==o&&(s.suspendedLanes=0,s.pingedLanes=0),(s=s.eventTimes)[o=31-Et(o)]=i}function Cc(s,o){var i=s.entangledLanes|=o;for(s=s.entanglements;i;){var a=31-Et(i),u=1<<a;u&o|s[a]&o&&(s[a]|=o),i&=~u}}var At=0;function Dc(s){return 1<(s&=-s)?4<s?268435455&s?16:536870912:4:1}var Ct,jt,Pt,It,Tt,Nt=!1,Mt=[],Rt=null,Dt=null,Lt=null,Ft=new Map,Bt=new Map,$t=[],qt=\"mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput copy cut paste click change contextmenu reset submit\".split(\" \");function Sc(s,o){switch(s){case\"focusin\":case\"focusout\":Rt=null;break;case\"dragenter\":case\"dragleave\":Dt=null;break;case\"mouseover\":case\"mouseout\":Lt=null;break;case\"pointerover\":case\"pointerout\":Ft.delete(o.pointerId);break;case\"gotpointercapture\":case\"lostpointercapture\":Bt.delete(o.pointerId)}}function Tc(s,o,i,a,u,_){return null===s||s.nativeEvent!==_?(s={blockedOn:o,domEventName:i,eventSystemFlags:a,nativeEvent:_,targetContainers:[u]},null!==o&&(null!==(o=Cb(o))&&jt(o)),s):(s.eventSystemFlags|=a,o=s.targetContainers,null!==u&&-1===o.indexOf(u)&&o.push(u),s)}function Vc(s){var o=Wc(s.target);if(null!==o){var i=Vb(o);if(null!==i)if(13===(o=i.tag)){if(null!==(o=Wb(i)))return s.blockedOn=o,void Tt(s.priority,(function(){Pt(i)}))}else if(3===o&&i.stateNode.current.memoizedState.isDehydrated)return void(s.blockedOn=3===i.tag?i.stateNode.containerInfo:null)}s.blockedOn=null}function Xc(s){if(null!==s.blockedOn)return!1;for(var o=s.targetContainers;0<o.length;){var i=Yc(s.domEventName,s.eventSystemFlags,o[0],s.nativeEvent);if(null!==i)return null!==(o=Cb(i))&&jt(o),s.blockedOn=i,!1;var a=new(i=s.nativeEvent).constructor(i.type,i);He=a,i.target.dispatchEvent(a),He=null,o.shift()}return!0}function Zc(s,o,i){Xc(s)&&i.delete(o)}function $c(){Nt=!1,null!==Rt&&Xc(Rt)&&(Rt=null),null!==Dt&&Xc(Dt)&&(Dt=null),null!==Lt&&Xc(Lt)&&(Lt=null),Ft.forEach(Zc),Bt.forEach(Zc)}function ad(s,o){s.blockedOn===o&&(s.blockedOn=null,Nt||(Nt=!0,u.unstable_scheduleCallback(u.unstable_NormalPriority,$c)))}function bd(s){function b(o){return ad(o,s)}if(0<Mt.length){ad(Mt[0],s);for(var o=1;o<Mt.length;o++){var i=Mt[o];i.blockedOn===s&&(i.blockedOn=null)}}for(null!==Rt&&ad(Rt,s),null!==Dt&&ad(Dt,s),null!==Lt&&ad(Lt,s),Ft.forEach(b),Bt.forEach(b),o=0;o<$t.length;o++)(i=$t[o]).blockedOn===s&&(i.blockedOn=null);for(;0<$t.length&&null===(o=$t[0]).blockedOn;)Vc(o),null===o.blockedOn&&$t.shift()}var Ut=V.ReactCurrentBatchConfig,Vt=!0;function ed(s,o,i,a){var u=At,_=Ut.transition;Ut.transition=null;try{At=1,fd(s,o,i,a)}finally{At=u,Ut.transition=_}}function gd(s,o,i,a){var u=At,_=Ut.transition;Ut.transition=null;try{At=4,fd(s,o,i,a)}finally{At=u,Ut.transition=_}}function fd(s,o,i,a){if(Vt){var u=Yc(s,o,i,a);if(null===u)hd(s,o,a,zt,i),Sc(s,a);else if(function Uc(s,o,i,a,u){switch(o){case\"focusin\":return Rt=Tc(Rt,s,o,i,a,u),!0;case\"dragenter\":return Dt=Tc(Dt,s,o,i,a,u),!0;case\"mouseover\":return Lt=Tc(Lt,s,o,i,a,u),!0;case\"pointerover\":var _=u.pointerId;return Ft.set(_,Tc(Ft.get(_)||null,s,o,i,a,u)),!0;case\"gotpointercapture\":return _=u.pointerId,Bt.set(_,Tc(Bt.get(_)||null,s,o,i,a,u)),!0}return!1}(u,s,o,i,a))a.stopPropagation();else if(Sc(s,a),4&o&&-1<qt.indexOf(s)){for(;null!==u;){var _=Cb(u);if(null!==_&&Ct(_),null===(_=Yc(s,o,i,a))&&hd(s,o,a,zt,i),_===u)break;u=_}null!==u&&a.stopPropagation()}else hd(s,o,a,null,i)}}var zt=null;function Yc(s,o,i,a){if(zt=null,null!==(s=Wc(s=xb(a))))if(null===(o=Vb(s)))s=null;else if(13===(i=o.tag)){if(null!==(s=Wb(o)))return s;s=null}else if(3===i){if(o.stateNode.current.memoizedState.isDehydrated)return 3===o.tag?o.stateNode.containerInfo:null;s=null}else o!==s&&(s=null);return zt=s,null}function jd(s){switch(s){case\"cancel\":case\"click\":case\"close\":case\"contextmenu\":case\"copy\":case\"cut\":case\"auxclick\":case\"dblclick\":case\"dragend\":case\"dragstart\":case\"drop\":case\"focusin\":case\"focusout\":case\"input\":case\"invalid\":case\"keydown\":case\"keypress\":case\"keyup\":case\"mousedown\":case\"mouseup\":case\"paste\":case\"pause\":case\"play\":case\"pointercancel\":case\"pointerdown\":case\"pointerup\":case\"ratechange\":case\"reset\":case\"resize\":case\"seeked\":case\"submit\":case\"touchcancel\":case\"touchend\":case\"touchstart\":case\"volumechange\":case\"change\":case\"selectionchange\":case\"textInput\":case\"compositionstart\":case\"compositionend\":case\"compositionupdate\":case\"beforeblur\":case\"afterblur\":case\"beforeinput\":case\"blur\":case\"fullscreenchange\":case\"focus\":case\"hashchange\":case\"popstate\":case\"select\":case\"selectstart\":return 1;case\"drag\":case\"dragenter\":case\"dragexit\":case\"dragleave\":case\"dragover\":case\"mousemove\":case\"mouseout\":case\"mouseover\":case\"pointermove\":case\"pointerout\":case\"pointerover\":case\"scroll\":case\"toggle\":case\"touchmove\":case\"wheel\":case\"mouseenter\":case\"mouseleave\":case\"pointerenter\":case\"pointerleave\":return 4;case\"message\":switch(dt()){case mt:return 1;case gt:return 4;case yt:case vt:return 16;case bt:return 536870912;default:return 16}default:return 16}}var Wt=null,Jt=null,Ht=null;function nd(){if(Ht)return Ht;var s,o,i=Jt,a=i.length,u=\"value\"in Wt?Wt.value:Wt.textContent,_=u.length;for(s=0;s<a&&i[s]===u[s];s++);var w=a-s;for(o=1;o<=w&&i[a-o]===u[_-o];o++);return Ht=u.slice(s,1<o?1-o:void 0)}function od(s){var o=s.keyCode;return\"charCode\"in s?0===(s=s.charCode)&&13===o&&(s=13):s=o,10===s&&(s=13),32<=s||13===s?s:0}function pd(){return!0}function qd(){return!1}function rd(s){function b(o,i,a,u,_){for(var w in this._reactName=o,this._targetInst=a,this.type=i,this.nativeEvent=u,this.target=_,this.currentTarget=null,s)s.hasOwnProperty(w)&&(o=s[w],this[w]=o?o(u):u[w]);return this.isDefaultPrevented=(null!=u.defaultPrevented?u.defaultPrevented:!1===u.returnValue)?pd:qd,this.isPropagationStopped=qd,this}return we(b.prototype,{preventDefault:function(){this.defaultPrevented=!0;var s=this.nativeEvent;s&&(s.preventDefault?s.preventDefault():\"unknown\"!=typeof s.returnValue&&(s.returnValue=!1),this.isDefaultPrevented=pd)},stopPropagation:function(){var s=this.nativeEvent;s&&(s.stopPropagation?s.stopPropagation():\"unknown\"!=typeof s.cancelBubble&&(s.cancelBubble=!0),this.isPropagationStopped=pd)},persist:function(){},isPersistent:pd}),b}var Kt,Gt,Yt,Xt={eventPhase:0,bubbles:0,cancelable:0,timeStamp:function(s){return s.timeStamp||Date.now()},defaultPrevented:0,isTrusted:0},Qt=rd(Xt),Zt=we({},Xt,{view:0,detail:0}),er=rd(Zt),tr=we({},Zt,{screenX:0,screenY:0,clientX:0,clientY:0,pageX:0,pageY:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,getModifierState:zd,button:0,buttons:0,relatedTarget:function(s){return void 0===s.relatedTarget?s.fromElement===s.srcElement?s.toElement:s.fromElement:s.relatedTarget},movementX:function(s){return\"movementX\"in s?s.movementX:(s!==Yt&&(Yt&&\"mousemove\"===s.type?(Kt=s.screenX-Yt.screenX,Gt=s.screenY-Yt.screenY):Gt=Kt=0,Yt=s),Kt)},movementY:function(s){return\"movementY\"in s?s.movementY:Gt}}),rr=rd(tr),nr=rd(we({},tr,{dataTransfer:0})),sr=rd(we({},Zt,{relatedTarget:0})),ir=rd(we({},Xt,{animationName:0,elapsedTime:0,pseudoElement:0})),ar=we({},Xt,{clipboardData:function(s){return\"clipboardData\"in s?s.clipboardData:window.clipboardData}}),cr=rd(ar),lr=rd(we({},Xt,{data:0})),ur={Esc:\"Escape\",Spacebar:\" \",Left:\"ArrowLeft\",Up:\"ArrowUp\",Right:\"ArrowRight\",Down:\"ArrowDown\",Del:\"Delete\",Win:\"OS\",Menu:\"ContextMenu\",Apps:\"ContextMenu\",Scroll:\"ScrollLock\",MozPrintableKey:\"Unidentified\"},pr={8:\"Backspace\",9:\"Tab\",12:\"Clear\",13:\"Enter\",16:\"Shift\",17:\"Control\",18:\"Alt\",19:\"Pause\",20:\"CapsLock\",27:\"Escape\",32:\" \",33:\"PageUp\",34:\"PageDown\",35:\"End\",36:\"Home\",37:\"ArrowLeft\",38:\"ArrowUp\",39:\"ArrowRight\",40:\"ArrowDown\",45:\"Insert\",46:\"Delete\",112:\"F1\",113:\"F2\",114:\"F3\",115:\"F4\",116:\"F5\",117:\"F6\",118:\"F7\",119:\"F8\",120:\"F9\",121:\"F10\",122:\"F11\",123:\"F12\",144:\"NumLock\",145:\"ScrollLock\",224:\"Meta\"},dr={Alt:\"altKey\",Control:\"ctrlKey\",Meta:\"metaKey\",Shift:\"shiftKey\"};function Pd(s){var o=this.nativeEvent;return o.getModifierState?o.getModifierState(s):!!(s=dr[s])&&!!o[s]}function zd(){return Pd}var fr=we({},Zt,{key:function(s){if(s.key){var o=ur[s.key]||s.key;if(\"Unidentified\"!==o)return o}return\"keypress\"===s.type?13===(s=od(s))?\"Enter\":String.fromCharCode(s):\"keydown\"===s.type||\"keyup\"===s.type?pr[s.keyCode]||\"Unidentified\":\"\"},code:0,location:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,repeat:0,locale:0,getModifierState:zd,charCode:function(s){return\"keypress\"===s.type?od(s):0},keyCode:function(s){return\"keydown\"===s.type||\"keyup\"===s.type?s.keyCode:0},which:function(s){return\"keypress\"===s.type?od(s):\"keydown\"===s.type||\"keyup\"===s.type?s.keyCode:0}}),mr=rd(fr),gr=rd(we({},tr,{pointerId:0,width:0,height:0,pressure:0,tangentialPressure:0,tiltX:0,tiltY:0,twist:0,pointerType:0,isPrimary:0})),yr=rd(we({},Zt,{touches:0,targetTouches:0,changedTouches:0,altKey:0,metaKey:0,ctrlKey:0,shiftKey:0,getModifierState:zd})),vr=rd(we({},Xt,{propertyName:0,elapsedTime:0,pseudoElement:0})),br=we({},tr,{deltaX:function(s){return\"deltaX\"in s?s.deltaX:\"wheelDeltaX\"in s?-s.wheelDeltaX:0},deltaY:function(s){return\"deltaY\"in s?s.deltaY:\"wheelDeltaY\"in s?-s.wheelDeltaY:\"wheelDelta\"in s?-s.wheelDelta:0},deltaZ:0,deltaMode:0}),_r=rd(br),Sr=[9,13,27,32],Er=x&&\"CompositionEvent\"in window,wr=null;x&&\"documentMode\"in document&&(wr=document.documentMode);var xr=x&&\"TextEvent\"in window&&!wr,kr=x&&(!Er||wr&&8<wr&&11>=wr),Or=String.fromCharCode(32),Ar=!1;function ge(s,o){switch(s){case\"keyup\":return-1!==Sr.indexOf(o.keyCode);case\"keydown\":return 229!==o.keyCode;case\"keypress\":case\"mousedown\":case\"focusout\":return!0;default:return!1}}function he(s){return\"object\"==typeof(s=s.detail)&&\"data\"in s?s.data:null}var Cr=!1;var jr={color:!0,date:!0,datetime:!0,\"datetime-local\":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0};function me(s){var o=s&&s.nodeName&&s.nodeName.toLowerCase();return\"input\"===o?!!jr[s.type]:\"textarea\"===o}function ne(s,o,i,a){Eb(a),0<(o=oe(o,\"onChange\")).length&&(i=new Qt(\"onChange\",\"change\",null,i,a),s.push({event:i,listeners:o}))}var Pr=null,Ir=null;function re(s){se(s,0)}function te(s){if(Wa(ue(s)))return s}function ve(s,o){if(\"change\"===s)return o}var Tr=!1;if(x){var Nr;if(x){var Mr=\"oninput\"in document;if(!Mr){var Rr=document.createElement(\"div\");Rr.setAttribute(\"oninput\",\"return;\"),Mr=\"function\"==typeof Rr.oninput}Nr=Mr}else Nr=!1;Tr=Nr&&(!document.documentMode||9<document.documentMode)}function Ae(){Pr&&(Pr.detachEvent(\"onpropertychange\",Be),Ir=Pr=null)}function Be(s){if(\"value\"===s.propertyName&&te(Ir)){var o=[];ne(o,Ir,s,xb(s)),Jb(re,o)}}function Ce(s,o,i){\"focusin\"===s?(Ae(),Ir=i,(Pr=o).attachEvent(\"onpropertychange\",Be)):\"focusout\"===s&&Ae()}function De(s){if(\"selectionchange\"===s||\"keyup\"===s||\"keydown\"===s)return te(Ir)}function Ee(s,o){if(\"click\"===s)return te(o)}function Fe(s,o){if(\"input\"===s||\"change\"===s)return te(o)}var Dr=\"function\"==typeof Object.is?Object.is:function Ge(s,o){return s===o&&(0!==s||1/s==1/o)||s!=s&&o!=o};function Ie(s,o){if(Dr(s,o))return!0;if(\"object\"!=typeof s||null===s||\"object\"!=typeof o||null===o)return!1;var i=Object.keys(s),a=Object.keys(o);if(i.length!==a.length)return!1;for(a=0;a<i.length;a++){var u=i[a];if(!C.call(o,u)||!Dr(s[u],o[u]))return!1}return!0}function Je(s){for(;s&&s.firstChild;)s=s.firstChild;return s}function Ke(s,o){var i,a=Je(s);for(s=0;a;){if(3===a.nodeType){if(i=s+a.textContent.length,s<=o&&i>=o)return{node:a,offset:o-s};s=i}e:{for(;a;){if(a.nextSibling){a=a.nextSibling;break e}a=a.parentNode}a=void 0}a=Je(a)}}function Le(s,o){return!(!s||!o)&&(s===o||(!s||3!==s.nodeType)&&(o&&3===o.nodeType?Le(s,o.parentNode):\"contains\"in s?s.contains(o):!!s.compareDocumentPosition&&!!(16&s.compareDocumentPosition(o))))}function Me(){for(var s=window,o=Xa();o instanceof s.HTMLIFrameElement;){try{var i=\"string\"==typeof o.contentWindow.location.href}catch(s){i=!1}if(!i)break;o=Xa((s=o.contentWindow).document)}return o}function Ne(s){var o=s&&s.nodeName&&s.nodeName.toLowerCase();return o&&(\"input\"===o&&(\"text\"===s.type||\"search\"===s.type||\"tel\"===s.type||\"url\"===s.type||\"password\"===s.type)||\"textarea\"===o||\"true\"===s.contentEditable)}function Oe(s){var o=Me(),i=s.focusedElem,a=s.selectionRange;if(o!==i&&i&&i.ownerDocument&&Le(i.ownerDocument.documentElement,i)){if(null!==a&&Ne(i))if(o=a.start,void 0===(s=a.end)&&(s=o),\"selectionStart\"in i)i.selectionStart=o,i.selectionEnd=Math.min(s,i.value.length);else if((s=(o=i.ownerDocument||document)&&o.defaultView||window).getSelection){s=s.getSelection();var u=i.textContent.length,_=Math.min(a.start,u);a=void 0===a.end?_:Math.min(a.end,u),!s.extend&&_>a&&(u=a,a=_,_=u),u=Ke(i,_);var w=Ke(i,a);u&&w&&(1!==s.rangeCount||s.anchorNode!==u.node||s.anchorOffset!==u.offset||s.focusNode!==w.node||s.focusOffset!==w.offset)&&((o=o.createRange()).setStart(u.node,u.offset),s.removeAllRanges(),_>a?(s.addRange(o),s.extend(w.node,w.offset)):(o.setEnd(w.node,w.offset),s.addRange(o)))}for(o=[],s=i;s=s.parentNode;)1===s.nodeType&&o.push({element:s,left:s.scrollLeft,top:s.scrollTop});for(\"function\"==typeof i.focus&&i.focus(),i=0;i<o.length;i++)(s=o[i]).element.scrollLeft=s.left,s.element.scrollTop=s.top}}var Lr=x&&\"documentMode\"in document&&11>=document.documentMode,Fr=null,Br=null,$r=null,qr=!1;function Ue(s,o,i){var a=i.window===i?i.document:9===i.nodeType?i:i.ownerDocument;qr||null==Fr||Fr!==Xa(a)||(\"selectionStart\"in(a=Fr)&&Ne(a)?a={start:a.selectionStart,end:a.selectionEnd}:a={anchorNode:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection()).anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset},$r&&Ie($r,a)||($r=a,0<(a=oe(Br,\"onSelect\")).length&&(o=new Qt(\"onSelect\",\"select\",null,o,i),s.push({event:o,listeners:a}),o.target=Fr)))}function Ve(s,o){var i={};return i[s.toLowerCase()]=o.toLowerCase(),i[\"Webkit\"+s]=\"webkit\"+o,i[\"Moz\"+s]=\"moz\"+o,i}var Ur={animationend:Ve(\"Animation\",\"AnimationEnd\"),animationiteration:Ve(\"Animation\",\"AnimationIteration\"),animationstart:Ve(\"Animation\",\"AnimationStart\"),transitionend:Ve(\"Transition\",\"TransitionEnd\")},Vr={},zr={};function Ze(s){if(Vr[s])return Vr[s];if(!Ur[s])return s;var o,i=Ur[s];for(o in i)if(i.hasOwnProperty(o)&&o in zr)return Vr[s]=i[o];return s}x&&(zr=document.createElement(\"div\").style,\"AnimationEvent\"in window||(delete Ur.animationend.animation,delete Ur.animationiteration.animation,delete Ur.animationstart.animation),\"TransitionEvent\"in window||delete Ur.transitionend.transition);var Wr=Ze(\"animationend\"),Jr=Ze(\"animationiteration\"),Hr=Ze(\"animationstart\"),Kr=Ze(\"transitionend\"),Gr=new Map,Yr=\"abort auxClick cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel\".split(\" \");function ff(s,o){Gr.set(s,o),fa(o,[s])}for(var Xr=0;Xr<Yr.length;Xr++){var Qr=Yr[Xr];ff(Qr.toLowerCase(),\"on\"+(Qr[0].toUpperCase()+Qr.slice(1)))}ff(Wr,\"onAnimationEnd\"),ff(Jr,\"onAnimationIteration\"),ff(Hr,\"onAnimationStart\"),ff(\"dblclick\",\"onDoubleClick\"),ff(\"focusin\",\"onFocus\"),ff(\"focusout\",\"onBlur\"),ff(Kr,\"onTransitionEnd\"),ha(\"onMouseEnter\",[\"mouseout\",\"mouseover\"]),ha(\"onMouseLeave\",[\"mouseout\",\"mouseover\"]),ha(\"onPointerEnter\",[\"pointerout\",\"pointerover\"]),ha(\"onPointerLeave\",[\"pointerout\",\"pointerover\"]),fa(\"onChange\",\"change click focusin focusout input keydown keyup selectionchange\".split(\" \")),fa(\"onSelect\",\"focusout contextmenu dragend focusin keydown keyup mousedown mouseup selectionchange\".split(\" \")),fa(\"onBeforeInput\",[\"compositionend\",\"keypress\",\"textInput\",\"paste\"]),fa(\"onCompositionEnd\",\"compositionend focusout keydown keypress keyup mousedown\".split(\" \")),fa(\"onCompositionStart\",\"compositionstart focusout keydown keypress keyup mousedown\".split(\" \")),fa(\"onCompositionUpdate\",\"compositionupdate focusout keydown keypress keyup mousedown\".split(\" \"));var Zr=\"abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting\".split(\" \"),en=new Set(\"cancel close invalid load scroll toggle\".split(\" \").concat(Zr));function nf(s,o,i){var a=s.type||\"unknown-event\";s.currentTarget=i,function Ub(s,o,i,a,u,_,w,x,C){if(Tb.apply(this,arguments),nt){if(!nt)throw Error(p(198));var j=st;nt=!1,st=null,ot||(ot=!0,it=j)}}(a,o,void 0,s),s.currentTarget=null}function se(s,o){o=!!(4&o);for(var i=0;i<s.length;i++){var a=s[i],u=a.event;a=a.listeners;e:{var _=void 0;if(o)for(var w=a.length-1;0<=w;w--){var x=a[w],C=x.instance,j=x.currentTarget;if(x=x.listener,C!==_&&u.isPropagationStopped())break e;nf(u,x,j),_=C}else for(w=0;w<a.length;w++){if(C=(x=a[w]).instance,j=x.currentTarget,x=x.listener,C!==_&&u.isPropagationStopped())break e;nf(u,x,j),_=C}}}if(ot)throw s=it,ot=!1,it=null,s}function D(s,o){var i=o[mn];void 0===i&&(i=o[mn]=new Set);var a=s+\"__bubble\";i.has(a)||(pf(o,s,2,!1),i.add(a))}function qf(s,o,i){var a=0;o&&(a|=4),pf(i,s,a,o)}var tn=\"_reactListening\"+Math.random().toString(36).slice(2);function sf(s){if(!s[tn]){s[tn]=!0,_.forEach((function(o){\"selectionchange\"!==o&&(en.has(o)||qf(o,!1,s),qf(o,!0,s))}));var o=9===s.nodeType?s:s.ownerDocument;null===o||o[tn]||(o[tn]=!0,qf(\"selectionchange\",!1,o))}}function pf(s,o,i,a){switch(jd(o)){case 1:var u=ed;break;case 4:u=gd;break;default:u=fd}i=u.bind(null,o,i,s),u=void 0,!tt||\"touchstart\"!==o&&\"touchmove\"!==o&&\"wheel\"!==o||(u=!0),a?void 0!==u?s.addEventListener(o,i,{capture:!0,passive:u}):s.addEventListener(o,i,!0):void 0!==u?s.addEventListener(o,i,{passive:u}):s.addEventListener(o,i,!1)}function hd(s,o,i,a,u){var _=a;if(!(1&o||2&o||null===a))e:for(;;){if(null===a)return;var w=a.tag;if(3===w||4===w){var x=a.stateNode.containerInfo;if(x===u||8===x.nodeType&&x.parentNode===u)break;if(4===w)for(w=a.return;null!==w;){var C=w.tag;if((3===C||4===C)&&((C=w.stateNode.containerInfo)===u||8===C.nodeType&&C.parentNode===u))return;w=w.return}for(;null!==x;){if(null===(w=Wc(x)))return;if(5===(C=w.tag)||6===C){a=_=w;continue e}x=x.parentNode}}a=a.return}Jb((function(){var a=_,u=xb(i),w=[];e:{var x=Gr.get(s);if(void 0!==x){var C=Qt,j=s;switch(s){case\"keypress\":if(0===od(i))break e;case\"keydown\":case\"keyup\":C=mr;break;case\"focusin\":j=\"focus\",C=sr;break;case\"focusout\":j=\"blur\",C=sr;break;case\"beforeblur\":case\"afterblur\":C=sr;break;case\"click\":if(2===i.button)break e;case\"auxclick\":case\"dblclick\":case\"mousedown\":case\"mousemove\":case\"mouseup\":case\"mouseout\":case\"mouseover\":case\"contextmenu\":C=rr;break;case\"drag\":case\"dragend\":case\"dragenter\":case\"dragexit\":case\"dragleave\":case\"dragover\":case\"dragstart\":case\"drop\":C=nr;break;case\"touchcancel\":case\"touchend\":case\"touchmove\":case\"touchstart\":C=yr;break;case Wr:case Jr:case Hr:C=ir;break;case Kr:C=vr;break;case\"scroll\":C=er;break;case\"wheel\":C=_r;break;case\"copy\":case\"cut\":case\"paste\":C=cr;break;case\"gotpointercapture\":case\"lostpointercapture\":case\"pointercancel\":case\"pointerdown\":case\"pointermove\":case\"pointerout\":case\"pointerover\":case\"pointerup\":C=gr}var L=!!(4&o),B=!L&&\"scroll\"===s,$=L?null!==x?x+\"Capture\":null:x;L=[];for(var U,V=a;null!==V;){var z=(U=V).stateNode;if(5===U.tag&&null!==z&&(U=z,null!==$&&(null!=(z=Kb(V,$))&&L.push(tf(V,z,U)))),B)break;V=V.return}0<L.length&&(x=new C(x,j,null,i,u),w.push({event:x,listeners:L}))}}if(!(7&o)){if(C=\"mouseout\"===s||\"pointerout\"===s,(!(x=\"mouseover\"===s||\"pointerover\"===s)||i===He||!(j=i.relatedTarget||i.fromElement)||!Wc(j)&&!j[fn])&&(C||x)&&(x=u.window===u?u:(x=u.ownerDocument)?x.defaultView||x.parentWindow:window,C?(C=a,null!==(j=(j=i.relatedTarget||i.toElement)?Wc(j):null)&&(j!==(B=Vb(j))||5!==j.tag&&6!==j.tag)&&(j=null)):(C=null,j=a),C!==j)){if(L=rr,z=\"onMouseLeave\",$=\"onMouseEnter\",V=\"mouse\",\"pointerout\"!==s&&\"pointerover\"!==s||(L=gr,z=\"onPointerLeave\",$=\"onPointerEnter\",V=\"pointer\"),B=null==C?x:ue(C),U=null==j?x:ue(j),(x=new L(z,V+\"leave\",C,i,u)).target=B,x.relatedTarget=U,z=null,Wc(u)===a&&((L=new L($,V+\"enter\",j,i,u)).target=U,L.relatedTarget=B,z=L),B=z,C&&j)e:{for($=j,V=0,U=L=C;U;U=vf(U))V++;for(U=0,z=$;z;z=vf(z))U++;for(;0<V-U;)L=vf(L),V--;for(;0<U-V;)$=vf($),U--;for(;V--;){if(L===$||null!==$&&L===$.alternate)break e;L=vf(L),$=vf($)}L=null}else L=null;null!==C&&wf(w,x,C,L,!1),null!==j&&null!==B&&wf(w,B,j,L,!0)}if(\"select\"===(C=(x=a?ue(a):window).nodeName&&x.nodeName.toLowerCase())||\"input\"===C&&\"file\"===x.type)var Y=ve;else if(me(x))if(Tr)Y=Fe;else{Y=De;var Z=Ce}else(C=x.nodeName)&&\"input\"===C.toLowerCase()&&(\"checkbox\"===x.type||\"radio\"===x.type)&&(Y=Ee);switch(Y&&(Y=Y(s,a))?ne(w,Y,i,u):(Z&&Z(s,x,a),\"focusout\"===s&&(Z=x._wrapperState)&&Z.controlled&&\"number\"===x.type&&cb(x,\"number\",x.value)),Z=a?ue(a):window,s){case\"focusin\":(me(Z)||\"true\"===Z.contentEditable)&&(Fr=Z,Br=a,$r=null);break;case\"focusout\":$r=Br=Fr=null;break;case\"mousedown\":qr=!0;break;case\"contextmenu\":case\"mouseup\":case\"dragend\":qr=!1,Ue(w,i,u);break;case\"selectionchange\":if(Lr)break;case\"keydown\":case\"keyup\":Ue(w,i,u)}var ee;if(Er)e:{switch(s){case\"compositionstart\":var ie=\"onCompositionStart\";break e;case\"compositionend\":ie=\"onCompositionEnd\";break e;case\"compositionupdate\":ie=\"onCompositionUpdate\";break e}ie=void 0}else Cr?ge(s,i)&&(ie=\"onCompositionEnd\"):\"keydown\"===s&&229===i.keyCode&&(ie=\"onCompositionStart\");ie&&(kr&&\"ko\"!==i.locale&&(Cr||\"onCompositionStart\"!==ie?\"onCompositionEnd\"===ie&&Cr&&(ee=nd()):(Jt=\"value\"in(Wt=u)?Wt.value:Wt.textContent,Cr=!0)),0<(Z=oe(a,ie)).length&&(ie=new lr(ie,s,null,i,u),w.push({event:ie,listeners:Z}),ee?ie.data=ee:null!==(ee=he(i))&&(ie.data=ee))),(ee=xr?function je(s,o){switch(s){case\"compositionend\":return he(o);case\"keypress\":return 32!==o.which?null:(Ar=!0,Or);case\"textInput\":return(s=o.data)===Or&&Ar?null:s;default:return null}}(s,i):function ke(s,o){if(Cr)return\"compositionend\"===s||!Er&&ge(s,o)?(s=nd(),Ht=Jt=Wt=null,Cr=!1,s):null;switch(s){case\"paste\":default:return null;case\"keypress\":if(!(o.ctrlKey||o.altKey||o.metaKey)||o.ctrlKey&&o.altKey){if(o.char&&1<o.char.length)return o.char;if(o.which)return String.fromCharCode(o.which)}return null;case\"compositionend\":return kr&&\"ko\"!==o.locale?null:o.data}}(s,i))&&(0<(a=oe(a,\"onBeforeInput\")).length&&(u=new lr(\"onBeforeInput\",\"beforeinput\",null,i,u),w.push({event:u,listeners:a}),u.data=ee))}se(w,o)}))}function tf(s,o,i){return{instance:s,listener:o,currentTarget:i}}function oe(s,o){for(var i=o+\"Capture\",a=[];null!==s;){var u=s,_=u.stateNode;5===u.tag&&null!==_&&(u=_,null!=(_=Kb(s,i))&&a.unshift(tf(s,_,u)),null!=(_=Kb(s,o))&&a.push(tf(s,_,u))),s=s.return}return a}function vf(s){if(null===s)return null;do{s=s.return}while(s&&5!==s.tag);return s||null}function wf(s,o,i,a,u){for(var _=o._reactName,w=[];null!==i&&i!==a;){var x=i,C=x.alternate,j=x.stateNode;if(null!==C&&C===a)break;5===x.tag&&null!==j&&(x=j,u?null!=(C=Kb(i,_))&&w.unshift(tf(i,C,x)):u||null!=(C=Kb(i,_))&&w.push(tf(i,C,x))),i=i.return}0!==w.length&&s.push({event:o,listeners:w})}var rn=/\\r\\n?/g,nn=/\\u0000|\\uFFFD/g;function zf(s){return(\"string\"==typeof s?s:\"\"+s).replace(rn,\"\\n\").replace(nn,\"\")}function Af(s,o,i){if(o=zf(o),zf(s)!==o&&i)throw Error(p(425))}function Bf(){}var sn=null,on=null;function Ef(s,o){return\"textarea\"===s||\"noscript\"===s||\"string\"==typeof o.children||\"number\"==typeof o.children||\"object\"==typeof o.dangerouslySetInnerHTML&&null!==o.dangerouslySetInnerHTML&&null!=o.dangerouslySetInnerHTML.__html}var an=\"function\"==typeof setTimeout?setTimeout:void 0,cn=\"function\"==typeof clearTimeout?clearTimeout:void 0,ln=\"function\"==typeof Promise?Promise:void 0,un=\"function\"==typeof queueMicrotask?queueMicrotask:void 0!==ln?function(s){return ln.resolve(null).then(s).catch(If)}:an;function If(s){setTimeout((function(){throw s}))}function Kf(s,o){var i=o,a=0;do{var u=i.nextSibling;if(s.removeChild(i),u&&8===u.nodeType)if(\"/$\"===(i=u.data)){if(0===a)return s.removeChild(u),void bd(o);a--}else\"$\"!==i&&\"$?\"!==i&&\"$!\"!==i||a++;i=u}while(i);bd(o)}function Lf(s){for(;null!=s;s=s.nextSibling){var o=s.nodeType;if(1===o||3===o)break;if(8===o){if(\"$\"===(o=s.data)||\"$!\"===o||\"$?\"===o)break;if(\"/$\"===o)return null}}return s}function Mf(s){s=s.previousSibling;for(var o=0;s;){if(8===s.nodeType){var i=s.data;if(\"$\"===i||\"$!\"===i||\"$?\"===i){if(0===o)return s;o--}else\"/$\"===i&&o++}s=s.previousSibling}return null}var pn=Math.random().toString(36).slice(2),hn=\"__reactFiber$\"+pn,dn=\"__reactProps$\"+pn,fn=\"__reactContainer$\"+pn,mn=\"__reactEvents$\"+pn,gn=\"__reactListeners$\"+pn,yn=\"__reactHandles$\"+pn;function Wc(s){var o=s[hn];if(o)return o;for(var i=s.parentNode;i;){if(o=i[fn]||i[hn]){if(i=o.alternate,null!==o.child||null!==i&&null!==i.child)for(s=Mf(s);null!==s;){if(i=s[hn])return i;s=Mf(s)}return o}i=(s=i).parentNode}return null}function Cb(s){return!(s=s[hn]||s[fn])||5!==s.tag&&6!==s.tag&&13!==s.tag&&3!==s.tag?null:s}function ue(s){if(5===s.tag||6===s.tag)return s.stateNode;throw Error(p(33))}function Db(s){return s[dn]||null}var vn=[],bn=-1;function Uf(s){return{current:s}}function E(s){0>bn||(s.current=vn[bn],vn[bn]=null,bn--)}function G(s,o){bn++,vn[bn]=s.current,s.current=o}var _n={},Sn=Uf(_n),En=Uf(!1),wn=_n;function Yf(s,o){var i=s.type.contextTypes;if(!i)return _n;var a=s.stateNode;if(a&&a.__reactInternalMemoizedUnmaskedChildContext===o)return a.__reactInternalMemoizedMaskedChildContext;var u,_={};for(u in i)_[u]=o[u];return a&&((s=s.stateNode).__reactInternalMemoizedUnmaskedChildContext=o,s.__reactInternalMemoizedMaskedChildContext=_),_}function Zf(s){return null!=(s=s.childContextTypes)}function $f(){E(En),E(Sn)}function ag(s,o,i){if(Sn.current!==_n)throw Error(p(168));G(Sn,o),G(En,i)}function bg(s,o,i){var a=s.stateNode;if(o=o.childContextTypes,\"function\"!=typeof a.getChildContext)return i;for(var u in a=a.getChildContext())if(!(u in o))throw Error(p(108,Ra(s)||\"Unknown\",u));return we({},i,a)}function cg(s){return s=(s=s.stateNode)&&s.__reactInternalMemoizedMergedChildContext||_n,wn=Sn.current,G(Sn,s),G(En,En.current),!0}function dg(s,o,i){var a=s.stateNode;if(!a)throw Error(p(169));i?(s=bg(s,o,wn),a.__reactInternalMemoizedMergedChildContext=s,E(En),E(Sn),G(Sn,s)):E(En),G(En,i)}var xn=null,kn=!1,On=!1;function hg(s){null===xn?xn=[s]:xn.push(s)}function jg(){if(!On&&null!==xn){On=!0;var s=0,o=At;try{var i=xn;for(At=1;s<i.length;s++){var a=i[s];do{a=a(!0)}while(null!==a)}xn=null,kn=!1}catch(o){throw null!==xn&&(xn=xn.slice(s+1)),ct(mt,jg),o}finally{At=o,On=!1}}return null}var An=[],Cn=0,jn=null,Pn=0,In=[],Tn=0,Nn=null,Mn=1,Rn=\"\";function tg(s,o){An[Cn++]=Pn,An[Cn++]=jn,jn=s,Pn=o}function ug(s,o,i){In[Tn++]=Mn,In[Tn++]=Rn,In[Tn++]=Nn,Nn=s;var a=Mn;s=Rn;var u=32-Et(a)-1;a&=~(1<<u),i+=1;var _=32-Et(o)+u;if(30<_){var w=u-u%5;_=(a&(1<<w)-1).toString(32),a>>=w,u-=w,Mn=1<<32-Et(o)+u|i<<u|a,Rn=_+s}else Mn=1<<_|i<<u|a,Rn=s}function vg(s){null!==s.return&&(tg(s,1),ug(s,1,0))}function wg(s){for(;s===jn;)jn=An[--Cn],An[Cn]=null,Pn=An[--Cn],An[Cn]=null;for(;s===Nn;)Nn=In[--Tn],In[Tn]=null,Rn=In[--Tn],In[Tn]=null,Mn=In[--Tn],In[Tn]=null}var Dn=null,Ln=null,Fn=!1,Bn=null;function Ag(s,o){var i=Bg(5,null,null,0);i.elementType=\"DELETED\",i.stateNode=o,i.return=s,null===(o=s.deletions)?(s.deletions=[i],s.flags|=16):o.push(i)}function Cg(s,o){switch(s.tag){case 5:var i=s.type;return null!==(o=1!==o.nodeType||i.toLowerCase()!==o.nodeName.toLowerCase()?null:o)&&(s.stateNode=o,Dn=s,Ln=Lf(o.firstChild),!0);case 6:return null!==(o=\"\"===s.pendingProps||3!==o.nodeType?null:o)&&(s.stateNode=o,Dn=s,Ln=null,!0);case 13:return null!==(o=8!==o.nodeType?null:o)&&(i=null!==Nn?{id:Mn,overflow:Rn}:null,s.memoizedState={dehydrated:o,treeContext:i,retryLane:1073741824},(i=Bg(18,null,null,0)).stateNode=o,i.return=s,s.child=i,Dn=s,Ln=null,!0);default:return!1}}function Dg(s){return!(!(1&s.mode)||128&s.flags)}function Eg(s){if(Fn){var o=Ln;if(o){var i=o;if(!Cg(s,o)){if(Dg(s))throw Error(p(418));o=Lf(i.nextSibling);var a=Dn;o&&Cg(s,o)?Ag(a,i):(s.flags=-4097&s.flags|2,Fn=!1,Dn=s)}}else{if(Dg(s))throw Error(p(418));s.flags=-4097&s.flags|2,Fn=!1,Dn=s}}}function Fg(s){for(s=s.return;null!==s&&5!==s.tag&&3!==s.tag&&13!==s.tag;)s=s.return;Dn=s}function Gg(s){if(s!==Dn)return!1;if(!Fn)return Fg(s),Fn=!0,!1;var o;if((o=3!==s.tag)&&!(o=5!==s.tag)&&(o=\"head\"!==(o=s.type)&&\"body\"!==o&&!Ef(s.type,s.memoizedProps)),o&&(o=Ln)){if(Dg(s))throw Hg(),Error(p(418));for(;o;)Ag(s,o),o=Lf(o.nextSibling)}if(Fg(s),13===s.tag){if(!(s=null!==(s=s.memoizedState)?s.dehydrated:null))throw Error(p(317));e:{for(s=s.nextSibling,o=0;s;){if(8===s.nodeType){var i=s.data;if(\"/$\"===i){if(0===o){Ln=Lf(s.nextSibling);break e}o--}else\"$\"!==i&&\"$!\"!==i&&\"$?\"!==i||o++}s=s.nextSibling}Ln=null}}else Ln=Dn?Lf(s.stateNode.nextSibling):null;return!0}function Hg(){for(var s=Ln;s;)s=Lf(s.nextSibling)}function Ig(){Ln=Dn=null,Fn=!1}function Jg(s){null===Bn?Bn=[s]:Bn.push(s)}var $n=V.ReactCurrentBatchConfig;function Lg(s,o,i){if(null!==(s=i.ref)&&\"function\"!=typeof s&&\"object\"!=typeof s){if(i._owner){if(i=i._owner){if(1!==i.tag)throw Error(p(309));var a=i.stateNode}if(!a)throw Error(p(147,s));var u=a,_=\"\"+s;return null!==o&&null!==o.ref&&\"function\"==typeof o.ref&&o.ref._stringRef===_?o.ref:(o=function(s){var o=u.refs;null===s?delete o[_]:o[_]=s},o._stringRef=_,o)}if(\"string\"!=typeof s)throw Error(p(284));if(!i._owner)throw Error(p(290,s))}return s}function Mg(s,o){throw s=Object.prototype.toString.call(o),Error(p(31,\"[object Object]\"===s?\"object with keys {\"+Object.keys(o).join(\", \")+\"}\":s))}function Ng(s){return(0,s._init)(s._payload)}function Og(s){function b(o,i){if(s){var a=o.deletions;null===a?(o.deletions=[i],o.flags|=16):a.push(i)}}function c(o,i){if(!s)return null;for(;null!==i;)b(o,i),i=i.sibling;return null}function d(s,o){for(s=new Map;null!==o;)null!==o.key?s.set(o.key,o):s.set(o.index,o),o=o.sibling;return s}function e(s,o){return(s=Pg(s,o)).index=0,s.sibling=null,s}function f(o,i,a){return o.index=a,s?null!==(a=o.alternate)?(a=a.index)<i?(o.flags|=2,i):a:(o.flags|=2,i):(o.flags|=1048576,i)}function g(o){return s&&null===o.alternate&&(o.flags|=2),o}function h(s,o,i,a){return null===o||6!==o.tag?((o=Qg(i,s.mode,a)).return=s,o):((o=e(o,i)).return=s,o)}function k(s,o,i,a){var u=i.type;return u===Z?m(s,o,i.props.children,a,i.key):null!==o&&(o.elementType===u||\"object\"==typeof u&&null!==u&&u.$$typeof===ye&&Ng(u)===o.type)?((a=e(o,i.props)).ref=Lg(s,o,i),a.return=s,a):((a=Rg(i.type,i.key,i.props,null,s.mode,a)).ref=Lg(s,o,i),a.return=s,a)}function l(s,o,i,a){return null===o||4!==o.tag||o.stateNode.containerInfo!==i.containerInfo||o.stateNode.implementation!==i.implementation?((o=Sg(i,s.mode,a)).return=s,o):((o=e(o,i.children||[])).return=s,o)}function m(s,o,i,a,u){return null===o||7!==o.tag?((o=Tg(i,s.mode,a,u)).return=s,o):((o=e(o,i)).return=s,o)}function q(s,o,i){if(\"string\"==typeof o&&\"\"!==o||\"number\"==typeof o)return(o=Qg(\"\"+o,s.mode,i)).return=s,o;if(\"object\"==typeof o&&null!==o){switch(o.$$typeof){case z:return(i=Rg(o.type,o.key,o.props,null,s.mode,i)).ref=Lg(s,null,o),i.return=s,i;case Y:return(o=Sg(o,s.mode,i)).return=s,o;case ye:return q(s,(0,o._init)(o._payload),i)}if(Pe(o)||Ka(o))return(o=Tg(o,s.mode,i,null)).return=s,o;Mg(s,o)}return null}function r(s,o,i,a){var u=null!==o?o.key:null;if(\"string\"==typeof i&&\"\"!==i||\"number\"==typeof i)return null!==u?null:h(s,o,\"\"+i,a);if(\"object\"==typeof i&&null!==i){switch(i.$$typeof){case z:return i.key===u?k(s,o,i,a):null;case Y:return i.key===u?l(s,o,i,a):null;case ye:return r(s,o,(u=i._init)(i._payload),a)}if(Pe(i)||Ka(i))return null!==u?null:m(s,o,i,a,null);Mg(s,i)}return null}function y(s,o,i,a,u){if(\"string\"==typeof a&&\"\"!==a||\"number\"==typeof a)return h(o,s=s.get(i)||null,\"\"+a,u);if(\"object\"==typeof a&&null!==a){switch(a.$$typeof){case z:return k(o,s=s.get(null===a.key?i:a.key)||null,a,u);case Y:return l(o,s=s.get(null===a.key?i:a.key)||null,a,u);case ye:return y(s,o,i,(0,a._init)(a._payload),u)}if(Pe(a)||Ka(a))return m(o,s=s.get(i)||null,a,u,null);Mg(o,a)}return null}function n(o,i,a,u){for(var _=null,w=null,x=i,C=i=0,j=null;null!==x&&C<a.length;C++){x.index>C?(j=x,x=null):j=x.sibling;var L=r(o,x,a[C],u);if(null===L){null===x&&(x=j);break}s&&x&&null===L.alternate&&b(o,x),i=f(L,i,C),null===w?_=L:w.sibling=L,w=L,x=j}if(C===a.length)return c(o,x),Fn&&tg(o,C),_;if(null===x){for(;C<a.length;C++)null!==(x=q(o,a[C],u))&&(i=f(x,i,C),null===w?_=x:w.sibling=x,w=x);return Fn&&tg(o,C),_}for(x=d(o,x);C<a.length;C++)null!==(j=y(x,o,C,a[C],u))&&(s&&null!==j.alternate&&x.delete(null===j.key?C:j.key),i=f(j,i,C),null===w?_=j:w.sibling=j,w=j);return s&&x.forEach((function(s){return b(o,s)})),Fn&&tg(o,C),_}function t(o,i,a,u){var _=Ka(a);if(\"function\"!=typeof _)throw Error(p(150));if(null==(a=_.call(a)))throw Error(p(151));for(var w=_=null,x=i,C=i=0,j=null,L=a.next();null!==x&&!L.done;C++,L=a.next()){x.index>C?(j=x,x=null):j=x.sibling;var B=r(o,x,L.value,u);if(null===B){null===x&&(x=j);break}s&&x&&null===B.alternate&&b(o,x),i=f(B,i,C),null===w?_=B:w.sibling=B,w=B,x=j}if(L.done)return c(o,x),Fn&&tg(o,C),_;if(null===x){for(;!L.done;C++,L=a.next())null!==(L=q(o,L.value,u))&&(i=f(L,i,C),null===w?_=L:w.sibling=L,w=L);return Fn&&tg(o,C),_}for(x=d(o,x);!L.done;C++,L=a.next())null!==(L=y(x,o,C,L.value,u))&&(s&&null!==L.alternate&&x.delete(null===L.key?C:L.key),i=f(L,i,C),null===w?_=L:w.sibling=L,w=L);return s&&x.forEach((function(s){return b(o,s)})),Fn&&tg(o,C),_}return function J(s,o,i,a){if(\"object\"==typeof i&&null!==i&&i.type===Z&&null===i.key&&(i=i.props.children),\"object\"==typeof i&&null!==i){switch(i.$$typeof){case z:e:{for(var u=i.key,_=o;null!==_;){if(_.key===u){if((u=i.type)===Z){if(7===_.tag){c(s,_.sibling),(o=e(_,i.props.children)).return=s,s=o;break e}}else if(_.elementType===u||\"object\"==typeof u&&null!==u&&u.$$typeof===ye&&Ng(u)===_.type){c(s,_.sibling),(o=e(_,i.props)).ref=Lg(s,_,i),o.return=s,s=o;break e}c(s,_);break}b(s,_),_=_.sibling}i.type===Z?((o=Tg(i.props.children,s.mode,a,i.key)).return=s,s=o):((a=Rg(i.type,i.key,i.props,null,s.mode,a)).ref=Lg(s,o,i),a.return=s,s=a)}return g(s);case Y:e:{for(_=i.key;null!==o;){if(o.key===_){if(4===o.tag&&o.stateNode.containerInfo===i.containerInfo&&o.stateNode.implementation===i.implementation){c(s,o.sibling),(o=e(o,i.children||[])).return=s,s=o;break e}c(s,o);break}b(s,o),o=o.sibling}(o=Sg(i,s.mode,a)).return=s,s=o}return g(s);case ye:return J(s,o,(_=i._init)(i._payload),a)}if(Pe(i))return n(s,o,i,a);if(Ka(i))return t(s,o,i,a);Mg(s,i)}return\"string\"==typeof i&&\"\"!==i||\"number\"==typeof i?(i=\"\"+i,null!==o&&6===o.tag?(c(s,o.sibling),(o=e(o,i)).return=s,s=o):(c(s,o),(o=Qg(i,s.mode,a)).return=s,s=o),g(s)):c(s,o)}}var qn=Og(!0),Un=Og(!1),Vn=Uf(null),zn=null,Wn=null,Jn=null;function $g(){Jn=Wn=zn=null}function ah(s){var o=Vn.current;E(Vn),s._currentValue=o}function bh(s,o,i){for(;null!==s;){var a=s.alternate;if((s.childLanes&o)!==o?(s.childLanes|=o,null!==a&&(a.childLanes|=o)):null!==a&&(a.childLanes&o)!==o&&(a.childLanes|=o),s===i)break;s=s.return}}function ch(s,o){zn=s,Jn=Wn=null,null!==(s=s.dependencies)&&null!==s.firstContext&&(!!(s.lanes&o)&&(bs=!0),s.firstContext=null)}function eh(s){var o=s._currentValue;if(Jn!==s)if(s={context:s,memoizedValue:o,next:null},null===Wn){if(null===zn)throw Error(p(308));Wn=s,zn.dependencies={lanes:0,firstContext:s}}else Wn=Wn.next=s;return o}var Hn=null;function gh(s){null===Hn?Hn=[s]:Hn.push(s)}function hh(s,o,i,a){var u=o.interleaved;return null===u?(i.next=i,gh(o)):(i.next=u.next,u.next=i),o.interleaved=i,ih(s,a)}function ih(s,o){s.lanes|=o;var i=s.alternate;for(null!==i&&(i.lanes|=o),i=s,s=s.return;null!==s;)s.childLanes|=o,null!==(i=s.alternate)&&(i.childLanes|=o),i=s,s=s.return;return 3===i.tag?i.stateNode:null}var Kn=!1;function kh(s){s.updateQueue={baseState:s.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function lh(s,o){s=s.updateQueue,o.updateQueue===s&&(o.updateQueue={baseState:s.baseState,firstBaseUpdate:s.firstBaseUpdate,lastBaseUpdate:s.lastBaseUpdate,shared:s.shared,effects:s.effects})}function mh(s,o){return{eventTime:s,lane:o,tag:0,payload:null,callback:null,next:null}}function nh(s,o,i){var a=s.updateQueue;if(null===a)return null;if(a=a.shared,2&Ls){var u=a.pending;return null===u?o.next=o:(o.next=u.next,u.next=o),a.pending=o,ih(s,i)}return null===(u=a.interleaved)?(o.next=o,gh(a)):(o.next=u.next,u.next=o),a.interleaved=o,ih(s,i)}function oh(s,o,i){if(null!==(o=o.updateQueue)&&(o=o.shared,4194240&i)){var a=o.lanes;i|=a&=s.pendingLanes,o.lanes=i,Cc(s,i)}}function ph(s,o){var i=s.updateQueue,a=s.alternate;if(null!==a&&i===(a=a.updateQueue)){var u=null,_=null;if(null!==(i=i.firstBaseUpdate)){do{var w={eventTime:i.eventTime,lane:i.lane,tag:i.tag,payload:i.payload,callback:i.callback,next:null};null===_?u=_=w:_=_.next=w,i=i.next}while(null!==i);null===_?u=_=o:_=_.next=o}else u=_=o;return i={baseState:a.baseState,firstBaseUpdate:u,lastBaseUpdate:_,shared:a.shared,effects:a.effects},void(s.updateQueue=i)}null===(s=i.lastBaseUpdate)?i.firstBaseUpdate=o:s.next=o,i.lastBaseUpdate=o}function qh(s,o,i,a){var u=s.updateQueue;Kn=!1;var _=u.firstBaseUpdate,w=u.lastBaseUpdate,x=u.shared.pending;if(null!==x){u.shared.pending=null;var C=x,j=C.next;C.next=null,null===w?_=j:w.next=j,w=C;var L=s.alternate;null!==L&&((x=(L=L.updateQueue).lastBaseUpdate)!==w&&(null===x?L.firstBaseUpdate=j:x.next=j,L.lastBaseUpdate=C))}if(null!==_){var B=u.baseState;for(w=0,L=j=C=null,x=_;;){var $=x.lane,U=x.eventTime;if((a&$)===$){null!==L&&(L=L.next={eventTime:U,lane:0,tag:x.tag,payload:x.payload,callback:x.callback,next:null});e:{var V=s,z=x;switch($=o,U=i,z.tag){case 1:if(\"function\"==typeof(V=z.payload)){B=V.call(U,B,$);break e}B=V;break e;case 3:V.flags=-65537&V.flags|128;case 0:if(null==($=\"function\"==typeof(V=z.payload)?V.call(U,B,$):V))break e;B=we({},B,$);break e;case 2:Kn=!0}}null!==x.callback&&0!==x.lane&&(s.flags|=64,null===($=u.effects)?u.effects=[x]:$.push(x))}else U={eventTime:U,lane:$,tag:x.tag,payload:x.payload,callback:x.callback,next:null},null===L?(j=L=U,C=B):L=L.next=U,w|=$;if(null===(x=x.next)){if(null===(x=u.shared.pending))break;x=($=x).next,$.next=null,u.lastBaseUpdate=$,u.shared.pending=null}}if(null===L&&(C=B),u.baseState=C,u.firstBaseUpdate=j,u.lastBaseUpdate=L,null!==(o=u.shared.interleaved)){u=o;do{w|=u.lane,u=u.next}while(u!==o)}else null===_&&(u.shared.lanes=0);Ws|=w,s.lanes=w,s.memoizedState=B}}function sh(s,o,i){if(s=o.effects,o.effects=null,null!==s)for(o=0;o<s.length;o++){var a=s[o],u=a.callback;if(null!==u){if(a.callback=null,a=i,\"function\"!=typeof u)throw Error(p(191,u));u.call(a)}}}var Gn={},Yn=Uf(Gn),Xn=Uf(Gn),Qn=Uf(Gn);function xh(s){if(s===Gn)throw Error(p(174));return s}function yh(s,o){switch(G(Qn,o),G(Xn,s),G(Yn,Gn),s=o.nodeType){case 9:case 11:o=(o=o.documentElement)?o.namespaceURI:lb(null,\"\");break;default:o=lb(o=(s=8===s?o.parentNode:o).namespaceURI||null,s=s.tagName)}E(Yn),G(Yn,o)}function zh(){E(Yn),E(Xn),E(Qn)}function Ah(s){xh(Qn.current);var o=xh(Yn.current),i=lb(o,s.type);o!==i&&(G(Xn,s),G(Yn,i))}function Bh(s){Xn.current===s&&(E(Yn),E(Xn))}var Zn=Uf(0);function Ch(s){for(var o=s;null!==o;){if(13===o.tag){var i=o.memoizedState;if(null!==i&&(null===(i=i.dehydrated)||\"$?\"===i.data||\"$!\"===i.data))return o}else if(19===o.tag&&void 0!==o.memoizedProps.revealOrder){if(128&o.flags)return o}else if(null!==o.child){o.child.return=o,o=o.child;continue}if(o===s)break;for(;null===o.sibling;){if(null===o.return||o.return===s)return null;o=o.return}o.sibling.return=o.return,o=o.sibling}return null}var es=[];function Eh(){for(var s=0;s<es.length;s++)es[s]._workInProgressVersionPrimary=null;es.length=0}var ts=V.ReactCurrentDispatcher,rs=V.ReactCurrentBatchConfig,ns=0,ss=null,os=null,as=null,cs=!1,ls=!1,us=0,ps=0;function P(){throw Error(p(321))}function Mh(s,o){if(null===o)return!1;for(var i=0;i<o.length&&i<s.length;i++)if(!Dr(s[i],o[i]))return!1;return!0}function Nh(s,o,i,a,u,_){if(ns=_,ss=o,o.memoizedState=null,o.updateQueue=null,o.lanes=0,ts.current=null===s||null===s.memoizedState?ds:fs,s=i(a,u),ls){_=0;do{if(ls=!1,us=0,25<=_)throw Error(p(301));_+=1,as=os=null,o.updateQueue=null,ts.current=ms,s=i(a,u)}while(ls)}if(ts.current=hs,o=null!==os&&null!==os.next,ns=0,as=os=ss=null,cs=!1,o)throw Error(p(300));return s}function Sh(){var s=0!==us;return us=0,s}function Th(){var s={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};return null===as?ss.memoizedState=as=s:as=as.next=s,as}function Uh(){if(null===os){var s=ss.alternate;s=null!==s?s.memoizedState:null}else s=os.next;var o=null===as?ss.memoizedState:as.next;if(null!==o)as=o,os=s;else{if(null===s)throw Error(p(310));s={memoizedState:(os=s).memoizedState,baseState:os.baseState,baseQueue:os.baseQueue,queue:os.queue,next:null},null===as?ss.memoizedState=as=s:as=as.next=s}return as}function Vh(s,o){return\"function\"==typeof o?o(s):o}function Wh(s){var o=Uh(),i=o.queue;if(null===i)throw Error(p(311));i.lastRenderedReducer=s;var a=os,u=a.baseQueue,_=i.pending;if(null!==_){if(null!==u){var w=u.next;u.next=_.next,_.next=w}a.baseQueue=u=_,i.pending=null}if(null!==u){_=u.next,a=a.baseState;var x=w=null,C=null,j=_;do{var L=j.lane;if((ns&L)===L)null!==C&&(C=C.next={lane:0,action:j.action,hasEagerState:j.hasEagerState,eagerState:j.eagerState,next:null}),a=j.hasEagerState?j.eagerState:s(a,j.action);else{var B={lane:L,action:j.action,hasEagerState:j.hasEagerState,eagerState:j.eagerState,next:null};null===C?(x=C=B,w=a):C=C.next=B,ss.lanes|=L,Ws|=L}j=j.next}while(null!==j&&j!==_);null===C?w=a:C.next=x,Dr(a,o.memoizedState)||(bs=!0),o.memoizedState=a,o.baseState=w,o.baseQueue=C,i.lastRenderedState=a}if(null!==(s=i.interleaved)){u=s;do{_=u.lane,ss.lanes|=_,Ws|=_,u=u.next}while(u!==s)}else null===u&&(i.lanes=0);return[o.memoizedState,i.dispatch]}function Xh(s){var o=Uh(),i=o.queue;if(null===i)throw Error(p(311));i.lastRenderedReducer=s;var a=i.dispatch,u=i.pending,_=o.memoizedState;if(null!==u){i.pending=null;var w=u=u.next;do{_=s(_,w.action),w=w.next}while(w!==u);Dr(_,o.memoizedState)||(bs=!0),o.memoizedState=_,null===o.baseQueue&&(o.baseState=_),i.lastRenderedState=_}return[_,a]}function Yh(){}function Zh(s,o){var i=ss,a=Uh(),u=o(),_=!Dr(a.memoizedState,u);if(_&&(a.memoizedState=u,bs=!0),a=a.queue,$h(ai.bind(null,i,a,s),[s]),a.getSnapshot!==o||_||null!==as&&1&as.memoizedState.tag){if(i.flags|=2048,bi(9,ci.bind(null,i,a,u,o),void 0,null),null===Fs)throw Error(p(349));30&ns||di(i,o,u)}return u}function di(s,o,i){s.flags|=16384,s={getSnapshot:o,value:i},null===(o=ss.updateQueue)?(o={lastEffect:null,stores:null},ss.updateQueue=o,o.stores=[s]):null===(i=o.stores)?o.stores=[s]:i.push(s)}function ci(s,o,i,a){o.value=i,o.getSnapshot=a,ei(o)&&fi(s)}function ai(s,o,i){return i((function(){ei(o)&&fi(s)}))}function ei(s){var o=s.getSnapshot;s=s.value;try{var i=o();return!Dr(s,i)}catch(s){return!0}}function fi(s){var o=ih(s,1);null!==o&&gi(o,s,1,-1)}function hi(s){var o=Th();return\"function\"==typeof s&&(s=s()),o.memoizedState=o.baseState=s,s={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:Vh,lastRenderedState:s},o.queue=s,s=s.dispatch=ii.bind(null,ss,s),[o.memoizedState,s]}function bi(s,o,i,a){return s={tag:s,create:o,destroy:i,deps:a,next:null},null===(o=ss.updateQueue)?(o={lastEffect:null,stores:null},ss.updateQueue=o,o.lastEffect=s.next=s):null===(i=o.lastEffect)?o.lastEffect=s.next=s:(a=i.next,i.next=s,s.next=a,o.lastEffect=s),s}function ji(){return Uh().memoizedState}function ki(s,o,i,a){var u=Th();ss.flags|=s,u.memoizedState=bi(1|o,i,void 0,void 0===a?null:a)}function li(s,o,i,a){var u=Uh();a=void 0===a?null:a;var _=void 0;if(null!==os){var w=os.memoizedState;if(_=w.destroy,null!==a&&Mh(a,w.deps))return void(u.memoizedState=bi(o,i,_,a))}ss.flags|=s,u.memoizedState=bi(1|o,i,_,a)}function mi(s,o){return ki(8390656,8,s,o)}function $h(s,o){return li(2048,8,s,o)}function ni(s,o){return li(4,2,s,o)}function oi(s,o){return li(4,4,s,o)}function pi(s,o){return\"function\"==typeof o?(s=s(),o(s),function(){o(null)}):null!=o?(s=s(),o.current=s,function(){o.current=null}):void 0}function qi(s,o,i){return i=null!=i?i.concat([s]):null,li(4,4,pi.bind(null,o,s),i)}function ri(){}function si(s,o){var i=Uh();o=void 0===o?null:o;var a=i.memoizedState;return null!==a&&null!==o&&Mh(o,a[1])?a[0]:(i.memoizedState=[s,o],s)}function ti(s,o){var i=Uh();o=void 0===o?null:o;var a=i.memoizedState;return null!==a&&null!==o&&Mh(o,a[1])?a[0]:(s=s(),i.memoizedState=[s,o],s)}function ui(s,o,i){return 21&ns?(Dr(i,o)||(i=yc(),ss.lanes|=i,Ws|=i,s.baseState=!0),o):(s.baseState&&(s.baseState=!1,bs=!0),s.memoizedState=i)}function vi(s,o){var i=At;At=0!==i&&4>i?i:4,s(!0);var a=rs.transition;rs.transition={};try{s(!1),o()}finally{At=i,rs.transition=a}}function wi(){return Uh().memoizedState}function xi(s,o,i){var a=yi(s);if(i={lane:a,action:i,hasEagerState:!1,eagerState:null,next:null},zi(s))Ai(o,i);else if(null!==(i=hh(s,o,i,a))){gi(i,s,a,R()),Bi(i,o,a)}}function ii(s,o,i){var a=yi(s),u={lane:a,action:i,hasEagerState:!1,eagerState:null,next:null};if(zi(s))Ai(o,u);else{var _=s.alternate;if(0===s.lanes&&(null===_||0===_.lanes)&&null!==(_=o.lastRenderedReducer))try{var w=o.lastRenderedState,x=_(w,i);if(u.hasEagerState=!0,u.eagerState=x,Dr(x,w)){var C=o.interleaved;return null===C?(u.next=u,gh(o)):(u.next=C.next,C.next=u),void(o.interleaved=u)}}catch(s){}null!==(i=hh(s,o,u,a))&&(gi(i,s,a,u=R()),Bi(i,o,a))}}function zi(s){var o=s.alternate;return s===ss||null!==o&&o===ss}function Ai(s,o){ls=cs=!0;var i=s.pending;null===i?o.next=o:(o.next=i.next,i.next=o),s.pending=o}function Bi(s,o,i){if(4194240&i){var a=o.lanes;i|=a&=s.pendingLanes,o.lanes=i,Cc(s,i)}}var hs={readContext:eh,useCallback:P,useContext:P,useEffect:P,useImperativeHandle:P,useInsertionEffect:P,useLayoutEffect:P,useMemo:P,useReducer:P,useRef:P,useState:P,useDebugValue:P,useDeferredValue:P,useTransition:P,useMutableSource:P,useSyncExternalStore:P,useId:P,unstable_isNewReconciler:!1},ds={readContext:eh,useCallback:function(s,o){return Th().memoizedState=[s,void 0===o?null:o],s},useContext:eh,useEffect:mi,useImperativeHandle:function(s,o,i){return i=null!=i?i.concat([s]):null,ki(4194308,4,pi.bind(null,o,s),i)},useLayoutEffect:function(s,o){return ki(4194308,4,s,o)},useInsertionEffect:function(s,o){return ki(4,2,s,o)},useMemo:function(s,o){var i=Th();return o=void 0===o?null:o,s=s(),i.memoizedState=[s,o],s},useReducer:function(s,o,i){var a=Th();return o=void 0!==i?i(o):o,a.memoizedState=a.baseState=o,s={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:s,lastRenderedState:o},a.queue=s,s=s.dispatch=xi.bind(null,ss,s),[a.memoizedState,s]},useRef:function(s){return s={current:s},Th().memoizedState=s},useState:hi,useDebugValue:ri,useDeferredValue:function(s){return Th().memoizedState=s},useTransition:function(){var s=hi(!1),o=s[0];return s=vi.bind(null,s[1]),Th().memoizedState=s,[o,s]},useMutableSource:function(){},useSyncExternalStore:function(s,o,i){var a=ss,u=Th();if(Fn){if(void 0===i)throw Error(p(407));i=i()}else{if(i=o(),null===Fs)throw Error(p(349));30&ns||di(a,o,i)}u.memoizedState=i;var _={value:i,getSnapshot:o};return u.queue=_,mi(ai.bind(null,a,_,s),[s]),a.flags|=2048,bi(9,ci.bind(null,a,_,i,o),void 0,null),i},useId:function(){var s=Th(),o=Fs.identifierPrefix;if(Fn){var i=Rn;o=\":\"+o+\"R\"+(i=(Mn&~(1<<32-Et(Mn)-1)).toString(32)+i),0<(i=us++)&&(o+=\"H\"+i.toString(32)),o+=\":\"}else o=\":\"+o+\"r\"+(i=ps++).toString(32)+\":\";return s.memoizedState=o},unstable_isNewReconciler:!1},fs={readContext:eh,useCallback:si,useContext:eh,useEffect:$h,useImperativeHandle:qi,useInsertionEffect:ni,useLayoutEffect:oi,useMemo:ti,useReducer:Wh,useRef:ji,useState:function(){return Wh(Vh)},useDebugValue:ri,useDeferredValue:function(s){return ui(Uh(),os.memoizedState,s)},useTransition:function(){return[Wh(Vh)[0],Uh().memoizedState]},useMutableSource:Yh,useSyncExternalStore:Zh,useId:wi,unstable_isNewReconciler:!1},ms={readContext:eh,useCallback:si,useContext:eh,useEffect:$h,useImperativeHandle:qi,useInsertionEffect:ni,useLayoutEffect:oi,useMemo:ti,useReducer:Xh,useRef:ji,useState:function(){return Xh(Vh)},useDebugValue:ri,useDeferredValue:function(s){var o=Uh();return null===os?o.memoizedState=s:ui(o,os.memoizedState,s)},useTransition:function(){return[Xh(Vh)[0],Uh().memoizedState]},useMutableSource:Yh,useSyncExternalStore:Zh,useId:wi,unstable_isNewReconciler:!1};function Ci(s,o){if(s&&s.defaultProps){for(var i in o=we({},o),s=s.defaultProps)void 0===o[i]&&(o[i]=s[i]);return o}return o}function Di(s,o,i,a){i=null==(i=i(a,o=s.memoizedState))?o:we({},o,i),s.memoizedState=i,0===s.lanes&&(s.updateQueue.baseState=i)}var gs={isMounted:function(s){return!!(s=s._reactInternals)&&Vb(s)===s},enqueueSetState:function(s,o,i){s=s._reactInternals;var a=R(),u=yi(s),_=mh(a,u);_.payload=o,null!=i&&(_.callback=i),null!==(o=nh(s,_,u))&&(gi(o,s,u,a),oh(o,s,u))},enqueueReplaceState:function(s,o,i){s=s._reactInternals;var a=R(),u=yi(s),_=mh(a,u);_.tag=1,_.payload=o,null!=i&&(_.callback=i),null!==(o=nh(s,_,u))&&(gi(o,s,u,a),oh(o,s,u))},enqueueForceUpdate:function(s,o){s=s._reactInternals;var i=R(),a=yi(s),u=mh(i,a);u.tag=2,null!=o&&(u.callback=o),null!==(o=nh(s,u,a))&&(gi(o,s,a,i),oh(o,s,a))}};function Fi(s,o,i,a,u,_,w){return\"function\"==typeof(s=s.stateNode).shouldComponentUpdate?s.shouldComponentUpdate(a,_,w):!o.prototype||!o.prototype.isPureReactComponent||(!Ie(i,a)||!Ie(u,_))}function Gi(s,o,i){var a=!1,u=_n,_=o.contextType;return\"object\"==typeof _&&null!==_?_=eh(_):(u=Zf(o)?wn:Sn.current,_=(a=null!=(a=o.contextTypes))?Yf(s,u):_n),o=new o(i,_),s.memoizedState=null!==o.state&&void 0!==o.state?o.state:null,o.updater=gs,s.stateNode=o,o._reactInternals=s,a&&((s=s.stateNode).__reactInternalMemoizedUnmaskedChildContext=u,s.__reactInternalMemoizedMaskedChildContext=_),o}function Hi(s,o,i,a){s=o.state,\"function\"==typeof o.componentWillReceiveProps&&o.componentWillReceiveProps(i,a),\"function\"==typeof o.UNSAFE_componentWillReceiveProps&&o.UNSAFE_componentWillReceiveProps(i,a),o.state!==s&&gs.enqueueReplaceState(o,o.state,null)}function Ii(s,o,i,a){var u=s.stateNode;u.props=i,u.state=s.memoizedState,u.refs={},kh(s);var _=o.contextType;\"object\"==typeof _&&null!==_?u.context=eh(_):(_=Zf(o)?wn:Sn.current,u.context=Yf(s,_)),u.state=s.memoizedState,\"function\"==typeof(_=o.getDerivedStateFromProps)&&(Di(s,o,_,i),u.state=s.memoizedState),\"function\"==typeof o.getDerivedStateFromProps||\"function\"==typeof u.getSnapshotBeforeUpdate||\"function\"!=typeof u.UNSAFE_componentWillMount&&\"function\"!=typeof u.componentWillMount||(o=u.state,\"function\"==typeof u.componentWillMount&&u.componentWillMount(),\"function\"==typeof u.UNSAFE_componentWillMount&&u.UNSAFE_componentWillMount(),o!==u.state&&gs.enqueueReplaceState(u,u.state,null),qh(s,i,u,a),u.state=s.memoizedState),\"function\"==typeof u.componentDidMount&&(s.flags|=4194308)}function Ji(s,o){try{var i=\"\",a=o;do{i+=Pa(a),a=a.return}while(a);var u=i}catch(s){u=\"\\nError generating stack: \"+s.message+\"\\n\"+s.stack}return{value:s,source:o,stack:u,digest:null}}function Ki(s,o,i){return{value:s,source:null,stack:null!=i?i:null,digest:null!=o?o:null}}function Li(s,o){try{console.error(o.value)}catch(s){setTimeout((function(){throw s}))}}var ys=\"function\"==typeof WeakMap?WeakMap:Map;function Ni(s,o,i){(i=mh(-1,i)).tag=3,i.payload={element:null};var a=o.value;return i.callback=function(){Zs||(Zs=!0,eo=a),Li(0,o)},i}function Qi(s,o,i){(i=mh(-1,i)).tag=3;var a=s.type.getDerivedStateFromError;if(\"function\"==typeof a){var u=o.value;i.payload=function(){return a(u)},i.callback=function(){Li(0,o)}}var _=s.stateNode;return null!==_&&\"function\"==typeof _.componentDidCatch&&(i.callback=function(){Li(0,o),\"function\"!=typeof a&&(null===to?to=new Set([this]):to.add(this));var s=o.stack;this.componentDidCatch(o.value,{componentStack:null!==s?s:\"\"})}),i}function Si(s,o,i){var a=s.pingCache;if(null===a){a=s.pingCache=new ys;var u=new Set;a.set(o,u)}else void 0===(u=a.get(o))&&(u=new Set,a.set(o,u));u.has(i)||(u.add(i),s=Ti.bind(null,s,o,i),o.then(s,s))}function Ui(s){do{var o;if((o=13===s.tag)&&(o=null===(o=s.memoizedState)||null!==o.dehydrated),o)return s;s=s.return}while(null!==s);return null}function Vi(s,o,i,a,u){return 1&s.mode?(s.flags|=65536,s.lanes=u,s):(s===o?s.flags|=65536:(s.flags|=128,i.flags|=131072,i.flags&=-52805,1===i.tag&&(null===i.alternate?i.tag=17:((o=mh(-1,1)).tag=2,nh(i,o,1))),i.lanes|=1),s)}var vs=V.ReactCurrentOwner,bs=!1;function Xi(s,o,i,a){o.child=null===s?Un(o,null,i,a):qn(o,s.child,i,a)}function Yi(s,o,i,a,u){i=i.render;var _=o.ref;return ch(o,u),a=Nh(s,o,i,a,_,u),i=Sh(),null===s||bs?(Fn&&i&&vg(o),o.flags|=1,Xi(s,o,a,u),o.child):(o.updateQueue=s.updateQueue,o.flags&=-2053,s.lanes&=~u,Zi(s,o,u))}function $i(s,o,i,a,u){if(null===s){var _=i.type;return\"function\"!=typeof _||aj(_)||void 0!==_.defaultProps||null!==i.compare||void 0!==i.defaultProps?((s=Rg(i.type,null,a,o,o.mode,u)).ref=o.ref,s.return=o,o.child=s):(o.tag=15,o.type=_,bj(s,o,_,a,u))}if(_=s.child,!(s.lanes&u)){var w=_.memoizedProps;if((i=null!==(i=i.compare)?i:Ie)(w,a)&&s.ref===o.ref)return Zi(s,o,u)}return o.flags|=1,(s=Pg(_,a)).ref=o.ref,s.return=o,o.child=s}function bj(s,o,i,a,u){if(null!==s){var _=s.memoizedProps;if(Ie(_,a)&&s.ref===o.ref){if(bs=!1,o.pendingProps=a=_,!(s.lanes&u))return o.lanes=s.lanes,Zi(s,o,u);131072&s.flags&&(bs=!0)}}return cj(s,o,i,a,u)}function dj(s,o,i){var a=o.pendingProps,u=a.children,_=null!==s?s.memoizedState:null;if(\"hidden\"===a.mode)if(1&o.mode){if(!(1073741824&i))return s=null!==_?_.baseLanes|i:i,o.lanes=o.childLanes=1073741824,o.memoizedState={baseLanes:s,cachePool:null,transitions:null},o.updateQueue=null,G(Us,qs),qs|=s,null;o.memoizedState={baseLanes:0,cachePool:null,transitions:null},a=null!==_?_.baseLanes:i,G(Us,qs),qs|=a}else o.memoizedState={baseLanes:0,cachePool:null,transitions:null},G(Us,qs),qs|=i;else null!==_?(a=_.baseLanes|i,o.memoizedState=null):a=i,G(Us,qs),qs|=a;return Xi(s,o,u,i),o.child}function gj(s,o){var i=o.ref;(null===s&&null!==i||null!==s&&s.ref!==i)&&(o.flags|=512,o.flags|=2097152)}function cj(s,o,i,a,u){var _=Zf(i)?wn:Sn.current;return _=Yf(o,_),ch(o,u),i=Nh(s,o,i,a,_,u),a=Sh(),null===s||bs?(Fn&&a&&vg(o),o.flags|=1,Xi(s,o,i,u),o.child):(o.updateQueue=s.updateQueue,o.flags&=-2053,s.lanes&=~u,Zi(s,o,u))}function hj(s,o,i,a,u){if(Zf(i)){var _=!0;cg(o)}else _=!1;if(ch(o,u),null===o.stateNode)ij(s,o),Gi(o,i,a),Ii(o,i,a,u),a=!0;else if(null===s){var w=o.stateNode,x=o.memoizedProps;w.props=x;var C=w.context,j=i.contextType;\"object\"==typeof j&&null!==j?j=eh(j):j=Yf(o,j=Zf(i)?wn:Sn.current);var L=i.getDerivedStateFromProps,B=\"function\"==typeof L||\"function\"==typeof w.getSnapshotBeforeUpdate;B||\"function\"!=typeof w.UNSAFE_componentWillReceiveProps&&\"function\"!=typeof w.componentWillReceiveProps||(x!==a||C!==j)&&Hi(o,w,a,j),Kn=!1;var $=o.memoizedState;w.state=$,qh(o,a,w,u),C=o.memoizedState,x!==a||$!==C||En.current||Kn?(\"function\"==typeof L&&(Di(o,i,L,a),C=o.memoizedState),(x=Kn||Fi(o,i,x,a,$,C,j))?(B||\"function\"!=typeof w.UNSAFE_componentWillMount&&\"function\"!=typeof w.componentWillMount||(\"function\"==typeof w.componentWillMount&&w.componentWillMount(),\"function\"==typeof w.UNSAFE_componentWillMount&&w.UNSAFE_componentWillMount()),\"function\"==typeof w.componentDidMount&&(o.flags|=4194308)):(\"function\"==typeof w.componentDidMount&&(o.flags|=4194308),o.memoizedProps=a,o.memoizedState=C),w.props=a,w.state=C,w.context=j,a=x):(\"function\"==typeof w.componentDidMount&&(o.flags|=4194308),a=!1)}else{w=o.stateNode,lh(s,o),x=o.memoizedProps,j=o.type===o.elementType?x:Ci(o.type,x),w.props=j,B=o.pendingProps,$=w.context,\"object\"==typeof(C=i.contextType)&&null!==C?C=eh(C):C=Yf(o,C=Zf(i)?wn:Sn.current);var U=i.getDerivedStateFromProps;(L=\"function\"==typeof U||\"function\"==typeof w.getSnapshotBeforeUpdate)||\"function\"!=typeof w.UNSAFE_componentWillReceiveProps&&\"function\"!=typeof w.componentWillReceiveProps||(x!==B||$!==C)&&Hi(o,w,a,C),Kn=!1,$=o.memoizedState,w.state=$,qh(o,a,w,u);var V=o.memoizedState;x!==B||$!==V||En.current||Kn?(\"function\"==typeof U&&(Di(o,i,U,a),V=o.memoizedState),(j=Kn||Fi(o,i,j,a,$,V,C)||!1)?(L||\"function\"!=typeof w.UNSAFE_componentWillUpdate&&\"function\"!=typeof w.componentWillUpdate||(\"function\"==typeof w.componentWillUpdate&&w.componentWillUpdate(a,V,C),\"function\"==typeof w.UNSAFE_componentWillUpdate&&w.UNSAFE_componentWillUpdate(a,V,C)),\"function\"==typeof w.componentDidUpdate&&(o.flags|=4),\"function\"==typeof w.getSnapshotBeforeUpdate&&(o.flags|=1024)):(\"function\"!=typeof w.componentDidUpdate||x===s.memoizedProps&&$===s.memoizedState||(o.flags|=4),\"function\"!=typeof w.getSnapshotBeforeUpdate||x===s.memoizedProps&&$===s.memoizedState||(o.flags|=1024),o.memoizedProps=a,o.memoizedState=V),w.props=a,w.state=V,w.context=C,a=j):(\"function\"!=typeof w.componentDidUpdate||x===s.memoizedProps&&$===s.memoizedState||(o.flags|=4),\"function\"!=typeof w.getSnapshotBeforeUpdate||x===s.memoizedProps&&$===s.memoizedState||(o.flags|=1024),a=!1)}return jj(s,o,i,a,_,u)}function jj(s,o,i,a,u,_){gj(s,o);var w=!!(128&o.flags);if(!a&&!w)return u&&dg(o,i,!1),Zi(s,o,_);a=o.stateNode,vs.current=o;var x=w&&\"function\"!=typeof i.getDerivedStateFromError?null:a.render();return o.flags|=1,null!==s&&w?(o.child=qn(o,s.child,null,_),o.child=qn(o,null,x,_)):Xi(s,o,x,_),o.memoizedState=a.state,u&&dg(o,i,!0),o.child}function kj(s){var o=s.stateNode;o.pendingContext?ag(0,o.pendingContext,o.pendingContext!==o.context):o.context&&ag(0,o.context,!1),yh(s,o.containerInfo)}function lj(s,o,i,a,u){return Ig(),Jg(u),o.flags|=256,Xi(s,o,i,a),o.child}var _s,Ss,Es,ws,xs={dehydrated:null,treeContext:null,retryLane:0};function nj(s){return{baseLanes:s,cachePool:null,transitions:null}}function oj(s,o,i){var a,u=o.pendingProps,_=Zn.current,w=!1,x=!!(128&o.flags);if((a=x)||(a=(null===s||null!==s.memoizedState)&&!!(2&_)),a?(w=!0,o.flags&=-129):null!==s&&null===s.memoizedState||(_|=1),G(Zn,1&_),null===s)return Eg(o),null!==(s=o.memoizedState)&&null!==(s=s.dehydrated)?(1&o.mode?\"$!\"===s.data?o.lanes=8:o.lanes=1073741824:o.lanes=1,null):(x=u.children,s=u.fallback,w?(u=o.mode,w=o.child,x={mode:\"hidden\",children:x},1&u||null===w?w=pj(x,u,0,null):(w.childLanes=0,w.pendingProps=x),s=Tg(s,u,i,null),w.return=o,s.return=o,w.sibling=s,o.child=w,o.child.memoizedState=nj(i),o.memoizedState=xs,s):qj(o,x));if(null!==(_=s.memoizedState)&&null!==(a=_.dehydrated))return function rj(s,o,i,a,u,_,w){if(i)return 256&o.flags?(o.flags&=-257,sj(s,o,w,a=Ki(Error(p(422))))):null!==o.memoizedState?(o.child=s.child,o.flags|=128,null):(_=a.fallback,u=o.mode,a=pj({mode:\"visible\",children:a.children},u,0,null),(_=Tg(_,u,w,null)).flags|=2,a.return=o,_.return=o,a.sibling=_,o.child=a,1&o.mode&&qn(o,s.child,null,w),o.child.memoizedState=nj(w),o.memoizedState=xs,_);if(!(1&o.mode))return sj(s,o,w,null);if(\"$!\"===u.data){if(a=u.nextSibling&&u.nextSibling.dataset)var x=a.dgst;return a=x,sj(s,o,w,a=Ki(_=Error(p(419)),a,void 0))}if(x=!!(w&s.childLanes),bs||x){if(null!==(a=Fs)){switch(w&-w){case 4:u=2;break;case 16:u=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:u=32;break;case 536870912:u=268435456;break;default:u=0}0!==(u=u&(a.suspendedLanes|w)?0:u)&&u!==_.retryLane&&(_.retryLane=u,ih(s,u),gi(a,s,u,-1))}return tj(),sj(s,o,w,a=Ki(Error(p(421))))}return\"$?\"===u.data?(o.flags|=128,o.child=s.child,o=uj.bind(null,s),u._reactRetry=o,null):(s=_.treeContext,Ln=Lf(u.nextSibling),Dn=o,Fn=!0,Bn=null,null!==s&&(In[Tn++]=Mn,In[Tn++]=Rn,In[Tn++]=Nn,Mn=s.id,Rn=s.overflow,Nn=o),o=qj(o,a.children),o.flags|=4096,o)}(s,o,x,u,a,_,i);if(w){w=u.fallback,x=o.mode,a=(_=s.child).sibling;var C={mode:\"hidden\",children:u.children};return 1&x||o.child===_?(u=Pg(_,C)).subtreeFlags=14680064&_.subtreeFlags:((u=o.child).childLanes=0,u.pendingProps=C,o.deletions=null),null!==a?w=Pg(a,w):(w=Tg(w,x,i,null)).flags|=2,w.return=o,u.return=o,u.sibling=w,o.child=u,u=w,w=o.child,x=null===(x=s.child.memoizedState)?nj(i):{baseLanes:x.baseLanes|i,cachePool:null,transitions:x.transitions},w.memoizedState=x,w.childLanes=s.childLanes&~i,o.memoizedState=xs,u}return s=(w=s.child).sibling,u=Pg(w,{mode:\"visible\",children:u.children}),!(1&o.mode)&&(u.lanes=i),u.return=o,u.sibling=null,null!==s&&(null===(i=o.deletions)?(o.deletions=[s],o.flags|=16):i.push(s)),o.child=u,o.memoizedState=null,u}function qj(s,o){return(o=pj({mode:\"visible\",children:o},s.mode,0,null)).return=s,s.child=o}function sj(s,o,i,a){return null!==a&&Jg(a),qn(o,s.child,null,i),(s=qj(o,o.pendingProps.children)).flags|=2,o.memoizedState=null,s}function vj(s,o,i){s.lanes|=o;var a=s.alternate;null!==a&&(a.lanes|=o),bh(s.return,o,i)}function wj(s,o,i,a,u){var _=s.memoizedState;null===_?s.memoizedState={isBackwards:o,rendering:null,renderingStartTime:0,last:a,tail:i,tailMode:u}:(_.isBackwards=o,_.rendering=null,_.renderingStartTime=0,_.last=a,_.tail=i,_.tailMode=u)}function xj(s,o,i){var a=o.pendingProps,u=a.revealOrder,_=a.tail;if(Xi(s,o,a.children,i),2&(a=Zn.current))a=1&a|2,o.flags|=128;else{if(null!==s&&128&s.flags)e:for(s=o.child;null!==s;){if(13===s.tag)null!==s.memoizedState&&vj(s,i,o);else if(19===s.tag)vj(s,i,o);else if(null!==s.child){s.child.return=s,s=s.child;continue}if(s===o)break e;for(;null===s.sibling;){if(null===s.return||s.return===o)break e;s=s.return}s.sibling.return=s.return,s=s.sibling}a&=1}if(G(Zn,a),1&o.mode)switch(u){case\"forwards\":for(i=o.child,u=null;null!==i;)null!==(s=i.alternate)&&null===Ch(s)&&(u=i),i=i.sibling;null===(i=u)?(u=o.child,o.child=null):(u=i.sibling,i.sibling=null),wj(o,!1,u,i,_);break;case\"backwards\":for(i=null,u=o.child,o.child=null;null!==u;){if(null!==(s=u.alternate)&&null===Ch(s)){o.child=u;break}s=u.sibling,u.sibling=i,i=u,u=s}wj(o,!0,i,null,_);break;case\"together\":wj(o,!1,null,null,void 0);break;default:o.memoizedState=null}else o.memoizedState=null;return o.child}function ij(s,o){!(1&o.mode)&&null!==s&&(s.alternate=null,o.alternate=null,o.flags|=2)}function Zi(s,o,i){if(null!==s&&(o.dependencies=s.dependencies),Ws|=o.lanes,!(i&o.childLanes))return null;if(null!==s&&o.child!==s.child)throw Error(p(153));if(null!==o.child){for(i=Pg(s=o.child,s.pendingProps),o.child=i,i.return=o;null!==s.sibling;)s=s.sibling,(i=i.sibling=Pg(s,s.pendingProps)).return=o;i.sibling=null}return o.child}function Dj(s,o){if(!Fn)switch(s.tailMode){case\"hidden\":o=s.tail;for(var i=null;null!==o;)null!==o.alternate&&(i=o),o=o.sibling;null===i?s.tail=null:i.sibling=null;break;case\"collapsed\":i=s.tail;for(var a=null;null!==i;)null!==i.alternate&&(a=i),i=i.sibling;null===a?o||null===s.tail?s.tail=null:s.tail.sibling=null:a.sibling=null}}function S(s){var o=null!==s.alternate&&s.alternate.child===s.child,i=0,a=0;if(o)for(var u=s.child;null!==u;)i|=u.lanes|u.childLanes,a|=14680064&u.subtreeFlags,a|=14680064&u.flags,u.return=s,u=u.sibling;else for(u=s.child;null!==u;)i|=u.lanes|u.childLanes,a|=u.subtreeFlags,a|=u.flags,u.return=s,u=u.sibling;return s.subtreeFlags|=a,s.childLanes=i,o}function Ej(s,o,i){var a=o.pendingProps;switch(wg(o),o.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return S(o),null;case 1:case 17:return Zf(o.type)&&$f(),S(o),null;case 3:return a=o.stateNode,zh(),E(En),E(Sn),Eh(),a.pendingContext&&(a.context=a.pendingContext,a.pendingContext=null),null!==s&&null!==s.child||(Gg(o)?o.flags|=4:null===s||s.memoizedState.isDehydrated&&!(256&o.flags)||(o.flags|=1024,null!==Bn&&(Fj(Bn),Bn=null))),Ss(s,o),S(o),null;case 5:Bh(o);var u=xh(Qn.current);if(i=o.type,null!==s&&null!=o.stateNode)Es(s,o,i,a,u),s.ref!==o.ref&&(o.flags|=512,o.flags|=2097152);else{if(!a){if(null===o.stateNode)throw Error(p(166));return S(o),null}if(s=xh(Yn.current),Gg(o)){a=o.stateNode,i=o.type;var _=o.memoizedProps;switch(a[hn]=o,a[dn]=_,s=!!(1&o.mode),i){case\"dialog\":D(\"cancel\",a),D(\"close\",a);break;case\"iframe\":case\"object\":case\"embed\":D(\"load\",a);break;case\"video\":case\"audio\":for(u=0;u<Zr.length;u++)D(Zr[u],a);break;case\"source\":D(\"error\",a);break;case\"img\":case\"image\":case\"link\":D(\"error\",a),D(\"load\",a);break;case\"details\":D(\"toggle\",a);break;case\"input\":Za(a,_),D(\"invalid\",a);break;case\"select\":a._wrapperState={wasMultiple:!!_.multiple},D(\"invalid\",a);break;case\"textarea\":hb(a,_),D(\"invalid\",a)}for(var x in ub(i,_),u=null,_)if(_.hasOwnProperty(x)){var C=_[x];\"children\"===x?\"string\"==typeof C?a.textContent!==C&&(!0!==_.suppressHydrationWarning&&Af(a.textContent,C,s),u=[\"children\",C]):\"number\"==typeof C&&a.textContent!==\"\"+C&&(!0!==_.suppressHydrationWarning&&Af(a.textContent,C,s),u=[\"children\",\"\"+C]):w.hasOwnProperty(x)&&null!=C&&\"onScroll\"===x&&D(\"scroll\",a)}switch(i){case\"input\":Va(a),db(a,_,!0);break;case\"textarea\":Va(a),jb(a);break;case\"select\":case\"option\":break;default:\"function\"==typeof _.onClick&&(a.onclick=Bf)}a=u,o.updateQueue=a,null!==a&&(o.flags|=4)}else{x=9===u.nodeType?u:u.ownerDocument,\"http://www.w3.org/1999/xhtml\"===s&&(s=kb(i)),\"http://www.w3.org/1999/xhtml\"===s?\"script\"===i?((s=x.createElement(\"div\")).innerHTML=\"<script><\\/script>\",s=s.removeChild(s.firstChild)):\"string\"==typeof a.is?s=x.createElement(i,{is:a.is}):(s=x.createElement(i),\"select\"===i&&(x=s,a.multiple?x.multiple=!0:a.size&&(x.size=a.size))):s=x.createElementNS(s,i),s[hn]=o,s[dn]=a,_s(s,o,!1,!1),o.stateNode=s;e:{switch(x=vb(i,a),i){case\"dialog\":D(\"cancel\",s),D(\"close\",s),u=a;break;case\"iframe\":case\"object\":case\"embed\":D(\"load\",s),u=a;break;case\"video\":case\"audio\":for(u=0;u<Zr.length;u++)D(Zr[u],s);u=a;break;case\"source\":D(\"error\",s),u=a;break;case\"img\":case\"image\":case\"link\":D(\"error\",s),D(\"load\",s),u=a;break;case\"details\":D(\"toggle\",s),u=a;break;case\"input\":Za(s,a),u=Ya(s,a),D(\"invalid\",s);break;case\"option\":default:u=a;break;case\"select\":s._wrapperState={wasMultiple:!!a.multiple},u=we({},a,{value:void 0}),D(\"invalid\",s);break;case\"textarea\":hb(s,a),u=gb(s,a),D(\"invalid\",s)}for(_ in ub(i,u),C=u)if(C.hasOwnProperty(_)){var j=C[_];\"style\"===_?sb(s,j):\"dangerouslySetInnerHTML\"===_?null!=(j=j?j.__html:void 0)&&$e(s,j):\"children\"===_?\"string\"==typeof j?(\"textarea\"!==i||\"\"!==j)&&ob(s,j):\"number\"==typeof j&&ob(s,\"\"+j):\"suppressContentEditableWarning\"!==_&&\"suppressHydrationWarning\"!==_&&\"autoFocus\"!==_&&(w.hasOwnProperty(_)?null!=j&&\"onScroll\"===_&&D(\"scroll\",s):null!=j&&ta(s,_,j,x))}switch(i){case\"input\":Va(s),db(s,a,!1);break;case\"textarea\":Va(s),jb(s);break;case\"option\":null!=a.value&&s.setAttribute(\"value\",\"\"+Sa(a.value));break;case\"select\":s.multiple=!!a.multiple,null!=(_=a.value)?fb(s,!!a.multiple,_,!1):null!=a.defaultValue&&fb(s,!!a.multiple,a.defaultValue,!0);break;default:\"function\"==typeof u.onClick&&(s.onclick=Bf)}switch(i){case\"button\":case\"input\":case\"select\":case\"textarea\":a=!!a.autoFocus;break e;case\"img\":a=!0;break e;default:a=!1}}a&&(o.flags|=4)}null!==o.ref&&(o.flags|=512,o.flags|=2097152)}return S(o),null;case 6:if(s&&null!=o.stateNode)ws(s,o,s.memoizedProps,a);else{if(\"string\"!=typeof a&&null===o.stateNode)throw Error(p(166));if(i=xh(Qn.current),xh(Yn.current),Gg(o)){if(a=o.stateNode,i=o.memoizedProps,a[hn]=o,(_=a.nodeValue!==i)&&null!==(s=Dn))switch(s.tag){case 3:Af(a.nodeValue,i,!!(1&s.mode));break;case 5:!0!==s.memoizedProps.suppressHydrationWarning&&Af(a.nodeValue,i,!!(1&s.mode))}_&&(o.flags|=4)}else(a=(9===i.nodeType?i:i.ownerDocument).createTextNode(a))[hn]=o,o.stateNode=a}return S(o),null;case 13:if(E(Zn),a=o.memoizedState,null===s||null!==s.memoizedState&&null!==s.memoizedState.dehydrated){if(Fn&&null!==Ln&&1&o.mode&&!(128&o.flags))Hg(),Ig(),o.flags|=98560,_=!1;else if(_=Gg(o),null!==a&&null!==a.dehydrated){if(null===s){if(!_)throw Error(p(318));if(!(_=null!==(_=o.memoizedState)?_.dehydrated:null))throw Error(p(317));_[hn]=o}else Ig(),!(128&o.flags)&&(o.memoizedState=null),o.flags|=4;S(o),_=!1}else null!==Bn&&(Fj(Bn),Bn=null),_=!0;if(!_)return 65536&o.flags?o:null}return 128&o.flags?(o.lanes=i,o):((a=null!==a)!==(null!==s&&null!==s.memoizedState)&&a&&(o.child.flags|=8192,1&o.mode&&(null===s||1&Zn.current?0===Vs&&(Vs=3):tj())),null!==o.updateQueue&&(o.flags|=4),S(o),null);case 4:return zh(),Ss(s,o),null===s&&sf(o.stateNode.containerInfo),S(o),null;case 10:return ah(o.type._context),S(o),null;case 19:if(E(Zn),null===(_=o.memoizedState))return S(o),null;if(a=!!(128&o.flags),null===(x=_.rendering))if(a)Dj(_,!1);else{if(0!==Vs||null!==s&&128&s.flags)for(s=o.child;null!==s;){if(null!==(x=Ch(s))){for(o.flags|=128,Dj(_,!1),null!==(a=x.updateQueue)&&(o.updateQueue=a,o.flags|=4),o.subtreeFlags=0,a=i,i=o.child;null!==i;)s=a,(_=i).flags&=14680066,null===(x=_.alternate)?(_.childLanes=0,_.lanes=s,_.child=null,_.subtreeFlags=0,_.memoizedProps=null,_.memoizedState=null,_.updateQueue=null,_.dependencies=null,_.stateNode=null):(_.childLanes=x.childLanes,_.lanes=x.lanes,_.child=x.child,_.subtreeFlags=0,_.deletions=null,_.memoizedProps=x.memoizedProps,_.memoizedState=x.memoizedState,_.updateQueue=x.updateQueue,_.type=x.type,s=x.dependencies,_.dependencies=null===s?null:{lanes:s.lanes,firstContext:s.firstContext}),i=i.sibling;return G(Zn,1&Zn.current|2),o.child}s=s.sibling}null!==_.tail&&ht()>Xs&&(o.flags|=128,a=!0,Dj(_,!1),o.lanes=4194304)}else{if(!a)if(null!==(s=Ch(x))){if(o.flags|=128,a=!0,null!==(i=s.updateQueue)&&(o.updateQueue=i,o.flags|=4),Dj(_,!0),null===_.tail&&\"hidden\"===_.tailMode&&!x.alternate&&!Fn)return S(o),null}else 2*ht()-_.renderingStartTime>Xs&&1073741824!==i&&(o.flags|=128,a=!0,Dj(_,!1),o.lanes=4194304);_.isBackwards?(x.sibling=o.child,o.child=x):(null!==(i=_.last)?i.sibling=x:o.child=x,_.last=x)}return null!==_.tail?(o=_.tail,_.rendering=o,_.tail=o.sibling,_.renderingStartTime=ht(),o.sibling=null,i=Zn.current,G(Zn,a?1&i|2:1&i),o):(S(o),null);case 22:case 23:return Hj(),a=null!==o.memoizedState,null!==s&&null!==s.memoizedState!==a&&(o.flags|=8192),a&&1&o.mode?!!(1073741824&qs)&&(S(o),6&o.subtreeFlags&&(o.flags|=8192)):S(o),null;case 24:case 25:return null}throw Error(p(156,o.tag))}function Ij(s,o){switch(wg(o),o.tag){case 1:return Zf(o.type)&&$f(),65536&(s=o.flags)?(o.flags=-65537&s|128,o):null;case 3:return zh(),E(En),E(Sn),Eh(),65536&(s=o.flags)&&!(128&s)?(o.flags=-65537&s|128,o):null;case 5:return Bh(o),null;case 13:if(E(Zn),null!==(s=o.memoizedState)&&null!==s.dehydrated){if(null===o.alternate)throw Error(p(340));Ig()}return 65536&(s=o.flags)?(o.flags=-65537&s|128,o):null;case 19:return E(Zn),null;case 4:return zh(),null;case 10:return ah(o.type._context),null;case 22:case 23:return Hj(),null;default:return null}}_s=function(s,o){for(var i=o.child;null!==i;){if(5===i.tag||6===i.tag)s.appendChild(i.stateNode);else if(4!==i.tag&&null!==i.child){i.child.return=i,i=i.child;continue}if(i===o)break;for(;null===i.sibling;){if(null===i.return||i.return===o)return;i=i.return}i.sibling.return=i.return,i=i.sibling}},Ss=function(){},Es=function(s,o,i,a){var u=s.memoizedProps;if(u!==a){s=o.stateNode,xh(Yn.current);var _,x=null;switch(i){case\"input\":u=Ya(s,u),a=Ya(s,a),x=[];break;case\"select\":u=we({},u,{value:void 0}),a=we({},a,{value:void 0}),x=[];break;case\"textarea\":u=gb(s,u),a=gb(s,a),x=[];break;default:\"function\"!=typeof u.onClick&&\"function\"==typeof a.onClick&&(s.onclick=Bf)}for(L in ub(i,a),i=null,u)if(!a.hasOwnProperty(L)&&u.hasOwnProperty(L)&&null!=u[L])if(\"style\"===L){var C=u[L];for(_ in C)C.hasOwnProperty(_)&&(i||(i={}),i[_]=\"\")}else\"dangerouslySetInnerHTML\"!==L&&\"children\"!==L&&\"suppressContentEditableWarning\"!==L&&\"suppressHydrationWarning\"!==L&&\"autoFocus\"!==L&&(w.hasOwnProperty(L)?x||(x=[]):(x=x||[]).push(L,null));for(L in a){var j=a[L];if(C=null!=u?u[L]:void 0,a.hasOwnProperty(L)&&j!==C&&(null!=j||null!=C))if(\"style\"===L)if(C){for(_ in C)!C.hasOwnProperty(_)||j&&j.hasOwnProperty(_)||(i||(i={}),i[_]=\"\");for(_ in j)j.hasOwnProperty(_)&&C[_]!==j[_]&&(i||(i={}),i[_]=j[_])}else i||(x||(x=[]),x.push(L,i)),i=j;else\"dangerouslySetInnerHTML\"===L?(j=j?j.__html:void 0,C=C?C.__html:void 0,null!=j&&C!==j&&(x=x||[]).push(L,j)):\"children\"===L?\"string\"!=typeof j&&\"number\"!=typeof j||(x=x||[]).push(L,\"\"+j):\"suppressContentEditableWarning\"!==L&&\"suppressHydrationWarning\"!==L&&(w.hasOwnProperty(L)?(null!=j&&\"onScroll\"===L&&D(\"scroll\",s),x||C===j||(x=[])):(x=x||[]).push(L,j))}i&&(x=x||[]).push(\"style\",i);var L=x;(o.updateQueue=L)&&(o.flags|=4)}},ws=function(s,o,i,a){i!==a&&(o.flags|=4)};var ks=!1,Os=!1,As=\"function\"==typeof WeakSet?WeakSet:Set,Cs=null;function Lj(s,o){var i=s.ref;if(null!==i)if(\"function\"==typeof i)try{i(null)}catch(i){W(s,o,i)}else i.current=null}function Mj(s,o,i){try{i()}catch(i){W(s,o,i)}}var js=!1;function Pj(s,o,i){var a=o.updateQueue;if(null!==(a=null!==a?a.lastEffect:null)){var u=a=a.next;do{if((u.tag&s)===s){var _=u.destroy;u.destroy=void 0,void 0!==_&&Mj(o,i,_)}u=u.next}while(u!==a)}}function Qj(s,o){if(null!==(o=null!==(o=o.updateQueue)?o.lastEffect:null)){var i=o=o.next;do{if((i.tag&s)===s){var a=i.create;i.destroy=a()}i=i.next}while(i!==o)}}function Rj(s){var o=s.ref;if(null!==o){var i=s.stateNode;s.tag,s=i,\"function\"==typeof o?o(s):o.current=s}}function Sj(s){var o=s.alternate;null!==o&&(s.alternate=null,Sj(o)),s.child=null,s.deletions=null,s.sibling=null,5===s.tag&&(null!==(o=s.stateNode)&&(delete o[hn],delete o[dn],delete o[mn],delete o[gn],delete o[yn])),s.stateNode=null,s.return=null,s.dependencies=null,s.memoizedProps=null,s.memoizedState=null,s.pendingProps=null,s.stateNode=null,s.updateQueue=null}function Tj(s){return 5===s.tag||3===s.tag||4===s.tag}function Uj(s){e:for(;;){for(;null===s.sibling;){if(null===s.return||Tj(s.return))return null;s=s.return}for(s.sibling.return=s.return,s=s.sibling;5!==s.tag&&6!==s.tag&&18!==s.tag;){if(2&s.flags)continue e;if(null===s.child||4===s.tag)continue e;s.child.return=s,s=s.child}if(!(2&s.flags))return s.stateNode}}function Vj(s,o,i){var a=s.tag;if(5===a||6===a)s=s.stateNode,o?8===i.nodeType?i.parentNode.insertBefore(s,o):i.insertBefore(s,o):(8===i.nodeType?(o=i.parentNode).insertBefore(s,i):(o=i).appendChild(s),null!=(i=i._reactRootContainer)||null!==o.onclick||(o.onclick=Bf));else if(4!==a&&null!==(s=s.child))for(Vj(s,o,i),s=s.sibling;null!==s;)Vj(s,o,i),s=s.sibling}function Wj(s,o,i){var a=s.tag;if(5===a||6===a)s=s.stateNode,o?i.insertBefore(s,o):i.appendChild(s);else if(4!==a&&null!==(s=s.child))for(Wj(s,o,i),s=s.sibling;null!==s;)Wj(s,o,i),s=s.sibling}var Ps=null,Is=!1;function Yj(s,o,i){for(i=i.child;null!==i;)Zj(s,o,i),i=i.sibling}function Zj(s,o,i){if(St&&\"function\"==typeof St.onCommitFiberUnmount)try{St.onCommitFiberUnmount(_t,i)}catch(s){}switch(i.tag){case 5:Os||Lj(i,o);case 6:var a=Ps,u=Is;Ps=null,Yj(s,o,i),Is=u,null!==(Ps=a)&&(Is?(s=Ps,i=i.stateNode,8===s.nodeType?s.parentNode.removeChild(i):s.removeChild(i)):Ps.removeChild(i.stateNode));break;case 18:null!==Ps&&(Is?(s=Ps,i=i.stateNode,8===s.nodeType?Kf(s.parentNode,i):1===s.nodeType&&Kf(s,i),bd(s)):Kf(Ps,i.stateNode));break;case 4:a=Ps,u=Is,Ps=i.stateNode.containerInfo,Is=!0,Yj(s,o,i),Ps=a,Is=u;break;case 0:case 11:case 14:case 15:if(!Os&&(null!==(a=i.updateQueue)&&null!==(a=a.lastEffect))){u=a=a.next;do{var _=u,w=_.destroy;_=_.tag,void 0!==w&&(2&_||4&_)&&Mj(i,o,w),u=u.next}while(u!==a)}Yj(s,o,i);break;case 1:if(!Os&&(Lj(i,o),\"function\"==typeof(a=i.stateNode).componentWillUnmount))try{a.props=i.memoizedProps,a.state=i.memoizedState,a.componentWillUnmount()}catch(s){W(i,o,s)}Yj(s,o,i);break;case 21:Yj(s,o,i);break;case 22:1&i.mode?(Os=(a=Os)||null!==i.memoizedState,Yj(s,o,i),Os=a):Yj(s,o,i);break;default:Yj(s,o,i)}}function ak(s){var o=s.updateQueue;if(null!==o){s.updateQueue=null;var i=s.stateNode;null===i&&(i=s.stateNode=new As),o.forEach((function(o){var a=bk.bind(null,s,o);i.has(o)||(i.add(o),o.then(a,a))}))}}function ck(s,o){var i=o.deletions;if(null!==i)for(var a=0;a<i.length;a++){var u=i[a];try{var _=s,w=o,x=w;e:for(;null!==x;){switch(x.tag){case 5:Ps=x.stateNode,Is=!1;break e;case 3:case 4:Ps=x.stateNode.containerInfo,Is=!0;break e}x=x.return}if(null===Ps)throw Error(p(160));Zj(_,w,u),Ps=null,Is=!1;var C=u.alternate;null!==C&&(C.return=null),u.return=null}catch(s){W(u,o,s)}}if(12854&o.subtreeFlags)for(o=o.child;null!==o;)dk(o,s),o=o.sibling}function dk(s,o){var i=s.alternate,a=s.flags;switch(s.tag){case 0:case 11:case 14:case 15:if(ck(o,s),ek(s),4&a){try{Pj(3,s,s.return),Qj(3,s)}catch(o){W(s,s.return,o)}try{Pj(5,s,s.return)}catch(o){W(s,s.return,o)}}break;case 1:ck(o,s),ek(s),512&a&&null!==i&&Lj(i,i.return);break;case 5:if(ck(o,s),ek(s),512&a&&null!==i&&Lj(i,i.return),32&s.flags){var u=s.stateNode;try{ob(u,\"\")}catch(o){W(s,s.return,o)}}if(4&a&&null!=(u=s.stateNode)){var _=s.memoizedProps,w=null!==i?i.memoizedProps:_,x=s.type,C=s.updateQueue;if(s.updateQueue=null,null!==C)try{\"input\"===x&&\"radio\"===_.type&&null!=_.name&&ab(u,_),vb(x,w);var j=vb(x,_);for(w=0;w<C.length;w+=2){var L=C[w],B=C[w+1];\"style\"===L?sb(u,B):\"dangerouslySetInnerHTML\"===L?$e(u,B):\"children\"===L?ob(u,B):ta(u,L,B,j)}switch(x){case\"input\":bb(u,_);break;case\"textarea\":ib(u,_);break;case\"select\":var $=u._wrapperState.wasMultiple;u._wrapperState.wasMultiple=!!_.multiple;var U=_.value;null!=U?fb(u,!!_.multiple,U,!1):$!==!!_.multiple&&(null!=_.defaultValue?fb(u,!!_.multiple,_.defaultValue,!0):fb(u,!!_.multiple,_.multiple?[]:\"\",!1))}u[dn]=_}catch(o){W(s,s.return,o)}}break;case 6:if(ck(o,s),ek(s),4&a){if(null===s.stateNode)throw Error(p(162));u=s.stateNode,_=s.memoizedProps;try{u.nodeValue=_}catch(o){W(s,s.return,o)}}break;case 3:if(ck(o,s),ek(s),4&a&&null!==i&&i.memoizedState.isDehydrated)try{bd(o.containerInfo)}catch(o){W(s,s.return,o)}break;case 4:default:ck(o,s),ek(s);break;case 13:ck(o,s),ek(s),8192&(u=s.child).flags&&(_=null!==u.memoizedState,u.stateNode.isHidden=_,!_||null!==u.alternate&&null!==u.alternate.memoizedState||(Ys=ht())),4&a&&ak(s);break;case 22:if(L=null!==i&&null!==i.memoizedState,1&s.mode?(Os=(j=Os)||L,ck(o,s),Os=j):ck(o,s),ek(s),8192&a){if(j=null!==s.memoizedState,(s.stateNode.isHidden=j)&&!L&&1&s.mode)for(Cs=s,L=s.child;null!==L;){for(B=Cs=L;null!==Cs;){switch(U=($=Cs).child,$.tag){case 0:case 11:case 14:case 15:Pj(4,$,$.return);break;case 1:Lj($,$.return);var V=$.stateNode;if(\"function\"==typeof V.componentWillUnmount){a=$,i=$.return;try{o=a,V.props=o.memoizedProps,V.state=o.memoizedState,V.componentWillUnmount()}catch(s){W(a,i,s)}}break;case 5:Lj($,$.return);break;case 22:if(null!==$.memoizedState){gk(B);continue}}null!==U?(U.return=$,Cs=U):gk(B)}L=L.sibling}e:for(L=null,B=s;;){if(5===B.tag){if(null===L){L=B;try{u=B.stateNode,j?\"function\"==typeof(_=u.style).setProperty?_.setProperty(\"display\",\"none\",\"important\"):_.display=\"none\":(x=B.stateNode,w=null!=(C=B.memoizedProps.style)&&C.hasOwnProperty(\"display\")?C.display:null,x.style.display=rb(\"display\",w))}catch(o){W(s,s.return,o)}}}else if(6===B.tag){if(null===L)try{B.stateNode.nodeValue=j?\"\":B.memoizedProps}catch(o){W(s,s.return,o)}}else if((22!==B.tag&&23!==B.tag||null===B.memoizedState||B===s)&&null!==B.child){B.child.return=B,B=B.child;continue}if(B===s)break e;for(;null===B.sibling;){if(null===B.return||B.return===s)break e;L===B&&(L=null),B=B.return}L===B&&(L=null),B.sibling.return=B.return,B=B.sibling}}break;case 19:ck(o,s),ek(s),4&a&&ak(s);case 21:}}function ek(s){var o=s.flags;if(2&o){try{e:{for(var i=s.return;null!==i;){if(Tj(i)){var a=i;break e}i=i.return}throw Error(p(160))}switch(a.tag){case 5:var u=a.stateNode;32&a.flags&&(ob(u,\"\"),a.flags&=-33),Wj(s,Uj(s),u);break;case 3:case 4:var _=a.stateNode.containerInfo;Vj(s,Uj(s),_);break;default:throw Error(p(161))}}catch(o){W(s,s.return,o)}s.flags&=-3}4096&o&&(s.flags&=-4097)}function hk(s,o,i){Cs=s,ik(s,o,i)}function ik(s,o,i){for(var a=!!(1&s.mode);null!==Cs;){var u=Cs,_=u.child;if(22===u.tag&&a){var w=null!==u.memoizedState||ks;if(!w){var x=u.alternate,C=null!==x&&null!==x.memoizedState||Os;x=ks;var j=Os;if(ks=w,(Os=C)&&!j)for(Cs=u;null!==Cs;)C=(w=Cs).child,22===w.tag&&null!==w.memoizedState?jk(u):null!==C?(C.return=w,Cs=C):jk(u);for(;null!==_;)Cs=_,ik(_,o,i),_=_.sibling;Cs=u,ks=x,Os=j}kk(s)}else 8772&u.subtreeFlags&&null!==_?(_.return=u,Cs=_):kk(s)}}function kk(s){for(;null!==Cs;){var o=Cs;if(8772&o.flags){var i=o.alternate;try{if(8772&o.flags)switch(o.tag){case 0:case 11:case 15:Os||Qj(5,o);break;case 1:var a=o.stateNode;if(4&o.flags&&!Os)if(null===i)a.componentDidMount();else{var u=o.elementType===o.type?i.memoizedProps:Ci(o.type,i.memoizedProps);a.componentDidUpdate(u,i.memoizedState,a.__reactInternalSnapshotBeforeUpdate)}var _=o.updateQueue;null!==_&&sh(o,_,a);break;case 3:var w=o.updateQueue;if(null!==w){if(i=null,null!==o.child)switch(o.child.tag){case 5:case 1:i=o.child.stateNode}sh(o,w,i)}break;case 5:var x=o.stateNode;if(null===i&&4&o.flags){i=x;var C=o.memoizedProps;switch(o.type){case\"button\":case\"input\":case\"select\":case\"textarea\":C.autoFocus&&i.focus();break;case\"img\":C.src&&(i.src=C.src)}}break;case 6:case 4:case 12:case 19:case 17:case 21:case 22:case 23:case 25:break;case 13:if(null===o.memoizedState){var j=o.alternate;if(null!==j){var L=j.memoizedState;if(null!==L){var B=L.dehydrated;null!==B&&bd(B)}}}break;default:throw Error(p(163))}Os||512&o.flags&&Rj(o)}catch(s){W(o,o.return,s)}}if(o===s){Cs=null;break}if(null!==(i=o.sibling)){i.return=o.return,Cs=i;break}Cs=o.return}}function gk(s){for(;null!==Cs;){var o=Cs;if(o===s){Cs=null;break}var i=o.sibling;if(null!==i){i.return=o.return,Cs=i;break}Cs=o.return}}function jk(s){for(;null!==Cs;){var o=Cs;try{switch(o.tag){case 0:case 11:case 15:var i=o.return;try{Qj(4,o)}catch(s){W(o,i,s)}break;case 1:var a=o.stateNode;if(\"function\"==typeof a.componentDidMount){var u=o.return;try{a.componentDidMount()}catch(s){W(o,u,s)}}var _=o.return;try{Rj(o)}catch(s){W(o,_,s)}break;case 5:var w=o.return;try{Rj(o)}catch(s){W(o,w,s)}}}catch(s){W(o,o.return,s)}if(o===s){Cs=null;break}var x=o.sibling;if(null!==x){x.return=o.return,Cs=x;break}Cs=o.return}}var Ts,Ns=Math.ceil,Ms=V.ReactCurrentDispatcher,Rs=V.ReactCurrentOwner,Ds=V.ReactCurrentBatchConfig,Ls=0,Fs=null,Bs=null,$s=0,qs=0,Us=Uf(0),Vs=0,zs=null,Ws=0,Js=0,Hs=0,Ks=null,Gs=null,Ys=0,Xs=1/0,Qs=null,Zs=!1,eo=null,to=null,ro=!1,no=null,so=0,oo=0,io=null,ao=-1,co=0;function R(){return 6&Ls?ht():-1!==ao?ao:ao=ht()}function yi(s){return 1&s.mode?2&Ls&&0!==$s?$s&-$s:null!==$n.transition?(0===co&&(co=yc()),co):0!==(s=At)?s:s=void 0===(s=window.event)?16:jd(s.type):1}function gi(s,o,i,a){if(50<oo)throw oo=0,io=null,Error(p(185));Ac(s,i,a),2&Ls&&s===Fs||(s===Fs&&(!(2&Ls)&&(Js|=i),4===Vs&&Ck(s,$s)),Dk(s,a),1===i&&0===Ls&&!(1&o.mode)&&(Xs=ht()+500,kn&&jg()))}function Dk(s,o){var i=s.callbackNode;!function wc(s,o){for(var i=s.suspendedLanes,a=s.pingedLanes,u=s.expirationTimes,_=s.pendingLanes;0<_;){var w=31-Et(_),x=1<<w,C=u[w];-1===C?x&i&&!(x&a)||(u[w]=vc(x,o)):C<=o&&(s.expiredLanes|=x),_&=~x}}(s,o);var a=uc(s,s===Fs?$s:0);if(0===a)null!==i&&lt(i),s.callbackNode=null,s.callbackPriority=0;else if(o=a&-a,s.callbackPriority!==o){if(null!=i&&lt(i),1===o)0===s.tag?function ig(s){kn=!0,hg(s)}(Ek.bind(null,s)):hg(Ek.bind(null,s)),un((function(){!(6&Ls)&&jg()})),i=null;else{switch(Dc(a)){case 1:i=mt;break;case 4:i=gt;break;case 16:default:i=yt;break;case 536870912:i=bt}i=Fk(i,Gk.bind(null,s))}s.callbackPriority=o,s.callbackNode=i}}function Gk(s,o){if(ao=-1,co=0,6&Ls)throw Error(p(327));var i=s.callbackNode;if(Hk()&&s.callbackNode!==i)return null;var a=uc(s,s===Fs?$s:0);if(0===a)return null;if(30&a||a&s.expiredLanes||o)o=Ik(s,a);else{o=a;var u=Ls;Ls|=2;var _=Jk();for(Fs===s&&$s===o||(Qs=null,Xs=ht()+500,Kk(s,o));;)try{Lk();break}catch(o){Mk(s,o)}$g(),Ms.current=_,Ls=u,null!==Bs?o=0:(Fs=null,$s=0,o=Vs)}if(0!==o){if(2===o&&(0!==(u=xc(s))&&(a=u,o=Nk(s,u))),1===o)throw i=zs,Kk(s,0),Ck(s,a),Dk(s,ht()),i;if(6===o)Ck(s,a);else{if(u=s.current.alternate,!(30&a||function Ok(s){for(var o=s;;){if(16384&o.flags){var i=o.updateQueue;if(null!==i&&null!==(i=i.stores))for(var a=0;a<i.length;a++){var u=i[a],_=u.getSnapshot;u=u.value;try{if(!Dr(_(),u))return!1}catch(s){return!1}}}if(i=o.child,16384&o.subtreeFlags&&null!==i)i.return=o,o=i;else{if(o===s)break;for(;null===o.sibling;){if(null===o.return||o.return===s)return!0;o=o.return}o.sibling.return=o.return,o=o.sibling}}return!0}(u)||(o=Ik(s,a),2===o&&(_=xc(s),0!==_&&(a=_,o=Nk(s,_))),1!==o)))throw i=zs,Kk(s,0),Ck(s,a),Dk(s,ht()),i;switch(s.finishedWork=u,s.finishedLanes=a,o){case 0:case 1:throw Error(p(345));case 2:case 5:Pk(s,Gs,Qs);break;case 3:if(Ck(s,a),(130023424&a)===a&&10<(o=Ys+500-ht())){if(0!==uc(s,0))break;if(((u=s.suspendedLanes)&a)!==a){R(),s.pingedLanes|=s.suspendedLanes&u;break}s.timeoutHandle=an(Pk.bind(null,s,Gs,Qs),o);break}Pk(s,Gs,Qs);break;case 4:if(Ck(s,a),(4194240&a)===a)break;for(o=s.eventTimes,u=-1;0<a;){var w=31-Et(a);_=1<<w,(w=o[w])>u&&(u=w),a&=~_}if(a=u,10<(a=(120>(a=ht()-a)?120:480>a?480:1080>a?1080:1920>a?1920:3e3>a?3e3:4320>a?4320:1960*Ns(a/1960))-a)){s.timeoutHandle=an(Pk.bind(null,s,Gs,Qs),a);break}Pk(s,Gs,Qs);break;default:throw Error(p(329))}}}return Dk(s,ht()),s.callbackNode===i?Gk.bind(null,s):null}function Nk(s,o){var i=Ks;return s.current.memoizedState.isDehydrated&&(Kk(s,o).flags|=256),2!==(s=Ik(s,o))&&(o=Gs,Gs=i,null!==o&&Fj(o)),s}function Fj(s){null===Gs?Gs=s:Gs.push.apply(Gs,s)}function Ck(s,o){for(o&=~Hs,o&=~Js,s.suspendedLanes|=o,s.pingedLanes&=~o,s=s.expirationTimes;0<o;){var i=31-Et(o),a=1<<i;s[i]=-1,o&=~a}}function Ek(s){if(6&Ls)throw Error(p(327));Hk();var o=uc(s,0);if(!(1&o))return Dk(s,ht()),null;var i=Ik(s,o);if(0!==s.tag&&2===i){var a=xc(s);0!==a&&(o=a,i=Nk(s,a))}if(1===i)throw i=zs,Kk(s,0),Ck(s,o),Dk(s,ht()),i;if(6===i)throw Error(p(345));return s.finishedWork=s.current.alternate,s.finishedLanes=o,Pk(s,Gs,Qs),Dk(s,ht()),null}function Qk(s,o){var i=Ls;Ls|=1;try{return s(o)}finally{0===(Ls=i)&&(Xs=ht()+500,kn&&jg())}}function Rk(s){null!==no&&0===no.tag&&!(6&Ls)&&Hk();var o=Ls;Ls|=1;var i=Ds.transition,a=At;try{if(Ds.transition=null,At=1,s)return s()}finally{At=a,Ds.transition=i,!(6&(Ls=o))&&jg()}}function Hj(){qs=Us.current,E(Us)}function Kk(s,o){s.finishedWork=null,s.finishedLanes=0;var i=s.timeoutHandle;if(-1!==i&&(s.timeoutHandle=-1,cn(i)),null!==Bs)for(i=Bs.return;null!==i;){var a=i;switch(wg(a),a.tag){case 1:null!=(a=a.type.childContextTypes)&&$f();break;case 3:zh(),E(En),E(Sn),Eh();break;case 5:Bh(a);break;case 4:zh();break;case 13:case 19:E(Zn);break;case 10:ah(a.type._context);break;case 22:case 23:Hj()}i=i.return}if(Fs=s,Bs=s=Pg(s.current,null),$s=qs=o,Vs=0,zs=null,Hs=Js=Ws=0,Gs=Ks=null,null!==Hn){for(o=0;o<Hn.length;o++)if(null!==(a=(i=Hn[o]).interleaved)){i.interleaved=null;var u=a.next,_=i.pending;if(null!==_){var w=_.next;_.next=u,a.next=w}i.pending=a}Hn=null}return s}function Mk(s,o){for(;;){var i=Bs;try{if($g(),ts.current=hs,cs){for(var a=ss.memoizedState;null!==a;){var u=a.queue;null!==u&&(u.pending=null),a=a.next}cs=!1}if(ns=0,as=os=ss=null,ls=!1,us=0,Rs.current=null,null===i||null===i.return){Vs=1,zs=o,Bs=null;break}e:{var _=s,w=i.return,x=i,C=o;if(o=$s,x.flags|=32768,null!==C&&\"object\"==typeof C&&\"function\"==typeof C.then){var j=C,L=x,B=L.tag;if(!(1&L.mode||0!==B&&11!==B&&15!==B)){var $=L.alternate;$?(L.updateQueue=$.updateQueue,L.memoizedState=$.memoizedState,L.lanes=$.lanes):(L.updateQueue=null,L.memoizedState=null)}var U=Ui(w);if(null!==U){U.flags&=-257,Vi(U,w,x,0,o),1&U.mode&&Si(_,j,o),C=j;var V=(o=U).updateQueue;if(null===V){var z=new Set;z.add(C),o.updateQueue=z}else V.add(C);break e}if(!(1&o)){Si(_,j,o),tj();break e}C=Error(p(426))}else if(Fn&&1&x.mode){var Y=Ui(w);if(null!==Y){!(65536&Y.flags)&&(Y.flags|=256),Vi(Y,w,x,0,o),Jg(Ji(C,x));break e}}_=C=Ji(C,x),4!==Vs&&(Vs=2),null===Ks?Ks=[_]:Ks.push(_),_=w;do{switch(_.tag){case 3:_.flags|=65536,o&=-o,_.lanes|=o,ph(_,Ni(0,C,o));break e;case 1:x=C;var Z=_.type,ee=_.stateNode;if(!(128&_.flags||\"function\"!=typeof Z.getDerivedStateFromError&&(null===ee||\"function\"!=typeof ee.componentDidCatch||null!==to&&to.has(ee)))){_.flags|=65536,o&=-o,_.lanes|=o,ph(_,Qi(_,x,o));break e}}_=_.return}while(null!==_)}Sk(i)}catch(s){o=s,Bs===i&&null!==i&&(Bs=i=i.return);continue}break}}function Jk(){var s=Ms.current;return Ms.current=hs,null===s?hs:s}function tj(){0!==Vs&&3!==Vs&&2!==Vs||(Vs=4),null===Fs||!(268435455&Ws)&&!(268435455&Js)||Ck(Fs,$s)}function Ik(s,o){var i=Ls;Ls|=2;var a=Jk();for(Fs===s&&$s===o||(Qs=null,Kk(s,o));;)try{Tk();break}catch(o){Mk(s,o)}if($g(),Ls=i,Ms.current=a,null!==Bs)throw Error(p(261));return Fs=null,$s=0,Vs}function Tk(){for(;null!==Bs;)Uk(Bs)}function Lk(){for(;null!==Bs&&!ut();)Uk(Bs)}function Uk(s){var o=Ts(s.alternate,s,qs);s.memoizedProps=s.pendingProps,null===o?Sk(s):Bs=o,Rs.current=null}function Sk(s){var o=s;do{var i=o.alternate;if(s=o.return,32768&o.flags){if(null!==(i=Ij(i,o)))return i.flags&=32767,void(Bs=i);if(null===s)return Vs=6,void(Bs=null);s.flags|=32768,s.subtreeFlags=0,s.deletions=null}else if(null!==(i=Ej(i,o,qs)))return void(Bs=i);if(null!==(o=o.sibling))return void(Bs=o);Bs=o=s}while(null!==o);0===Vs&&(Vs=5)}function Pk(s,o,i){var a=At,u=Ds.transition;try{Ds.transition=null,At=1,function Wk(s,o,i,a){do{Hk()}while(null!==no);if(6&Ls)throw Error(p(327));i=s.finishedWork;var u=s.finishedLanes;if(null===i)return null;if(s.finishedWork=null,s.finishedLanes=0,i===s.current)throw Error(p(177));s.callbackNode=null,s.callbackPriority=0;var _=i.lanes|i.childLanes;if(function Bc(s,o){var i=s.pendingLanes&~o;s.pendingLanes=o,s.suspendedLanes=0,s.pingedLanes=0,s.expiredLanes&=o,s.mutableReadLanes&=o,s.entangledLanes&=o,o=s.entanglements;var a=s.eventTimes;for(s=s.expirationTimes;0<i;){var u=31-Et(i),_=1<<u;o[u]=0,a[u]=-1,s[u]=-1,i&=~_}}(s,_),s===Fs&&(Bs=Fs=null,$s=0),!(2064&i.subtreeFlags)&&!(2064&i.flags)||ro||(ro=!0,Fk(yt,(function(){return Hk(),null}))),_=!!(15990&i.flags),!!(15990&i.subtreeFlags)||_){_=Ds.transition,Ds.transition=null;var w=At;At=1;var x=Ls;Ls|=4,Rs.current=null,function Oj(s,o){if(sn=Vt,Ne(s=Me())){if(\"selectionStart\"in s)var i={start:s.selectionStart,end:s.selectionEnd};else e:{var a=(i=(i=s.ownerDocument)&&i.defaultView||window).getSelection&&i.getSelection();if(a&&0!==a.rangeCount){i=a.anchorNode;var u=a.anchorOffset,_=a.focusNode;a=a.focusOffset;try{i.nodeType,_.nodeType}catch(s){i=null;break e}var w=0,x=-1,C=-1,j=0,L=0,B=s,$=null;t:for(;;){for(var U;B!==i||0!==u&&3!==B.nodeType||(x=w+u),B!==_||0!==a&&3!==B.nodeType||(C=w+a),3===B.nodeType&&(w+=B.nodeValue.length),null!==(U=B.firstChild);)$=B,B=U;for(;;){if(B===s)break t;if($===i&&++j===u&&(x=w),$===_&&++L===a&&(C=w),null!==(U=B.nextSibling))break;$=(B=$).parentNode}B=U}i=-1===x||-1===C?null:{start:x,end:C}}else i=null}i=i||{start:0,end:0}}else i=null;for(on={focusedElem:s,selectionRange:i},Vt=!1,Cs=o;null!==Cs;)if(s=(o=Cs).child,1028&o.subtreeFlags&&null!==s)s.return=o,Cs=s;else for(;null!==Cs;){o=Cs;try{var V=o.alternate;if(1024&o.flags)switch(o.tag){case 0:case 11:case 15:case 5:case 6:case 4:case 17:break;case 1:if(null!==V){var z=V.memoizedProps,Y=V.memoizedState,Z=o.stateNode,ee=Z.getSnapshotBeforeUpdate(o.elementType===o.type?z:Ci(o.type,z),Y);Z.__reactInternalSnapshotBeforeUpdate=ee}break;case 3:var ie=o.stateNode.containerInfo;1===ie.nodeType?ie.textContent=\"\":9===ie.nodeType&&ie.documentElement&&ie.removeChild(ie.documentElement);break;default:throw Error(p(163))}}catch(s){W(o,o.return,s)}if(null!==(s=o.sibling)){s.return=o.return,Cs=s;break}Cs=o.return}return V=js,js=!1,V}(s,i),dk(i,s),Oe(on),Vt=!!sn,on=sn=null,s.current=i,hk(i,s,u),pt(),Ls=x,At=w,Ds.transition=_}else s.current=i;if(ro&&(ro=!1,no=s,so=u),_=s.pendingLanes,0===_&&(to=null),function mc(s){if(St&&\"function\"==typeof St.onCommitFiberRoot)try{St.onCommitFiberRoot(_t,s,void 0,!(128&~s.current.flags))}catch(s){}}(i.stateNode),Dk(s,ht()),null!==o)for(a=s.onRecoverableError,i=0;i<o.length;i++)u=o[i],a(u.value,{componentStack:u.stack,digest:u.digest});if(Zs)throw Zs=!1,s=eo,eo=null,s;return!!(1&so)&&0!==s.tag&&Hk(),_=s.pendingLanes,1&_?s===io?oo++:(oo=0,io=s):oo=0,jg(),null}(s,o,i,a)}finally{Ds.transition=u,At=a}return null}function Hk(){if(null!==no){var s=Dc(so),o=Ds.transition,i=At;try{if(Ds.transition=null,At=16>s?16:s,null===no)var a=!1;else{if(s=no,no=null,so=0,6&Ls)throw Error(p(331));var u=Ls;for(Ls|=4,Cs=s.current;null!==Cs;){var _=Cs,w=_.child;if(16&Cs.flags){var x=_.deletions;if(null!==x){for(var C=0;C<x.length;C++){var j=x[C];for(Cs=j;null!==Cs;){var L=Cs;switch(L.tag){case 0:case 11:case 15:Pj(8,L,_)}var B=L.child;if(null!==B)B.return=L,Cs=B;else for(;null!==Cs;){var $=(L=Cs).sibling,U=L.return;if(Sj(L),L===j){Cs=null;break}if(null!==$){$.return=U,Cs=$;break}Cs=U}}}var V=_.alternate;if(null!==V){var z=V.child;if(null!==z){V.child=null;do{var Y=z.sibling;z.sibling=null,z=Y}while(null!==z)}}Cs=_}}if(2064&_.subtreeFlags&&null!==w)w.return=_,Cs=w;else e:for(;null!==Cs;){if(2048&(_=Cs).flags)switch(_.tag){case 0:case 11:case 15:Pj(9,_,_.return)}var Z=_.sibling;if(null!==Z){Z.return=_.return,Cs=Z;break e}Cs=_.return}}var ee=s.current;for(Cs=ee;null!==Cs;){var ie=(w=Cs).child;if(2064&w.subtreeFlags&&null!==ie)ie.return=w,Cs=ie;else e:for(w=ee;null!==Cs;){if(2048&(x=Cs).flags)try{switch(x.tag){case 0:case 11:case 15:Qj(9,x)}}catch(s){W(x,x.return,s)}if(x===w){Cs=null;break e}var ae=x.sibling;if(null!==ae){ae.return=x.return,Cs=ae;break e}Cs=x.return}}if(Ls=u,jg(),St&&\"function\"==typeof St.onPostCommitFiberRoot)try{St.onPostCommitFiberRoot(_t,s)}catch(s){}a=!0}return a}finally{At=i,Ds.transition=o}}return!1}function Xk(s,o,i){s=nh(s,o=Ni(0,o=Ji(i,o),1),1),o=R(),null!==s&&(Ac(s,1,o),Dk(s,o))}function W(s,o,i){if(3===s.tag)Xk(s,s,i);else for(;null!==o;){if(3===o.tag){Xk(o,s,i);break}if(1===o.tag){var a=o.stateNode;if(\"function\"==typeof o.type.getDerivedStateFromError||\"function\"==typeof a.componentDidCatch&&(null===to||!to.has(a))){o=nh(o,s=Qi(o,s=Ji(i,s),1),1),s=R(),null!==o&&(Ac(o,1,s),Dk(o,s));break}}o=o.return}}function Ti(s,o,i){var a=s.pingCache;null!==a&&a.delete(o),o=R(),s.pingedLanes|=s.suspendedLanes&i,Fs===s&&($s&i)===i&&(4===Vs||3===Vs&&(130023424&$s)===$s&&500>ht()-Ys?Kk(s,0):Hs|=i),Dk(s,o)}function Yk(s,o){0===o&&(1&s.mode?(o=Ot,!(130023424&(Ot<<=1))&&(Ot=4194304)):o=1);var i=R();null!==(s=ih(s,o))&&(Ac(s,o,i),Dk(s,i))}function uj(s){var o=s.memoizedState,i=0;null!==o&&(i=o.retryLane),Yk(s,i)}function bk(s,o){var i=0;switch(s.tag){case 13:var a=s.stateNode,u=s.memoizedState;null!==u&&(i=u.retryLane);break;case 19:a=s.stateNode;break;default:throw Error(p(314))}null!==a&&a.delete(o),Yk(s,i)}function Fk(s,o){return ct(s,o)}function $k(s,o,i,a){this.tag=s,this.key=i,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=o,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=a,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Bg(s,o,i,a){return new $k(s,o,i,a)}function aj(s){return!(!(s=s.prototype)||!s.isReactComponent)}function Pg(s,o){var i=s.alternate;return null===i?((i=Bg(s.tag,o,s.key,s.mode)).elementType=s.elementType,i.type=s.type,i.stateNode=s.stateNode,i.alternate=s,s.alternate=i):(i.pendingProps=o,i.type=s.type,i.flags=0,i.subtreeFlags=0,i.deletions=null),i.flags=14680064&s.flags,i.childLanes=s.childLanes,i.lanes=s.lanes,i.child=s.child,i.memoizedProps=s.memoizedProps,i.memoizedState=s.memoizedState,i.updateQueue=s.updateQueue,o=s.dependencies,i.dependencies=null===o?null:{lanes:o.lanes,firstContext:o.firstContext},i.sibling=s.sibling,i.index=s.index,i.ref=s.ref,i}function Rg(s,o,i,a,u,_){var w=2;if(a=s,\"function\"==typeof s)aj(s)&&(w=1);else if(\"string\"==typeof s)w=5;else e:switch(s){case Z:return Tg(i.children,u,_,o);case ee:w=8,u|=8;break;case ie:return(s=Bg(12,i,o,2|u)).elementType=ie,s.lanes=_,s;case pe:return(s=Bg(13,i,o,u)).elementType=pe,s.lanes=_,s;case de:return(s=Bg(19,i,o,u)).elementType=de,s.lanes=_,s;case be:return pj(i,u,_,o);default:if(\"object\"==typeof s&&null!==s)switch(s.$$typeof){case ae:w=10;break e;case ce:w=9;break e;case le:w=11;break e;case fe:w=14;break e;case ye:w=16,a=null;break e}throw Error(p(130,null==s?s:typeof s,\"\"))}return(o=Bg(w,i,o,u)).elementType=s,o.type=a,o.lanes=_,o}function Tg(s,o,i,a){return(s=Bg(7,s,a,o)).lanes=i,s}function pj(s,o,i,a){return(s=Bg(22,s,a,o)).elementType=be,s.lanes=i,s.stateNode={isHidden:!1},s}function Qg(s,o,i){return(s=Bg(6,s,null,o)).lanes=i,s}function Sg(s,o,i){return(o=Bg(4,null!==s.children?s.children:[],s.key,o)).lanes=i,o.stateNode={containerInfo:s.containerInfo,pendingChildren:null,implementation:s.implementation},o}function al(s,o,i,a,u){this.tag=o,this.containerInfo=s,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=zc(0),this.expirationTimes=zc(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=zc(0),this.identifierPrefix=a,this.onRecoverableError=u,this.mutableSourceEagerHydrationData=null}function bl(s,o,i,a,u,_,w,x,C){return s=new al(s,o,i,x,C),1===o?(o=1,!0===_&&(o|=8)):o=0,_=Bg(3,null,null,o),s.current=_,_.stateNode=s,_.memoizedState={element:a,isDehydrated:i,cache:null,transitions:null,pendingSuspenseBoundaries:null},kh(_),s}function dl(s){if(!s)return _n;e:{if(Vb(s=s._reactInternals)!==s||1!==s.tag)throw Error(p(170));var o=s;do{switch(o.tag){case 3:o=o.stateNode.context;break e;case 1:if(Zf(o.type)){o=o.stateNode.__reactInternalMemoizedMergedChildContext;break e}}o=o.return}while(null!==o);throw Error(p(171))}if(1===s.tag){var i=s.type;if(Zf(i))return bg(s,i,o)}return o}function el(s,o,i,a,u,_,w,x,C){return(s=bl(i,a,!0,s,0,_,0,x,C)).context=dl(null),i=s.current,(_=mh(a=R(),u=yi(i))).callback=null!=o?o:null,nh(i,_,u),s.current.lanes=u,Ac(s,u,a),Dk(s,a),s}function fl(s,o,i,a){var u=o.current,_=R(),w=yi(u);return i=dl(i),null===o.context?o.context=i:o.pendingContext=i,(o=mh(_,w)).payload={element:s},null!==(a=void 0===a?null:a)&&(o.callback=a),null!==(s=nh(u,o,w))&&(gi(s,u,w,_),oh(s,u,w)),w}function gl(s){return(s=s.current).child?(s.child.tag,s.child.stateNode):null}function hl(s,o){if(null!==(s=s.memoizedState)&&null!==s.dehydrated){var i=s.retryLane;s.retryLane=0!==i&&i<o?i:o}}function il(s,o){hl(s,o),(s=s.alternate)&&hl(s,o)}Ts=function(s,o,i){if(null!==s)if(s.memoizedProps!==o.pendingProps||En.current)bs=!0;else{if(!(s.lanes&i||128&o.flags))return bs=!1,function yj(s,o,i){switch(o.tag){case 3:kj(o),Ig();break;case 5:Ah(o);break;case 1:Zf(o.type)&&cg(o);break;case 4:yh(o,o.stateNode.containerInfo);break;case 10:var a=o.type._context,u=o.memoizedProps.value;G(Vn,a._currentValue),a._currentValue=u;break;case 13:if(null!==(a=o.memoizedState))return null!==a.dehydrated?(G(Zn,1&Zn.current),o.flags|=128,null):i&o.child.childLanes?oj(s,o,i):(G(Zn,1&Zn.current),null!==(s=Zi(s,o,i))?s.sibling:null);G(Zn,1&Zn.current);break;case 19:if(a=!!(i&o.childLanes),128&s.flags){if(a)return xj(s,o,i);o.flags|=128}if(null!==(u=o.memoizedState)&&(u.rendering=null,u.tail=null,u.lastEffect=null),G(Zn,Zn.current),a)break;return null;case 22:case 23:return o.lanes=0,dj(s,o,i)}return Zi(s,o,i)}(s,o,i);bs=!!(131072&s.flags)}else bs=!1,Fn&&1048576&o.flags&&ug(o,Pn,o.index);switch(o.lanes=0,o.tag){case 2:var a=o.type;ij(s,o),s=o.pendingProps;var u=Yf(o,Sn.current);ch(o,i),u=Nh(null,o,a,s,u,i);var _=Sh();return o.flags|=1,\"object\"==typeof u&&null!==u&&\"function\"==typeof u.render&&void 0===u.$$typeof?(o.tag=1,o.memoizedState=null,o.updateQueue=null,Zf(a)?(_=!0,cg(o)):_=!1,o.memoizedState=null!==u.state&&void 0!==u.state?u.state:null,kh(o),u.updater=gs,o.stateNode=u,u._reactInternals=o,Ii(o,a,s,i),o=jj(null,o,a,!0,_,i)):(o.tag=0,Fn&&_&&vg(o),Xi(null,o,u,i),o=o.child),o;case 16:a=o.elementType;e:{switch(ij(s,o),s=o.pendingProps,a=(u=a._init)(a._payload),o.type=a,u=o.tag=function Zk(s){if(\"function\"==typeof s)return aj(s)?1:0;if(null!=s){if((s=s.$$typeof)===le)return 11;if(s===fe)return 14}return 2}(a),s=Ci(a,s),u){case 0:o=cj(null,o,a,s,i);break e;case 1:o=hj(null,o,a,s,i);break e;case 11:o=Yi(null,o,a,s,i);break e;case 14:o=$i(null,o,a,Ci(a.type,s),i);break e}throw Error(p(306,a,\"\"))}return o;case 0:return a=o.type,u=o.pendingProps,cj(s,o,a,u=o.elementType===a?u:Ci(a,u),i);case 1:return a=o.type,u=o.pendingProps,hj(s,o,a,u=o.elementType===a?u:Ci(a,u),i);case 3:e:{if(kj(o),null===s)throw Error(p(387));a=o.pendingProps,u=(_=o.memoizedState).element,lh(s,o),qh(o,a,null,i);var w=o.memoizedState;if(a=w.element,_.isDehydrated){if(_={element:a,isDehydrated:!1,cache:w.cache,pendingSuspenseBoundaries:w.pendingSuspenseBoundaries,transitions:w.transitions},o.updateQueue.baseState=_,o.memoizedState=_,256&o.flags){o=lj(s,o,a,i,u=Ji(Error(p(423)),o));break e}if(a!==u){o=lj(s,o,a,i,u=Ji(Error(p(424)),o));break e}for(Ln=Lf(o.stateNode.containerInfo.firstChild),Dn=o,Fn=!0,Bn=null,i=Un(o,null,a,i),o.child=i;i;)i.flags=-3&i.flags|4096,i=i.sibling}else{if(Ig(),a===u){o=Zi(s,o,i);break e}Xi(s,o,a,i)}o=o.child}return o;case 5:return Ah(o),null===s&&Eg(o),a=o.type,u=o.pendingProps,_=null!==s?s.memoizedProps:null,w=u.children,Ef(a,u)?w=null:null!==_&&Ef(a,_)&&(o.flags|=32),gj(s,o),Xi(s,o,w,i),o.child;case 6:return null===s&&Eg(o),null;case 13:return oj(s,o,i);case 4:return yh(o,o.stateNode.containerInfo),a=o.pendingProps,null===s?o.child=qn(o,null,a,i):Xi(s,o,a,i),o.child;case 11:return a=o.type,u=o.pendingProps,Yi(s,o,a,u=o.elementType===a?u:Ci(a,u),i);case 7:return Xi(s,o,o.pendingProps,i),o.child;case 8:case 12:return Xi(s,o,o.pendingProps.children,i),o.child;case 10:e:{if(a=o.type._context,u=o.pendingProps,_=o.memoizedProps,w=u.value,G(Vn,a._currentValue),a._currentValue=w,null!==_)if(Dr(_.value,w)){if(_.children===u.children&&!En.current){o=Zi(s,o,i);break e}}else for(null!==(_=o.child)&&(_.return=o);null!==_;){var x=_.dependencies;if(null!==x){w=_.child;for(var C=x.firstContext;null!==C;){if(C.context===a){if(1===_.tag){(C=mh(-1,i&-i)).tag=2;var j=_.updateQueue;if(null!==j){var L=(j=j.shared).pending;null===L?C.next=C:(C.next=L.next,L.next=C),j.pending=C}}_.lanes|=i,null!==(C=_.alternate)&&(C.lanes|=i),bh(_.return,i,o),x.lanes|=i;break}C=C.next}}else if(10===_.tag)w=_.type===o.type?null:_.child;else if(18===_.tag){if(null===(w=_.return))throw Error(p(341));w.lanes|=i,null!==(x=w.alternate)&&(x.lanes|=i),bh(w,i,o),w=_.sibling}else w=_.child;if(null!==w)w.return=_;else for(w=_;null!==w;){if(w===o){w=null;break}if(null!==(_=w.sibling)){_.return=w.return,w=_;break}w=w.return}_=w}Xi(s,o,u.children,i),o=o.child}return o;case 9:return u=o.type,a=o.pendingProps.children,ch(o,i),a=a(u=eh(u)),o.flags|=1,Xi(s,o,a,i),o.child;case 14:return u=Ci(a=o.type,o.pendingProps),$i(s,o,a,u=Ci(a.type,u),i);case 15:return bj(s,o,o.type,o.pendingProps,i);case 17:return a=o.type,u=o.pendingProps,u=o.elementType===a?u:Ci(a,u),ij(s,o),o.tag=1,Zf(a)?(s=!0,cg(o)):s=!1,ch(o,i),Gi(o,a,u),Ii(o,a,u,i),jj(null,o,a,!0,s,i);case 19:return xj(s,o,i);case 22:return dj(s,o,i)}throw Error(p(156,o.tag))};var lo=\"function\"==typeof reportError?reportError:function(s){console.error(s)};function ll(s){this._internalRoot=s}function ml(s){this._internalRoot=s}function nl(s){return!(!s||1!==s.nodeType&&9!==s.nodeType&&11!==s.nodeType)}function ol(s){return!(!s||1!==s.nodeType&&9!==s.nodeType&&11!==s.nodeType&&(8!==s.nodeType||\" react-mount-point-unstable \"!==s.nodeValue))}function pl(){}function rl(s,o,i,a,u){var _=i._reactRootContainer;if(_){var w=_;if(\"function\"==typeof u){var x=u;u=function(){var s=gl(w);x.call(s)}}fl(o,w,s,u)}else w=function ql(s,o,i,a,u){if(u){if(\"function\"==typeof a){var _=a;a=function(){var s=gl(w);_.call(s)}}var w=el(o,a,s,0,null,!1,0,\"\",pl);return s._reactRootContainer=w,s[fn]=w.current,sf(8===s.nodeType?s.parentNode:s),Rk(),w}for(;u=s.lastChild;)s.removeChild(u);if(\"function\"==typeof a){var x=a;a=function(){var s=gl(C);x.call(s)}}var C=bl(s,0,!1,null,0,!1,0,\"\",pl);return s._reactRootContainer=C,s[fn]=C.current,sf(8===s.nodeType?s.parentNode:s),Rk((function(){fl(o,C,i,a)})),C}(i,o,s,u,a);return gl(w)}ml.prototype.render=ll.prototype.render=function(s){var o=this._internalRoot;if(null===o)throw Error(p(409));fl(s,o,null,null)},ml.prototype.unmount=ll.prototype.unmount=function(){var s=this._internalRoot;if(null!==s){this._internalRoot=null;var o=s.containerInfo;Rk((function(){fl(null,s,null,null)})),o[fn]=null}},ml.prototype.unstable_scheduleHydration=function(s){if(s){var o=It();s={blockedOn:null,target:s,priority:o};for(var i=0;i<$t.length&&0!==o&&o<$t[i].priority;i++);$t.splice(i,0,s),0===i&&Vc(s)}},Ct=function(s){switch(s.tag){case 3:var o=s.stateNode;if(o.current.memoizedState.isDehydrated){var i=tc(o.pendingLanes);0!==i&&(Cc(o,1|i),Dk(o,ht()),!(6&Ls)&&(Xs=ht()+500,jg()))}break;case 13:Rk((function(){var o=ih(s,1);if(null!==o){var i=R();gi(o,s,1,i)}})),il(s,1)}},jt=function(s){if(13===s.tag){var o=ih(s,134217728);if(null!==o)gi(o,s,134217728,R());il(s,134217728)}},Pt=function(s){if(13===s.tag){var o=yi(s),i=ih(s,o);if(null!==i)gi(i,s,o,R());il(s,o)}},It=function(){return At},Tt=function(s,o){var i=At;try{return At=s,o()}finally{At=i}},Ye=function(s,o,i){switch(o){case\"input\":if(bb(s,i),o=i.name,\"radio\"===i.type&&null!=o){for(i=s;i.parentNode;)i=i.parentNode;for(i=i.querySelectorAll(\"input[name=\"+JSON.stringify(\"\"+o)+'][type=\"radio\"]'),o=0;o<i.length;o++){var a=i[o];if(a!==s&&a.form===s.form){var u=Db(a);if(!u)throw Error(p(90));Wa(a),bb(a,u)}}}break;case\"textarea\":ib(s,i);break;case\"select\":null!=(o=i.value)&&fb(s,!!i.multiple,o,!1)}},Gb=Qk,Hb=Rk;var uo={usingClientEntryPoint:!1,Events:[Cb,ue,Db,Eb,Fb,Qk]},po={findFiberByHostInstance:Wc,bundleType:0,version:\"18.3.1\",rendererPackageName:\"react-dom\"},ho={bundleType:po.bundleType,version:po.version,rendererPackageName:po.rendererPackageName,rendererConfig:po.rendererConfig,overrideHookState:null,overrideHookStateDeletePath:null,overrideHookStateRenamePath:null,overrideProps:null,overridePropsDeletePath:null,overridePropsRenamePath:null,setErrorHandler:null,setSuspenseHandler:null,scheduleUpdate:null,currentDispatcherRef:V.ReactCurrentDispatcher,findHostInstanceByFiber:function(s){return null===(s=Zb(s))?null:s.stateNode},findFiberByHostInstance:po.findFiberByHostInstance||function jl(){return null},findHostInstancesForRefresh:null,scheduleRefresh:null,scheduleRoot:null,setRefreshHandler:null,getCurrentFiber:null,reconcilerVersion:\"18.3.1-next-f1338f8080-20240426\"};if(\"undefined\"!=typeof __REACT_DEVTOOLS_GLOBAL_HOOK__){var fo=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(!fo.isDisabled&&fo.supportsFiber)try{_t=fo.inject(ho),St=fo}catch(Re){}}o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=uo,o.createPortal=function(s,o){var i=2<arguments.length&&void 0!==arguments[2]?arguments[2]:null;if(!nl(o))throw Error(p(200));return function cl(s,o,i){var a=3<arguments.length&&void 0!==arguments[3]?arguments[3]:null;return{$$typeof:Y,key:null==a?null:\"\"+a,children:s,containerInfo:o,implementation:i}}(s,o,null,i)},o.createRoot=function(s,o){if(!nl(s))throw Error(p(299));var i=!1,a=\"\",u=lo;return null!=o&&(!0===o.unstable_strictMode&&(i=!0),void 0!==o.identifierPrefix&&(a=o.identifierPrefix),void 0!==o.onRecoverableError&&(u=o.onRecoverableError)),o=bl(s,1,!1,null,0,i,0,a,u),s[fn]=o.current,sf(8===s.nodeType?s.parentNode:s),new ll(o)},o.findDOMNode=function(s){if(null==s)return null;if(1===s.nodeType)return s;var o=s._reactInternals;if(void 0===o){if(\"function\"==typeof s.render)throw Error(p(188));throw s=Object.keys(s).join(\",\"),Error(p(268,s))}return s=null===(s=Zb(o))?null:s.stateNode},o.flushSync=function(s){return Rk(s)},o.hydrate=function(s,o,i){if(!ol(o))throw Error(p(200));return rl(null,s,o,!0,i)},o.hydrateRoot=function(s,o,i){if(!nl(s))throw Error(p(405));var a=null!=i&&i.hydratedSources||null,u=!1,_=\"\",w=lo;if(null!=i&&(!0===i.unstable_strictMode&&(u=!0),void 0!==i.identifierPrefix&&(_=i.identifierPrefix),void 0!==i.onRecoverableError&&(w=i.onRecoverableError)),o=el(o,null,s,1,null!=i?i:null,u,0,_,w),s[fn]=o.current,sf(s),a)for(s=0;s<a.length;s++)u=(u=(i=a[s])._getVersion)(i._source),null==o.mutableSourceEagerHydrationData?o.mutableSourceEagerHydrationData=[i,u]:o.mutableSourceEagerHydrationData.push(i,u);return new ml(o)},o.render=function(s,o,i){if(!ol(o))throw Error(p(200));return rl(null,s,o,!1,i)},o.unmountComponentAtNode=function(s){if(!ol(s))throw Error(p(40));return!!s._reactRootContainer&&(Rk((function(){rl(null,null,s,!1,(function(){s._reactRootContainer=null,s[fn]=null}))})),!0)},o.unstable_batchedUpdates=Qk,o.unstable_renderSubtreeIntoContainer=function(s,o,i,a){if(!ol(i))throw Error(p(200));if(null==s||void 0===s._reactInternals)throw Error(p(38));return rl(s,o,i,!1,a)},o.version=\"18.3.1-next-f1338f8080-20240426\"},22574:(s,o)=>{\"use strict\";var i={}.propertyIsEnumerable,a=Object.getOwnPropertyDescriptor,u=a&&!i.call({1:2},1);o.f=u?function propertyIsEnumerable(s){var o=a(this,s);return!!o&&o.enumerable}:i},23007:s=>{s.exports=function copyArray(s,o){var i=-1,a=s.length;for(o||(o=Array(a));++i<a;)o[i]=s[i];return o}},23034:(s,o,i)=>{\"use strict\";var a=i(88280),u=i(32567),_=Function.prototype;s.exports=function(s){var o=s.bind;return s===_||a(_,s)&&o===_.bind?u:o}},23045:(s,o,i)=>{\"use strict\";var a=i(1907),u=i(49724),_=i(4993),w=i(74436).indexOf,x=i(38530),C=a([].push);s.exports=function(s,o){var i,a=_(s),j=0,L=[];for(i in a)!u(x,i)&&u(a,i)&&C(L,i);for(;o.length>j;)u(a,i=o[j++])&&(~w(L,i)||C(L,i));return L}},23546:(s,o,i)=>{var a=i(72552),u=i(40346),_=i(11331);s.exports=function isError(s){if(!u(s))return!1;var o=a(s);return\"[object Error]\"==o||\"[object DOMException]\"==o||\"string\"==typeof s.message&&\"string\"==typeof s.name&&!_(s)}},23805:s=>{s.exports=function isObject(s){var o=typeof s;return null!=s&&(\"object\"==o||\"function\"==o)}},23888:(s,o,i)=>{\"use strict\";var a=i(98828),u=i(75817);s.exports=!a((function(){var s=new Error(\"a\");return!(\"stack\"in s)||(Object.defineProperty(s,\"stack\",u(1,7)),7!==s.stack)}))},24107:(s,o,i)=>{\"use strict\";var a=i(56698),u=i(90392),_=i(92861).Buffer,w=[1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298],x=new Array(64);function Sha256(){this.init(),this._w=x,u.call(this,64,56)}function ch(s,o,i){return i^s&(o^i)}function maj(s,o,i){return s&o|i&(s|o)}function sigma0(s){return(s>>>2|s<<30)^(s>>>13|s<<19)^(s>>>22|s<<10)}function sigma1(s){return(s>>>6|s<<26)^(s>>>11|s<<21)^(s>>>25|s<<7)}function gamma0(s){return(s>>>7|s<<25)^(s>>>18|s<<14)^s>>>3}a(Sha256,u),Sha256.prototype.init=function(){return this._a=1779033703,this._b=3144134277,this._c=1013904242,this._d=2773480762,this._e=1359893119,this._f=2600822924,this._g=528734635,this._h=1541459225,this},Sha256.prototype._update=function(s){for(var o,i=this._w,a=0|this._a,u=0|this._b,_=0|this._c,x=0|this._d,C=0|this._e,j=0|this._f,L=0|this._g,B=0|this._h,$=0;$<16;++$)i[$]=s.readInt32BE(4*$);for(;$<64;++$)i[$]=0|(((o=i[$-2])>>>17|o<<15)^(o>>>19|o<<13)^o>>>10)+i[$-7]+gamma0(i[$-15])+i[$-16];for(var U=0;U<64;++U){var V=B+sigma1(C)+ch(C,j,L)+w[U]+i[U]|0,z=sigma0(a)+maj(a,u,_)|0;B=L,L=j,j=C,C=x+V|0,x=_,_=u,u=a,a=V+z|0}this._a=a+this._a|0,this._b=u+this._b|0,this._c=_+this._c|0,this._d=x+this._d|0,this._e=C+this._e|0,this._f=j+this._f|0,this._g=L+this._g|0,this._h=B+this._h|0},Sha256.prototype._hash=function(){var s=_.allocUnsafe(32);return s.writeInt32BE(this._a,0),s.writeInt32BE(this._b,4),s.writeInt32BE(this._c,8),s.writeInt32BE(this._d,12),s.writeInt32BE(this._e,16),s.writeInt32BE(this._f,20),s.writeInt32BE(this._g,24),s.writeInt32BE(this._h,28),s},s.exports=Sha256},24168:(s,o,i)=>{var a=i(91033),u=i(82819),_=i(9325);s.exports=function createPartial(s,o,i,w){var x=1&o,C=u(s);return function wrapper(){for(var o=-1,u=arguments.length,j=-1,L=w.length,B=Array(L+u),$=this&&this!==_&&this instanceof wrapper?C:s;++j<L;)B[j]=w[j];for(;u--;)B[j++]=arguments[++o];return a($,x?i:this,B)}}},24443:(s,o,i)=>{\"use strict\";var a=i(23045),u=i(80376).concat(\"length\",\"prototype\");o.f=Object.getOwnPropertyNames||function getOwnPropertyNames(s){return a(s,u)}},24647:(s,o,i)=>{var a=i(54552)({À:\"A\",Á:\"A\",Â:\"A\",Ã:\"A\",Ä:\"A\",Å:\"A\",à:\"a\",á:\"a\",â:\"a\",ã:\"a\",ä:\"a\",å:\"a\",Ç:\"C\",ç:\"c\",Ð:\"D\",ð:\"d\",È:\"E\",É:\"E\",Ê:\"E\",Ë:\"E\",è:\"e\",é:\"e\",ê:\"e\",ë:\"e\",Ì:\"I\",Í:\"I\",Î:\"I\",Ï:\"I\",ì:\"i\",í:\"i\",î:\"i\",ï:\"i\",Ñ:\"N\",ñ:\"n\",Ò:\"O\",Ó:\"O\",Ô:\"O\",Õ:\"O\",Ö:\"O\",Ø:\"O\",ò:\"o\",ó:\"o\",ô:\"o\",õ:\"o\",ö:\"o\",ø:\"o\",Ù:\"U\",Ú:\"U\",Û:\"U\",Ü:\"U\",ù:\"u\",ú:\"u\",û:\"u\",ü:\"u\",Ý:\"Y\",ý:\"y\",ÿ:\"y\",Æ:\"Ae\",æ:\"ae\",Þ:\"Th\",þ:\"th\",ß:\"ss\",Ā:\"A\",Ă:\"A\",Ą:\"A\",ā:\"a\",ă:\"a\",ą:\"a\",Ć:\"C\",Ĉ:\"C\",Ċ:\"C\",Č:\"C\",ć:\"c\",ĉ:\"c\",ċ:\"c\",č:\"c\",Ď:\"D\",Đ:\"D\",ď:\"d\",đ:\"d\",Ē:\"E\",Ĕ:\"E\",Ė:\"E\",Ę:\"E\",Ě:\"E\",ē:\"e\",ĕ:\"e\",ė:\"e\",ę:\"e\",ě:\"e\",Ĝ:\"G\",Ğ:\"G\",Ġ:\"G\",Ģ:\"G\",ĝ:\"g\",ğ:\"g\",ġ:\"g\",ģ:\"g\",Ĥ:\"H\",Ħ:\"H\",ĥ:\"h\",ħ:\"h\",Ĩ:\"I\",Ī:\"I\",Ĭ:\"I\",Į:\"I\",İ:\"I\",ĩ:\"i\",ī:\"i\",ĭ:\"i\",į:\"i\",ı:\"i\",Ĵ:\"J\",ĵ:\"j\",Ķ:\"K\",ķ:\"k\",ĸ:\"k\",Ĺ:\"L\",Ļ:\"L\",Ľ:\"L\",Ŀ:\"L\",Ł:\"L\",ĺ:\"l\",ļ:\"l\",ľ:\"l\",ŀ:\"l\",ł:\"l\",Ń:\"N\",Ņ:\"N\",Ň:\"N\",Ŋ:\"N\",ń:\"n\",ņ:\"n\",ň:\"n\",ŋ:\"n\",Ō:\"O\",Ŏ:\"O\",Ő:\"O\",ō:\"o\",ŏ:\"o\",ő:\"o\",Ŕ:\"R\",Ŗ:\"R\",Ř:\"R\",ŕ:\"r\",ŗ:\"r\",ř:\"r\",Ś:\"S\",Ŝ:\"S\",Ş:\"S\",Š:\"S\",ś:\"s\",ŝ:\"s\",ş:\"s\",š:\"s\",Ţ:\"T\",Ť:\"T\",Ŧ:\"T\",ţ:\"t\",ť:\"t\",ŧ:\"t\",Ũ:\"U\",Ū:\"U\",Ŭ:\"U\",Ů:\"U\",Ű:\"U\",Ų:\"U\",ũ:\"u\",ū:\"u\",ŭ:\"u\",ů:\"u\",ű:\"u\",ų:\"u\",Ŵ:\"W\",ŵ:\"w\",Ŷ:\"Y\",ŷ:\"y\",Ÿ:\"Y\",Ź:\"Z\",Ż:\"Z\",Ž:\"Z\",ź:\"z\",ż:\"z\",ž:\"z\",Ĳ:\"IJ\",ĳ:\"ij\",Œ:\"Oe\",œ:\"oe\",ŉ:\"'n\",ſ:\"s\"});s.exports=a},24677:(s,o,i)=>{\"use strict\";var a=i(81214).DebounceInput;a.DebounceInput=a,s.exports=a},24713:(s,o,i)=>{var a=i(2523),u=i(15389),_=i(61489),w=Math.max;s.exports=function findIndex(s,o,i){var x=null==s?0:s.length;if(!x)return-1;var C=null==i?0:_(i);return C<0&&(C=w(x+C,0)),a(s,u(o,3),C)}},24739:(s,o,i)=>{var a=i(26025);s.exports=function listCacheGet(s){var o=this.__data__,i=a(o,s);return i<0?void 0:o[i][1]}},24823:(s,o,i)=>{\"use strict\";var a=i(28311),u=i(13930),_=i(36624),w=i(4640),x=i(37812),C=i(20575),j=i(88280),L=i(10300),B=i(73448),$=i(40154),U=TypeError,Result=function(s,o){this.stopped=s,this.result=o},V=Result.prototype;s.exports=function(s,o,i){var z,Y,Z,ee,ie,ae,ce,le=i&&i.that,pe=!(!i||!i.AS_ENTRIES),de=!(!i||!i.IS_RECORD),fe=!(!i||!i.IS_ITERATOR),ye=!(!i||!i.INTERRUPTED),be=a(o,le),stop=function(s){return z&&$(z,\"normal\",s),new Result(!0,s)},callFn=function(s){return pe?(_(s),ye?be(s[0],s[1],stop):be(s[0],s[1])):ye?be(s,stop):be(s)};if(de)z=s.iterator;else if(fe)z=s;else{if(!(Y=B(s)))throw new U(w(s)+\" is not iterable\");if(x(Y)){for(Z=0,ee=C(s);ee>Z;Z++)if((ie=callFn(s[Z]))&&j(V,ie))return ie;return new Result(!1)}z=L(s,Y)}for(ae=de?s.next:z.next;!(ce=u(ae,z)).done;){try{ie=callFn(ce.value)}catch(s){$(z,\"throw\",s)}if(\"object\"==typeof ie&&ie&&j(V,ie))return ie}return new Result(!1)}},25160:s=>{s.exports=function baseSlice(s,o,i){var a=-1,u=s.length;o<0&&(o=-o>u?0:u+o),(i=i>u?u:i)<0&&(i+=u),u=o>i?0:i-o>>>0,o>>>=0;for(var _=Array(u);++a<u;)_[a]=s[a+o];return _}},25264:(s,o,i)=>{\"use strict\";function _typeof(s){return _typeof=\"function\"==typeof Symbol&&\"symbol\"==typeof Symbol.iterator?function(s){return typeof s}:function(s){return s&&\"function\"==typeof Symbol&&s.constructor===Symbol&&s!==Symbol.prototype?\"symbol\":typeof s},_typeof(s)}Object.defineProperty(o,\"__esModule\",{value:!0}),o.CopyToClipboard=void 0;var a=_interopRequireDefault(i(96540)),u=_interopRequireDefault(i(17965)),_=[\"text\",\"onCopy\",\"options\",\"children\"];function _interopRequireDefault(s){return s&&s.__esModule?s:{default:s}}function ownKeys(s,o){var i=Object.keys(s);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(s);o&&(a=a.filter((function(o){return Object.getOwnPropertyDescriptor(s,o).enumerable}))),i.push.apply(i,a)}return i}function _objectSpread(s){for(var o=1;o<arguments.length;o++){var i=null!=arguments[o]?arguments[o]:{};o%2?ownKeys(Object(i),!0).forEach((function(o){_defineProperty(s,o,i[o])})):Object.getOwnPropertyDescriptors?Object.defineProperties(s,Object.getOwnPropertyDescriptors(i)):ownKeys(Object(i)).forEach((function(o){Object.defineProperty(s,o,Object.getOwnPropertyDescriptor(i,o))}))}return s}function _objectWithoutProperties(s,o){if(null==s)return{};var i,a,u=function _objectWithoutPropertiesLoose(s,o){if(null==s)return{};var i,a,u={},_=Object.keys(s);for(a=0;a<_.length;a++)i=_[a],o.indexOf(i)>=0||(u[i]=s[i]);return u}(s,o);if(Object.getOwnPropertySymbols){var _=Object.getOwnPropertySymbols(s);for(a=0;a<_.length;a++)i=_[a],o.indexOf(i)>=0||Object.prototype.propertyIsEnumerable.call(s,i)&&(u[i]=s[i])}return u}function _defineProperties(s,o){for(var i=0;i<o.length;i++){var a=o[i];a.enumerable=a.enumerable||!1,a.configurable=!0,\"value\"in a&&(a.writable=!0),Object.defineProperty(s,a.key,a)}}function _setPrototypeOf(s,o){return _setPrototypeOf=Object.setPrototypeOf||function _setPrototypeOf(s,o){return s.__proto__=o,s},_setPrototypeOf(s,o)}function _createSuper(s){var o=function _isNativeReflectConstruct(){if(\"undefined\"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if(\"function\"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(s){return!1}}();return function _createSuperInternal(){var i,a=_getPrototypeOf(s);if(o){var u=_getPrototypeOf(this).constructor;i=Reflect.construct(a,arguments,u)}else i=a.apply(this,arguments);return function _possibleConstructorReturn(s,o){if(o&&(\"object\"===_typeof(o)||\"function\"==typeof o))return o;if(void 0!==o)throw new TypeError(\"Derived constructors may only return object or undefined\");return _assertThisInitialized(s)}(this,i)}}function _assertThisInitialized(s){if(void 0===s)throw new ReferenceError(\"this hasn't been initialised - super() hasn't been called\");return s}function _getPrototypeOf(s){return _getPrototypeOf=Object.setPrototypeOf?Object.getPrototypeOf:function _getPrototypeOf(s){return s.__proto__||Object.getPrototypeOf(s)},_getPrototypeOf(s)}function _defineProperty(s,o,i){return o in s?Object.defineProperty(s,o,{value:i,enumerable:!0,configurable:!0,writable:!0}):s[o]=i,s}var w=function(s){!function _inherits(s,o){if(\"function\"!=typeof o&&null!==o)throw new TypeError(\"Super expression must either be null or a function\");s.prototype=Object.create(o&&o.prototype,{constructor:{value:s,writable:!0,configurable:!0}}),Object.defineProperty(s,\"prototype\",{writable:!1}),o&&_setPrototypeOf(s,o)}(CopyToClipboard,s);var o=_createSuper(CopyToClipboard);function CopyToClipboard(){var s;!function _classCallCheck(s,o){if(!(s instanceof o))throw new TypeError(\"Cannot call a class as a function\")}(this,CopyToClipboard);for(var i=arguments.length,_=new Array(i),w=0;w<i;w++)_[w]=arguments[w];return _defineProperty(_assertThisInitialized(s=o.call.apply(o,[this].concat(_))),\"onClick\",(function(o){var i=s.props,_=i.text,w=i.onCopy,x=i.children,C=i.options,j=a.default.Children.only(x),L=(0,u.default)(_,C);w&&w(_,L),j&&j.props&&\"function\"==typeof j.props.onClick&&j.props.onClick(o)})),s}return function _createClass(s,o,i){return o&&_defineProperties(s.prototype,o),i&&_defineProperties(s,i),Object.defineProperty(s,\"prototype\",{writable:!1}),s}(CopyToClipboard,[{key:\"render\",value:function render(){var s=this.props,o=(s.text,s.onCopy,s.options,s.children),i=_objectWithoutProperties(s,_),u=a.default.Children.only(o);return a.default.cloneElement(u,_objectSpread(_objectSpread({},i),{},{onClick:this.onClick}))}}]),CopyToClipboard}(a.default.PureComponent);o.CopyToClipboard=w,_defineProperty(w,\"defaultProps\",{onCopy:void 0,options:void 0})},25382:(s,o,i)=>{\"use strict\";var a=i(65606),u=Object.keys||function(s){var o=[];for(var i in s)o.push(i);return o};s.exports=Duplex;var _=i(45412),w=i(16708);i(56698)(Duplex,_);for(var x=u(w.prototype),C=0;C<x.length;C++){var j=x[C];Duplex.prototype[j]||(Duplex.prototype[j]=w.prototype[j])}function Duplex(s){if(!(this instanceof Duplex))return new Duplex(s);_.call(this,s),w.call(this,s),this.allowHalfOpen=!0,s&&(!1===s.readable&&(this.readable=!1),!1===s.writable&&(this.writable=!1),!1===s.allowHalfOpen&&(this.allowHalfOpen=!1,this.once(\"end\",onend)))}function onend(){this._writableState.ended||a.nextTick(onEndNT,this)}function onEndNT(s){s.end()}Object.defineProperty(Duplex.prototype,\"writableHighWaterMark\",{enumerable:!1,get:function get(){return this._writableState.highWaterMark}}),Object.defineProperty(Duplex.prototype,\"writableBuffer\",{enumerable:!1,get:function get(){return this._writableState&&this._writableState.getBuffer()}}),Object.defineProperty(Duplex.prototype,\"writableLength\",{enumerable:!1,get:function get(){return this._writableState.length}}),Object.defineProperty(Duplex.prototype,\"destroyed\",{enumerable:!1,get:function get(){return void 0!==this._readableState&&void 0!==this._writableState&&(this._readableState.destroyed&&this._writableState.destroyed)},set:function set(s){void 0!==this._readableState&&void 0!==this._writableState&&(this._readableState.destroyed=s,this._writableState.destroyed=s)}})},25594:(s,o,i)=>{\"use strict\";var a=i(85582),u=i(62250),_=i(88280),w=i(51175),x=Object;s.exports=w?function(s){return\"symbol\"==typeof s}:function(s){var o=a(\"Symbol\");return u(o)&&_(o.prototype,x(s))}},25767:(s,o,i)=>{\"use strict\";var a=i(82682),u=i(39209),_=i(10487),w=i(36556),x=i(75795),C=w(\"Object.prototype.toString\"),j=i(49092)(),L=\"undefined\"==typeof globalThis?i.g:globalThis,B=u(),$=w(\"String.prototype.slice\"),U=Object.getPrototypeOf,V=w(\"Array.prototype.indexOf\",!0)||function indexOf(s,o){for(var i=0;i<s.length;i+=1)if(s[i]===o)return i;return-1},z={__proto__:null};a(B,j&&x&&U?function(s){var o=new L[s];if(Symbol.toStringTag in o){var i=U(o),a=x(i,Symbol.toStringTag);if(!a){var u=U(i);a=x(u,Symbol.toStringTag)}z[\"$\"+s]=_(a.get)}}:function(s){var o=new L[s],i=o.slice||o.set;i&&(z[\"$\"+s]=_(i))});s.exports=function whichTypedArray(s){if(!s||\"object\"!=typeof s)return!1;if(!j){var o=$(C(s),8,-1);return V(B,o)>-1?o:\"Object\"===o&&function tryAllSlices(s){var o=!1;return a(z,(function(i,a){if(!o)try{i(s),o=$(a,1)}catch(s){}})),o}(s)}return x?function tryAllTypedArrays(s){var o=!1;return a(z,(function(i,a){if(!o)try{\"$\"+i(s)===a&&(o=$(a,1))}catch(s){}})),o}(s):null}},25911:(s,o,i)=>{var a=i(38859),u=i(14248),_=i(19219);s.exports=function equalArrays(s,o,i,w,x,C){var j=1&i,L=s.length,B=o.length;if(L!=B&&!(j&&B>L))return!1;var $=C.get(s),U=C.get(o);if($&&U)return $==o&&U==s;var V=-1,z=!0,Y=2&i?new a:void 0;for(C.set(s,o),C.set(o,s);++V<L;){var Z=s[V],ee=o[V];if(w)var ie=j?w(ee,Z,V,o,s,C):w(Z,ee,V,s,o,C);if(void 0!==ie){if(ie)continue;z=!1;break}if(Y){if(!u(o,(function(s,o){if(!_(Y,o)&&(Z===s||x(Z,s,i,w,C)))return Y.push(o)}))){z=!1;break}}else if(Z!==ee&&!x(Z,ee,i,w,C)){z=!1;break}}return C.delete(s),C.delete(o),z}},26025:(s,o,i)=>{var a=i(75288);s.exports=function assocIndexOf(s,o){for(var i=s.length;i--;)if(a(s[i][0],o))return i;return-1}},26311:s=>{!function(){var o;function format(s){for(var o,i,a,u,_=1,w=[].slice.call(arguments),x=0,C=s.length,j=\"\",L=!1,B=!1,nextArg=function(){return w[_++]},slurpNumber=function(){for(var i=\"\";/\\d/.test(s[x]);)i+=s[x++],o=s[x];return i.length>0?parseInt(i):null};x<C;++x)if(o=s[x],L)switch(L=!1,\".\"==o?(B=!1,o=s[++x]):\"0\"==o&&\".\"==s[x+1]?(B=!0,o=s[x+=2]):B=!0,u=slurpNumber(),o){case\"b\":j+=parseInt(nextArg(),10).toString(2);break;case\"c\":j+=\"string\"==typeof(i=nextArg())||i instanceof String?i:String.fromCharCode(parseInt(i,10));break;case\"d\":j+=parseInt(nextArg(),10);break;case\"f\":a=String(parseFloat(nextArg()).toFixed(u||6)),j+=B?a:a.replace(/^0/,\"\");break;case\"j\":j+=JSON.stringify(nextArg());break;case\"o\":j+=\"0\"+parseInt(nextArg(),10).toString(8);break;case\"s\":j+=nextArg();break;case\"x\":j+=\"0x\"+parseInt(nextArg(),10).toString(16);break;case\"X\":j+=\"0x\"+parseInt(nextArg(),10).toString(16).toUpperCase();break;default:j+=o}else\"%\"===o?L=!0:j+=o;return j}(o=s.exports=format).format=format,o.vsprintf=function vsprintf(s,o){return format.apply(null,[s].concat(o))},\"undefined\"!=typeof console&&\"function\"==typeof console.log&&(o.printf=function printf(){console.log(format.apply(null,arguments))})}()},26571:s=>{s.exports=function powershell(s){const o={$pattern:/-?[A-z\\.\\-]+\\b/,keyword:\"if else foreach return do while until elseif begin for trap data dynamicparam end break throw param continue finally in switch exit filter try process catch hidden static parameter\",built_in:\"ac asnp cat cd CFS chdir clc clear clhy cli clp cls clv cnsn compare copy cp cpi cpp curl cvpa dbp del diff dir dnsn ebp echo|0 epal epcsv epsn erase etsn exsn fc fhx fl ft fw gal gbp gc gcb gci gcm gcs gdr gerr ghy gi gin gjb gl gm gmo gp gps gpv group gsn gsnp gsv gtz gu gv gwmi h history icm iex ihy ii ipal ipcsv ipmo ipsn irm ise iwmi iwr kill lp ls man md measure mi mount move mp mv nal ndr ni nmo npssc nsn nv ogv oh popd ps pushd pwd r rbp rcjb rcsn rd rdr ren ri rjb rm rmdir rmo rni rnp rp rsn rsnp rujb rv rvpa rwmi sajb sal saps sasv sbp sc scb select set shcm si sl sleep sls sort sp spjb spps spsv start stz sujb sv swmi tee trcm type wget where wjb write\"},i={begin:\"`[\\\\s\\\\S]\",relevance:0},a={className:\"variable\",variants:[{begin:/\\$\\B/},{className:\"keyword\",begin:/\\$this/},{begin:/\\$[\\w\\d][\\w\\d_:]*/}]},u={className:\"string\",variants:[{begin:/\"/,end:/\"/},{begin:/@\"/,end:/^\"@/}],contains:[i,a,{className:\"variable\",begin:/\\$[A-z]/,end:/[^A-z]/}]},_={className:\"string\",variants:[{begin:/'/,end:/'/},{begin:/@'/,end:/^'@/}]},w=s.inherit(s.COMMENT(null,null),{variants:[{begin:/#/,end:/$/},{begin:/<#/,end:/#>/}],contains:[{className:\"doctag\",variants:[{begin:/\\.(synopsis|description|example|inputs|outputs|notes|link|component|role|functionality)/},{begin:/\\.(parameter|forwardhelptargetname|forwardhelpcategory|remotehelprunspace|externalhelp)\\s+\\S+/}]}]}),x={className:\"built_in\",variants:[{begin:\"(\".concat(\"Add|Clear|Close|Copy|Enter|Exit|Find|Format|Get|Hide|Join|Lock|Move|New|Open|Optimize|Pop|Push|Redo|Remove|Rename|Reset|Resize|Search|Select|Set|Show|Skip|Split|Step|Switch|Undo|Unlock|Watch|Backup|Checkpoint|Compare|Compress|Convert|ConvertFrom|ConvertTo|Dismount|Edit|Expand|Export|Group|Import|Initialize|Limit|Merge|Mount|Out|Publish|Restore|Save|Sync|Unpublish|Update|Approve|Assert|Build|Complete|Confirm|Deny|Deploy|Disable|Enable|Install|Invoke|Register|Request|Restart|Resume|Start|Stop|Submit|Suspend|Uninstall|Unregister|Wait|Debug|Measure|Ping|Repair|Resolve|Test|Trace|Connect|Disconnect|Read|Receive|Send|Write|Block|Grant|Protect|Revoke|Unblock|Unprotect|Use|ForEach|Sort|Tee|Where\",\")+(-)[\\\\w\\\\d]+\")}]},C={className:\"class\",beginKeywords:\"class enum\",end:/\\s*[{]/,excludeEnd:!0,relevance:0,contains:[s.TITLE_MODE]},j={className:\"function\",begin:/function\\s+/,end:/\\s*\\{|$/,excludeEnd:!0,returnBegin:!0,relevance:0,contains:[{begin:\"function\",relevance:0,className:\"keyword\"},{className:\"title\",begin:/\\w[\\w\\d]*((-)[\\w\\d]+)*/,relevance:0},{begin:/\\(/,end:/\\)/,className:\"params\",relevance:0,contains:[a]}]},L={begin:/using\\s/,end:/$/,returnBegin:!0,contains:[u,_,{className:\"keyword\",begin:/(using|assembly|command|module|namespace|type)/}]},B={variants:[{className:\"operator\",begin:\"(\".concat(\"-and|-as|-band|-bnot|-bor|-bxor|-casesensitive|-ccontains|-ceq|-cge|-cgt|-cle|-clike|-clt|-cmatch|-cne|-cnotcontains|-cnotlike|-cnotmatch|-contains|-creplace|-csplit|-eq|-exact|-f|-file|-ge|-gt|-icontains|-ieq|-ige|-igt|-ile|-ilike|-ilt|-imatch|-in|-ine|-inotcontains|-inotlike|-inotmatch|-ireplace|-is|-isnot|-isplit|-join|-le|-like|-lt|-match|-ne|-not|-notcontains|-notin|-notlike|-notmatch|-or|-regex|-replace|-shl|-shr|-split|-wildcard|-xor\",\")\\\\b\")},{className:\"literal\",begin:/(-)[\\w\\d]+/,relevance:0}]},$={className:\"function\",begin:/\\[.*\\]\\s*[\\w]+[ ]??\\(/,end:/$/,returnBegin:!0,relevance:0,contains:[{className:\"keyword\",begin:\"(\".concat(o.keyword.toString().replace(/\\s/g,\"|\"),\")\\\\b\"),endsParent:!0,relevance:0},s.inherit(s.TITLE_MODE,{endsParent:!0})]},U=[$,w,i,s.NUMBER_MODE,u,_,x,a,{className:\"literal\",begin:/\\$(null|true|false)\\b/},{className:\"selector-tag\",begin:/@\\B/,relevance:0}],V={begin:/\\[/,end:/\\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[].concat(\"self\",U,{begin:\"(\"+[\"string\",\"char\",\"byte\",\"int\",\"long\",\"bool\",\"decimal\",\"single\",\"double\",\"DateTime\",\"xml\",\"array\",\"hashtable\",\"void\"].join(\"|\")+\")\",className:\"built_in\",relevance:0},{className:\"type\",begin:/[\\.\\w\\d]+/,relevance:0})};return $.contains.unshift(V),{name:\"PowerShell\",aliases:[\"ps\",\"ps1\"],case_insensitive:!0,keywords:o,contains:U.concat(C,j,L,B,V)}}},26657:(s,o,i)=>{\"use strict\";var a=i(75208),u=function isClosingTag(s){return/<\\/+[^>]+>/.test(s)},_=function isSelfClosingTag(s){return/<[^>]+\\/>/.test(s)};function getType(s){return u(s)?\"ClosingTag\":function isOpeningTag(s){return function isTag(s){return/<[^>!]+>/.test(s)}(s)&&!u(s)&&!_(s)}(s)?\"OpeningTag\":_(s)?\"SelfClosingTag\":\"Text\"}s.exports=function(s){var o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=o.indentor,u=o.textNodesOnSameLine,_=0,w=[];i=i||\"    \";var x=function lexer(s){return function splitOnTags(s){return s.split(/(<\\/?[^>]+>)/g).filter((function(s){return\"\"!==s.trim()}))}(s).map((function(s){return{value:s,type:getType(s)}}))}(s).map((function(s,o,x){var C=s.value,j=s.type;\"ClosingTag\"===j&&_--;var L=a(i,_),B=L+C;if(\"OpeningTag\"===j&&_++,u){var $=x[o-1],U=x[o-2];\"ClosingTag\"===j&&\"Text\"===$.type&&\"OpeningTag\"===U.type&&(B=\"\"+L+U.value+$.value+C,w.push(o-2,o-1))}return B}));return w.forEach((function(s){return x[s]=null})),x.filter((function(s){return!!s})).join(\"\\n\")}},26710:(s,o,i)=>{\"use strict\";var a=i(56698),u=i(24107),_=i(90392),w=i(92861).Buffer,x=new Array(64);function Sha224(){this.init(),this._w=x,_.call(this,64,56)}a(Sha224,u),Sha224.prototype.init=function(){return this._a=3238371032,this._b=914150663,this._c=812702999,this._d=4144912697,this._e=4290775857,this._f=1750603025,this._g=1694076839,this._h=3204075428,this},Sha224.prototype._hash=function(){var s=w.allocUnsafe(28);return s.writeInt32BE(this._a,0),s.writeInt32BE(this._b,4),s.writeInt32BE(this._c,8),s.writeInt32BE(this._d,12),s.writeInt32BE(this._e,16),s.writeInt32BE(this._f,20),s.writeInt32BE(this._g,24),s},s.exports=Sha224},27096:(s,o,i)=>{const a=i(87586),u=i(6205),_=i(10023),w=i(8048);s.exports=s=>{var o,i,x=0,C={type:u.ROOT,stack:[]},j=C,L=C.stack,B=[],repeatErr=o=>{a.error(s,\"Nothing to repeat at column \"+(o-1))},$=a.strToChars(s);for(o=$.length;x<o;)switch(i=$[x++]){case\"\\\\\":switch(i=$[x++]){case\"b\":L.push(w.wordBoundary());break;case\"B\":L.push(w.nonWordBoundary());break;case\"w\":L.push(_.words());break;case\"W\":L.push(_.notWords());break;case\"d\":L.push(_.ints());break;case\"D\":L.push(_.notInts());break;case\"s\":L.push(_.whitespace());break;case\"S\":L.push(_.notWhitespace());break;default:/\\d/.test(i)?L.push({type:u.REFERENCE,value:parseInt(i,10)}):L.push({type:u.CHAR,value:i.charCodeAt(0)})}break;case\"^\":L.push(w.begin());break;case\"$\":L.push(w.end());break;case\"[\":var U;\"^\"===$[x]?(U=!0,x++):U=!1;var V=a.tokenizeClass($.slice(x),s);x+=V[1],L.push({type:u.SET,set:V[0],not:U});break;case\".\":L.push(_.anyChar());break;case\"(\":var z={type:u.GROUP,stack:[],remember:!0};\"?\"===(i=$[x])&&(i=$[x+1],x+=2,\"=\"===i?z.followedBy=!0:\"!\"===i?z.notFollowedBy=!0:\":\"!==i&&a.error(s,`Invalid group, character '${i}' after '?' at column `+(x-1)),z.remember=!1),L.push(z),B.push(j),j=z,L=z.stack;break;case\")\":0===B.length&&a.error(s,\"Unmatched ) at column \"+(x-1)),L=(j=B.pop()).options?j.options[j.options.length-1]:j.stack;break;case\"|\":j.options||(j.options=[j.stack],delete j.stack);var Y=[];j.options.push(Y),L=Y;break;case\"{\":var Z,ee,ie=/^(\\d+)(,(\\d+)?)?\\}/.exec($.slice(x));null!==ie?(0===L.length&&repeatErr(x),Z=parseInt(ie[1],10),ee=ie[2]?ie[3]?parseInt(ie[3],10):1/0:Z,x+=ie[0].length,L.push({type:u.REPETITION,min:Z,max:ee,value:L.pop()})):L.push({type:u.CHAR,value:123});break;case\"?\":0===L.length&&repeatErr(x),L.push({type:u.REPETITION,min:0,max:1,value:L.pop()});break;case\"+\":0===L.length&&repeatErr(x),L.push({type:u.REPETITION,min:1,max:1/0,value:L.pop()});break;case\"*\":0===L.length&&repeatErr(x),L.push({type:u.REPETITION,min:0,max:1/0,value:L.pop()});break;default:L.push({type:u.CHAR,value:i.charCodeAt(0)})}return 0!==B.length&&a.error(s,\"Unterminated group\"),C},s.exports.types=u},27301:s=>{s.exports=function baseUnary(s){return function(o){return s(o)}}},27374:(s,o)=>{\"use strict\";Object.defineProperty(o,\"__esModule\",{value:!0}),o.default=function(s,o,i){if(void 0===s)throw new Error('Reducer \"'+o+'\" returned undefined when handling \"'+i.type+'\" action. To ignore an action, you must explicitly return the previous state.')},s.exports=o.default},27534:(s,o,i)=>{var a=i(72552),u=i(40346);s.exports=function baseIsArguments(s){return u(s)&&\"[object Arguments]\"==a(s)}},27816:(s,o,i)=>{\"use strict\";var a=i(56698),u=i(90392),_=i(92861).Buffer,w=[1518500249,1859775393,-1894007588,-899497514],x=new Array(80);function Sha(){this.init(),this._w=x,u.call(this,64,56)}function rotl30(s){return s<<30|s>>>2}function ft(s,o,i,a){return 0===s?o&i|~o&a:2===s?o&i|o&a|i&a:o^i^a}a(Sha,u),Sha.prototype.init=function(){return this._a=1732584193,this._b=4023233417,this._c=2562383102,this._d=271733878,this._e=3285377520,this},Sha.prototype._update=function(s){for(var o,i=this._w,a=0|this._a,u=0|this._b,_=0|this._c,x=0|this._d,C=0|this._e,j=0;j<16;++j)i[j]=s.readInt32BE(4*j);for(;j<80;++j)i[j]=i[j-3]^i[j-8]^i[j-14]^i[j-16];for(var L=0;L<80;++L){var B=~~(L/20),$=0|((o=a)<<5|o>>>27)+ft(B,u,_,x)+C+i[L]+w[B];C=x,x=_,_=rotl30(u),u=a,a=$}this._a=a+this._a|0,this._b=u+this._b|0,this._c=_+this._c|0,this._d=x+this._d|0,this._e=C+this._e|0},Sha.prototype._hash=function(){var s=_.allocUnsafe(20);return s.writeInt32BE(0|this._a,0),s.writeInt32BE(0|this._b,4),s.writeInt32BE(0|this._c,8),s.writeInt32BE(0|this._d,12),s.writeInt32BE(0|this._e,16),s},s.exports=Sha},28077:s=>{s.exports=function baseHasIn(s,o){return null!=s&&o in Object(s)}},28303:(s,o,i)=>{var a=i(56110)(i(9325),\"WeakMap\");s.exports=a},28311:(s,o,i)=>{\"use strict\";var a=i(92361),u=i(82159),_=i(41505),w=a(a.bind);s.exports=function(s,o){return u(s),void 0===o?s:_?w(s,o):function(){return s.apply(o,arguments)}}},28586:(s,o,i)=>{var a=i(56449),u=i(44394),_=/\\.|\\[(?:[^[\\]]*|([\"'])(?:(?!\\1)[^\\\\]|\\\\.)*?\\1)\\]/,w=/^\\w*$/;s.exports=function isKey(s,o){if(a(s))return!1;var i=typeof s;return!(\"number\"!=i&&\"symbol\"!=i&&\"boolean\"!=i&&null!=s&&!u(s))||(w.test(s)||!_.test(s)||null!=o&&s in Object(o))}},28754:(s,o,i)=>{var a=i(25160);s.exports=function castSlice(s,o,i){var u=s.length;return i=void 0===i?u:i,!o&&i>=u?s:a(s,o,i)}},28879:(s,o,i)=>{var a=i(74335)(Object.getPrototypeOf,Object);s.exports=a},29172:(s,o,i)=>{var a=i(5861),u=i(40346);s.exports=function baseIsMap(s){return u(s)&&\"[object Map]\"==a(s)}},29367:(s,o,i)=>{\"use strict\";var a=i(82159),u=i(87136);s.exports=function(s,o){var i=s[o];return u(i)?void 0:a(i)}},29538:(s,o,i)=>{\"use strict\";var a=i(39447),u=i(1907),_=i(13930),w=i(98828),x=i(2875),C=i(87170),j=i(22574),L=i(39298),B=i(16946),$=Object.assign,U=Object.defineProperty,V=u([].concat);s.exports=!$||w((function(){if(a&&1!==$({b:1},$(U({},\"a\",{enumerable:!0,get:function(){U(this,\"b\",{value:3,enumerable:!1})}}),{b:2})).b)return!0;var s={},o={},i=Symbol(\"assign detection\"),u=\"abcdefghijklmnopqrst\";return s[i]=7,u.split(\"\").forEach((function(s){o[s]=s})),7!==$({},s)[i]||x($({},o)).join(\"\")!==u}))?function assign(s,o){for(var i=L(s),u=arguments.length,w=1,$=C.f,U=j.f;u>w;)for(var z,Y=B(arguments[w++]),Z=$?V(x(Y),$(Y)):x(Y),ee=Z.length,ie=0;ee>ie;)z=Z[ie++],a&&!_(U,Y,z)||(i[z]=Y[z]);return i}:$},29817:s=>{s.exports=function stackHas(s){return this.__data__.has(s)}},29844:(s,o)=>{\"use strict\";function f(s,o){var i=s.length;s.push(o);e:for(;0<i;){var a=i-1>>>1,u=s[a];if(!(0<g(u,o)))break e;s[a]=o,s[i]=u,i=a}}function h(s){return 0===s.length?null:s[0]}function k(s){if(0===s.length)return null;var o=s[0],i=s.pop();if(i!==o){s[0]=i;e:for(var a=0,u=s.length,_=u>>>1;a<_;){var w=2*(a+1)-1,x=s[w],C=w+1,j=s[C];if(0>g(x,i))C<u&&0>g(j,x)?(s[a]=j,s[C]=i,a=C):(s[a]=x,s[w]=i,a=w);else{if(!(C<u&&0>g(j,i)))break e;s[a]=j,s[C]=i,a=C}}}return o}function g(s,o){var i=s.sortIndex-o.sortIndex;return 0!==i?i:s.id-o.id}if(\"object\"==typeof performance&&\"function\"==typeof performance.now){var i=performance;o.unstable_now=function(){return i.now()}}else{var a=Date,u=a.now();o.unstable_now=function(){return a.now()-u}}var _=[],w=[],x=1,C=null,j=3,L=!1,B=!1,$=!1,U=\"function\"==typeof setTimeout?setTimeout:null,V=\"function\"==typeof clearTimeout?clearTimeout:null,z=\"undefined\"!=typeof setImmediate?setImmediate:null;function G(s){for(var o=h(w);null!==o;){if(null===o.callback)k(w);else{if(!(o.startTime<=s))break;k(w),o.sortIndex=o.expirationTime,f(_,o)}o=h(w)}}function H(s){if($=!1,G(s),!B)if(null!==h(_))B=!0,I(J);else{var o=h(w);null!==o&&K(H,o.startTime-s)}}function J(s,i){B=!1,$&&($=!1,V(ie),ie=-1),L=!0;var a=j;try{for(G(i),C=h(_);null!==C&&(!(C.expirationTime>i)||s&&!M());){var u=C.callback;if(\"function\"==typeof u){C.callback=null,j=C.priorityLevel;var x=u(C.expirationTime<=i);i=o.unstable_now(),\"function\"==typeof x?C.callback=x:C===h(_)&&k(_),G(i)}else k(_);C=h(_)}if(null!==C)var U=!0;else{var z=h(w);null!==z&&K(H,z.startTime-i),U=!1}return U}finally{C=null,j=a,L=!1}}\"undefined\"!=typeof navigator&&void 0!==navigator.scheduling&&void 0!==navigator.scheduling.isInputPending&&navigator.scheduling.isInputPending.bind(navigator.scheduling);var Y,Z=!1,ee=null,ie=-1,ae=5,ce=-1;function M(){return!(o.unstable_now()-ce<ae)}function R(){if(null!==ee){var s=o.unstable_now();ce=s;var i=!0;try{i=ee(!0,s)}finally{i?Y():(Z=!1,ee=null)}}else Z=!1}if(\"function\"==typeof z)Y=function(){z(R)};else if(\"undefined\"!=typeof MessageChannel){var le=new MessageChannel,pe=le.port2;le.port1.onmessage=R,Y=function(){pe.postMessage(null)}}else Y=function(){U(R,0)};function I(s){ee=s,Z||(Z=!0,Y())}function K(s,i){ie=U((function(){s(o.unstable_now())}),i)}o.unstable_IdlePriority=5,o.unstable_ImmediatePriority=1,o.unstable_LowPriority=4,o.unstable_NormalPriority=3,o.unstable_Profiling=null,o.unstable_UserBlockingPriority=2,o.unstable_cancelCallback=function(s){s.callback=null},o.unstable_continueExecution=function(){B||L||(B=!0,I(J))},o.unstable_forceFrameRate=function(s){0>s||125<s?console.error(\"forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported\"):ae=0<s?Math.floor(1e3/s):5},o.unstable_getCurrentPriorityLevel=function(){return j},o.unstable_getFirstCallbackNode=function(){return h(_)},o.unstable_next=function(s){switch(j){case 1:case 2:case 3:var o=3;break;default:o=j}var i=j;j=o;try{return s()}finally{j=i}},o.unstable_pauseExecution=function(){},o.unstable_requestPaint=function(){},o.unstable_runWithPriority=function(s,o){switch(s){case 1:case 2:case 3:case 4:case 5:break;default:s=3}var i=j;j=s;try{return o()}finally{j=i}},o.unstable_scheduleCallback=function(s,i,a){var u=o.unstable_now();switch(\"object\"==typeof a&&null!==a?a=\"number\"==typeof(a=a.delay)&&0<a?u+a:u:a=u,s){case 1:var C=-1;break;case 2:C=250;break;case 5:C=1073741823;break;case 4:C=1e4;break;default:C=5e3}return s={id:x++,callback:i,priorityLevel:s,startTime:a,expirationTime:C=a+C,sortIndex:-1},a>u?(s.sortIndex=a,f(w,s),null===h(_)&&s===h(w)&&($?(V(ie),ie=-1):$=!0,K(H,a-u))):(s.sortIndex=C,f(_,s),B||L||(B=!0,I(J))),s},o.unstable_shouldYield=M,o.unstable_wrapCallback=function(s){var o=j;return function(){var i=j;j=o;try{return s.apply(this,arguments)}finally{j=i}}}},30041:(s,o,i)=>{\"use strict\";var a=i(30655),u=i(58068),_=i(69675),w=i(75795);s.exports=function defineDataProperty(s,o,i){if(!s||\"object\"!=typeof s&&\"function\"!=typeof s)throw new _(\"`obj` must be an object or a function`\");if(\"string\"!=typeof o&&\"symbol\"!=typeof o)throw new _(\"`property` must be a string or a symbol`\");if(arguments.length>3&&\"boolean\"!=typeof arguments[3]&&null!==arguments[3])throw new _(\"`nonEnumerable`, if provided, must be a boolean or null\");if(arguments.length>4&&\"boolean\"!=typeof arguments[4]&&null!==arguments[4])throw new _(\"`nonWritable`, if provided, must be a boolean or null\");if(arguments.length>5&&\"boolean\"!=typeof arguments[5]&&null!==arguments[5])throw new _(\"`nonConfigurable`, if provided, must be a boolean or null\");if(arguments.length>6&&\"boolean\"!=typeof arguments[6])throw new _(\"`loose`, if provided, must be a boolean\");var x=arguments.length>3?arguments[3]:null,C=arguments.length>4?arguments[4]:null,j=arguments.length>5?arguments[5]:null,L=arguments.length>6&&arguments[6],B=!!w&&w(s,o);if(a)a(s,o,{configurable:null===j&&B?B.configurable:!j,enumerable:null===x&&B?B.enumerable:!x,value:i,writable:null===C&&B?B.writable:!C});else{if(!L&&(x||C||j))throw new u(\"This environment does not support defining a property as non-configurable, non-writable, or non-enumerable.\");s[o]=i}}},30294:s=>{s.exports=function isLength(s){return\"number\"==typeof s&&s>-1&&s%1==0&&s<=9007199254740991}},30361:s=>{var o=/^(?:0|[1-9]\\d*)$/;s.exports=function isIndex(s,i){var a=typeof s;return!!(i=null==i?9007199254740991:i)&&(\"number\"==a||\"symbol\"!=a&&o.test(s))&&s>-1&&s%1==0&&s<i}},30592:(s,o,i)=>{\"use strict\";var a=i(30655),u=function hasPropertyDescriptors(){return!!a};u.hasArrayLengthDefineBug=function hasArrayLengthDefineBug(){if(!a)return null;try{return 1!==a([],\"length\",{value:1}).length}catch(s){return!0}},s.exports=u},30641:(s,o,i)=>{var a=i(86649),u=i(95950);s.exports=function baseForOwn(s,o){return s&&a(s,o,u)}},30655:s=>{\"use strict\";var o=Object.defineProperty||!1;if(o)try{o({},\"a\",{value:1})}catch(s){o=!1}s.exports=o},30756:(s,o,i)=>{var a=i(23805);s.exports=function isStrictComparable(s){return s==s&&!a(s)}},30980:(s,o,i)=>{var a=i(39344),u=i(94033);function LazyWrapper(s){this.__wrapped__=s,this.__actions__=[],this.__dir__=1,this.__filtered__=!1,this.__iteratees__=[],this.__takeCount__=4294967295,this.__views__=[]}LazyWrapper.prototype=a(u.prototype),LazyWrapper.prototype.constructor=LazyWrapper,s.exports=LazyWrapper},31175:(s,o,i)=>{var a=i(26025);s.exports=function listCacheSet(s,o){var i=this.__data__,u=a(i,s);return u<0?(++this.size,i.push([s,o])):i[u][1]=o,this}},31380:s=>{s.exports=function setCacheAdd(s){return this.__data__.set(s,\"__lodash_hash_undefined__\"),this}},31499:s=>{var o={\"&\":\"&amp;\",'\"':\"&quot;\",\"'\":\"&apos;\",\"<\":\"&lt;\",\">\":\"&gt;\"};s.exports=function escapeForXML(s){return s&&s.replace?s.replace(/([&\"<>'])/g,(function(s,i){return o[i]})):s}},31769:(s,o,i)=>{var a=i(56449),u=i(28586),_=i(61802),w=i(13222);s.exports=function castPath(s,o){return a(s)?s:u(s,o)?[s]:_(w(s))}},31800:s=>{var o=/\\s/;s.exports=function trimmedEndIndex(s){for(var i=s.length;i--&&o.test(s.charAt(i)););return i}},32096:(s,o,i)=>{\"use strict\";var a=i(90160);s.exports=function(s,o){return void 0===s?arguments.length<2?\"\":o:a(s)}},32567:(s,o,i)=>{\"use strict\";i(79307);var a=i(61747);s.exports=a(\"Function\",\"bind\")},32629:(s,o,i)=>{var a=i(9999);s.exports=function clone(s){return a(s,4)}},32804:(s,o,i)=>{var a=i(56110)(i(9325),\"Promise\");s.exports=a},32827:(s,o,i)=>{\"use strict\";var a=i(56698),u=i(82890),_=i(90392),w=i(92861).Buffer,x=new Array(160);function Sha384(){this.init(),this._w=x,_.call(this,128,112)}a(Sha384,u),Sha384.prototype.init=function(){return this._ah=3418070365,this._bh=1654270250,this._ch=2438529370,this._dh=355462360,this._eh=1731405415,this._fh=2394180231,this._gh=3675008525,this._hh=1203062813,this._al=3238371032,this._bl=914150663,this._cl=812702999,this._dl=4144912697,this._el=4290775857,this._fl=1750603025,this._gl=1694076839,this._hl=3204075428,this},Sha384.prototype._hash=function(){var s=w.allocUnsafe(48);function writeInt64BE(o,i,a){s.writeInt32BE(o,a),s.writeInt32BE(i,a+4)}return writeInt64BE(this._ah,this._al,0),writeInt64BE(this._bh,this._bl,8),writeInt64BE(this._ch,this._cl,16),writeInt64BE(this._dh,this._dl,24),writeInt64BE(this._eh,this._el,32),writeInt64BE(this._fh,this._fl,40),s},s.exports=Sha384},32865:(s,o,i)=>{var a=i(19570),u=i(51811)(a);s.exports=u},33855:(s,o,i)=>{var a=i(9999),u=i(15389);s.exports=function iteratee(s){return u(\"function\"==typeof s?s:a(s,1))}},34035:(s,o,i)=>{const a=i(3110),u=i(86804);o.g$=a,o.KeyValuePair=i(55973),o.G6=u.ArraySlice,o.ot=u.ObjectSlice,o.Hg=u.Element,o.Om=u.StringElement,o.kT=u.NumberElement,o.bd=u.BooleanElement,o.Os=u.NullElement,o.wE=u.ArrayElement,o.Sh=u.ObjectElement,o.Pr=u.MemberElement,o.sI=u.RefElement,o.Ft=u.LinkElement,o.e=u.refract,i(85105),i(75147)},34084:(s,o,i)=>{\"use strict\";var a=i(62250),u=i(46285),_=i(79192);s.exports=function(s,o,i){var w,x;return _&&a(w=o.constructor)&&w!==i&&u(x=w.prototype)&&x!==i.prototype&&_(s,x),s}},34840:(s,o,i)=>{var a=\"object\"==typeof i.g&&i.g&&i.g.Object===Object&&i.g;s.exports=a},34849:(s,o,i)=>{\"use strict\";var a=i(65482),u=Math.max,_=Math.min;s.exports=function(s,o){var i=a(s);return i<0?u(i+o,0):_(i,o)}},34932:s=>{s.exports=function arrayMap(s,o){for(var i=-1,a=null==s?0:s.length,u=Array(a);++i<a;)u[i]=o(s[i],i,s);return u}},35344:s=>{function concat(...s){return s.map((s=>function source(s){return s?\"string\"==typeof s?s:s.source:null}(s))).join(\"\")}s.exports=function bash(s){const o={},i={begin:/\\$\\{/,end:/\\}/,contains:[\"self\",{begin:/:-/,contains:[o]}]};Object.assign(o,{className:\"variable\",variants:[{begin:concat(/\\$[\\w\\d#@][\\w\\d_]*/,\"(?![\\\\w\\\\d])(?![$])\")},i]});const a={className:\"subst\",begin:/\\$\\(/,end:/\\)/,contains:[s.BACKSLASH_ESCAPE]},u={begin:/<<-?\\s*(?=\\w+)/,starts:{contains:[s.END_SAME_AS_BEGIN({begin:/(\\w+)/,end:/(\\w+)/,className:\"string\"})]}},_={className:\"string\",begin:/\"/,end:/\"/,contains:[s.BACKSLASH_ESCAPE,o,a]};a.contains.push(_);const w={begin:/\\$\\(\\(/,end:/\\)\\)/,contains:[{begin:/\\d+#[0-9a-f]+/,className:\"number\"},s.NUMBER_MODE,o]},x=s.SHEBANG({binary:`(${[\"fish\",\"bash\",\"zsh\",\"sh\",\"csh\",\"ksh\",\"tcsh\",\"dash\",\"scsh\"].join(\"|\")})`,relevance:10}),C={className:\"function\",begin:/\\w[\\w\\d_]*\\s*\\(\\s*\\)\\s*\\{/,returnBegin:!0,contains:[s.inherit(s.TITLE_MODE,{begin:/\\w[\\w\\d_]*/})],relevance:0};return{name:\"Bash\",aliases:[\"sh\",\"zsh\"],keywords:{$pattern:/\\b[a-z._-]+\\b/,keyword:\"if then else elif fi for while in do done case esac function\",literal:\"true false\",built_in:\"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp\"},contains:[x,s.SHEBANG(),C,w,s.HASH_COMMENT_MODE,u,_,{className:\"\",begin:/\\\\\"/},{className:\"string\",begin:/'/,end:/'/},o]}}},35345:s=>{\"use strict\";s.exports=URIError},35529:(s,o,i)=>{var a=i(39344),u=i(28879),_=i(55527);s.exports=function initCloneObject(s){return\"function\"!=typeof s.constructor||_(s)?{}:a(u(s))}},35680:(s,o,i)=>{\"use strict\";var a=i(25767);s.exports=function isTypedArray(s){return!!a(s)}},35749:(s,o,i)=>{var a=i(81042);s.exports=function hashSet(s,o){var i=this.__data__;return this.size+=this.has(s)?0:1,i[s]=a&&void 0===o?\"__lodash_hash_undefined__\":o,this}},35970:(s,o,i)=>{var a=i(83120);s.exports=function flatten(s){return(null==s?0:s.length)?a(s,1):[]}},36128:(s,o,i)=>{\"use strict\";var a=i(7376),u=i(45951),_=i(2532),w=\"__core-js_shared__\",x=s.exports=u[w]||_(w,{});(x.versions||(x.versions=[])).push({version:\"3.40.0\",mode:a?\"pure\":\"global\",copyright:\"© 2014-2025 Denis Pushkarev (zloirock.ru)\",license:\"https://github.com/zloirock/core-js/blob/v3.40.0/LICENSE\",source:\"https://github.com/zloirock/core-js\"})},36306:s=>{var o=\"__lodash_placeholder__\";s.exports=function replaceHolders(s,i){for(var a=-1,u=s.length,_=0,w=[];++a<u;){var x=s[a];x!==i&&x!==o||(s[a]=o,w[_++]=a)}return w}},36371:(s,o,i)=>{\"use strict\";var a=i(11091),u=i(85582),_=i(76024),w=i(98828),x=i(19358),C=\"AggregateError\",j=u(C),L=!w((function(){return 1!==j([1]).errors[0]}))&&w((function(){return 7!==j([1],C,{cause:7}).cause}));a({global:!0,constructor:!0,arity:2,forced:L},{AggregateError:x(C,(function(s){return function AggregateError(o,i){return _(s,this,arguments)}}),L,!0)})},36556:(s,o,i)=>{\"use strict\";var a=i(70453),u=i(73126),_=u([a(\"%String.prototype.indexOf%\")]);s.exports=function callBoundIntrinsic(s,o){var i=a(s,!!o);return\"function\"==typeof i&&_(s,\".prototype.\")>-1?u([i]):i}},36624:(s,o,i)=>{\"use strict\";var a=i(46285),u=String,_=TypeError;s.exports=function(s){if(a(s))return s;throw new _(u(s)+\" is not an object\")}},36800:(s,o,i)=>{var a=i(75288),u=i(64894),_=i(30361),w=i(23805);s.exports=function isIterateeCall(s,o,i){if(!w(i))return!1;var x=typeof o;return!!(\"number\"==x?u(i)&&_(o,i.length):\"string\"==x&&o in i)&&a(i[o],s)}},36833:(s,o,i)=>{\"use strict\";var a=i(39447),u=i(49724),_=Function.prototype,w=a&&Object.getOwnPropertyDescriptor,x=u(_,\"name\"),C=x&&\"something\"===function something(){}.name,j=x&&(!a||a&&w(_,\"name\").configurable);s.exports={EXISTS:x,PROPER:C,CONFIGURABLE:j}},37007:s=>{\"use strict\";var o,i=\"object\"==typeof Reflect?Reflect:null,a=i&&\"function\"==typeof i.apply?i.apply:function ReflectApply(s,o,i){return Function.prototype.apply.call(s,o,i)};o=i&&\"function\"==typeof i.ownKeys?i.ownKeys:Object.getOwnPropertySymbols?function ReflectOwnKeys(s){return Object.getOwnPropertyNames(s).concat(Object.getOwnPropertySymbols(s))}:function ReflectOwnKeys(s){return Object.getOwnPropertyNames(s)};var u=Number.isNaN||function NumberIsNaN(s){return s!=s};function EventEmitter(){EventEmitter.init.call(this)}s.exports=EventEmitter,s.exports.once=function once(s,o){return new Promise((function(i,a){function errorListener(i){s.removeListener(o,resolver),a(i)}function resolver(){\"function\"==typeof s.removeListener&&s.removeListener(\"error\",errorListener),i([].slice.call(arguments))}eventTargetAgnosticAddListener(s,o,resolver,{once:!0}),\"error\"!==o&&function addErrorHandlerIfEventEmitter(s,o,i){\"function\"==typeof s.on&&eventTargetAgnosticAddListener(s,\"error\",o,i)}(s,errorListener,{once:!0})}))},EventEmitter.EventEmitter=EventEmitter,EventEmitter.prototype._events=void 0,EventEmitter.prototype._eventsCount=0,EventEmitter.prototype._maxListeners=void 0;var _=10;function checkListener(s){if(\"function\"!=typeof s)throw new TypeError('The \"listener\" argument must be of type Function. Received type '+typeof s)}function _getMaxListeners(s){return void 0===s._maxListeners?EventEmitter.defaultMaxListeners:s._maxListeners}function _addListener(s,o,i,a){var u,_,w;if(checkListener(i),void 0===(_=s._events)?(_=s._events=Object.create(null),s._eventsCount=0):(void 0!==_.newListener&&(s.emit(\"newListener\",o,i.listener?i.listener:i),_=s._events),w=_[o]),void 0===w)w=_[o]=i,++s._eventsCount;else if(\"function\"==typeof w?w=_[o]=a?[i,w]:[w,i]:a?w.unshift(i):w.push(i),(u=_getMaxListeners(s))>0&&w.length>u&&!w.warned){w.warned=!0;var x=new Error(\"Possible EventEmitter memory leak detected. \"+w.length+\" \"+String(o)+\" listeners added. Use emitter.setMaxListeners() to increase limit\");x.name=\"MaxListenersExceededWarning\",x.emitter=s,x.type=o,x.count=w.length,function ProcessEmitWarning(s){console&&console.warn&&console.warn(s)}(x)}return s}function onceWrapper(){if(!this.fired)return this.target.removeListener(this.type,this.wrapFn),this.fired=!0,0===arguments.length?this.listener.call(this.target):this.listener.apply(this.target,arguments)}function _onceWrap(s,o,i){var a={fired:!1,wrapFn:void 0,target:s,type:o,listener:i},u=onceWrapper.bind(a);return u.listener=i,a.wrapFn=u,u}function _listeners(s,o,i){var a=s._events;if(void 0===a)return[];var u=a[o];return void 0===u?[]:\"function\"==typeof u?i?[u.listener||u]:[u]:i?function unwrapListeners(s){for(var o=new Array(s.length),i=0;i<o.length;++i)o[i]=s[i].listener||s[i];return o}(u):arrayClone(u,u.length)}function listenerCount(s){var o=this._events;if(void 0!==o){var i=o[s];if(\"function\"==typeof i)return 1;if(void 0!==i)return i.length}return 0}function arrayClone(s,o){for(var i=new Array(o),a=0;a<o;++a)i[a]=s[a];return i}function eventTargetAgnosticAddListener(s,o,i,a){if(\"function\"==typeof s.on)a.once?s.once(o,i):s.on(o,i);else{if(\"function\"!=typeof s.addEventListener)throw new TypeError('The \"emitter\" argument must be of type EventEmitter. Received type '+typeof s);s.addEventListener(o,(function wrapListener(u){a.once&&s.removeEventListener(o,wrapListener),i(u)}))}}Object.defineProperty(EventEmitter,\"defaultMaxListeners\",{enumerable:!0,get:function(){return _},set:function(s){if(\"number\"!=typeof s||s<0||u(s))throw new RangeError('The value of \"defaultMaxListeners\" is out of range. It must be a non-negative number. Received '+s+\".\");_=s}}),EventEmitter.init=function(){void 0!==this._events&&this._events!==Object.getPrototypeOf(this)._events||(this._events=Object.create(null),this._eventsCount=0),this._maxListeners=this._maxListeners||void 0},EventEmitter.prototype.setMaxListeners=function setMaxListeners(s){if(\"number\"!=typeof s||s<0||u(s))throw new RangeError('The value of \"n\" is out of range. It must be a non-negative number. Received '+s+\".\");return this._maxListeners=s,this},EventEmitter.prototype.getMaxListeners=function getMaxListeners(){return _getMaxListeners(this)},EventEmitter.prototype.emit=function emit(s){for(var o=[],i=1;i<arguments.length;i++)o.push(arguments[i]);var u=\"error\"===s,_=this._events;if(void 0!==_)u=u&&void 0===_.error;else if(!u)return!1;if(u){var w;if(o.length>0&&(w=o[0]),w instanceof Error)throw w;var x=new Error(\"Unhandled error.\"+(w?\" (\"+w.message+\")\":\"\"));throw x.context=w,x}var C=_[s];if(void 0===C)return!1;if(\"function\"==typeof C)a(C,this,o);else{var j=C.length,L=arrayClone(C,j);for(i=0;i<j;++i)a(L[i],this,o)}return!0},EventEmitter.prototype.addListener=function addListener(s,o){return _addListener(this,s,o,!1)},EventEmitter.prototype.on=EventEmitter.prototype.addListener,EventEmitter.prototype.prependListener=function prependListener(s,o){return _addListener(this,s,o,!0)},EventEmitter.prototype.once=function once(s,o){return checkListener(o),this.on(s,_onceWrap(this,s,o)),this},EventEmitter.prototype.prependOnceListener=function prependOnceListener(s,o){return checkListener(o),this.prependListener(s,_onceWrap(this,s,o)),this},EventEmitter.prototype.removeListener=function removeListener(s,o){var i,a,u,_,w;if(checkListener(o),void 0===(a=this._events))return this;if(void 0===(i=a[s]))return this;if(i===o||i.listener===o)0==--this._eventsCount?this._events=Object.create(null):(delete a[s],a.removeListener&&this.emit(\"removeListener\",s,i.listener||o));else if(\"function\"!=typeof i){for(u=-1,_=i.length-1;_>=0;_--)if(i[_]===o||i[_].listener===o){w=i[_].listener,u=_;break}if(u<0)return this;0===u?i.shift():function spliceOne(s,o){for(;o+1<s.length;o++)s[o]=s[o+1];s.pop()}(i,u),1===i.length&&(a[s]=i[0]),void 0!==a.removeListener&&this.emit(\"removeListener\",s,w||o)}return this},EventEmitter.prototype.off=EventEmitter.prototype.removeListener,EventEmitter.prototype.removeAllListeners=function removeAllListeners(s){var o,i,a;if(void 0===(i=this._events))return this;if(void 0===i.removeListener)return 0===arguments.length?(this._events=Object.create(null),this._eventsCount=0):void 0!==i[s]&&(0==--this._eventsCount?this._events=Object.create(null):delete i[s]),this;if(0===arguments.length){var u,_=Object.keys(i);for(a=0;a<_.length;++a)\"removeListener\"!==(u=_[a])&&this.removeAllListeners(u);return this.removeAllListeners(\"removeListener\"),this._events=Object.create(null),this._eventsCount=0,this}if(\"function\"==typeof(o=i[s]))this.removeListener(s,o);else if(void 0!==o)for(a=o.length-1;a>=0;a--)this.removeListener(s,o[a]);return this},EventEmitter.prototype.listeners=function listeners(s){return _listeners(this,s,!0)},EventEmitter.prototype.rawListeners=function rawListeners(s){return _listeners(this,s,!1)},EventEmitter.listenerCount=function(s,o){return\"function\"==typeof s.listenerCount?s.listenerCount(o):listenerCount.call(s,o)},EventEmitter.prototype.listenerCount=listenerCount,EventEmitter.prototype.eventNames=function eventNames(){return this._eventsCount>0?o(this._events):[]}},37167:(s,o,i)=>{var a=i(4901),u=i(27301),_=i(86009),w=_&&_.isTypedArray,x=w?u(w):a;s.exports=x},37217:(s,o,i)=>{var a=i(80079),u=i(51420),_=i(90938),w=i(63605),x=i(29817),C=i(80945);function Stack(s){var o=this.__data__=new a(s);this.size=o.size}Stack.prototype.clear=u,Stack.prototype.delete=_,Stack.prototype.get=w,Stack.prototype.has=x,Stack.prototype.set=C,s.exports=Stack},37241:(s,o,i)=>{var a=i(70695),u=i(72903),_=i(64894);s.exports=function keysIn(s){return _(s)?a(s,!0):u(s)}},37257:(s,o,i)=>{\"use strict\";i(96605),i(64502),i(36371),i(99363),i(7057);var a=i(92046);s.exports=a.AggregateError},37334:s=>{s.exports=function constant(s){return function(){return s}}},37381:(s,o,i)=>{var a=i(48152),u=i(63950),_=a?function(s){return a.get(s)}:u;s.exports=_},37471:(s,o,i)=>{var a=i(91596),u=i(53320),_=i(58523),w=i(82819),x=i(18073),C=i(11287),j=i(68294),L=i(36306),B=i(9325);s.exports=function createHybrid(s,o,i,$,U,V,z,Y,Z,ee){var ie=128&o,ae=1&o,ce=2&o,le=24&o,pe=512&o,de=ce?void 0:w(s);return function wrapper(){for(var fe=arguments.length,ye=Array(fe),be=fe;be--;)ye[be]=arguments[be];if(le)var _e=C(wrapper),Se=_(ye,_e);if($&&(ye=a(ye,$,U,le)),V&&(ye=u(ye,V,z,le)),fe-=Se,le&&fe<ee){var we=L(ye,_e);return x(s,o,createHybrid,wrapper.placeholder,i,ye,we,Y,Z,ee-fe)}var xe=ae?i:this,Pe=ce?xe[s]:s;return fe=ye.length,Y?ye=j(ye,Y):pe&&fe>1&&ye.reverse(),ie&&Z<fe&&(ye.length=Z),this&&this!==B&&this instanceof wrapper&&(Pe=de||w(Pe)),Pe.apply(xe,ye)}}},37812:(s,o,i)=>{\"use strict\";var a=i(76264),u=i(93742),_=a(\"iterator\"),w=Array.prototype;s.exports=function(s){return void 0!==s&&(u.Array===s||w[_]===s)}},37828:(s,o,i)=>{var a=i(9325).Uint8Array;s.exports=a},38221:(s,o,i)=>{var a=i(23805),u=i(10124),_=i(99374),w=Math.max,x=Math.min;s.exports=function debounce(s,o,i){var C,j,L,B,$,U,V=0,z=!1,Y=!1,Z=!0;if(\"function\"!=typeof s)throw new TypeError(\"Expected a function\");function invokeFunc(o){var i=C,a=j;return C=j=void 0,V=o,B=s.apply(a,i)}function shouldInvoke(s){var i=s-U;return void 0===U||i>=o||i<0||Y&&s-V>=L}function timerExpired(){var s=u();if(shouldInvoke(s))return trailingEdge(s);$=setTimeout(timerExpired,function remainingWait(s){var i=o-(s-U);return Y?x(i,L-(s-V)):i}(s))}function trailingEdge(s){return $=void 0,Z&&C?invokeFunc(s):(C=j=void 0,B)}function debounced(){var s=u(),i=shouldInvoke(s);if(C=arguments,j=this,U=s,i){if(void 0===$)return function leadingEdge(s){return V=s,$=setTimeout(timerExpired,o),z?invokeFunc(s):B}(U);if(Y)return clearTimeout($),$=setTimeout(timerExpired,o),invokeFunc(U)}return void 0===$&&($=setTimeout(timerExpired,o)),B}return o=_(o)||0,a(i)&&(z=!!i.leading,L=(Y=\"maxWait\"in i)?w(_(i.maxWait)||0,o):L,Z=\"trailing\"in i?!!i.trailing:Z),debounced.cancel=function cancel(){void 0!==$&&clearTimeout($),V=0,C=U=j=$=void 0},debounced.flush=function flush(){return void 0===$?B:trailingEdge(u())},debounced}},38329:(s,o,i)=>{var a=i(64894);s.exports=function createBaseEach(s,o){return function(i,u){if(null==i)return i;if(!a(i))return s(i,u);for(var _=i.length,w=o?_:-1,x=Object(i);(o?w--:++w<_)&&!1!==u(x[w],w,x););return i}}},38440:(s,o,i)=>{var a=i(16038),u=i(27301),_=i(86009),w=_&&_.isSet,x=w?u(w):a;s.exports=x},38530:s=>{\"use strict\";s.exports={}},38816:(s,o,i)=>{var a=i(35970),u=i(56757),_=i(32865);s.exports=function flatRest(s){return _(u(s,void 0,a),s+\"\")}},38859:(s,o,i)=>{var a=i(53661),u=i(31380),_=i(51459);function SetCache(s){var o=-1,i=null==s?0:s.length;for(this.__data__=new a;++o<i;)this.add(s[o])}SetCache.prototype.add=SetCache.prototype.push=u,SetCache.prototype.has=_,s.exports=SetCache},39209:(s,o,i)=>{\"use strict\";var a=i(76578),u=\"undefined\"==typeof globalThis?i.g:globalThis;s.exports=function availableTypedArrays(){for(var s=[],o=0;o<a.length;o++)\"function\"==typeof u[a[o]]&&(s[s.length]=a[o]);return s}},39259:(s,o,i)=>{\"use strict\";var a=i(46285),u=i(61626);s.exports=function(s,o){a(o)&&\"cause\"in o&&u(s,\"cause\",o.cause)}},39298:(s,o,i)=>{\"use strict\";var a=i(74239),u=Object;s.exports=function(s){return u(a(s))}},39344:(s,o,i)=>{var a=i(23805),u=Object.create,_=function(){function object(){}return function(s){if(!a(s))return{};if(u)return u(s);object.prototype=s;var o=new object;return object.prototype=void 0,o}}();s.exports=_},39447:(s,o,i)=>{\"use strict\";var a=i(98828);s.exports=!a((function(){return 7!==Object.defineProperty({},1,{get:function(){return 7}})[1]}))},40154:(s,o,i)=>{\"use strict\";var a=i(13930),u=i(36624),_=i(29367);s.exports=function(s,o,i){var w,x;u(s);try{if(!(w=_(s,\"return\"))){if(\"throw\"===o)throw i;return i}w=a(w,s)}catch(s){x=!0,w=s}if(\"throw\"===o)throw i;if(x)throw w;return u(w),i}},40239:(s,o,i)=>{const a=i(10316);s.exports=class NumberElement extends a{constructor(s,o,i){super(s,o,i),this.element=\"number\"}primitive(){return\"number\"}}},40345:(s,o,i)=>{s.exports=i(37007).EventEmitter},40346:s=>{s.exports=function isObjectLike(s){return null!=s&&\"object\"==typeof s}},40551:(s,o,i)=>{\"use strict\";var a=i(45951),u=i(62250),_=a.WeakMap;s.exports=u(_)&&/native code/.test(String(_))},40860:(s,o,i)=>{var a=i(40882),u=i(80909),_=i(15389),w=i(85558),x=i(56449);s.exports=function reduce(s,o,i){var C=x(s)?a:w,j=arguments.length<3;return C(s,_(o,4),i,j,u)}},40882:s=>{s.exports=function arrayReduce(s,o,i,a){var u=-1,_=null==s?0:s.length;for(a&&_&&(i=s[++u]);++u<_;)i=o(i,s[u],u,s);return i}},40961:(s,o,i)=>{\"use strict\";!function checkDCE(){if(\"undefined\"!=typeof __REACT_DEVTOOLS_GLOBAL_HOOK__&&\"function\"==typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE)try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(checkDCE)}catch(s){console.error(s)}}(),s.exports=i(22551)},40975:(s,o,i)=>{\"use strict\";var a=i(9748);s.exports=a},41067:(s,o,i)=>{const a=i(10316);s.exports=class NullElement extends a{constructor(s,o,i){super(s||null,o,i),this.element=\"null\"}primitive(){return\"null\"}set(){return new Error(\"Cannot set the value of null\")}}},41176:s=>{\"use strict\";var o=Math.ceil,i=Math.floor;s.exports=Math.trunc||function trunc(s){var a=+s;return(a>0?i:o)(a)}},41237:s=>{\"use strict\";s.exports=EvalError},41333:s=>{\"use strict\";s.exports=function hasSymbols(){if(\"function\"!=typeof Symbol||\"function\"!=typeof Object.getOwnPropertySymbols)return!1;if(\"symbol\"==typeof Symbol.iterator)return!0;var s={},o=Symbol(\"test\"),i=Object(o);if(\"string\"==typeof o)return!1;if(\"[object Symbol]\"!==Object.prototype.toString.call(o))return!1;if(\"[object Symbol]\"!==Object.prototype.toString.call(i))return!1;for(var a in s[o]=42,s)return!1;if(\"function\"==typeof Object.keys&&0!==Object.keys(s).length)return!1;if(\"function\"==typeof Object.getOwnPropertyNames&&0!==Object.getOwnPropertyNames(s).length)return!1;var u=Object.getOwnPropertySymbols(s);if(1!==u.length||u[0]!==o)return!1;if(!Object.prototype.propertyIsEnumerable.call(s,o))return!1;if(\"function\"==typeof Object.getOwnPropertyDescriptor){var _=Object.getOwnPropertyDescriptor(s,o);if(42!==_.value||!0!==_.enumerable)return!1}return!0}},41505:(s,o,i)=>{\"use strict\";var a=i(98828);s.exports=!a((function(){var s=function(){}.bind();return\"function\"!=typeof s||s.hasOwnProperty(\"prototype\")}))},41799:(s,o,i)=>{var a=i(37217),u=i(60270);s.exports=function baseIsMatch(s,o,i,_){var w=i.length,x=w,C=!_;if(null==s)return!x;for(s=Object(s);w--;){var j=i[w];if(C&&j[2]?j[1]!==s[j[0]]:!(j[0]in s))return!1}for(;++w<x;){var L=(j=i[w])[0],B=s[L],$=j[1];if(C&&j[2]){if(void 0===B&&!(L in s))return!1}else{var U=new a;if(_)var V=_(B,$,L,s,o,U);if(!(void 0===V?u($,B,3,_,U):V))return!1}}return!0}},41859:(s,o,i)=>{const a=i(27096),u=i(78004),_=a.types;s.exports=class RandExp{constructor(s,o){if(this._setDefaults(s),s instanceof RegExp)this.ignoreCase=s.ignoreCase,this.multiline=s.multiline,s=s.source;else{if(\"string\"!=typeof s)throw new Error(\"Expected a regexp or string\");this.ignoreCase=o&&-1!==o.indexOf(\"i\"),this.multiline=o&&-1!==o.indexOf(\"m\")}this.tokens=a(s)}_setDefaults(s){this.max=null!=s.max?s.max:null!=RandExp.prototype.max?RandExp.prototype.max:100,this.defaultRange=s.defaultRange?s.defaultRange:this.defaultRange.clone(),s.randInt&&(this.randInt=s.randInt)}gen(){return this._gen(this.tokens,[])}_gen(s,o){var i,a,u,w,x;switch(s.type){case _.ROOT:case _.GROUP:if(s.followedBy||s.notFollowedBy)return\"\";for(s.remember&&void 0===s.groupNumber&&(s.groupNumber=o.push(null)-1),a=\"\",w=0,x=(i=s.options?this._randSelect(s.options):s.stack).length;w<x;w++)a+=this._gen(i[w],o);return s.remember&&(o[s.groupNumber]=a),a;case _.POSITION:return\"\";case _.SET:var C=this._expand(s);return C.length?String.fromCharCode(this._randSelect(C)):\"\";case _.REPETITION:for(u=this.randInt(s.min,s.max===1/0?s.min+this.max:s.max),a=\"\",w=0;w<u;w++)a+=this._gen(s.value,o);return a;case _.REFERENCE:return o[s.value-1]||\"\";case _.CHAR:var j=this.ignoreCase&&this._randBool()?this._toOtherCase(s.value):s.value;return String.fromCharCode(j)}}_toOtherCase(s){return s+(97<=s&&s<=122?-32:65<=s&&s<=90?32:0)}_randBool(){return!this.randInt(0,1)}_randSelect(s){return s instanceof u?s.index(this.randInt(0,s.length-1)):s[this.randInt(0,s.length-1)]}_expand(s){if(s.type===a.types.CHAR)return new u(s.value);if(s.type===a.types.RANGE)return new u(s.from,s.to);{let o=new u;for(let i=0;i<s.set.length;i++){let a=this._expand(s.set[i]);if(o.add(a),this.ignoreCase)for(let s=0;s<a.length;s++){let i=a.index(s),u=this._toOtherCase(i);i!==u&&o.add(u)}}return s.not?this.defaultRange.clone().subtract(o):this.defaultRange.clone().intersect(o)}}randInt(s,o){return s+Math.floor(Math.random()*(1+o-s))}get defaultRange(){return this._range=this._range||new u(32,126)}set defaultRange(s){this._range=s}static randexp(s,o){var i;return\"string\"==typeof s&&(s=new RegExp(s,o)),void 0===s._randexp?(i=new RandExp(s,o),s._randexp=i):(i=s._randexp)._setDefaults(s),i.gen()}static sugar(){RegExp.prototype.gen=function(){return RandExp.randexp(this)}}}},42054:s=>{var o=\"\\\\ud800-\\\\udfff\",i=\"[\"+o+\"]\",a=\"[\\\\u0300-\\\\u036f\\\\ufe20-\\\\ufe2f\\\\u20d0-\\\\u20ff]\",u=\"\\\\ud83c[\\\\udffb-\\\\udfff]\",_=\"[^\"+o+\"]\",w=\"(?:\\\\ud83c[\\\\udde6-\\\\uddff]){2}\",x=\"[\\\\ud800-\\\\udbff][\\\\udc00-\\\\udfff]\",C=\"(?:\"+a+\"|\"+u+\")\"+\"?\",j=\"[\\\\ufe0e\\\\ufe0f]?\",L=j+C+(\"(?:\\\\u200d(?:\"+[_,w,x].join(\"|\")+\")\"+j+C+\")*\"),B=\"(?:\"+[_+a+\"?\",a,w,x,i].join(\"|\")+\")\",$=RegExp(u+\"(?=\"+u+\")|\"+B+L,\"g\");s.exports=function unicodeToArray(s){return s.match($)||[]}},42072:(s,o,i)=>{var a=i(34932),u=i(23007),_=i(56449),w=i(44394),x=i(61802),C=i(77797),j=i(13222);s.exports=function toPath(s){return _(s)?a(s,C):w(s)?[s]:u(x(j(s)))}},42156:s=>{\"use strict\";s.exports=function(){}},42220:(s,o,i)=>{\"use strict\";var a=i(39447),u=i(58661),_=i(74284),w=i(36624),x=i(4993),C=i(2875);o.f=a&&!u?Object.defineProperties:function defineProperties(s,o){w(s);for(var i,a=x(o),u=C(o),j=u.length,L=0;j>L;)_.f(s,i=u[L++],a[i]);return s}},42426:(s,o,i)=>{var a=i(14248),u=i(15389),_=i(90916),w=i(56449),x=i(36800);s.exports=function some(s,o,i){var C=w(s)?a:_;return i&&x(s,o,i)&&(o=void 0),C(s,u(o,3))}},42824:(s,o,i)=>{var a=i(87805),u=i(93290),_=i(71961),w=i(23007),x=i(35529),C=i(72428),j=i(56449),L=i(83693),B=i(3656),$=i(1882),U=i(23805),V=i(11331),z=i(37167),Y=i(14974),Z=i(69884);s.exports=function baseMergeDeep(s,o,i,ee,ie,ae,ce){var le=Y(s,i),pe=Y(o,i),de=ce.get(pe);if(de)a(s,i,de);else{var fe=ae?ae(le,pe,i+\"\",s,o,ce):void 0,ye=void 0===fe;if(ye){var be=j(pe),_e=!be&&B(pe),Se=!be&&!_e&&z(pe);fe=pe,be||_e||Se?j(le)?fe=le:L(le)?fe=w(le):_e?(ye=!1,fe=u(pe,!0)):Se?(ye=!1,fe=_(pe,!0)):fe=[]:V(pe)||C(pe)?(fe=le,C(le)?fe=Z(le):U(le)&&!$(le)||(fe=x(pe))):ye=!1}ye&&(ce.set(pe,fe),ie(fe,pe,ee,ae,ce),ce.delete(pe)),a(s,i,fe)}}},43360:(s,o,i)=>{var a=i(93243);s.exports=function baseAssignValue(s,o,i){\"__proto__\"==o&&a?a(s,o,{configurable:!0,enumerable:!0,value:i,writable:!0}):s[o]=i}},43768:(s,o,i)=>{\"use strict\";var a=i(45981),u=i(85587);o.highlight=highlight,o.highlightAuto=function highlightAuto(s,o){var i,w,x,C,j=o||{},L=j.subset||a.listLanguages(),B=j.prefix,$=L.length,U=-1;null==B&&(B=_);if(\"string\"!=typeof s)throw u(\"Expected `string` for value, got `%s`\",s);w={relevance:0,language:null,value:[]},i={relevance:0,language:null,value:[]};for(;++U<$;)C=L[U],a.getLanguage(C)&&((x=highlight(C,s,o)).language=C,x.relevance>w.relevance&&(w=x),x.relevance>i.relevance&&(w=i,i=x));w.language&&(i.secondBest=w);return i},o.registerLanguage=function registerLanguage(s,o){a.registerLanguage(s,o)},o.listLanguages=function listLanguages(){return a.listLanguages()},o.registerAlias=function registerAlias(s,o){var i,u=s;o&&((u={})[s]=o);for(i in u)a.registerAliases(u[i],{languageName:i})},Emitter.prototype.addText=function text(s){var o,i,a=this.stack;if(\"\"===s)return;o=a[a.length-1],(i=o.children[o.children.length-1])&&\"text\"===i.type?i.value+=s:o.children.push({type:\"text\",value:s})},Emitter.prototype.addKeyword=function addKeyword(s,o){this.openNode(o),this.addText(s),this.closeNode()},Emitter.prototype.addSublanguage=function addSublanguage(s,o){var i=this.stack,a=i[i.length-1],u=s.rootNode.children,_=o?{type:\"element\",tagName:\"span\",properties:{className:[o]},children:u}:u;a.children=a.children.concat(_)},Emitter.prototype.openNode=function open(s){var o=this.stack,i=this.options.classPrefix+s,a=o[o.length-1],u={type:\"element\",tagName:\"span\",properties:{className:[i]},children:[]};a.children.push(u),o.push(u)},Emitter.prototype.closeNode=function close(){this.stack.pop()},Emitter.prototype.closeAllNodes=noop,Emitter.prototype.finalize=noop,Emitter.prototype.toHTML=function toHtmlNoop(){return\"\"};var _=\"hljs-\";function highlight(s,o,i){var w,x=a.configure({}),C=(i||{}).prefix;if(\"string\"!=typeof s)throw u(\"Expected `string` for name, got `%s`\",s);if(!a.getLanguage(s))throw u(\"Unknown language: `%s` is not registered\",s);if(\"string\"!=typeof o)throw u(\"Expected `string` for value, got `%s`\",o);if(null==C&&(C=_),a.configure({__emitter:Emitter,classPrefix:C}),w=a.highlight(o,{language:s,ignoreIllegals:!0}),a.configure(x||{}),w.errorRaised)throw w.errorRaised;return{relevance:w.relevance,language:w.language,value:w.emitter.rootNode.children}}function Emitter(s){this.options=s,this.rootNode={children:[]},this.stack=[this.rootNode]}function noop(){}},43838:(s,o,i)=>{var a=i(21791),u=i(37241);s.exports=function baseAssignIn(s,o){return s&&a(o,u(o),s)}},44394:(s,o,i)=>{var a=i(72552),u=i(40346);s.exports=function isSymbol(s){return\"symbol\"==typeof s||u(s)&&\"[object Symbol]\"==a(s)}},44673:(s,o,i)=>{\"use strict\";var a=i(1907),u=i(82159),_=i(46285),w=i(49724),x=i(93427),C=i(41505),j=Function,L=a([].concat),B=a([].join),$={};s.exports=C?j.bind:function bind(s){var o=u(this),i=o.prototype,a=x(arguments,1),C=function bound(){var i=L(a,x(arguments));return this instanceof C?function(s,o,i){if(!w($,o)){for(var a=[],u=0;u<o;u++)a[u]=\"a[\"+u+\"]\";$[o]=j(\"C,a\",\"return new C(\"+B(a,\",\")+\")\")}return $[o](s,i)}(o,i.length,i):o.apply(s,i)};return _(i)&&(C.prototype=i),C}},45083:(s,o,i)=>{var a=i(1882),u=i(87296),_=i(23805),w=i(47473),x=/^\\[object .+?Constructor\\]$/,C=Function.prototype,j=Object.prototype,L=C.toString,B=j.hasOwnProperty,$=RegExp(\"^\"+L.call(B).replace(/[\\\\^$.*+?()[\\]{}|]/g,\"\\\\$&\").replace(/hasOwnProperty|(function).*?(?=\\\\\\()| for .+?(?=\\\\\\])/g,\"$1.*?\")+\"$\");s.exports=function baseIsNative(s){return!(!_(s)||u(s))&&(a(s)?$:x).test(w(s))}},45412:(s,o,i)=>{\"use strict\";var a,u=i(65606);s.exports=Readable,Readable.ReadableState=ReadableState;i(37007).EventEmitter;var _=function EElistenerCount(s,o){return s.listeners(o).length},w=i(40345),x=i(48287).Buffer,C=(void 0!==i.g?i.g:\"undefined\"!=typeof window?window:\"undefined\"!=typeof self?self:{}).Uint8Array||function(){};var j,L=i(79838);j=L&&L.debuglog?L.debuglog(\"stream\"):function debug(){};var B,$,U,V=i(80345),z=i(75896),Y=i(65291).getHighWaterMark,Z=i(86048).F,ee=Z.ERR_INVALID_ARG_TYPE,ie=Z.ERR_STREAM_PUSH_AFTER_EOF,ae=Z.ERR_METHOD_NOT_IMPLEMENTED,ce=Z.ERR_STREAM_UNSHIFT_AFTER_END_EVENT;i(56698)(Readable,w);var le=z.errorOrDestroy,pe=[\"error\",\"close\",\"destroy\",\"pause\",\"resume\"];function ReadableState(s,o,u){a=a||i(25382),s=s||{},\"boolean\"!=typeof u&&(u=o instanceof a),this.objectMode=!!s.objectMode,u&&(this.objectMode=this.objectMode||!!s.readableObjectMode),this.highWaterMark=Y(this,s,\"readableHighWaterMark\",u),this.buffer=new V,this.length=0,this.pipes=null,this.pipesCount=0,this.flowing=null,this.ended=!1,this.endEmitted=!1,this.reading=!1,this.sync=!0,this.needReadable=!1,this.emittedReadable=!1,this.readableListening=!1,this.resumeScheduled=!1,this.paused=!0,this.emitClose=!1!==s.emitClose,this.autoDestroy=!!s.autoDestroy,this.destroyed=!1,this.defaultEncoding=s.defaultEncoding||\"utf8\",this.awaitDrain=0,this.readingMore=!1,this.decoder=null,this.encoding=null,s.encoding&&(B||(B=i(83141).I),this.decoder=new B(s.encoding),this.encoding=s.encoding)}function Readable(s){if(a=a||i(25382),!(this instanceof Readable))return new Readable(s);var o=this instanceof a;this._readableState=new ReadableState(s,this,o),this.readable=!0,s&&(\"function\"==typeof s.read&&(this._read=s.read),\"function\"==typeof s.destroy&&(this._destroy=s.destroy)),w.call(this)}function readableAddChunk(s,o,i,a,u){j(\"readableAddChunk\",o);var _,w=s._readableState;if(null===o)w.reading=!1,function onEofChunk(s,o){if(j(\"onEofChunk\"),o.ended)return;if(o.decoder){var i=o.decoder.end();i&&i.length&&(o.buffer.push(i),o.length+=o.objectMode?1:i.length)}o.ended=!0,o.sync?emitReadable(s):(o.needReadable=!1,o.emittedReadable||(o.emittedReadable=!0,emitReadable_(s)))}(s,w);else if(u||(_=function chunkInvalid(s,o){var i;(function _isUint8Array(s){return x.isBuffer(s)||s instanceof C})(o)||\"string\"==typeof o||void 0===o||s.objectMode||(i=new ee(\"chunk\",[\"string\",\"Buffer\",\"Uint8Array\"],o));return i}(w,o)),_)le(s,_);else if(w.objectMode||o&&o.length>0)if(\"string\"==typeof o||w.objectMode||Object.getPrototypeOf(o)===x.prototype||(o=function _uint8ArrayToBuffer(s){return x.from(s)}(o)),a)w.endEmitted?le(s,new ce):addChunk(s,w,o,!0);else if(w.ended)le(s,new ie);else{if(w.destroyed)return!1;w.reading=!1,w.decoder&&!i?(o=w.decoder.write(o),w.objectMode||0!==o.length?addChunk(s,w,o,!1):maybeReadMore(s,w)):addChunk(s,w,o,!1)}else a||(w.reading=!1,maybeReadMore(s,w));return!w.ended&&(w.length<w.highWaterMark||0===w.length)}function addChunk(s,o,i,a){o.flowing&&0===o.length&&!o.sync?(o.awaitDrain=0,s.emit(\"data\",i)):(o.length+=o.objectMode?1:i.length,a?o.buffer.unshift(i):o.buffer.push(i),o.needReadable&&emitReadable(s)),maybeReadMore(s,o)}Object.defineProperty(Readable.prototype,\"destroyed\",{enumerable:!1,get:function get(){return void 0!==this._readableState&&this._readableState.destroyed},set:function set(s){this._readableState&&(this._readableState.destroyed=s)}}),Readable.prototype.destroy=z.destroy,Readable.prototype._undestroy=z.undestroy,Readable.prototype._destroy=function(s,o){o(s)},Readable.prototype.push=function(s,o){var i,a=this._readableState;return a.objectMode?i=!0:\"string\"==typeof s&&((o=o||a.defaultEncoding)!==a.encoding&&(s=x.from(s,o),o=\"\"),i=!0),readableAddChunk(this,s,o,!1,i)},Readable.prototype.unshift=function(s){return readableAddChunk(this,s,null,!0,!1)},Readable.prototype.isPaused=function(){return!1===this._readableState.flowing},Readable.prototype.setEncoding=function(s){B||(B=i(83141).I);var o=new B(s);this._readableState.decoder=o,this._readableState.encoding=this._readableState.decoder.encoding;for(var a=this._readableState.buffer.head,u=\"\";null!==a;)u+=o.write(a.data),a=a.next;return this._readableState.buffer.clear(),\"\"!==u&&this._readableState.buffer.push(u),this._readableState.length=u.length,this};var de=1073741824;function howMuchToRead(s,o){return s<=0||0===o.length&&o.ended?0:o.objectMode?1:s!=s?o.flowing&&o.length?o.buffer.head.data.length:o.length:(s>o.highWaterMark&&(o.highWaterMark=function computeNewHighWaterMark(s){return s>=de?s=de:(s--,s|=s>>>1,s|=s>>>2,s|=s>>>4,s|=s>>>8,s|=s>>>16,s++),s}(s)),s<=o.length?s:o.ended?o.length:(o.needReadable=!0,0))}function emitReadable(s){var o=s._readableState;j(\"emitReadable\",o.needReadable,o.emittedReadable),o.needReadable=!1,o.emittedReadable||(j(\"emitReadable\",o.flowing),o.emittedReadable=!0,u.nextTick(emitReadable_,s))}function emitReadable_(s){var o=s._readableState;j(\"emitReadable_\",o.destroyed,o.length,o.ended),o.destroyed||!o.length&&!o.ended||(s.emit(\"readable\"),o.emittedReadable=!1),o.needReadable=!o.flowing&&!o.ended&&o.length<=o.highWaterMark,flow(s)}function maybeReadMore(s,o){o.readingMore||(o.readingMore=!0,u.nextTick(maybeReadMore_,s,o))}function maybeReadMore_(s,o){for(;!o.reading&&!o.ended&&(o.length<o.highWaterMark||o.flowing&&0===o.length);){var i=o.length;if(j(\"maybeReadMore read 0\"),s.read(0),i===o.length)break}o.readingMore=!1}function updateReadableListening(s){var o=s._readableState;o.readableListening=s.listenerCount(\"readable\")>0,o.resumeScheduled&&!o.paused?o.flowing=!0:s.listenerCount(\"data\")>0&&s.resume()}function nReadingNextTick(s){j(\"readable nexttick read 0\"),s.read(0)}function resume_(s,o){j(\"resume\",o.reading),o.reading||s.read(0),o.resumeScheduled=!1,s.emit(\"resume\"),flow(s),o.flowing&&!o.reading&&s.read(0)}function flow(s){var o=s._readableState;for(j(\"flow\",o.flowing);o.flowing&&null!==s.read(););}function fromList(s,o){return 0===o.length?null:(o.objectMode?i=o.buffer.shift():!s||s>=o.length?(i=o.decoder?o.buffer.join(\"\"):1===o.buffer.length?o.buffer.first():o.buffer.concat(o.length),o.buffer.clear()):i=o.buffer.consume(s,o.decoder),i);var i}function endReadable(s){var o=s._readableState;j(\"endReadable\",o.endEmitted),o.endEmitted||(o.ended=!0,u.nextTick(endReadableNT,o,s))}function endReadableNT(s,o){if(j(\"endReadableNT\",s.endEmitted,s.length),!s.endEmitted&&0===s.length&&(s.endEmitted=!0,o.readable=!1,o.emit(\"end\"),s.autoDestroy)){var i=o._writableState;(!i||i.autoDestroy&&i.finished)&&o.destroy()}}function indexOf(s,o){for(var i=0,a=s.length;i<a;i++)if(s[i]===o)return i;return-1}Readable.prototype.read=function(s){j(\"read\",s),s=parseInt(s,10);var o=this._readableState,i=s;if(0!==s&&(o.emittedReadable=!1),0===s&&o.needReadable&&((0!==o.highWaterMark?o.length>=o.highWaterMark:o.length>0)||o.ended))return j(\"read: emitReadable\",o.length,o.ended),0===o.length&&o.ended?endReadable(this):emitReadable(this),null;if(0===(s=howMuchToRead(s,o))&&o.ended)return 0===o.length&&endReadable(this),null;var a,u=o.needReadable;return j(\"need readable\",u),(0===o.length||o.length-s<o.highWaterMark)&&j(\"length less than watermark\",u=!0),o.ended||o.reading?j(\"reading or ended\",u=!1):u&&(j(\"do read\"),o.reading=!0,o.sync=!0,0===o.length&&(o.needReadable=!0),this._read(o.highWaterMark),o.sync=!1,o.reading||(s=howMuchToRead(i,o))),null===(a=s>0?fromList(s,o):null)?(o.needReadable=o.length<=o.highWaterMark,s=0):(o.length-=s,o.awaitDrain=0),0===o.length&&(o.ended||(o.needReadable=!0),i!==s&&o.ended&&endReadable(this)),null!==a&&this.emit(\"data\",a),a},Readable.prototype._read=function(s){le(this,new ae(\"_read()\"))},Readable.prototype.pipe=function(s,o){var i=this,a=this._readableState;switch(a.pipesCount){case 0:a.pipes=s;break;case 1:a.pipes=[a.pipes,s];break;default:a.pipes.push(s)}a.pipesCount+=1,j(\"pipe count=%d opts=%j\",a.pipesCount,o);var w=(!o||!1!==o.end)&&s!==u.stdout&&s!==u.stderr?onend:unpipe;function onunpipe(o,u){j(\"onunpipe\"),o===i&&u&&!1===u.hasUnpiped&&(u.hasUnpiped=!0,function cleanup(){j(\"cleanup\"),s.removeListener(\"close\",onclose),s.removeListener(\"finish\",onfinish),s.removeListener(\"drain\",x),s.removeListener(\"error\",onerror),s.removeListener(\"unpipe\",onunpipe),i.removeListener(\"end\",onend),i.removeListener(\"end\",unpipe),i.removeListener(\"data\",ondata),C=!0,!a.awaitDrain||s._writableState&&!s._writableState.needDrain||x()}())}function onend(){j(\"onend\"),s.end()}a.endEmitted?u.nextTick(w):i.once(\"end\",w),s.on(\"unpipe\",onunpipe);var x=function pipeOnDrain(s){return function pipeOnDrainFunctionResult(){var o=s._readableState;j(\"pipeOnDrain\",o.awaitDrain),o.awaitDrain&&o.awaitDrain--,0===o.awaitDrain&&_(s,\"data\")&&(o.flowing=!0,flow(s))}}(i);s.on(\"drain\",x);var C=!1;function ondata(o){j(\"ondata\");var u=s.write(o);j(\"dest.write\",u),!1===u&&((1===a.pipesCount&&a.pipes===s||a.pipesCount>1&&-1!==indexOf(a.pipes,s))&&!C&&(j(\"false write response, pause\",a.awaitDrain),a.awaitDrain++),i.pause())}function onerror(o){j(\"onerror\",o),unpipe(),s.removeListener(\"error\",onerror),0===_(s,\"error\")&&le(s,o)}function onclose(){s.removeListener(\"finish\",onfinish),unpipe()}function onfinish(){j(\"onfinish\"),s.removeListener(\"close\",onclose),unpipe()}function unpipe(){j(\"unpipe\"),i.unpipe(s)}return i.on(\"data\",ondata),function prependListener(s,o,i){if(\"function\"==typeof s.prependListener)return s.prependListener(o,i);s._events&&s._events[o]?Array.isArray(s._events[o])?s._events[o].unshift(i):s._events[o]=[i,s._events[o]]:s.on(o,i)}(s,\"error\",onerror),s.once(\"close\",onclose),s.once(\"finish\",onfinish),s.emit(\"pipe\",i),a.flowing||(j(\"pipe resume\"),i.resume()),s},Readable.prototype.unpipe=function(s){var o=this._readableState,i={hasUnpiped:!1};if(0===o.pipesCount)return this;if(1===o.pipesCount)return s&&s!==o.pipes||(s||(s=o.pipes),o.pipes=null,o.pipesCount=0,o.flowing=!1,s&&s.emit(\"unpipe\",this,i)),this;if(!s){var a=o.pipes,u=o.pipesCount;o.pipes=null,o.pipesCount=0,o.flowing=!1;for(var _=0;_<u;_++)a[_].emit(\"unpipe\",this,{hasUnpiped:!1});return this}var w=indexOf(o.pipes,s);return-1===w||(o.pipes.splice(w,1),o.pipesCount-=1,1===o.pipesCount&&(o.pipes=o.pipes[0]),s.emit(\"unpipe\",this,i)),this},Readable.prototype.on=function(s,o){var i=w.prototype.on.call(this,s,o),a=this._readableState;return\"data\"===s?(a.readableListening=this.listenerCount(\"readable\")>0,!1!==a.flowing&&this.resume()):\"readable\"===s&&(a.endEmitted||a.readableListening||(a.readableListening=a.needReadable=!0,a.flowing=!1,a.emittedReadable=!1,j(\"on readable\",a.length,a.reading),a.length?emitReadable(this):a.reading||u.nextTick(nReadingNextTick,this))),i},Readable.prototype.addListener=Readable.prototype.on,Readable.prototype.removeListener=function(s,o){var i=w.prototype.removeListener.call(this,s,o);return\"readable\"===s&&u.nextTick(updateReadableListening,this),i},Readable.prototype.removeAllListeners=function(s){var o=w.prototype.removeAllListeners.apply(this,arguments);return\"readable\"!==s&&void 0!==s||u.nextTick(updateReadableListening,this),o},Readable.prototype.resume=function(){var s=this._readableState;return s.flowing||(j(\"resume\"),s.flowing=!s.readableListening,function resume(s,o){o.resumeScheduled||(o.resumeScheduled=!0,u.nextTick(resume_,s,o))}(this,s)),s.paused=!1,this},Readable.prototype.pause=function(){return j(\"call pause flowing=%j\",this._readableState.flowing),!1!==this._readableState.flowing&&(j(\"pause\"),this._readableState.flowing=!1,this.emit(\"pause\")),this._readableState.paused=!0,this},Readable.prototype.wrap=function(s){var o=this,i=this._readableState,a=!1;for(var u in s.on(\"end\",(function(){if(j(\"wrapped end\"),i.decoder&&!i.ended){var s=i.decoder.end();s&&s.length&&o.push(s)}o.push(null)})),s.on(\"data\",(function(u){(j(\"wrapped data\"),i.decoder&&(u=i.decoder.write(u)),i.objectMode&&null==u)||(i.objectMode||u&&u.length)&&(o.push(u)||(a=!0,s.pause()))})),s)void 0===this[u]&&\"function\"==typeof s[u]&&(this[u]=function methodWrap(o){return function methodWrapReturnFunction(){return s[o].apply(s,arguments)}}(u));for(var _=0;_<pe.length;_++)s.on(pe[_],this.emit.bind(this,pe[_]));return this._read=function(o){j(\"wrapped _read\",o),a&&(a=!1,s.resume())},this},\"function\"==typeof Symbol&&(Readable.prototype[Symbol.asyncIterator]=function(){return void 0===$&&($=i(2955)),$(this)}),Object.defineProperty(Readable.prototype,\"readableHighWaterMark\",{enumerable:!1,get:function get(){return this._readableState.highWaterMark}}),Object.defineProperty(Readable.prototype,\"readableBuffer\",{enumerable:!1,get:function get(){return this._readableState&&this._readableState.buffer}}),Object.defineProperty(Readable.prototype,\"readableFlowing\",{enumerable:!1,get:function get(){return this._readableState.flowing},set:function set(s){this._readableState&&(this._readableState.flowing=s)}}),Readable._fromList=fromList,Object.defineProperty(Readable.prototype,\"readableLength\",{enumerable:!1,get:function get(){return this._readableState.length}}),\"function\"==typeof Symbol&&(Readable.from=function(s,o){return void 0===U&&(U=i(55157)),U(Readable,s,o)})},45434:s=>{var o=/[a-z][A-Z]|[A-Z]{2}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/;s.exports=function hasUnicodeWord(s){return o.test(s)}},45539:(s,o,i)=>{var a=i(40882),u=i(50828),_=i(66645),w=RegExp(\"['’]\",\"g\");s.exports=function createCompounder(s){return function(o){return a(_(u(o).replace(w,\"\")),s,\"\")}}},45807:(s,o,i)=>{\"use strict\";var a=i(1907),u=a({}.toString),_=a(\"\".slice);s.exports=function(s){return _(u(s),8,-1)}},45891:(s,o,i)=>{var a=i(51873),u=i(72428),_=i(56449),w=a?a.isConcatSpreadable:void 0;s.exports=function isFlattenable(s){return _(s)||u(s)||!!(w&&s&&s[w])}},45951:function(s,o,i){\"use strict\";var check=function(s){return s&&s.Math===Math&&s};s.exports=check(\"object\"==typeof globalThis&&globalThis)||check(\"object\"==typeof window&&window)||check(\"object\"==typeof self&&self)||check(\"object\"==typeof i.g&&i.g)||check(\"object\"==typeof this&&this)||function(){return this}()||Function(\"return this\")()},45981:s=>{function deepFreeze(s){return s instanceof Map?s.clear=s.delete=s.set=function(){throw new Error(\"map is read-only\")}:s instanceof Set&&(s.add=s.clear=s.delete=function(){throw new Error(\"set is read-only\")}),Object.freeze(s),Object.getOwnPropertyNames(s).forEach((function(o){var i=s[o];\"object\"!=typeof i||Object.isFrozen(i)||deepFreeze(i)})),s}var o=deepFreeze,i=deepFreeze;o.default=i;class Response{constructor(s){void 0===s.data&&(s.data={}),this.data=s.data,this.isMatchIgnored=!1}ignoreMatch(){this.isMatchIgnored=!0}}function escapeHTML(s){return s.replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\").replace(/'/g,\"&#x27;\")}function inherit(s,...o){const i=Object.create(null);for(const o in s)i[o]=s[o];return o.forEach((function(s){for(const o in s)i[o]=s[o]})),i}const emitsWrappingTags=s=>!!s.kind;class HTMLRenderer{constructor(s,o){this.buffer=\"\",this.classPrefix=o.classPrefix,s.walk(this)}addText(s){this.buffer+=escapeHTML(s)}openNode(s){if(!emitsWrappingTags(s))return;let o=s.kind;s.sublanguage||(o=`${this.classPrefix}${o}`),this.span(o)}closeNode(s){emitsWrappingTags(s)&&(this.buffer+=\"</span>\")}value(){return this.buffer}span(s){this.buffer+=`<span class=\"${s}\">`}}class TokenTree{constructor(){this.rootNode={children:[]},this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(s){this.top.children.push(s)}openNode(s){const o={kind:s,children:[]};this.add(o),this.stack.push(o)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(s){return this.constructor._walk(s,this.rootNode)}static _walk(s,o){return\"string\"==typeof o?s.addText(o):o.children&&(s.openNode(o),o.children.forEach((o=>this._walk(s,o))),s.closeNode(o)),s}static _collapse(s){\"string\"!=typeof s&&s.children&&(s.children.every((s=>\"string\"==typeof s))?s.children=[s.children.join(\"\")]:s.children.forEach((s=>{TokenTree._collapse(s)})))}}class TokenTreeEmitter extends TokenTree{constructor(s){super(),this.options=s}addKeyword(s,o){\"\"!==s&&(this.openNode(o),this.addText(s),this.closeNode())}addText(s){\"\"!==s&&this.add(s)}addSublanguage(s,o){const i=s.root;i.kind=o,i.sublanguage=!0,this.add(i)}toHTML(){return new HTMLRenderer(this,this.options).value()}finalize(){return!0}}function source(s){return s?\"string\"==typeof s?s:s.source:null}const a=/\\[(?:[^\\\\\\]]|\\\\.)*\\]|\\(\\??|\\\\([1-9][0-9]*)|\\\\./;const u=\"[a-zA-Z]\\\\w*\",_=\"[a-zA-Z_]\\\\w*\",w=\"\\\\b\\\\d+(\\\\.\\\\d+)?\",x=\"(-?)(\\\\b0[xX][a-fA-F0-9]+|(\\\\b\\\\d+(\\\\.\\\\d*)?|\\\\.\\\\d+)([eE][-+]?\\\\d+)?)\",C=\"\\\\b(0b[01]+)\",j={begin:\"\\\\\\\\[\\\\s\\\\S]\",relevance:0},L={className:\"string\",begin:\"'\",end:\"'\",illegal:\"\\\\n\",contains:[j]},B={className:\"string\",begin:'\"',end:'\"',illegal:\"\\\\n\",contains:[j]},$={begin:/\\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\\b/},COMMENT=function(s,o,i={}){const a=inherit({className:\"comment\",begin:s,end:o,contains:[]},i);return a.contains.push($),a.contains.push({className:\"doctag\",begin:\"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):\",relevance:0}),a},U=COMMENT(\"//\",\"$\"),V=COMMENT(\"/\\\\*\",\"\\\\*/\"),z=COMMENT(\"#\",\"$\"),Y={className:\"number\",begin:w,relevance:0},Z={className:\"number\",begin:x,relevance:0},ee={className:\"number\",begin:C,relevance:0},ie={className:\"number\",begin:w+\"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?\",relevance:0},ae={begin:/(?=\\/[^/\\n]*\\/)/,contains:[{className:\"regexp\",begin:/\\//,end:/\\/[gimuy]*/,illegal:/\\n/,contains:[j,{begin:/\\[/,end:/\\]/,relevance:0,contains:[j]}]}]},ce={className:\"title\",begin:u,relevance:0},le={className:\"title\",begin:_,relevance:0},pe={begin:\"\\\\.\\\\s*\"+_,relevance:0};var de=Object.freeze({__proto__:null,MATCH_NOTHING_RE:/\\b\\B/,IDENT_RE:u,UNDERSCORE_IDENT_RE:_,NUMBER_RE:w,C_NUMBER_RE:x,BINARY_NUMBER_RE:C,RE_STARTERS_RE:\"!|!=|!==|%|%=|&|&&|&=|\\\\*|\\\\*=|\\\\+|\\\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\\\?|\\\\[|\\\\{|\\\\(|\\\\^|\\\\^=|\\\\||\\\\|=|\\\\|\\\\||~\",SHEBANG:(s={})=>{const o=/^#![ ]*\\//;return s.binary&&(s.begin=function concat(...s){return s.map((s=>source(s))).join(\"\")}(o,/.*\\b/,s.binary,/\\b.*/)),inherit({className:\"meta\",begin:o,end:/$/,relevance:0,\"on:begin\":(s,o)=>{0!==s.index&&o.ignoreMatch()}},s)},BACKSLASH_ESCAPE:j,APOS_STRING_MODE:L,QUOTE_STRING_MODE:B,PHRASAL_WORDS_MODE:$,COMMENT,C_LINE_COMMENT_MODE:U,C_BLOCK_COMMENT_MODE:V,HASH_COMMENT_MODE:z,NUMBER_MODE:Y,C_NUMBER_MODE:Z,BINARY_NUMBER_MODE:ee,CSS_NUMBER_MODE:ie,REGEXP_MODE:ae,TITLE_MODE:ce,UNDERSCORE_TITLE_MODE:le,METHOD_GUARD:pe,END_SAME_AS_BEGIN:function(s){return Object.assign(s,{\"on:begin\":(s,o)=>{o.data._beginMatch=s[1]},\"on:end\":(s,o)=>{o.data._beginMatch!==s[1]&&o.ignoreMatch()}})}});function skipIfhasPrecedingDot(s,o){\".\"===s.input[s.index-1]&&o.ignoreMatch()}function beginKeywords(s,o){o&&s.beginKeywords&&(s.begin=\"\\\\b(\"+s.beginKeywords.split(\" \").join(\"|\")+\")(?!\\\\.)(?=\\\\b|\\\\s)\",s.__beforeBegin=skipIfhasPrecedingDot,s.keywords=s.keywords||s.beginKeywords,delete s.beginKeywords,void 0===s.relevance&&(s.relevance=0))}function compileIllegal(s,o){Array.isArray(s.illegal)&&(s.illegal=function either(...s){return\"(\"+s.map((s=>source(s))).join(\"|\")+\")\"}(...s.illegal))}function compileMatch(s,o){if(s.match){if(s.begin||s.end)throw new Error(\"begin & end are not supported with match\");s.begin=s.match,delete s.match}}function compileRelevance(s,o){void 0===s.relevance&&(s.relevance=1)}const fe=[\"of\",\"and\",\"for\",\"in\",\"not\",\"or\",\"if\",\"then\",\"parent\",\"list\",\"value\"];function compileKeywords(s,o,i=\"keyword\"){const a={};return\"string\"==typeof s?compileList(i,s.split(\" \")):Array.isArray(s)?compileList(i,s):Object.keys(s).forEach((function(i){Object.assign(a,compileKeywords(s[i],o,i))})),a;function compileList(s,i){o&&(i=i.map((s=>s.toLowerCase()))),i.forEach((function(o){const i=o.split(\"|\");a[i[0]]=[s,scoreForKeyword(i[0],i[1])]}))}}function scoreForKeyword(s,o){return o?Number(o):function commonKeyword(s){return fe.includes(s.toLowerCase())}(s)?0:1}function compileLanguage(s,{plugins:o}){function langRe(o,i){return new RegExp(source(o),\"m\"+(s.case_insensitive?\"i\":\"\")+(i?\"g\":\"\"))}class MultiRegex{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(s,o){o.position=this.position++,this.matchIndexes[this.matchAt]=o,this.regexes.push([o,s]),this.matchAt+=function countMatchGroups(s){return new RegExp(s.toString()+\"|\").exec(\"\").length-1}(s)+1}compile(){0===this.regexes.length&&(this.exec=()=>null);const s=this.regexes.map((s=>s[1]));this.matcherRe=langRe(function join(s,o=\"|\"){let i=0;return s.map((s=>{i+=1;const o=i;let u=source(s),_=\"\";for(;u.length>0;){const s=a.exec(u);if(!s){_+=u;break}_+=u.substring(0,s.index),u=u.substring(s.index+s[0].length),\"\\\\\"===s[0][0]&&s[1]?_+=\"\\\\\"+String(Number(s[1])+o):(_+=s[0],\"(\"===s[0]&&i++)}return _})).map((s=>`(${s})`)).join(o)}(s),!0),this.lastIndex=0}exec(s){this.matcherRe.lastIndex=this.lastIndex;const o=this.matcherRe.exec(s);if(!o)return null;const i=o.findIndex(((s,o)=>o>0&&void 0!==s)),a=this.matchIndexes[i];return o.splice(0,i),Object.assign(o,a)}}class ResumableMultiRegex{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(s){if(this.multiRegexes[s])return this.multiRegexes[s];const o=new MultiRegex;return this.rules.slice(s).forEach((([s,i])=>o.addRule(s,i))),o.compile(),this.multiRegexes[s]=o,o}resumingScanAtSamePosition(){return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(s,o){this.rules.push([s,o]),\"begin\"===o.type&&this.count++}exec(s){const o=this.getMatcher(this.regexIndex);o.lastIndex=this.lastIndex;let i=o.exec(s);if(this.resumingScanAtSamePosition())if(i&&i.index===this.lastIndex);else{const o=this.getMatcher(0);o.lastIndex=this.lastIndex+1,i=o.exec(s)}return i&&(this.regexIndex+=i.position+1,this.regexIndex===this.count&&this.considerAll()),i}}if(s.compilerExtensions||(s.compilerExtensions=[]),s.contains&&s.contains.includes(\"self\"))throw new Error(\"ERR: contains `self` is not supported at the top-level of a language.  See documentation.\");return s.classNameAliases=inherit(s.classNameAliases||{}),function compileMode(o,i){const a=o;if(o.isCompiled)return a;[compileMatch].forEach((s=>s(o,i))),s.compilerExtensions.forEach((s=>s(o,i))),o.__beforeBegin=null,[beginKeywords,compileIllegal,compileRelevance].forEach((s=>s(o,i))),o.isCompiled=!0;let u=null;if(\"object\"==typeof o.keywords&&(u=o.keywords.$pattern,delete o.keywords.$pattern),o.keywords&&(o.keywords=compileKeywords(o.keywords,s.case_insensitive)),o.lexemes&&u)throw new Error(\"ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) \");return u=u||o.lexemes||/\\w+/,a.keywordPatternRe=langRe(u,!0),i&&(o.begin||(o.begin=/\\B|\\b/),a.beginRe=langRe(o.begin),o.endSameAsBegin&&(o.end=o.begin),o.end||o.endsWithParent||(o.end=/\\B|\\b/),o.end&&(a.endRe=langRe(o.end)),a.terminatorEnd=source(o.end)||\"\",o.endsWithParent&&i.terminatorEnd&&(a.terminatorEnd+=(o.end?\"|\":\"\")+i.terminatorEnd)),o.illegal&&(a.illegalRe=langRe(o.illegal)),o.contains||(o.contains=[]),o.contains=[].concat(...o.contains.map((function(s){return function expandOrCloneMode(s){s.variants&&!s.cachedVariants&&(s.cachedVariants=s.variants.map((function(o){return inherit(s,{variants:null},o)})));if(s.cachedVariants)return s.cachedVariants;if(dependencyOnParent(s))return inherit(s,{starts:s.starts?inherit(s.starts):null});if(Object.isFrozen(s))return inherit(s);return s}(\"self\"===s?o:s)}))),o.contains.forEach((function(s){compileMode(s,a)})),o.starts&&compileMode(o.starts,i),a.matcher=function buildModeRegex(s){const o=new ResumableMultiRegex;return s.contains.forEach((s=>o.addRule(s.begin,{rule:s,type:\"begin\"}))),s.terminatorEnd&&o.addRule(s.terminatorEnd,{type:\"end\"}),s.illegal&&o.addRule(s.illegal,{type:\"illegal\"}),o}(a),a}(s)}function dependencyOnParent(s){return!!s&&(s.endsWithParent||dependencyOnParent(s.starts))}function BuildVuePlugin(s){const o={props:[\"language\",\"code\",\"autodetect\"],data:function(){return{detectedLanguage:\"\",unknownLanguage:!1}},computed:{className(){return this.unknownLanguage?\"\":\"hljs \"+this.detectedLanguage},highlighted(){if(!this.autoDetect&&!s.getLanguage(this.language))return console.warn(`The language \"${this.language}\" you specified could not be found.`),this.unknownLanguage=!0,escapeHTML(this.code);let o={};return this.autoDetect?(o=s.highlightAuto(this.code),this.detectedLanguage=o.language):(o=s.highlight(this.language,this.code,this.ignoreIllegals),this.detectedLanguage=this.language),o.value},autoDetect(){return!this.language||function hasValueOrEmptyAttribute(s){return Boolean(s||\"\"===s)}(this.autodetect)},ignoreIllegals:()=>!0},render(s){return s(\"pre\",{},[s(\"code\",{class:this.className,domProps:{innerHTML:this.highlighted}})])}};return{Component:o,VuePlugin:{install(s){s.component(\"highlightjs\",o)}}}}const ye={\"after:highlightElement\":({el:s,result:o,text:i})=>{const a=nodeStream(s);if(!a.length)return;const u=document.createElement(\"div\");u.innerHTML=o.value,o.value=function mergeStreams(s,o,i){let a=0,u=\"\";const _=[];function selectStream(){return s.length&&o.length?s[0].offset!==o[0].offset?s[0].offset<o[0].offset?s:o:\"start\"===o[0].event?s:o:s.length?s:o}function open(s){function attributeString(s){return\" \"+s.nodeName+'=\"'+escapeHTML(s.value)+'\"'}u+=\"<\"+tag(s)+[].map.call(s.attributes,attributeString).join(\"\")+\">\"}function close(s){u+=\"</\"+tag(s)+\">\"}function render(s){(\"start\"===s.event?open:close)(s.node)}for(;s.length||o.length;){let o=selectStream();if(u+=escapeHTML(i.substring(a,o[0].offset)),a=o[0].offset,o===s){_.reverse().forEach(close);do{render(o.splice(0,1)[0]),o=selectStream()}while(o===s&&o.length&&o[0].offset===a);_.reverse().forEach(open)}else\"start\"===o[0].event?_.push(o[0].node):_.pop(),render(o.splice(0,1)[0])}return u+escapeHTML(i.substr(a))}(a,nodeStream(u),i)}};function tag(s){return s.nodeName.toLowerCase()}function nodeStream(s){const o=[];return function _nodeStream(s,i){for(let a=s.firstChild;a;a=a.nextSibling)3===a.nodeType?i+=a.nodeValue.length:1===a.nodeType&&(o.push({event:\"start\",offset:i,node:a}),i=_nodeStream(a,i),tag(a).match(/br|hr|img|input/)||o.push({event:\"stop\",offset:i,node:a}));return i}(s,0),o}const be={},error=s=>{console.error(s)},warn=(s,...o)=>{console.log(`WARN: ${s}`,...o)},deprecated=(s,o)=>{be[`${s}/${o}`]||(console.log(`Deprecated as of ${s}. ${o}`),be[`${s}/${o}`]=!0)},_e=escapeHTML,Se=inherit,we=Symbol(\"nomatch\");var xe=function(s){const i=Object.create(null),a=Object.create(null),u=[];let _=!0;const w=/(^(<[^>]+>|\\t|)+|\\n)/gm,x=\"Could not find the language '{}', did you forget to load/include a language module?\",C={disableAutodetect:!0,name:\"Plain text\",contains:[]};let j={noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\\blang(?:uage)?-([\\w-]+)\\b/i,classPrefix:\"hljs-\",tabReplace:null,useBR:!1,languages:null,__emitter:TokenTreeEmitter};function shouldNotHighlight(s){return j.noHighlightRe.test(s)}function highlight(s,o,i,a){let u=\"\",_=\"\";\"object\"==typeof o?(u=s,i=o.ignoreIllegals,_=o.language,a=void 0):(deprecated(\"10.7.0\",\"highlight(lang, code, ...args) has been deprecated.\"),deprecated(\"10.7.0\",\"Please use highlight(code, options) instead.\\nhttps://github.com/highlightjs/highlight.js/issues/2277\"),_=s,u=o);const w={code:u,language:_};fire(\"before:highlight\",w);const x=w.result?w.result:_highlight(w.language,w.code,i,a);return x.code=w.code,fire(\"after:highlight\",x),x}function _highlight(s,o,a,w){function keywordData(s,o){const i=L.case_insensitive?o[0].toLowerCase():o[0];return Object.prototype.hasOwnProperty.call(s.keywords,i)&&s.keywords[i]}function processBuffer(){null!=U.subLanguage?function processSubLanguage(){if(\"\"===Y)return;let s=null;if(\"string\"==typeof U.subLanguage){if(!i[U.subLanguage])return void z.addText(Y);s=_highlight(U.subLanguage,Y,!0,V[U.subLanguage]),V[U.subLanguage]=s.top}else s=highlightAuto(Y,U.subLanguage.length?U.subLanguage:null);U.relevance>0&&(Z+=s.relevance),z.addSublanguage(s.emitter,s.language)}():function processKeywords(){if(!U.keywords)return void z.addText(Y);let s=0;U.keywordPatternRe.lastIndex=0;let o=U.keywordPatternRe.exec(Y),i=\"\";for(;o;){i+=Y.substring(s,o.index);const a=keywordData(U,o);if(a){const[s,u]=a;if(z.addText(i),i=\"\",Z+=u,s.startsWith(\"_\"))i+=o[0];else{const i=L.classNameAliases[s]||s;z.addKeyword(o[0],i)}}else i+=o[0];s=U.keywordPatternRe.lastIndex,o=U.keywordPatternRe.exec(Y)}i+=Y.substr(s),z.addText(i)}(),Y=\"\"}function startNewMode(s){return s.className&&z.openNode(L.classNameAliases[s.className]||s.className),U=Object.create(s,{parent:{value:U}}),U}function endOfMode(s,o,i){let a=function startsWith(s,o){const i=s&&s.exec(o);return i&&0===i.index}(s.endRe,i);if(a){if(s[\"on:end\"]){const i=new Response(s);s[\"on:end\"](o,i),i.isMatchIgnored&&(a=!1)}if(a){for(;s.endsParent&&s.parent;)s=s.parent;return s}}if(s.endsWithParent)return endOfMode(s.parent,o,i)}function doIgnore(s){return 0===U.matcher.regexIndex?(Y+=s[0],1):(ae=!0,0)}function doBeginMatch(s){const o=s[0],i=s.rule,a=new Response(i),u=[i.__beforeBegin,i[\"on:begin\"]];for(const i of u)if(i&&(i(s,a),a.isMatchIgnored))return doIgnore(o);return i&&i.endSameAsBegin&&(i.endRe=function escape(s){return new RegExp(s.replace(/[-/\\\\^$*+?.()|[\\]{}]/g,\"\\\\$&\"),\"m\")}(o)),i.skip?Y+=o:(i.excludeBegin&&(Y+=o),processBuffer(),i.returnBegin||i.excludeBegin||(Y=o)),startNewMode(i),i.returnBegin?0:o.length}function doEndMatch(s){const i=s[0],a=o.substr(s.index),u=endOfMode(U,s,a);if(!u)return we;const _=U;_.skip?Y+=i:(_.returnEnd||_.excludeEnd||(Y+=i),processBuffer(),_.excludeEnd&&(Y=i));do{U.className&&z.closeNode(),U.skip||U.subLanguage||(Z+=U.relevance),U=U.parent}while(U!==u.parent);return u.starts&&(u.endSameAsBegin&&(u.starts.endRe=u.endRe),startNewMode(u.starts)),_.returnEnd?0:i.length}let C={};function processLexeme(i,u){const w=u&&u[0];if(Y+=i,null==w)return processBuffer(),0;if(\"begin\"===C.type&&\"end\"===u.type&&C.index===u.index&&\"\"===w){if(Y+=o.slice(u.index,u.index+1),!_){const o=new Error(\"0 width match regex\");throw o.languageName=s,o.badRule=C.rule,o}return 1}if(C=u,\"begin\"===u.type)return doBeginMatch(u);if(\"illegal\"===u.type&&!a){const s=new Error('Illegal lexeme \"'+w+'\" for mode \"'+(U.className||\"<unnamed>\")+'\"');throw s.mode=U,s}if(\"end\"===u.type){const s=doEndMatch(u);if(s!==we)return s}if(\"illegal\"===u.type&&\"\"===w)return 1;if(ie>1e5&&ie>3*u.index){throw new Error(\"potential infinite loop, way more iterations than matches\")}return Y+=w,w.length}const L=getLanguage(s);if(!L)throw error(x.replace(\"{}\",s)),new Error('Unknown language: \"'+s+'\"');const B=compileLanguage(L,{plugins:u});let $=\"\",U=w||B;const V={},z=new j.__emitter(j);!function processContinuations(){const s=[];for(let o=U;o!==L;o=o.parent)o.className&&s.unshift(o.className);s.forEach((s=>z.openNode(s)))}();let Y=\"\",Z=0,ee=0,ie=0,ae=!1;try{for(U.matcher.considerAll();;){ie++,ae?ae=!1:U.matcher.considerAll(),U.matcher.lastIndex=ee;const s=U.matcher.exec(o);if(!s)break;const i=processLexeme(o.substring(ee,s.index),s);ee=s.index+i}return processLexeme(o.substr(ee)),z.closeAllNodes(),z.finalize(),$=z.toHTML(),{relevance:Math.floor(Z),value:$,language:s,illegal:!1,emitter:z,top:U}}catch(i){if(i.message&&i.message.includes(\"Illegal\"))return{illegal:!0,illegalBy:{msg:i.message,context:o.slice(ee-100,ee+100),mode:i.mode},sofar:$,relevance:0,value:_e(o),emitter:z};if(_)return{illegal:!1,relevance:0,value:_e(o),emitter:z,language:s,top:U,errorRaised:i};throw i}}function highlightAuto(s,o){o=o||j.languages||Object.keys(i);const a=function justTextHighlightResult(s){const o={relevance:0,emitter:new j.__emitter(j),value:_e(s),illegal:!1,top:C};return o.emitter.addText(s),o}(s),u=o.filter(getLanguage).filter(autoDetection).map((o=>_highlight(o,s,!1)));u.unshift(a);const _=u.sort(((s,o)=>{if(s.relevance!==o.relevance)return o.relevance-s.relevance;if(s.language&&o.language){if(getLanguage(s.language).supersetOf===o.language)return 1;if(getLanguage(o.language).supersetOf===s.language)return-1}return 0})),[w,x]=_,L=w;return L.second_best=x,L}const L={\"before:highlightElement\":({el:s})=>{j.useBR&&(s.innerHTML=s.innerHTML.replace(/\\n/g,\"\").replace(/<br[ /]*>/g,\"\\n\"))},\"after:highlightElement\":({result:s})=>{j.useBR&&(s.value=s.value.replace(/\\n/g,\"<br>\"))}},B=/^(<[^>]+>|\\t)+/gm,$={\"after:highlightElement\":({result:s})=>{j.tabReplace&&(s.value=s.value.replace(B,(s=>s.replace(/\\t/g,j.tabReplace))))}};function highlightElement(s){let o=null;const i=function blockLanguage(s){let o=s.className+\" \";o+=s.parentNode?s.parentNode.className:\"\";const i=j.languageDetectRe.exec(o);if(i){const o=getLanguage(i[1]);return o||(warn(x.replace(\"{}\",i[1])),warn(\"Falling back to no-highlight mode for this block.\",s)),o?i[1]:\"no-highlight\"}return o.split(/\\s+/).find((s=>shouldNotHighlight(s)||getLanguage(s)))}(s);if(shouldNotHighlight(i))return;fire(\"before:highlightElement\",{el:s,language:i}),o=s;const u=o.textContent,_=i?highlight(u,{language:i,ignoreIllegals:!0}):highlightAuto(u);fire(\"after:highlightElement\",{el:s,result:_,text:u}),s.innerHTML=_.value,function updateClassName(s,o,i){const u=o?a[o]:i;s.classList.add(\"hljs\"),u&&s.classList.add(u)}(s,i,_.language),s.result={language:_.language,re:_.relevance,relavance:_.relevance},_.second_best&&(s.second_best={language:_.second_best.language,re:_.second_best.relevance,relavance:_.second_best.relevance})}const initHighlighting=()=>{if(initHighlighting.called)return;initHighlighting.called=!0,deprecated(\"10.6.0\",\"initHighlighting() is deprecated.  Use highlightAll() instead.\");document.querySelectorAll(\"pre code\").forEach(highlightElement)};let U=!1;function highlightAll(){if(\"loading\"===document.readyState)return void(U=!0);document.querySelectorAll(\"pre code\").forEach(highlightElement)}function getLanguage(s){return s=(s||\"\").toLowerCase(),i[s]||i[a[s]]}function registerAliases(s,{languageName:o}){\"string\"==typeof s&&(s=[s]),s.forEach((s=>{a[s.toLowerCase()]=o}))}function autoDetection(s){const o=getLanguage(s);return o&&!o.disableAutodetect}function fire(s,o){const i=s;u.forEach((function(s){s[i]&&s[i](o)}))}\"undefined\"!=typeof window&&window.addEventListener&&window.addEventListener(\"DOMContentLoaded\",(function boot(){U&&highlightAll()}),!1),Object.assign(s,{highlight,highlightAuto,highlightAll,fixMarkup:function deprecateFixMarkup(s){return deprecated(\"10.2.0\",\"fixMarkup will be removed entirely in v11.0\"),deprecated(\"10.2.0\",\"Please see https://github.com/highlightjs/highlight.js/issues/2534\"),function fixMarkup(s){return j.tabReplace||j.useBR?s.replace(w,(s=>\"\\n\"===s?j.useBR?\"<br>\":s:j.tabReplace?s.replace(/\\t/g,j.tabReplace):s)):s}(s)},highlightElement,highlightBlock:function deprecateHighlightBlock(s){return deprecated(\"10.7.0\",\"highlightBlock will be removed entirely in v12.0\"),deprecated(\"10.7.0\",\"Please use highlightElement now.\"),highlightElement(s)},configure:function configure(s){s.useBR&&(deprecated(\"10.3.0\",\"'useBR' will be removed entirely in v11.0\"),deprecated(\"10.3.0\",\"Please see https://github.com/highlightjs/highlight.js/issues/2559\")),j=Se(j,s)},initHighlighting,initHighlightingOnLoad:function initHighlightingOnLoad(){deprecated(\"10.6.0\",\"initHighlightingOnLoad() is deprecated.  Use highlightAll() instead.\"),U=!0},registerLanguage:function registerLanguage(o,a){let u=null;try{u=a(s)}catch(s){if(error(\"Language definition for '{}' could not be registered.\".replace(\"{}\",o)),!_)throw s;error(s),u=C}u.name||(u.name=o),i[o]=u,u.rawDefinition=a.bind(null,s),u.aliases&&registerAliases(u.aliases,{languageName:o})},unregisterLanguage:function unregisterLanguage(s){delete i[s];for(const o of Object.keys(a))a[o]===s&&delete a[o]},listLanguages:function listLanguages(){return Object.keys(i)},getLanguage,registerAliases,requireLanguage:function requireLanguage(s){deprecated(\"10.4.0\",\"requireLanguage will be removed entirely in v11.\"),deprecated(\"10.4.0\",\"Please see https://github.com/highlightjs/highlight.js/pull/2844\");const o=getLanguage(s);if(o)return o;throw new Error(\"The '{}' language is required, but not loaded.\".replace(\"{}\",s))},autoDetection,inherit:Se,addPlugin:function addPlugin(s){!function upgradePluginAPI(s){s[\"before:highlightBlock\"]&&!s[\"before:highlightElement\"]&&(s[\"before:highlightElement\"]=o=>{s[\"before:highlightBlock\"](Object.assign({block:o.el},o))}),s[\"after:highlightBlock\"]&&!s[\"after:highlightElement\"]&&(s[\"after:highlightElement\"]=o=>{s[\"after:highlightBlock\"](Object.assign({block:o.el},o))})}(s),u.push(s)},vuePlugin:BuildVuePlugin(s).VuePlugin}),s.debugMode=function(){_=!1},s.safeMode=function(){_=!0},s.versionString=\"10.7.3\";for(const s in de)\"object\"==typeof de[s]&&o(de[s]);return Object.assign(s,de),s.addPlugin(L),s.addPlugin(ye),s.addPlugin($),s}({});s.exports=xe},46028:(s,o,i)=>{\"use strict\";var a=i(13930),u=i(46285),_=i(25594),w=i(29367),x=i(60581),C=i(76264),j=TypeError,L=C(\"toPrimitive\");s.exports=function(s,o){if(!u(s)||_(s))return s;var i,C=w(s,L);if(C){if(void 0===o&&(o=\"default\"),i=a(C,s,o),!u(i)||_(i))return i;throw new j(\"Can't convert object to primitive value\")}return void 0===o&&(o=\"number\"),x(s,o)}},46076:(s,o,i)=>{\"use strict\";i(91599);var a=i(68623);s.exports=a},46285:(s,o,i)=>{\"use strict\";var a=i(62250);s.exports=function(s){return\"object\"==typeof s?null!==s:a(s)}},46942:(s,o)=>{var i;!function(){\"use strict\";var a={}.hasOwnProperty;function classNames(){for(var s=\"\",o=0;o<arguments.length;o++){var i=arguments[o];i&&(s=appendClass(s,parseValue(i)))}return s}function parseValue(s){if(\"string\"==typeof s||\"number\"==typeof s)return s;if(\"object\"!=typeof s)return\"\";if(Array.isArray(s))return classNames.apply(null,s);if(s.toString!==Object.prototype.toString&&!s.toString.toString().includes(\"[native code]\"))return s.toString();var o=\"\";for(var i in s)a.call(s,i)&&s[i]&&(o=appendClass(o,i));return o}function appendClass(s,o){return o?s?s+\" \"+o:s+o:s}s.exports?(classNames.default=classNames,s.exports=classNames):void 0===(i=function(){return classNames}.apply(o,[]))||(s.exports=i)}()},47119:s=>{\"use strict\";s.exports=\"undefined\"!=typeof Reflect&&Reflect&&Reflect.apply},47181:(s,o,i)=>{\"use strict\";var a=i(95116).IteratorPrototype,u=i(58075),_=i(75817),w=i(14840),x=i(93742),returnThis=function(){return this};s.exports=function(s,o,i,C){var j=o+\" Iterator\";return s.prototype=u(a,{next:_(+!C,i)}),w(s,j,!1,!0),x[j]=returnThis,s}},47237:s=>{s.exports=function baseProperty(s){return function(o){return null==o?void 0:o[s]}}},47248:(s,o,i)=>{var a=i(16547),u=i(51234);s.exports=function zipObject(s,o){return u(s||[],o||[],a)}},47422:(s,o,i)=>{var a=i(31769),u=i(77797);s.exports=function baseGet(s,o){for(var i=0,_=(o=a(o,s)).length;null!=s&&i<_;)s=s[u(o[i++])];return i&&i==_?s:void 0}},47473:s=>{var o=Function.prototype.toString;s.exports=function toSource(s){if(null!=s){try{return o.call(s)}catch(s){}try{return s+\"\"}catch(s){}}return\"\"}},47886:(s,o,i)=>{var a=i(5861),u=i(40346);s.exports=function isWeakMap(s){return u(s)&&\"[object WeakMap]\"==a(s)}},47934:(s,o,i)=>{s.exports={ary:i(64626),assign:i(74733),clone:i(32629),curry:i(49747),forEach:i(83729),isArray:i(56449),isError:i(23546),isFunction:i(1882),isWeakMap:i(47886),iteratee:i(33855),keys:i(88984),rearg:i(84195),toInteger:i(61489),toPath:i(42072)}},48152:(s,o,i)=>{var a=i(28303),u=a&&new a;s.exports=u},48287:(s,o,i)=>{\"use strict\";const a=i(67526),u=i(251),_=\"function\"==typeof Symbol&&\"function\"==typeof Symbol.for?Symbol.for(\"nodejs.util.inspect.custom\"):null;o.Buffer=Buffer,o.SlowBuffer=function SlowBuffer(s){+s!=s&&(s=0);return Buffer.alloc(+s)},o.INSPECT_MAX_BYTES=50;const w=2147483647;function createBuffer(s){if(s>w)throw new RangeError('The value \"'+s+'\" is invalid for option \"size\"');const o=new Uint8Array(s);return Object.setPrototypeOf(o,Buffer.prototype),o}function Buffer(s,o,i){if(\"number\"==typeof s){if(\"string\"==typeof o)throw new TypeError('The \"string\" argument must be of type string. Received type number');return allocUnsafe(s)}return from(s,o,i)}function from(s,o,i){if(\"string\"==typeof s)return function fromString(s,o){\"string\"==typeof o&&\"\"!==o||(o=\"utf8\");if(!Buffer.isEncoding(o))throw new TypeError(\"Unknown encoding: \"+o);const i=0|byteLength(s,o);let a=createBuffer(i);const u=a.write(s,o);u!==i&&(a=a.slice(0,u));return a}(s,o);if(ArrayBuffer.isView(s))return function fromArrayView(s){if(isInstance(s,Uint8Array)){const o=new Uint8Array(s);return fromArrayBuffer(o.buffer,o.byteOffset,o.byteLength)}return fromArrayLike(s)}(s);if(null==s)throw new TypeError(\"The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type \"+typeof s);if(isInstance(s,ArrayBuffer)||s&&isInstance(s.buffer,ArrayBuffer))return fromArrayBuffer(s,o,i);if(\"undefined\"!=typeof SharedArrayBuffer&&(isInstance(s,SharedArrayBuffer)||s&&isInstance(s.buffer,SharedArrayBuffer)))return fromArrayBuffer(s,o,i);if(\"number\"==typeof s)throw new TypeError('The \"value\" argument must not be of type number. Received type number');const a=s.valueOf&&s.valueOf();if(null!=a&&a!==s)return Buffer.from(a,o,i);const u=function fromObject(s){if(Buffer.isBuffer(s)){const o=0|checked(s.length),i=createBuffer(o);return 0===i.length||s.copy(i,0,0,o),i}if(void 0!==s.length)return\"number\"!=typeof s.length||numberIsNaN(s.length)?createBuffer(0):fromArrayLike(s);if(\"Buffer\"===s.type&&Array.isArray(s.data))return fromArrayLike(s.data)}(s);if(u)return u;if(\"undefined\"!=typeof Symbol&&null!=Symbol.toPrimitive&&\"function\"==typeof s[Symbol.toPrimitive])return Buffer.from(s[Symbol.toPrimitive](\"string\"),o,i);throw new TypeError(\"The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type \"+typeof s)}function assertSize(s){if(\"number\"!=typeof s)throw new TypeError('\"size\" argument must be of type number');if(s<0)throw new RangeError('The value \"'+s+'\" is invalid for option \"size\"')}function allocUnsafe(s){return assertSize(s),createBuffer(s<0?0:0|checked(s))}function fromArrayLike(s){const o=s.length<0?0:0|checked(s.length),i=createBuffer(o);for(let a=0;a<o;a+=1)i[a]=255&s[a];return i}function fromArrayBuffer(s,o,i){if(o<0||s.byteLength<o)throw new RangeError('\"offset\" is outside of buffer bounds');if(s.byteLength<o+(i||0))throw new RangeError('\"length\" is outside of buffer bounds');let a;return a=void 0===o&&void 0===i?new Uint8Array(s):void 0===i?new Uint8Array(s,o):new Uint8Array(s,o,i),Object.setPrototypeOf(a,Buffer.prototype),a}function checked(s){if(s>=w)throw new RangeError(\"Attempt to allocate Buffer larger than maximum size: 0x\"+w.toString(16)+\" bytes\");return 0|s}function byteLength(s,o){if(Buffer.isBuffer(s))return s.length;if(ArrayBuffer.isView(s)||isInstance(s,ArrayBuffer))return s.byteLength;if(\"string\"!=typeof s)throw new TypeError('The \"string\" argument must be one of type string, Buffer, or ArrayBuffer. Received type '+typeof s);const i=s.length,a=arguments.length>2&&!0===arguments[2];if(!a&&0===i)return 0;let u=!1;for(;;)switch(o){case\"ascii\":case\"latin1\":case\"binary\":return i;case\"utf8\":case\"utf-8\":return utf8ToBytes(s).length;case\"ucs2\":case\"ucs-2\":case\"utf16le\":case\"utf-16le\":return 2*i;case\"hex\":return i>>>1;case\"base64\":return base64ToBytes(s).length;default:if(u)return a?-1:utf8ToBytes(s).length;o=(\"\"+o).toLowerCase(),u=!0}}function slowToString(s,o,i){let a=!1;if((void 0===o||o<0)&&(o=0),o>this.length)return\"\";if((void 0===i||i>this.length)&&(i=this.length),i<=0)return\"\";if((i>>>=0)<=(o>>>=0))return\"\";for(s||(s=\"utf8\");;)switch(s){case\"hex\":return hexSlice(this,o,i);case\"utf8\":case\"utf-8\":return utf8Slice(this,o,i);case\"ascii\":return asciiSlice(this,o,i);case\"latin1\":case\"binary\":return latin1Slice(this,o,i);case\"base64\":return base64Slice(this,o,i);case\"ucs2\":case\"ucs-2\":case\"utf16le\":case\"utf-16le\":return utf16leSlice(this,o,i);default:if(a)throw new TypeError(\"Unknown encoding: \"+s);s=(s+\"\").toLowerCase(),a=!0}}function swap(s,o,i){const a=s[o];s[o]=s[i],s[i]=a}function bidirectionalIndexOf(s,o,i,a,u){if(0===s.length)return-1;if(\"string\"==typeof i?(a=i,i=0):i>2147483647?i=2147483647:i<-2147483648&&(i=-2147483648),numberIsNaN(i=+i)&&(i=u?0:s.length-1),i<0&&(i=s.length+i),i>=s.length){if(u)return-1;i=s.length-1}else if(i<0){if(!u)return-1;i=0}if(\"string\"==typeof o&&(o=Buffer.from(o,a)),Buffer.isBuffer(o))return 0===o.length?-1:arrayIndexOf(s,o,i,a,u);if(\"number\"==typeof o)return o&=255,\"function\"==typeof Uint8Array.prototype.indexOf?u?Uint8Array.prototype.indexOf.call(s,o,i):Uint8Array.prototype.lastIndexOf.call(s,o,i):arrayIndexOf(s,[o],i,a,u);throw new TypeError(\"val must be string, number or Buffer\")}function arrayIndexOf(s,o,i,a,u){let _,w=1,x=s.length,C=o.length;if(void 0!==a&&(\"ucs2\"===(a=String(a).toLowerCase())||\"ucs-2\"===a||\"utf16le\"===a||\"utf-16le\"===a)){if(s.length<2||o.length<2)return-1;w=2,x/=2,C/=2,i/=2}function read(s,o){return 1===w?s[o]:s.readUInt16BE(o*w)}if(u){let a=-1;for(_=i;_<x;_++)if(read(s,_)===read(o,-1===a?0:_-a)){if(-1===a&&(a=_),_-a+1===C)return a*w}else-1!==a&&(_-=_-a),a=-1}else for(i+C>x&&(i=x-C),_=i;_>=0;_--){let i=!0;for(let a=0;a<C;a++)if(read(s,_+a)!==read(o,a)){i=!1;break}if(i)return _}return-1}function hexWrite(s,o,i,a){i=Number(i)||0;const u=s.length-i;a?(a=Number(a))>u&&(a=u):a=u;const _=o.length;let w;for(a>_/2&&(a=_/2),w=0;w<a;++w){const a=parseInt(o.substr(2*w,2),16);if(numberIsNaN(a))return w;s[i+w]=a}return w}function utf8Write(s,o,i,a){return blitBuffer(utf8ToBytes(o,s.length-i),s,i,a)}function asciiWrite(s,o,i,a){return blitBuffer(function asciiToBytes(s){const o=[];for(let i=0;i<s.length;++i)o.push(255&s.charCodeAt(i));return o}(o),s,i,a)}function base64Write(s,o,i,a){return blitBuffer(base64ToBytes(o),s,i,a)}function ucs2Write(s,o,i,a){return blitBuffer(function utf16leToBytes(s,o){let i,a,u;const _=[];for(let w=0;w<s.length&&!((o-=2)<0);++w)i=s.charCodeAt(w),a=i>>8,u=i%256,_.push(u),_.push(a);return _}(o,s.length-i),s,i,a)}function base64Slice(s,o,i){return 0===o&&i===s.length?a.fromByteArray(s):a.fromByteArray(s.slice(o,i))}function utf8Slice(s,o,i){i=Math.min(s.length,i);const a=[];let u=o;for(;u<i;){const o=s[u];let _=null,w=o>239?4:o>223?3:o>191?2:1;if(u+w<=i){let i,a,x,C;switch(w){case 1:o<128&&(_=o);break;case 2:i=s[u+1],128==(192&i)&&(C=(31&o)<<6|63&i,C>127&&(_=C));break;case 3:i=s[u+1],a=s[u+2],128==(192&i)&&128==(192&a)&&(C=(15&o)<<12|(63&i)<<6|63&a,C>2047&&(C<55296||C>57343)&&(_=C));break;case 4:i=s[u+1],a=s[u+2],x=s[u+3],128==(192&i)&&128==(192&a)&&128==(192&x)&&(C=(15&o)<<18|(63&i)<<12|(63&a)<<6|63&x,C>65535&&C<1114112&&(_=C))}}null===_?(_=65533,w=1):_>65535&&(_-=65536,a.push(_>>>10&1023|55296),_=56320|1023&_),a.push(_),u+=w}return function decodeCodePointsArray(s){const o=s.length;if(o<=x)return String.fromCharCode.apply(String,s);let i=\"\",a=0;for(;a<o;)i+=String.fromCharCode.apply(String,s.slice(a,a+=x));return i}(a)}o.kMaxLength=w,Buffer.TYPED_ARRAY_SUPPORT=function typedArraySupport(){try{const s=new Uint8Array(1),o={foo:function(){return 42}};return Object.setPrototypeOf(o,Uint8Array.prototype),Object.setPrototypeOf(s,o),42===s.foo()}catch(s){return!1}}(),Buffer.TYPED_ARRAY_SUPPORT||\"undefined\"==typeof console||\"function\"!=typeof console.error||console.error(\"This browser lacks typed array (Uint8Array) support which is required by `buffer` v5.x. Use `buffer` v4.x if you require old browser support.\"),Object.defineProperty(Buffer.prototype,\"parent\",{enumerable:!0,get:function(){if(Buffer.isBuffer(this))return this.buffer}}),Object.defineProperty(Buffer.prototype,\"offset\",{enumerable:!0,get:function(){if(Buffer.isBuffer(this))return this.byteOffset}}),Buffer.poolSize=8192,Buffer.from=function(s,o,i){return from(s,o,i)},Object.setPrototypeOf(Buffer.prototype,Uint8Array.prototype),Object.setPrototypeOf(Buffer,Uint8Array),Buffer.alloc=function(s,o,i){return function alloc(s,o,i){return assertSize(s),s<=0?createBuffer(s):void 0!==o?\"string\"==typeof i?createBuffer(s).fill(o,i):createBuffer(s).fill(o):createBuffer(s)}(s,o,i)},Buffer.allocUnsafe=function(s){return allocUnsafe(s)},Buffer.allocUnsafeSlow=function(s){return allocUnsafe(s)},Buffer.isBuffer=function isBuffer(s){return null!=s&&!0===s._isBuffer&&s!==Buffer.prototype},Buffer.compare=function compare(s,o){if(isInstance(s,Uint8Array)&&(s=Buffer.from(s,s.offset,s.byteLength)),isInstance(o,Uint8Array)&&(o=Buffer.from(o,o.offset,o.byteLength)),!Buffer.isBuffer(s)||!Buffer.isBuffer(o))throw new TypeError('The \"buf1\", \"buf2\" arguments must be one of type Buffer or Uint8Array');if(s===o)return 0;let i=s.length,a=o.length;for(let u=0,_=Math.min(i,a);u<_;++u)if(s[u]!==o[u]){i=s[u],a=o[u];break}return i<a?-1:a<i?1:0},Buffer.isEncoding=function isEncoding(s){switch(String(s).toLowerCase()){case\"hex\":case\"utf8\":case\"utf-8\":case\"ascii\":case\"latin1\":case\"binary\":case\"base64\":case\"ucs2\":case\"ucs-2\":case\"utf16le\":case\"utf-16le\":return!0;default:return!1}},Buffer.concat=function concat(s,o){if(!Array.isArray(s))throw new TypeError('\"list\" argument must be an Array of Buffers');if(0===s.length)return Buffer.alloc(0);let i;if(void 0===o)for(o=0,i=0;i<s.length;++i)o+=s[i].length;const a=Buffer.allocUnsafe(o);let u=0;for(i=0;i<s.length;++i){let o=s[i];if(isInstance(o,Uint8Array))u+o.length>a.length?(Buffer.isBuffer(o)||(o=Buffer.from(o)),o.copy(a,u)):Uint8Array.prototype.set.call(a,o,u);else{if(!Buffer.isBuffer(o))throw new TypeError('\"list\" argument must be an Array of Buffers');o.copy(a,u)}u+=o.length}return a},Buffer.byteLength=byteLength,Buffer.prototype._isBuffer=!0,Buffer.prototype.swap16=function swap16(){const s=this.length;if(s%2!=0)throw new RangeError(\"Buffer size must be a multiple of 16-bits\");for(let o=0;o<s;o+=2)swap(this,o,o+1);return this},Buffer.prototype.swap32=function swap32(){const s=this.length;if(s%4!=0)throw new RangeError(\"Buffer size must be a multiple of 32-bits\");for(let o=0;o<s;o+=4)swap(this,o,o+3),swap(this,o+1,o+2);return this},Buffer.prototype.swap64=function swap64(){const s=this.length;if(s%8!=0)throw new RangeError(\"Buffer size must be a multiple of 64-bits\");for(let o=0;o<s;o+=8)swap(this,o,o+7),swap(this,o+1,o+6),swap(this,o+2,o+5),swap(this,o+3,o+4);return this},Buffer.prototype.toString=function toString(){const s=this.length;return 0===s?\"\":0===arguments.length?utf8Slice(this,0,s):slowToString.apply(this,arguments)},Buffer.prototype.toLocaleString=Buffer.prototype.toString,Buffer.prototype.equals=function equals(s){if(!Buffer.isBuffer(s))throw new TypeError(\"Argument must be a Buffer\");return this===s||0===Buffer.compare(this,s)},Buffer.prototype.inspect=function inspect(){let s=\"\";const i=o.INSPECT_MAX_BYTES;return s=this.toString(\"hex\",0,i).replace(/(.{2})/g,\"$1 \").trim(),this.length>i&&(s+=\" ... \"),\"<Buffer \"+s+\">\"},_&&(Buffer.prototype[_]=Buffer.prototype.inspect),Buffer.prototype.compare=function compare(s,o,i,a,u){if(isInstance(s,Uint8Array)&&(s=Buffer.from(s,s.offset,s.byteLength)),!Buffer.isBuffer(s))throw new TypeError('The \"target\" argument must be one of type Buffer or Uint8Array. Received type '+typeof s);if(void 0===o&&(o=0),void 0===i&&(i=s?s.length:0),void 0===a&&(a=0),void 0===u&&(u=this.length),o<0||i>s.length||a<0||u>this.length)throw new RangeError(\"out of range index\");if(a>=u&&o>=i)return 0;if(a>=u)return-1;if(o>=i)return 1;if(this===s)return 0;let _=(u>>>=0)-(a>>>=0),w=(i>>>=0)-(o>>>=0);const x=Math.min(_,w),C=this.slice(a,u),j=s.slice(o,i);for(let s=0;s<x;++s)if(C[s]!==j[s]){_=C[s],w=j[s];break}return _<w?-1:w<_?1:0},Buffer.prototype.includes=function includes(s,o,i){return-1!==this.indexOf(s,o,i)},Buffer.prototype.indexOf=function indexOf(s,o,i){return bidirectionalIndexOf(this,s,o,i,!0)},Buffer.prototype.lastIndexOf=function lastIndexOf(s,o,i){return bidirectionalIndexOf(this,s,o,i,!1)},Buffer.prototype.write=function write(s,o,i,a){if(void 0===o)a=\"utf8\",i=this.length,o=0;else if(void 0===i&&\"string\"==typeof o)a=o,i=this.length,o=0;else{if(!isFinite(o))throw new Error(\"Buffer.write(string, encoding, offset[, length]) is no longer supported\");o>>>=0,isFinite(i)?(i>>>=0,void 0===a&&(a=\"utf8\")):(a=i,i=void 0)}const u=this.length-o;if((void 0===i||i>u)&&(i=u),s.length>0&&(i<0||o<0)||o>this.length)throw new RangeError(\"Attempt to write outside buffer bounds\");a||(a=\"utf8\");let _=!1;for(;;)switch(a){case\"hex\":return hexWrite(this,s,o,i);case\"utf8\":case\"utf-8\":return utf8Write(this,s,o,i);case\"ascii\":case\"latin1\":case\"binary\":return asciiWrite(this,s,o,i);case\"base64\":return base64Write(this,s,o,i);case\"ucs2\":case\"ucs-2\":case\"utf16le\":case\"utf-16le\":return ucs2Write(this,s,o,i);default:if(_)throw new TypeError(\"Unknown encoding: \"+a);a=(\"\"+a).toLowerCase(),_=!0}},Buffer.prototype.toJSON=function toJSON(){return{type:\"Buffer\",data:Array.prototype.slice.call(this._arr||this,0)}};const x=4096;function asciiSlice(s,o,i){let a=\"\";i=Math.min(s.length,i);for(let u=o;u<i;++u)a+=String.fromCharCode(127&s[u]);return a}function latin1Slice(s,o,i){let a=\"\";i=Math.min(s.length,i);for(let u=o;u<i;++u)a+=String.fromCharCode(s[u]);return a}function hexSlice(s,o,i){const a=s.length;(!o||o<0)&&(o=0),(!i||i<0||i>a)&&(i=a);let u=\"\";for(let a=o;a<i;++a)u+=L[s[a]];return u}function utf16leSlice(s,o,i){const a=s.slice(o,i);let u=\"\";for(let s=0;s<a.length-1;s+=2)u+=String.fromCharCode(a[s]+256*a[s+1]);return u}function checkOffset(s,o,i){if(s%1!=0||s<0)throw new RangeError(\"offset is not uint\");if(s+o>i)throw new RangeError(\"Trying to access beyond buffer length\")}function checkInt(s,o,i,a,u,_){if(!Buffer.isBuffer(s))throw new TypeError('\"buffer\" argument must be a Buffer instance');if(o>u||o<_)throw new RangeError('\"value\" argument is out of bounds');if(i+a>s.length)throw new RangeError(\"Index out of range\")}function wrtBigUInt64LE(s,o,i,a,u){checkIntBI(o,a,u,s,i,7);let _=Number(o&BigInt(4294967295));s[i++]=_,_>>=8,s[i++]=_,_>>=8,s[i++]=_,_>>=8,s[i++]=_;let w=Number(o>>BigInt(32)&BigInt(4294967295));return s[i++]=w,w>>=8,s[i++]=w,w>>=8,s[i++]=w,w>>=8,s[i++]=w,i}function wrtBigUInt64BE(s,o,i,a,u){checkIntBI(o,a,u,s,i,7);let _=Number(o&BigInt(4294967295));s[i+7]=_,_>>=8,s[i+6]=_,_>>=8,s[i+5]=_,_>>=8,s[i+4]=_;let w=Number(o>>BigInt(32)&BigInt(4294967295));return s[i+3]=w,w>>=8,s[i+2]=w,w>>=8,s[i+1]=w,w>>=8,s[i]=w,i+8}function checkIEEE754(s,o,i,a,u,_){if(i+a>s.length)throw new RangeError(\"Index out of range\");if(i<0)throw new RangeError(\"Index out of range\")}function writeFloat(s,o,i,a,_){return o=+o,i>>>=0,_||checkIEEE754(s,0,i,4),u.write(s,o,i,a,23,4),i+4}function writeDouble(s,o,i,a,_){return o=+o,i>>>=0,_||checkIEEE754(s,0,i,8),u.write(s,o,i,a,52,8),i+8}Buffer.prototype.slice=function slice(s,o){const i=this.length;(s=~~s)<0?(s+=i)<0&&(s=0):s>i&&(s=i),(o=void 0===o?i:~~o)<0?(o+=i)<0&&(o=0):o>i&&(o=i),o<s&&(o=s);const a=this.subarray(s,o);return Object.setPrototypeOf(a,Buffer.prototype),a},Buffer.prototype.readUintLE=Buffer.prototype.readUIntLE=function readUIntLE(s,o,i){s>>>=0,o>>>=0,i||checkOffset(s,o,this.length);let a=this[s],u=1,_=0;for(;++_<o&&(u*=256);)a+=this[s+_]*u;return a},Buffer.prototype.readUintBE=Buffer.prototype.readUIntBE=function readUIntBE(s,o,i){s>>>=0,o>>>=0,i||checkOffset(s,o,this.length);let a=this[s+--o],u=1;for(;o>0&&(u*=256);)a+=this[s+--o]*u;return a},Buffer.prototype.readUint8=Buffer.prototype.readUInt8=function readUInt8(s,o){return s>>>=0,o||checkOffset(s,1,this.length),this[s]},Buffer.prototype.readUint16LE=Buffer.prototype.readUInt16LE=function readUInt16LE(s,o){return s>>>=0,o||checkOffset(s,2,this.length),this[s]|this[s+1]<<8},Buffer.prototype.readUint16BE=Buffer.prototype.readUInt16BE=function readUInt16BE(s,o){return s>>>=0,o||checkOffset(s,2,this.length),this[s]<<8|this[s+1]},Buffer.prototype.readUint32LE=Buffer.prototype.readUInt32LE=function readUInt32LE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),(this[s]|this[s+1]<<8|this[s+2]<<16)+16777216*this[s+3]},Buffer.prototype.readUint32BE=Buffer.prototype.readUInt32BE=function readUInt32BE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),16777216*this[s]+(this[s+1]<<16|this[s+2]<<8|this[s+3])},Buffer.prototype.readBigUInt64LE=defineBigIntMethod((function readBigUInt64LE(s){validateNumber(s>>>=0,\"offset\");const o=this[s],i=this[s+7];void 0!==o&&void 0!==i||boundsError(s,this.length-8);const a=o+256*this[++s]+65536*this[++s]+this[++s]*2**24,u=this[++s]+256*this[++s]+65536*this[++s]+i*2**24;return BigInt(a)+(BigInt(u)<<BigInt(32))})),Buffer.prototype.readBigUInt64BE=defineBigIntMethod((function readBigUInt64BE(s){validateNumber(s>>>=0,\"offset\");const o=this[s],i=this[s+7];void 0!==o&&void 0!==i||boundsError(s,this.length-8);const a=o*2**24+65536*this[++s]+256*this[++s]+this[++s],u=this[++s]*2**24+65536*this[++s]+256*this[++s]+i;return(BigInt(a)<<BigInt(32))+BigInt(u)})),Buffer.prototype.readIntLE=function readIntLE(s,o,i){s>>>=0,o>>>=0,i||checkOffset(s,o,this.length);let a=this[s],u=1,_=0;for(;++_<o&&(u*=256);)a+=this[s+_]*u;return u*=128,a>=u&&(a-=Math.pow(2,8*o)),a},Buffer.prototype.readIntBE=function readIntBE(s,o,i){s>>>=0,o>>>=0,i||checkOffset(s,o,this.length);let a=o,u=1,_=this[s+--a];for(;a>0&&(u*=256);)_+=this[s+--a]*u;return u*=128,_>=u&&(_-=Math.pow(2,8*o)),_},Buffer.prototype.readInt8=function readInt8(s,o){return s>>>=0,o||checkOffset(s,1,this.length),128&this[s]?-1*(255-this[s]+1):this[s]},Buffer.prototype.readInt16LE=function readInt16LE(s,o){s>>>=0,o||checkOffset(s,2,this.length);const i=this[s]|this[s+1]<<8;return 32768&i?4294901760|i:i},Buffer.prototype.readInt16BE=function readInt16BE(s,o){s>>>=0,o||checkOffset(s,2,this.length);const i=this[s+1]|this[s]<<8;return 32768&i?4294901760|i:i},Buffer.prototype.readInt32LE=function readInt32LE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),this[s]|this[s+1]<<8|this[s+2]<<16|this[s+3]<<24},Buffer.prototype.readInt32BE=function readInt32BE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),this[s]<<24|this[s+1]<<16|this[s+2]<<8|this[s+3]},Buffer.prototype.readBigInt64LE=defineBigIntMethod((function readBigInt64LE(s){validateNumber(s>>>=0,\"offset\");const o=this[s],i=this[s+7];void 0!==o&&void 0!==i||boundsError(s,this.length-8);const a=this[s+4]+256*this[s+5]+65536*this[s+6]+(i<<24);return(BigInt(a)<<BigInt(32))+BigInt(o+256*this[++s]+65536*this[++s]+this[++s]*2**24)})),Buffer.prototype.readBigInt64BE=defineBigIntMethod((function readBigInt64BE(s){validateNumber(s>>>=0,\"offset\");const o=this[s],i=this[s+7];void 0!==o&&void 0!==i||boundsError(s,this.length-8);const a=(o<<24)+65536*this[++s]+256*this[++s]+this[++s];return(BigInt(a)<<BigInt(32))+BigInt(this[++s]*2**24+65536*this[++s]+256*this[++s]+i)})),Buffer.prototype.readFloatLE=function readFloatLE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),u.read(this,s,!0,23,4)},Buffer.prototype.readFloatBE=function readFloatBE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),u.read(this,s,!1,23,4)},Buffer.prototype.readDoubleLE=function readDoubleLE(s,o){return s>>>=0,o||checkOffset(s,8,this.length),u.read(this,s,!0,52,8)},Buffer.prototype.readDoubleBE=function readDoubleBE(s,o){return s>>>=0,o||checkOffset(s,8,this.length),u.read(this,s,!1,52,8)},Buffer.prototype.writeUintLE=Buffer.prototype.writeUIntLE=function writeUIntLE(s,o,i,a){if(s=+s,o>>>=0,i>>>=0,!a){checkInt(this,s,o,i,Math.pow(2,8*i)-1,0)}let u=1,_=0;for(this[o]=255&s;++_<i&&(u*=256);)this[o+_]=s/u&255;return o+i},Buffer.prototype.writeUintBE=Buffer.prototype.writeUIntBE=function writeUIntBE(s,o,i,a){if(s=+s,o>>>=0,i>>>=0,!a){checkInt(this,s,o,i,Math.pow(2,8*i)-1,0)}let u=i-1,_=1;for(this[o+u]=255&s;--u>=0&&(_*=256);)this[o+u]=s/_&255;return o+i},Buffer.prototype.writeUint8=Buffer.prototype.writeUInt8=function writeUInt8(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,1,255,0),this[o]=255&s,o+1},Buffer.prototype.writeUint16LE=Buffer.prototype.writeUInt16LE=function writeUInt16LE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,2,65535,0),this[o]=255&s,this[o+1]=s>>>8,o+2},Buffer.prototype.writeUint16BE=Buffer.prototype.writeUInt16BE=function writeUInt16BE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,2,65535,0),this[o]=s>>>8,this[o+1]=255&s,o+2},Buffer.prototype.writeUint32LE=Buffer.prototype.writeUInt32LE=function writeUInt32LE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,4,4294967295,0),this[o+3]=s>>>24,this[o+2]=s>>>16,this[o+1]=s>>>8,this[o]=255&s,o+4},Buffer.prototype.writeUint32BE=Buffer.prototype.writeUInt32BE=function writeUInt32BE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,4,4294967295,0),this[o]=s>>>24,this[o+1]=s>>>16,this[o+2]=s>>>8,this[o+3]=255&s,o+4},Buffer.prototype.writeBigUInt64LE=defineBigIntMethod((function writeBigUInt64LE(s,o=0){return wrtBigUInt64LE(this,s,o,BigInt(0),BigInt(\"0xffffffffffffffff\"))})),Buffer.prototype.writeBigUInt64BE=defineBigIntMethod((function writeBigUInt64BE(s,o=0){return wrtBigUInt64BE(this,s,o,BigInt(0),BigInt(\"0xffffffffffffffff\"))})),Buffer.prototype.writeIntLE=function writeIntLE(s,o,i,a){if(s=+s,o>>>=0,!a){const a=Math.pow(2,8*i-1);checkInt(this,s,o,i,a-1,-a)}let u=0,_=1,w=0;for(this[o]=255&s;++u<i&&(_*=256);)s<0&&0===w&&0!==this[o+u-1]&&(w=1),this[o+u]=(s/_|0)-w&255;return o+i},Buffer.prototype.writeIntBE=function writeIntBE(s,o,i,a){if(s=+s,o>>>=0,!a){const a=Math.pow(2,8*i-1);checkInt(this,s,o,i,a-1,-a)}let u=i-1,_=1,w=0;for(this[o+u]=255&s;--u>=0&&(_*=256);)s<0&&0===w&&0!==this[o+u+1]&&(w=1),this[o+u]=(s/_|0)-w&255;return o+i},Buffer.prototype.writeInt8=function writeInt8(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,1,127,-128),s<0&&(s=255+s+1),this[o]=255&s,o+1},Buffer.prototype.writeInt16LE=function writeInt16LE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,2,32767,-32768),this[o]=255&s,this[o+1]=s>>>8,o+2},Buffer.prototype.writeInt16BE=function writeInt16BE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,2,32767,-32768),this[o]=s>>>8,this[o+1]=255&s,o+2},Buffer.prototype.writeInt32LE=function writeInt32LE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,4,2147483647,-2147483648),this[o]=255&s,this[o+1]=s>>>8,this[o+2]=s>>>16,this[o+3]=s>>>24,o+4},Buffer.prototype.writeInt32BE=function writeInt32BE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,4,2147483647,-2147483648),s<0&&(s=4294967295+s+1),this[o]=s>>>24,this[o+1]=s>>>16,this[o+2]=s>>>8,this[o+3]=255&s,o+4},Buffer.prototype.writeBigInt64LE=defineBigIntMethod((function writeBigInt64LE(s,o=0){return wrtBigUInt64LE(this,s,o,-BigInt(\"0x8000000000000000\"),BigInt(\"0x7fffffffffffffff\"))})),Buffer.prototype.writeBigInt64BE=defineBigIntMethod((function writeBigInt64BE(s,o=0){return wrtBigUInt64BE(this,s,o,-BigInt(\"0x8000000000000000\"),BigInt(\"0x7fffffffffffffff\"))})),Buffer.prototype.writeFloatLE=function writeFloatLE(s,o,i){return writeFloat(this,s,o,!0,i)},Buffer.prototype.writeFloatBE=function writeFloatBE(s,o,i){return writeFloat(this,s,o,!1,i)},Buffer.prototype.writeDoubleLE=function writeDoubleLE(s,o,i){return writeDouble(this,s,o,!0,i)},Buffer.prototype.writeDoubleBE=function writeDoubleBE(s,o,i){return writeDouble(this,s,o,!1,i)},Buffer.prototype.copy=function copy(s,o,i,a){if(!Buffer.isBuffer(s))throw new TypeError(\"argument should be a Buffer\");if(i||(i=0),a||0===a||(a=this.length),o>=s.length&&(o=s.length),o||(o=0),a>0&&a<i&&(a=i),a===i)return 0;if(0===s.length||0===this.length)return 0;if(o<0)throw new RangeError(\"targetStart out of bounds\");if(i<0||i>=this.length)throw new RangeError(\"Index out of range\");if(a<0)throw new RangeError(\"sourceEnd out of bounds\");a>this.length&&(a=this.length),s.length-o<a-i&&(a=s.length-o+i);const u=a-i;return this===s&&\"function\"==typeof Uint8Array.prototype.copyWithin?this.copyWithin(o,i,a):Uint8Array.prototype.set.call(s,this.subarray(i,a),o),u},Buffer.prototype.fill=function fill(s,o,i,a){if(\"string\"==typeof s){if(\"string\"==typeof o?(a=o,o=0,i=this.length):\"string\"==typeof i&&(a=i,i=this.length),void 0!==a&&\"string\"!=typeof a)throw new TypeError(\"encoding must be a string\");if(\"string\"==typeof a&&!Buffer.isEncoding(a))throw new TypeError(\"Unknown encoding: \"+a);if(1===s.length){const o=s.charCodeAt(0);(\"utf8\"===a&&o<128||\"latin1\"===a)&&(s=o)}}else\"number\"==typeof s?s&=255:\"boolean\"==typeof s&&(s=Number(s));if(o<0||this.length<o||this.length<i)throw new RangeError(\"Out of range index\");if(i<=o)return this;let u;if(o>>>=0,i=void 0===i?this.length:i>>>0,s||(s=0),\"number\"==typeof s)for(u=o;u<i;++u)this[u]=s;else{const _=Buffer.isBuffer(s)?s:Buffer.from(s,a),w=_.length;if(0===w)throw new TypeError('The value \"'+s+'\" is invalid for argument \"value\"');for(u=0;u<i-o;++u)this[u+o]=_[u%w]}return this};const C={};function E(s,o,i){C[s]=class NodeError extends i{constructor(){super(),Object.defineProperty(this,\"message\",{value:o.apply(this,arguments),writable:!0,configurable:!0}),this.name=`${this.name} [${s}]`,this.stack,delete this.name}get code(){return s}set code(s){Object.defineProperty(this,\"code\",{configurable:!0,enumerable:!0,value:s,writable:!0})}toString(){return`${this.name} [${s}]: ${this.message}`}}}function addNumericalSeparator(s){let o=\"\",i=s.length;const a=\"-\"===s[0]?1:0;for(;i>=a+4;i-=3)o=`_${s.slice(i-3,i)}${o}`;return`${s.slice(0,i)}${o}`}function checkIntBI(s,o,i,a,u,_){if(s>i||s<o){const a=\"bigint\"==typeof o?\"n\":\"\";let u;throw u=_>3?0===o||o===BigInt(0)?`>= 0${a} and < 2${a} ** ${8*(_+1)}${a}`:`>= -(2${a} ** ${8*(_+1)-1}${a}) and < 2 ** ${8*(_+1)-1}${a}`:`>= ${o}${a} and <= ${i}${a}`,new C.ERR_OUT_OF_RANGE(\"value\",u,s)}!function checkBounds(s,o,i){validateNumber(o,\"offset\"),void 0!==s[o]&&void 0!==s[o+i]||boundsError(o,s.length-(i+1))}(a,u,_)}function validateNumber(s,o){if(\"number\"!=typeof s)throw new C.ERR_INVALID_ARG_TYPE(o,\"number\",s)}function boundsError(s,o,i){if(Math.floor(s)!==s)throw validateNumber(s,i),new C.ERR_OUT_OF_RANGE(i||\"offset\",\"an integer\",s);if(o<0)throw new C.ERR_BUFFER_OUT_OF_BOUNDS;throw new C.ERR_OUT_OF_RANGE(i||\"offset\",`>= ${i?1:0} and <= ${o}`,s)}E(\"ERR_BUFFER_OUT_OF_BOUNDS\",(function(s){return s?`${s} is outside of buffer bounds`:\"Attempt to access memory outside buffer bounds\"}),RangeError),E(\"ERR_INVALID_ARG_TYPE\",(function(s,o){return`The \"${s}\" argument must be of type number. Received type ${typeof o}`}),TypeError),E(\"ERR_OUT_OF_RANGE\",(function(s,o,i){let a=`The value of \"${s}\" is out of range.`,u=i;return Number.isInteger(i)&&Math.abs(i)>2**32?u=addNumericalSeparator(String(i)):\"bigint\"==typeof i&&(u=String(i),(i>BigInt(2)**BigInt(32)||i<-(BigInt(2)**BigInt(32)))&&(u=addNumericalSeparator(u)),u+=\"n\"),a+=` It must be ${o}. Received ${u}`,a}),RangeError);const j=/[^+/0-9A-Za-z-_]/g;function utf8ToBytes(s,o){let i;o=o||1/0;const a=s.length;let u=null;const _=[];for(let w=0;w<a;++w){if(i=s.charCodeAt(w),i>55295&&i<57344){if(!u){if(i>56319){(o-=3)>-1&&_.push(239,191,189);continue}if(w+1===a){(o-=3)>-1&&_.push(239,191,189);continue}u=i;continue}if(i<56320){(o-=3)>-1&&_.push(239,191,189),u=i;continue}i=65536+(u-55296<<10|i-56320)}else u&&(o-=3)>-1&&_.push(239,191,189);if(u=null,i<128){if((o-=1)<0)break;_.push(i)}else if(i<2048){if((o-=2)<0)break;_.push(i>>6|192,63&i|128)}else if(i<65536){if((o-=3)<0)break;_.push(i>>12|224,i>>6&63|128,63&i|128)}else{if(!(i<1114112))throw new Error(\"Invalid code point\");if((o-=4)<0)break;_.push(i>>18|240,i>>12&63|128,i>>6&63|128,63&i|128)}}return _}function base64ToBytes(s){return a.toByteArray(function base64clean(s){if((s=(s=s.split(\"=\")[0]).trim().replace(j,\"\")).length<2)return\"\";for(;s.length%4!=0;)s+=\"=\";return s}(s))}function blitBuffer(s,o,i,a){let u;for(u=0;u<a&&!(u+i>=o.length||u>=s.length);++u)o[u+i]=s[u];return u}function isInstance(s,o){return s instanceof o||null!=s&&null!=s.constructor&&null!=s.constructor.name&&s.constructor.name===o.name}function numberIsNaN(s){return s!=s}const L=function(){const s=\"0123456789abcdef\",o=new Array(256);for(let i=0;i<16;++i){const a=16*i;for(let u=0;u<16;++u)o[a+u]=s[i]+s[u]}return o}();function defineBigIntMethod(s){return\"undefined\"==typeof BigInt?BufferBigIntNotDefined:s}function BufferBigIntNotDefined(){throw new Error(\"BigInt not supported\")}},48590:(s,o)=>{\"use strict\";Object.defineProperty(o,\"__esModule\",{value:!0}),o.default=function(s){return s&&\"@@redux/INIT\"===s.type?\"initialState argument passed to createStore\":\"previous state received by the reducer\"},s.exports=o.default},48648:s=>{\"use strict\";s.exports=\"undefined\"!=typeof Reflect&&Reflect.getPrototypeOf||null},48655:(s,o,i)=>{var a=i(26025);s.exports=function listCacheHas(s){return a(this.__data__,s)>-1}},48675:(s,o,i)=>{s.exports=i(20850)},48948:(s,o,i)=>{var a=i(21791),u=i(86375);s.exports=function copySymbolsIn(s,o){return a(s,u(s),o)}},49092:(s,o,i)=>{\"use strict\";var a=i(41333);s.exports=function hasToStringTagShams(){return a()&&!!Symbol.toStringTag}},49326:(s,o,i)=>{var a=i(31769),u=i(72428),_=i(56449),w=i(30361),x=i(30294),C=i(77797);s.exports=function hasPath(s,o,i){for(var j=-1,L=(o=a(o,s)).length,B=!1;++j<L;){var $=C(o[j]);if(!(B=null!=s&&i(s,$)))break;s=s[$]}return B||++j!=L?B:!!(L=null==s?0:s.length)&&x(L)&&w($,L)&&(_(s)||u(s))}},49552:(s,o,i)=>{\"use strict\";var a=i(45951),u=i(46285),_=a.document,w=u(_)&&u(_.createElement);s.exports=function(s){return w?_.createElement(s):{}}},49653:(s,o,i)=>{var a=i(37828);s.exports=function cloneArrayBuffer(s){var o=new s.constructor(s.byteLength);return new a(o).set(new a(s)),o}},49698:s=>{var o=RegExp(\"[\\\\u200d\\\\ud800-\\\\udfff\\\\u0300-\\\\u036f\\\\ufe20-\\\\ufe2f\\\\u20d0-\\\\u20ff\\\\ufe0e\\\\ufe0f]\");s.exports=function hasUnicode(s){return o.test(s)}},49724:(s,o,i)=>{\"use strict\";var a=i(1907),u=i(39298),_=a({}.hasOwnProperty);s.exports=Object.hasOwn||function hasOwn(s,o){return _(u(s),o)}},49747:(s,o,i)=>{var a=i(66977);function curry(s,o,i){var u=a(s,8,void 0,void 0,void 0,void 0,void 0,o=i?void 0:o);return u.placeholder=curry.placeholder,u}curry.placeholder={},s.exports=curry},50002:(s,o,i)=>{var a=i(82199),u=i(4664),_=i(95950);s.exports=function getAllKeys(s){return a(s,_,u)}},50104:(s,o,i)=>{var a=i(53661);function memoize(s,o){if(\"function\"!=typeof s||null!=o&&\"function\"!=typeof o)throw new TypeError(\"Expected a function\");var memoized=function(){var i=arguments,a=o?o.apply(this,i):i[0],u=memoized.cache;if(u.has(a))return u.get(a);var _=s.apply(this,i);return memoized.cache=u.set(a,_)||u,_};return memoized.cache=new(memoize.Cache||a),memoized}memoize.Cache=a,s.exports=memoize},50583:(s,o,i)=>{var a=i(47237),u=i(17255),_=i(28586),w=i(77797);s.exports=function property(s){return _(s)?a(w(s)):u(s)}},50689:(s,o,i)=>{var a=i(50002),u=Object.prototype.hasOwnProperty;s.exports=function equalObjects(s,o,i,_,w,x){var C=1&i,j=a(s),L=j.length;if(L!=a(o).length&&!C)return!1;for(var B=L;B--;){var $=j[B];if(!(C?$ in o:u.call(o,$)))return!1}var U=x.get(s),V=x.get(o);if(U&&V)return U==o&&V==s;var z=!0;x.set(s,o),x.set(o,s);for(var Y=C;++B<L;){var Z=s[$=j[B]],ee=o[$];if(_)var ie=C?_(ee,Z,$,o,s,x):_(Z,ee,$,s,o,x);if(!(void 0===ie?Z===ee||w(Z,ee,i,_,x):ie)){z=!1;break}Y||(Y=\"constructor\"==$)}if(z&&!Y){var ae=s.constructor,ce=o.constructor;ae==ce||!(\"constructor\"in s)||!(\"constructor\"in o)||\"function\"==typeof ae&&ae instanceof ae&&\"function\"==typeof ce&&ce instanceof ce||(z=!1)}return x.delete(s),x.delete(o),z}},50828:(s,o,i)=>{var a=i(24647),u=i(13222),_=/[\\xc0-\\xd6\\xd8-\\xf6\\xf8-\\xff\\u0100-\\u017f]/g,w=RegExp(\"[\\\\u0300-\\\\u036f\\\\ufe20-\\\\ufe2f\\\\u20d0-\\\\u20ff]\",\"g\");s.exports=function deburr(s){return(s=u(s))&&s.replace(_,a).replace(w,\"\")}},51175:(s,o,i)=>{\"use strict\";var a=i(19846);s.exports=a&&!Symbol.sham&&\"symbol\"==typeof Symbol.iterator},51234:s=>{s.exports=function baseZipObject(s,o,i){for(var a=-1,u=s.length,_=o.length,w={};++a<u;){var x=a<_?o[a]:void 0;i(w,s[a],x)}return w}},51420:(s,o,i)=>{var a=i(80079);s.exports=function stackClear(){this.__data__=new a,this.size=0}},51459:s=>{s.exports=function setCacheHas(s){return this.__data__.has(s)}},51811:s=>{var o=Date.now;s.exports=function shortOut(s){var i=0,a=0;return function(){var u=o(),_=16-(u-a);if(a=u,_>0){if(++i>=800)return arguments[0]}else i=0;return s.apply(void 0,arguments)}}},51871:(s,o,i)=>{\"use strict\";var a=i(1907),u=i(82159);s.exports=function(s,o,i){try{return a(u(Object.getOwnPropertyDescriptor(s,o)[i]))}catch(s){}}},51873:(s,o,i)=>{var a=i(9325).Symbol;s.exports=a},52623:(s,o,i)=>{\"use strict\";var a={};a[i(76264)(\"toStringTag\")]=\"z\",s.exports=\"[object z]\"===String(a)},53138:(s,o,i)=>{var a=i(11331);s.exports=function customOmitClone(s){return a(s)?void 0:s}},53209:(s,o,i)=>{\"use strict\";var a=i(65606),u=65536,_=4294967295;var w=i(92861).Buffer,x=i.g.crypto||i.g.msCrypto;x&&x.getRandomValues?s.exports=function randomBytes(s,o){if(s>_)throw new RangeError(\"requested too many random bytes\");var i=w.allocUnsafe(s);if(s>0)if(s>u)for(var C=0;C<s;C+=u)x.getRandomValues(i.slice(C,C+u));else x.getRandomValues(i);if(\"function\"==typeof o)return a.nextTick((function(){o(null,i)}));return i}:s.exports=function oldBrowser(){throw new Error(\"Secure random number generation is not supported by this browser.\\nUse Chrome, Firefox or Internet Explorer 11\")}},53320:s=>{var o=Math.max;s.exports=function composeArgsRight(s,i,a,u){for(var _=-1,w=s.length,x=-1,C=a.length,j=-1,L=i.length,B=o(w-C,0),$=Array(B+L),U=!u;++_<B;)$[_]=s[_];for(var V=_;++j<L;)$[V+j]=i[j];for(;++x<C;)(U||_<w)&&($[V+a[x]]=s[_++]);return $}},53375:(s,o,i)=>{\"use strict\";var a=i(93700);s.exports=a},53661:(s,o,i)=>{var a=i(63040),u=i(17670),_=i(90289),w=i(4509),x=i(72949);function MapCache(s){var o=-1,i=null==s?0:s.length;for(this.clear();++o<i;){var a=s[o];this.set(a[0],a[1])}}MapCache.prototype.clear=a,MapCache.prototype.delete=u,MapCache.prototype.get=_,MapCache.prototype.has=w,MapCache.prototype.set=x,s.exports=MapCache},53758:(s,o,i)=>{var a=i(30980),u=i(56017),_=i(94033),w=i(56449),x=i(40346),C=i(80257),j=Object.prototype.hasOwnProperty;function lodash(s){if(x(s)&&!w(s)&&!(s instanceof a)){if(s instanceof u)return s;if(j.call(s,\"__wrapped__\"))return C(s)}return new u(s)}lodash.prototype=_.prototype,lodash.prototype.constructor=lodash,s.exports=lodash},53812:(s,o,i)=>{var a=i(72552),u=i(40346);s.exports=function isBoolean(s){return!0===s||!1===s||u(s)&&\"[object Boolean]\"==a(s)}},54018:(s,o,i)=>{\"use strict\";var a=i(46285);s.exports=function(s){return a(s)||null===s}},54128:(s,o,i)=>{var a=i(31800),u=/^\\s+/;s.exports=function baseTrim(s){return s?s.slice(0,a(s)+1).replace(u,\"\"):s}},54552:s=>{s.exports=function basePropertyOf(s){return function(o){return null==s?void 0:s[o]}}},54641:(s,o,i)=>{var a=i(68882),u=i(51811)(a);s.exports=u},54829:(s,o,i)=>{\"use strict\";var a=i(74284).f;s.exports=function(s,o,i){i in s||a(s,i,{configurable:!0,get:function(){return o[i]},set:function(s){o[i]=s}})}},54878:(s,o,i)=>{\"use strict\";var a=i(52623),u=i(73948);s.exports=a?{}.toString:function toString(){return\"[object \"+u(this)+\"]\"}},55157:s=>{s.exports=function(){throw new Error(\"Readable.from is not available in the browser\")}},55364:(s,o,i)=>{var a=i(85250),u=i(20999)((function(s,o,i){a(s,o,i)}));s.exports=u},55481:(s,o,i)=>{var a=i(9325)[\"__core-js_shared__\"];s.exports=a},55527:s=>{var o=Object.prototype;s.exports=function isPrototype(s){var i=s&&s.constructor;return s===(\"function\"==typeof i&&i.prototype||o)}},55580:(s,o,i)=>{var a=i(56110)(i(9325),\"DataView\");s.exports=a},55674:(s,o,i)=>{\"use strict\";Object.defineProperty(o,\"__esModule\",{value:!0}),o.validateNextState=o.getUnexpectedInvocationParameterMessage=o.getStateName=void 0;var a=_interopRequireDefault(i(48590)),u=_interopRequireDefault(i(82261)),_=_interopRequireDefault(i(27374));function _interopRequireDefault(s){return s&&s.__esModule?s:{default:s}}o.getStateName=a.default,o.getUnexpectedInvocationParameterMessage=u.default,o.validateNextState=_.default},55808:(s,o,i)=>{var a=i(12507)(\"toUpperCase\");s.exports=a},55973:s=>{class KeyValuePair{constructor(s,o){this.key=s,this.value=o}clone(){const s=new KeyValuePair;return this.key&&(s.key=this.key.clone()),this.value&&(s.value=this.value.clone()),s}}s.exports=KeyValuePair},56017:(s,o,i)=>{var a=i(39344),u=i(94033);function LodashWrapper(s,o){this.__wrapped__=s,this.__actions__=[],this.__chain__=!!o,this.__index__=0,this.__values__=void 0}LodashWrapper.prototype=a(u.prototype),LodashWrapper.prototype.constructor=LodashWrapper,s.exports=LodashWrapper},56110:(s,o,i)=>{var a=i(45083),u=i(10392);s.exports=function getNative(s,o){var i=u(s,o);return a(i)?i:void 0}},56367:(s,o,i)=>{s.exports=i(77731)},56449:s=>{var o=Array.isArray;s.exports=o},56698:s=>{\"function\"==typeof Object.create?s.exports=function inherits(s,o){o&&(s.super_=o,s.prototype=Object.create(o.prototype,{constructor:{value:s,enumerable:!1,writable:!0,configurable:!0}}))}:s.exports=function inherits(s,o){if(o){s.super_=o;var TempCtor=function(){};TempCtor.prototype=o.prototype,s.prototype=new TempCtor,s.prototype.constructor=s}}},56757:(s,o,i)=>{var a=i(91033),u=Math.max;s.exports=function overRest(s,o,i){return o=u(void 0===o?s.length-1:o,0),function(){for(var _=arguments,w=-1,x=u(_.length-o,0),C=Array(x);++w<x;)C[w]=_[o+w];w=-1;for(var j=Array(o+1);++w<o;)j[w]=_[w];return j[o]=i(C),a(s,this,j)}}},57382:(s,o,i)=>{\"use strict\";var a=i(98828);s.exports=!a((function(){function F(){}return F.prototype.constructor=null,Object.getPrototypeOf(new F)!==F.prototype}))},57758:(s,o,i)=>{\"use strict\";var a;var u=i(86048).F,_=u.ERR_MISSING_ARGS,w=u.ERR_STREAM_DESTROYED;function noop(s){if(s)throw s}function call(s){s()}function pipe(s,o){return s.pipe(o)}s.exports=function pipeline(){for(var s=arguments.length,o=new Array(s),u=0;u<s;u++)o[u]=arguments[u];var x,C=function popCallback(s){return s.length?\"function\"!=typeof s[s.length-1]?noop:s.pop():noop}(o);if(Array.isArray(o[0])&&(o=o[0]),o.length<2)throw new _(\"streams\");var j=o.map((function(s,u){var _=u<o.length-1;return function destroyer(s,o,u,_){_=function once(s){var o=!1;return function(){o||(o=!0,s.apply(void 0,arguments))}}(_);var x=!1;s.on(\"close\",(function(){x=!0})),void 0===a&&(a=i(86238)),a(s,{readable:o,writable:u},(function(s){if(s)return _(s);x=!0,_()}));var C=!1;return function(o){if(!x&&!C)return C=!0,function isRequest(s){return s.setHeader&&\"function\"==typeof s.abort}(s)?s.abort():\"function\"==typeof s.destroy?s.destroy():void _(o||new w(\"pipe\"))}}(s,_,u>0,(function(s){x||(x=s),s&&j.forEach(call),_||(j.forEach(call),C(x))}))}));return o.reduce(pipe)}},58068:s=>{\"use strict\";s.exports=SyntaxError},58075:(s,o,i)=>{\"use strict\";var a,u=i(36624),_=i(42220),w=i(80376),x=i(38530),C=i(62416),j=i(49552),L=i(92522),B=\"prototype\",$=\"script\",U=L(\"IE_PROTO\"),EmptyConstructor=function(){},scriptTag=function(s){return\"<\"+$+\">\"+s+\"</\"+$+\">\"},NullProtoObjectViaActiveX=function(s){s.write(scriptTag(\"\")),s.close();var o=s.parentWindow.Object;return s=null,o},NullProtoObject=function(){try{a=new ActiveXObject(\"htmlfile\")}catch(s){}var s,o,i;NullProtoObject=\"undefined\"!=typeof document?document.domain&&a?NullProtoObjectViaActiveX(a):(o=j(\"iframe\"),i=\"java\"+$+\":\",o.style.display=\"none\",C.appendChild(o),o.src=String(i),(s=o.contentWindow.document).open(),s.write(scriptTag(\"document.F=Object\")),s.close(),s.F):NullProtoObjectViaActiveX(a);for(var u=w.length;u--;)delete NullProtoObject[B][w[u]];return NullProtoObject()};x[U]=!0,s.exports=Object.create||function create(s,o){var i;return null!==s?(EmptyConstructor[B]=u(s),i=new EmptyConstructor,EmptyConstructor[B]=null,i[U]=s):i=NullProtoObject(),void 0===o?i:_.f(i,o)}},58156:(s,o,i)=>{var a=i(47422);s.exports=function get(s,o,i){var u=null==s?void 0:a(s,o);return void 0===u?i:u}},58523:s=>{s.exports=function countHolders(s,o){for(var i=s.length,a=0;i--;)s[i]===o&&++a;return a}},58661:(s,o,i)=>{\"use strict\";var a=i(39447),u=i(98828);s.exports=a&&u((function(){return 42!==Object.defineProperty((function(){}),\"prototype\",{value:42,writable:!1}).prototype}))},58968:s=>{\"use strict\";s.exports=Math.floor},59350:s=>{var o=Object.prototype.toString;s.exports=function objectToString(s){return o.call(s)}},59399:(s,o,i)=>{\"use strict\";var a=i(25264).CopyToClipboard;a.CopyToClipboard=a,s.exports=a},59550:s=>{\"use strict\";s.exports=function(s,o){return{value:s,done:o}}},60183:(s,o,i)=>{\"use strict\";var a=i(11091),u=i(13930),_=i(7376),w=i(36833),x=i(62250),C=i(47181),j=i(15972),L=i(79192),B=i(14840),$=i(61626),U=i(68055),V=i(76264),z=i(93742),Y=i(95116),Z=w.PROPER,ee=w.CONFIGURABLE,ie=Y.IteratorPrototype,ae=Y.BUGGY_SAFARI_ITERATORS,ce=V(\"iterator\"),le=\"keys\",pe=\"values\",de=\"entries\",returnThis=function(){return this};s.exports=function(s,o,i,w,V,Y,fe){C(i,o,w);var ye,be,_e,getIterationMethod=function(s){if(s===V&&Te)return Te;if(!ae&&s&&s in xe)return xe[s];switch(s){case le:return function keys(){return new i(this,s)};case pe:return function values(){return new i(this,s)};case de:return function entries(){return new i(this,s)}}return function(){return new i(this)}},Se=o+\" Iterator\",we=!1,xe=s.prototype,Pe=xe[ce]||xe[\"@@iterator\"]||V&&xe[V],Te=!ae&&Pe||getIterationMethod(V),Re=\"Array\"===o&&xe.entries||Pe;if(Re&&(ye=j(Re.call(new s)))!==Object.prototype&&ye.next&&(_||j(ye)===ie||(L?L(ye,ie):x(ye[ce])||U(ye,ce,returnThis)),B(ye,Se,!0,!0),_&&(z[Se]=returnThis)),Z&&V===pe&&Pe&&Pe.name!==pe&&(!_&&ee?$(xe,\"name\",pe):(we=!0,Te=function values(){return u(Pe,this)})),V)if(be={values:getIterationMethod(pe),keys:Y?Te:getIterationMethod(le),entries:getIterationMethod(de)},fe)for(_e in be)(ae||we||!(_e in xe))&&U(xe,_e,be[_e]);else a({target:o,proto:!0,forced:ae||we},be);return _&&!fe||xe[ce]===Te||U(xe,ce,Te,{name:V}),z[o]=Te,be}},60270:(s,o,i)=>{var a=i(87068),u=i(40346);s.exports=function baseIsEqual(s,o,i,_,w){return s===o||(null==s||null==o||!u(s)&&!u(o)?s!=s&&o!=o:a(s,o,i,_,baseIsEqual,w))}},60581:(s,o,i)=>{\"use strict\";var a=i(13930),u=i(62250),_=i(46285),w=TypeError;s.exports=function(s,o){var i,x;if(\"string\"===o&&u(i=s.toString)&&!_(x=a(i,s)))return x;if(u(i=s.valueOf)&&!_(x=a(i,s)))return x;if(\"string\"!==o&&u(i=s.toString)&&!_(x=a(i,s)))return x;throw new w(\"Can't convert object to primitive value\")}},60680:(s,o,i)=>{var a=i(13222),u=/[\\\\^$.*+?()[\\]{}|]/g,_=RegExp(u.source);s.exports=function escapeRegExp(s){return(s=a(s))&&_.test(s)?s.replace(u,\"\\\\$&\"):s}},61045:(s,o,i)=>{const a=i(6048),u=i(23805),_=i(6233),w=i(87726),x=i(10866);s.exports=class ObjectElement extends _{constructor(s,o,i){super(s||[],o,i),this.element=\"object\"}primitive(){return\"object\"}toValue(){return this.content.reduce(((s,o)=>(s[o.key.toValue()]=o.value?o.value.toValue():void 0,s)),{})}get(s){const o=this.getMember(s);if(o)return o.value}getMember(s){if(void 0!==s)return this.content.find((o=>o.key.toValue()===s))}remove(s){let o=null;return this.content=this.content.filter((i=>i.key.toValue()!==s||(o=i,!1))),o}getKey(s){const o=this.getMember(s);if(o)return o.key}set(s,o){if(u(s))return Object.keys(s).forEach((o=>{this.set(o,s[o])})),this;const i=s,a=this.getMember(i);return a?a.value=o:this.content.push(new w(i,o)),this}keys(){return this.content.map((s=>s.key.toValue()))}values(){return this.content.map((s=>s.value.toValue()))}hasKey(s){return this.content.some((o=>o.key.equals(s)))}items(){return this.content.map((s=>[s.key.toValue(),s.value.toValue()]))}map(s,o){return this.content.map((i=>s.bind(o)(i.value,i.key,i)))}compactMap(s,o){const i=[];return this.forEach(((a,u,_)=>{const w=s.bind(o)(a,u,_);w&&i.push(w)})),i}filter(s,o){return new x(this.content).filter(s,o)}reject(s,o){return this.filter(a(s),o)}forEach(s,o){return this.content.forEach((i=>s.bind(o)(i.value,i.key,i)))}}},61074:s=>{s.exports=function asciiToArray(s){return s.split(\"\")}},61160:(s,o,i)=>{\"use strict\";var a=i(92063),u=i(73992),_=/^[\\x00-\\x20\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]+/,w=/[\\n\\r\\t]/g,x=/^[A-Za-z][A-Za-z0-9+-.]*:\\/\\//,C=/:\\d+$/,j=/^([a-z][a-z0-9.+-]*:)?(\\/\\/)?([\\\\/]+)?([\\S\\s]*)/i,L=/^[a-zA-Z]:/;function trimLeft(s){return(s||\"\").toString().replace(_,\"\")}var B=[[\"#\",\"hash\"],[\"?\",\"query\"],function sanitize(s,o){return isSpecial(o.protocol)?s.replace(/\\\\/g,\"/\"):s},[\"/\",\"pathname\"],[\"@\",\"auth\",1],[NaN,\"host\",void 0,1,1],[/:(\\d*)$/,\"port\",void 0,1],[NaN,\"hostname\",void 0,1,1]],$={hash:1,query:1};function lolcation(s){var o,a=(\"undefined\"!=typeof window?window:void 0!==i.g?i.g:\"undefined\"!=typeof self?self:{}).location||{},u={},_=typeof(s=s||a);if(\"blob:\"===s.protocol)u=new Url(unescape(s.pathname),{});else if(\"string\"===_)for(o in u=new Url(s,{}),$)delete u[o];else if(\"object\"===_){for(o in s)o in $||(u[o]=s[o]);void 0===u.slashes&&(u.slashes=x.test(s.href))}return u}function isSpecial(s){return\"file:\"===s||\"ftp:\"===s||\"http:\"===s||\"https:\"===s||\"ws:\"===s||\"wss:\"===s}function extractProtocol(s,o){s=(s=trimLeft(s)).replace(w,\"\"),o=o||{};var i,a=j.exec(s),u=a[1]?a[1].toLowerCase():\"\",_=!!a[2],x=!!a[3],C=0;return _?x?(i=a[2]+a[3]+a[4],C=a[2].length+a[3].length):(i=a[2]+a[4],C=a[2].length):x?(i=a[3]+a[4],C=a[3].length):i=a[4],\"file:\"===u?C>=2&&(i=i.slice(2)):isSpecial(u)?i=a[4]:u?_&&(i=i.slice(2)):C>=2&&isSpecial(o.protocol)&&(i=a[4]),{protocol:u,slashes:_||isSpecial(u),slashesCount:C,rest:i}}function Url(s,o,i){if(s=(s=trimLeft(s)).replace(w,\"\"),!(this instanceof Url))return new Url(s,o,i);var _,x,C,j,$,U,V=B.slice(),z=typeof o,Y=this,Z=0;for(\"object\"!==z&&\"string\"!==z&&(i=o,o=null),i&&\"function\"!=typeof i&&(i=u.parse),_=!(x=extractProtocol(s||\"\",o=lolcation(o))).protocol&&!x.slashes,Y.slashes=x.slashes||_&&o.slashes,Y.protocol=x.protocol||o.protocol||\"\",s=x.rest,(\"file:\"===x.protocol&&(2!==x.slashesCount||L.test(s))||!x.slashes&&(x.protocol||x.slashesCount<2||!isSpecial(Y.protocol)))&&(V[3]=[/(.*)/,\"pathname\"]);Z<V.length;Z++)\"function\"!=typeof(j=V[Z])?(C=j[0],U=j[1],C!=C?Y[U]=s:\"string\"==typeof C?~($=\"@\"===C?s.lastIndexOf(C):s.indexOf(C))&&(\"number\"==typeof j[2]?(Y[U]=s.slice(0,$),s=s.slice($+j[2])):(Y[U]=s.slice($),s=s.slice(0,$))):($=C.exec(s))&&(Y[U]=$[1],s=s.slice(0,$.index)),Y[U]=Y[U]||_&&j[3]&&o[U]||\"\",j[4]&&(Y[U]=Y[U].toLowerCase())):s=j(s,Y);i&&(Y.query=i(Y.query)),_&&o.slashes&&\"/\"!==Y.pathname.charAt(0)&&(\"\"!==Y.pathname||\"\"!==o.pathname)&&(Y.pathname=function resolve(s,o){if(\"\"===s)return o;for(var i=(o||\"/\").split(\"/\").slice(0,-1).concat(s.split(\"/\")),a=i.length,u=i[a-1],_=!1,w=0;a--;)\".\"===i[a]?i.splice(a,1):\"..\"===i[a]?(i.splice(a,1),w++):w&&(0===a&&(_=!0),i.splice(a,1),w--);return _&&i.unshift(\"\"),\".\"!==u&&\"..\"!==u||i.push(\"\"),i.join(\"/\")}(Y.pathname,o.pathname)),\"/\"!==Y.pathname.charAt(0)&&isSpecial(Y.protocol)&&(Y.pathname=\"/\"+Y.pathname),a(Y.port,Y.protocol)||(Y.host=Y.hostname,Y.port=\"\"),Y.username=Y.password=\"\",Y.auth&&(~($=Y.auth.indexOf(\":\"))?(Y.username=Y.auth.slice(0,$),Y.username=encodeURIComponent(decodeURIComponent(Y.username)),Y.password=Y.auth.slice($+1),Y.password=encodeURIComponent(decodeURIComponent(Y.password))):Y.username=encodeURIComponent(decodeURIComponent(Y.auth)),Y.auth=Y.password?Y.username+\":\"+Y.password:Y.username),Y.origin=\"file:\"!==Y.protocol&&isSpecial(Y.protocol)&&Y.host?Y.protocol+\"//\"+Y.host:\"null\",Y.href=Y.toString()}Url.prototype={set:function set(s,o,i){var _=this;switch(s){case\"query\":\"string\"==typeof o&&o.length&&(o=(i||u.parse)(o)),_[s]=o;break;case\"port\":_[s]=o,a(o,_.protocol)?o&&(_.host=_.hostname+\":\"+o):(_.host=_.hostname,_[s]=\"\");break;case\"hostname\":_[s]=o,_.port&&(o+=\":\"+_.port),_.host=o;break;case\"host\":_[s]=o,C.test(o)?(o=o.split(\":\"),_.port=o.pop(),_.hostname=o.join(\":\")):(_.hostname=o,_.port=\"\");break;case\"protocol\":_.protocol=o.toLowerCase(),_.slashes=!i;break;case\"pathname\":case\"hash\":if(o){var w=\"pathname\"===s?\"/\":\"#\";_[s]=o.charAt(0)!==w?w+o:o}else _[s]=o;break;case\"username\":case\"password\":_[s]=encodeURIComponent(o);break;case\"auth\":var x=o.indexOf(\":\");~x?(_.username=o.slice(0,x),_.username=encodeURIComponent(decodeURIComponent(_.username)),_.password=o.slice(x+1),_.password=encodeURIComponent(decodeURIComponent(_.password))):_.username=encodeURIComponent(decodeURIComponent(o))}for(var j=0;j<B.length;j++){var L=B[j];L[4]&&(_[L[1]]=_[L[1]].toLowerCase())}return _.auth=_.password?_.username+\":\"+_.password:_.username,_.origin=\"file:\"!==_.protocol&&isSpecial(_.protocol)&&_.host?_.protocol+\"//\"+_.host:\"null\",_.href=_.toString(),_},toString:function toString(s){s&&\"function\"==typeof s||(s=u.stringify);var o,i=this,a=i.host,_=i.protocol;_&&\":\"!==_.charAt(_.length-1)&&(_+=\":\");var w=_+(i.protocol&&i.slashes||isSpecial(i.protocol)?\"//\":\"\");return i.username?(w+=i.username,i.password&&(w+=\":\"+i.password),w+=\"@\"):i.password?(w+=\":\"+i.password,w+=\"@\"):\"file:\"!==i.protocol&&isSpecial(i.protocol)&&!a&&\"/\"!==i.pathname&&(w+=\"@\"),(\":\"===a[a.length-1]||C.test(i.hostname)&&!i.port)&&(a+=\":\"),w+=a+i.pathname,(o=\"object\"==typeof i.query?s(i.query):i.query)&&(w+=\"?\"!==o.charAt(0)?\"?\"+o:o),i.hash&&(w+=i.hash),w}},Url.extractProtocol=extractProtocol,Url.location=lolcation,Url.trimLeft=trimLeft,Url.qs=u,s.exports=Url},61448:(s,o,i)=>{var a=i(20426),u=i(49326);s.exports=function has(s,o){return null!=s&&u(s,o,a)}},61489:(s,o,i)=>{var a=i(17400);s.exports=function toInteger(s){var o=a(s),i=o%1;return o==o?i?o-i:o:0}},61626:(s,o,i)=>{\"use strict\";var a=i(39447),u=i(74284),_=i(75817);s.exports=a?function(s,o,i){return u.f(s,o,_(1,i))}:function(s,o,i){return s[o]=i,s}},61747:(s,o,i)=>{\"use strict\";var a=i(45951),u=i(92046);s.exports=function(s,o){var i=u[s+\"Prototype\"],_=i&&i[o];if(_)return _;var w=a[s],x=w&&w.prototype;return x&&x[o]}},61802:(s,o,i)=>{var a=i(62224),u=/[^.[\\]]+|\\[(?:(-?\\d+(?:\\.\\d+)?)|([\"'])((?:(?!\\2)[^\\\\]|\\\\.)*?)\\2)\\]|(?=(?:\\.|\\[\\])(?:\\.|\\[\\]|$))/g,_=/\\\\(\\\\)?/g,w=a((function(s){var o=[];return 46===s.charCodeAt(0)&&o.push(\"\"),s.replace(u,(function(s,i,a,u){o.push(a?u.replace(_,\"$1\"):i||s)})),o}));s.exports=w},62006:(s,o,i)=>{var a=i(15389),u=i(64894),_=i(95950);s.exports=function createFind(s){return function(o,i,w){var x=Object(o);if(!u(o)){var C=a(i,3);o=_(o),i=function(s){return C(x[s],s,x)}}var j=s(o,i,w);return j>-1?x[C?o[j]:j]:void 0}}},62060:s=>{var o=/\\{(?:\\n\\/\\* \\[wrapped with .+\\] \\*\\/)?\\n?/;s.exports=function insertWrapDetails(s,i){var a=i.length;if(!a)return s;var u=a-1;return i[u]=(a>1?\"& \":\"\")+i[u],i=i.join(a>2?\", \":\" \"),s.replace(o,\"{\\n/* [wrapped with \"+i+\"] */\\n\")}},62193:(s,o,i)=>{var a=i(88984),u=i(5861),_=i(72428),w=i(56449),x=i(64894),C=i(3656),j=i(55527),L=i(37167),B=Object.prototype.hasOwnProperty;s.exports=function isEmpty(s){if(null==s)return!0;if(x(s)&&(w(s)||\"string\"==typeof s||\"function\"==typeof s.splice||C(s)||L(s)||_(s)))return!s.length;var o=u(s);if(\"[object Map]\"==o||\"[object Set]\"==o)return!s.size;if(j(s))return!a(s).length;for(var i in s)if(B.call(s,i))return!1;return!0}},62224:(s,o,i)=>{var a=i(50104);s.exports=function memoizeCapped(s){var o=a(s,(function(s){return 500===i.size&&i.clear(),s})),i=o.cache;return o}},62250:s=>{\"use strict\";var o=\"object\"==typeof document&&document.all;s.exports=void 0===o&&void 0!==o?function(s){return\"function\"==typeof s||s===o}:function(s){return\"function\"==typeof s}},62284:(s,o,i)=>{var a=i(84629),u=Object.prototype.hasOwnProperty;s.exports=function getFuncName(s){for(var o=s.name+\"\",i=a[o],_=u.call(a,o)?i.length:0;_--;){var w=i[_],x=w.func;if(null==x||x==s)return w.name}return o}},62416:(s,o,i)=>{\"use strict\";var a=i(85582);s.exports=a(\"document\",\"documentElement\")},62802:(s,o,i)=>{\"use strict\";s.exports=function SHA(o){var i=o.toLowerCase(),a=s.exports[i];if(!a)throw new Error(i+\" is not supported (we accept pull requests)\");return new a},s.exports.sha=i(27816),s.exports.sha1=i(63737),s.exports.sha224=i(26710),s.exports.sha256=i(24107),s.exports.sha384=i(32827),s.exports.sha512=i(82890)},63040:(s,o,i)=>{var a=i(21549),u=i(80079),_=i(68223);s.exports=function mapCacheClear(){this.size=0,this.__data__={hash:new a,map:new(_||u),string:new a}}},63345:s=>{s.exports=function stubArray(){return[]}},63560:(s,o,i)=>{var a=i(73170);s.exports=function set(s,o,i){return null==s?s:a(s,o,i)}},63600:(s,o,i)=>{\"use strict\";s.exports=PassThrough;var a=i(74610);function PassThrough(s){if(!(this instanceof PassThrough))return new PassThrough(s);a.call(this,s)}i(56698)(PassThrough,a),PassThrough.prototype._transform=function(s,o,i){i(null,s)}},63605:s=>{s.exports=function stackGet(s){return this.__data__.get(s)}},63702:s=>{s.exports=function listCacheClear(){this.__data__=[],this.size=0}},63737:(s,o,i)=>{\"use strict\";var a=i(56698),u=i(90392),_=i(92861).Buffer,w=[1518500249,1859775393,-1894007588,-899497514],x=new Array(80);function Sha1(){this.init(),this._w=x,u.call(this,64,56)}function rotl5(s){return s<<5|s>>>27}function rotl30(s){return s<<30|s>>>2}function ft(s,o,i,a){return 0===s?o&i|~o&a:2===s?o&i|o&a|i&a:o^i^a}a(Sha1,u),Sha1.prototype.init=function(){return this._a=1732584193,this._b=4023233417,this._c=2562383102,this._d=271733878,this._e=3285377520,this},Sha1.prototype._update=function(s){for(var o,i=this._w,a=0|this._a,u=0|this._b,_=0|this._c,x=0|this._d,C=0|this._e,j=0;j<16;++j)i[j]=s.readInt32BE(4*j);for(;j<80;++j)i[j]=(o=i[j-3]^i[j-8]^i[j-14]^i[j-16])<<1|o>>>31;for(var L=0;L<80;++L){var B=~~(L/20),$=rotl5(a)+ft(B,u,_,x)+C+i[L]+w[B]|0;C=x,x=_,_=rotl30(u),u=a,a=$}this._a=a+this._a|0,this._b=u+this._b|0,this._c=_+this._c|0,this._d=x+this._d|0,this._e=C+this._e|0},Sha1.prototype._hash=function(){var s=_.allocUnsafe(20);return s.writeInt32BE(0|this._a,0),s.writeInt32BE(0|this._b,4),s.writeInt32BE(0|this._c,8),s.writeInt32BE(0|this._d,12),s.writeInt32BE(0|this._e,16),s},s.exports=Sha1},63862:s=>{s.exports=function hashDelete(s){var o=this.has(s)&&delete this.__data__[s];return this.size-=o?1:0,o}},63912:(s,o,i)=>{var a=i(61074),u=i(49698),_=i(42054);s.exports=function stringToArray(s){return u(s)?_(s):a(s)}},63950:s=>{s.exports=function noop(){}},64039:(s,o,i)=>{\"use strict\";var a=\"undefined\"!=typeof Symbol&&Symbol,u=i(41333);s.exports=function hasNativeSymbols(){return\"function\"==typeof a&&(\"function\"==typeof Symbol&&(\"symbol\"==typeof a(\"foo\")&&(\"symbol\"==typeof Symbol(\"bar\")&&u())))}},64502:(s,o,i)=>{\"use strict\";i(82048)},64626:(s,o,i)=>{var a=i(66977);s.exports=function ary(s,o,i){return o=i?void 0:o,o=s&&null==o?s.length:o,a(s,128,void 0,void 0,void 0,void 0,o)}},64634:s=>{var o={}.toString;s.exports=Array.isArray||function(s){return\"[object Array]\"==o.call(s)}},64894:(s,o,i)=>{var a=i(1882),u=i(30294);s.exports=function isArrayLike(s){return null!=s&&u(s.length)&&!a(s)}},64932:(s,o,i)=>{\"use strict\";var a,u,_,w=i(40551),x=i(45951),C=i(46285),j=i(61626),L=i(49724),B=i(36128),$=i(92522),U=i(38530),V=\"Object already initialized\",z=x.TypeError,Y=x.WeakMap;if(w||B.state){var Z=B.state||(B.state=new Y);Z.get=Z.get,Z.has=Z.has,Z.set=Z.set,a=function(s,o){if(Z.has(s))throw new z(V);return o.facade=s,Z.set(s,o),o},u=function(s){return Z.get(s)||{}},_=function(s){return Z.has(s)}}else{var ee=$(\"state\");U[ee]=!0,a=function(s,o){if(L(s,ee))throw new z(V);return o.facade=s,j(s,ee,o),o},u=function(s){return L(s,ee)?s[ee]:{}},_=function(s){return L(s,ee)}}s.exports={set:a,get:u,has:_,enforce:function(s){return _(s)?u(s):a(s,{})},getterFor:function(s){return function(o){var i;if(!C(o)||(i=u(o)).type!==s)throw new z(\"Incompatible receiver, \"+s+\" required\");return i}}}},65291:(s,o,i)=>{\"use strict\";var a=i(86048).F.ERR_INVALID_OPT_VALUE;s.exports={getHighWaterMark:function getHighWaterMark(s,o,i,u){var _=function highWaterMarkFrom(s,o,i){return null!=s.highWaterMark?s.highWaterMark:o?s[i]:null}(o,u,i);if(null!=_){if(!isFinite(_)||Math.floor(_)!==_||_<0)throw new a(u?i:\"highWaterMark\",_);return Math.floor(_)}return s.objectMode?16:16384}}},65482:(s,o,i)=>{\"use strict\";var a=i(41176);s.exports=function(s){var o=+s;return o!=o||0===o?0:a(o)}},65606:s=>{var o,i,a=s.exports={};function defaultSetTimout(){throw new Error(\"setTimeout has not been defined\")}function defaultClearTimeout(){throw new Error(\"clearTimeout has not been defined\")}function runTimeout(s){if(o===setTimeout)return setTimeout(s,0);if((o===defaultSetTimout||!o)&&setTimeout)return o=setTimeout,setTimeout(s,0);try{return o(s,0)}catch(i){try{return o.call(null,s,0)}catch(i){return o.call(this,s,0)}}}!function(){try{o=\"function\"==typeof setTimeout?setTimeout:defaultSetTimout}catch(s){o=defaultSetTimout}try{i=\"function\"==typeof clearTimeout?clearTimeout:defaultClearTimeout}catch(s){i=defaultClearTimeout}}();var u,_=[],w=!1,x=-1;function cleanUpNextTick(){w&&u&&(w=!1,u.length?_=u.concat(_):x=-1,_.length&&drainQueue())}function drainQueue(){if(!w){var s=runTimeout(cleanUpNextTick);w=!0;for(var o=_.length;o;){for(u=_,_=[];++x<o;)u&&u[x].run();x=-1,o=_.length}u=null,w=!1,function runClearTimeout(s){if(i===clearTimeout)return clearTimeout(s);if((i===defaultClearTimeout||!i)&&clearTimeout)return i=clearTimeout,clearTimeout(s);try{return i(s)}catch(o){try{return i.call(null,s)}catch(o){return i.call(this,s)}}}(s)}}function Item(s,o){this.fun=s,this.array=o}function noop(){}a.nextTick=function(s){var o=new Array(arguments.length-1);if(arguments.length>1)for(var i=1;i<arguments.length;i++)o[i-1]=arguments[i];_.push(new Item(s,o)),1!==_.length||w||runTimeout(drainQueue)},Item.prototype.run=function(){this.fun.apply(null,this.array)},a.title=\"browser\",a.browser=!0,a.env={},a.argv=[],a.version=\"\",a.versions={},a.on=noop,a.addListener=noop,a.once=noop,a.off=noop,a.removeListener=noop,a.removeAllListeners=noop,a.emit=noop,a.prependListener=noop,a.prependOnceListener=noop,a.listeners=function(s){return[]},a.binding=function(s){throw new Error(\"process.binding is not supported\")},a.cwd=function(){return\"/\"},a.chdir=function(s){throw new Error(\"process.chdir is not supported\")},a.umask=function(){return 0}},65772:s=>{s.exports=function json(s){const o={literal:\"true false null\"},i=[s.C_LINE_COMMENT_MODE,s.C_BLOCK_COMMENT_MODE],a=[s.QUOTE_STRING_MODE,s.C_NUMBER_MODE],u={end:\",\",endsWithParent:!0,excludeEnd:!0,contains:a,keywords:o},_={begin:/\\{/,end:/\\}/,contains:[{className:\"attr\",begin:/\"/,end:/\"/,contains:[s.BACKSLASH_ESCAPE],illegal:\"\\\\n\"},s.inherit(u,{begin:/:/})].concat(i),illegal:\"\\\\S\"},w={begin:\"\\\\[\",end:\"\\\\]\",contains:[s.inherit(u)],illegal:\"\\\\S\"};return a.push(_,w),i.forEach((function(s){a.push(s)})),{name:\"JSON\",contains:a,keywords:o,illegal:\"\\\\S\"}}},66645:(s,o,i)=>{var a=i(1733),u=i(45434),_=i(13222),w=i(22225);s.exports=function words(s,o,i){return s=_(s),void 0===(o=i?void 0:o)?u(s)?w(s):a(s):s.match(o)||[]}},66721:(s,o,i)=>{var a=i(81042),u=Object.prototype.hasOwnProperty;s.exports=function hashGet(s){var o=this.__data__;if(a){var i=o[s];return\"__lodash_hash_undefined__\"===i?void 0:i}return u.call(o,s)?o[s]:void 0}},66743:(s,o,i)=>{\"use strict\";var a=i(89353);s.exports=Function.prototype.bind||a},66977:(s,o,i)=>{var a=i(68882),u=i(11842),_=i(77078),w=i(37471),x=i(24168),C=i(37381),j=i(3209),L=i(54641),B=i(70981),$=i(61489),U=Math.max;s.exports=function createWrap(s,o,i,V,z,Y,Z,ee){var ie=2&o;if(!ie&&\"function\"!=typeof s)throw new TypeError(\"Expected a function\");var ae=V?V.length:0;if(ae||(o&=-97,V=z=void 0),Z=void 0===Z?Z:U($(Z),0),ee=void 0===ee?ee:$(ee),ae-=z?z.length:0,64&o){var ce=V,le=z;V=z=void 0}var pe=ie?void 0:C(s),de=[s,o,i,V,z,ce,le,Y,Z,ee];if(pe&&j(de,pe),s=de[0],o=de[1],i=de[2],V=de[3],z=de[4],!(ee=de[9]=void 0===de[9]?ie?0:s.length:U(de[9]-ae,0))&&24&o&&(o&=-25),o&&1!=o)fe=8==o||16==o?_(s,o,ee):32!=o&&33!=o||z.length?w.apply(void 0,de):x(s,o,i,V);else var fe=u(s,o,i);return B((pe?a:L)(fe,de),s,o)}},67197:s=>{s.exports=function matchesStrictComparable(s,o){return function(i){return null!=i&&(i[s]===o&&(void 0!==o||s in Object(i)))}}},67526:(s,o)=>{\"use strict\";o.byteLength=function byteLength(s){var o=getLens(s),i=o[0],a=o[1];return 3*(i+a)/4-a},o.toByteArray=function toByteArray(s){var o,i,_=getLens(s),w=_[0],x=_[1],C=new u(function _byteLength(s,o,i){return 3*(o+i)/4-i}(0,w,x)),j=0,L=x>0?w-4:w;for(i=0;i<L;i+=4)o=a[s.charCodeAt(i)]<<18|a[s.charCodeAt(i+1)]<<12|a[s.charCodeAt(i+2)]<<6|a[s.charCodeAt(i+3)],C[j++]=o>>16&255,C[j++]=o>>8&255,C[j++]=255&o;2===x&&(o=a[s.charCodeAt(i)]<<2|a[s.charCodeAt(i+1)]>>4,C[j++]=255&o);1===x&&(o=a[s.charCodeAt(i)]<<10|a[s.charCodeAt(i+1)]<<4|a[s.charCodeAt(i+2)]>>2,C[j++]=o>>8&255,C[j++]=255&o);return C},o.fromByteArray=function fromByteArray(s){for(var o,a=s.length,u=a%3,_=[],w=16383,x=0,C=a-u;x<C;x+=w)_.push(encodeChunk(s,x,x+w>C?C:x+w));1===u?(o=s[a-1],_.push(i[o>>2]+i[o<<4&63]+\"==\")):2===u&&(o=(s[a-2]<<8)+s[a-1],_.push(i[o>>10]+i[o>>4&63]+i[o<<2&63]+\"=\"));return _.join(\"\")};for(var i=[],a=[],u=\"undefined\"!=typeof Uint8Array?Uint8Array:Array,_=\"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\",w=0;w<64;++w)i[w]=_[w],a[_.charCodeAt(w)]=w;function getLens(s){var o=s.length;if(o%4>0)throw new Error(\"Invalid string. Length must be a multiple of 4\");var i=s.indexOf(\"=\");return-1===i&&(i=o),[i,i===o?0:4-i%4]}function encodeChunk(s,o,a){for(var u,_,w=[],x=o;x<a;x+=3)u=(s[x]<<16&16711680)+(s[x+1]<<8&65280)+(255&s[x+2]),w.push(i[(_=u)>>18&63]+i[_>>12&63]+i[_>>6&63]+i[63&_]);return w.join(\"\")}a[\"-\".charCodeAt(0)]=62,a[\"_\".charCodeAt(0)]=63},68002:s=>{\"use strict\";s.exports=Math.min},68055:(s,o,i)=>{\"use strict\";var a=i(61626);s.exports=function(s,o,i,u){return u&&u.enumerable?s[o]=i:a(s,o,i),s}},68090:s=>{s.exports=function last(s){var o=null==s?0:s.length;return o?s[o-1]:void 0}},68223:(s,o,i)=>{var a=i(56110)(i(9325),\"Map\");s.exports=a},68294:(s,o,i)=>{var a=i(23007),u=i(30361),_=Math.min;s.exports=function reorder(s,o){for(var i=s.length,w=_(o.length,i),x=a(s);w--;){var C=o[w];s[w]=u(C,i)?x[C]:void 0}return s}},68623:(s,o,i)=>{\"use strict\";var a=i(694);s.exports=a},68882:(s,o,i)=>{var a=i(83488),u=i(48152),_=u?function(s,o){return u.set(s,o),s}:a;s.exports=_},68969:(s,o,i)=>{var a=i(47422),u=i(25160);s.exports=function parent(s,o){return o.length<2?s:a(s,u(o,0,-1))}},69302:(s,o,i)=>{var a=i(83488),u=i(56757),_=i(32865);s.exports=function baseRest(s,o){return _(u(s,o,a),s+\"\")}},69383:s=>{\"use strict\";s.exports=Error},69600:s=>{\"use strict\";var o,i,a=Function.prototype.toString,u=\"object\"==typeof Reflect&&null!==Reflect&&Reflect.apply;if(\"function\"==typeof u&&\"function\"==typeof Object.defineProperty)try{o=Object.defineProperty({},\"length\",{get:function(){throw i}}),i={},u((function(){throw 42}),null,o)}catch(s){s!==i&&(u=null)}else u=null;var _=/^\\s*class\\b/,w=function isES6ClassFunction(s){try{var o=a.call(s);return _.test(o)}catch(s){return!1}},x=function tryFunctionToStr(s){try{return!w(s)&&(a.call(s),!0)}catch(s){return!1}},C=Object.prototype.toString,j=\"function\"==typeof Symbol&&!!Symbol.toStringTag,L=!(0 in[,]),B=function isDocumentDotAll(){return!1};if(\"object\"==typeof document){var $=document.all;C.call($)===C.call(document.all)&&(B=function isDocumentDotAll(s){if((L||!s)&&(void 0===s||\"object\"==typeof s))try{var o=C.call(s);return(\"[object HTMLAllCollection]\"===o||\"[object HTML document.all class]\"===o||\"[object HTMLCollection]\"===o||\"[object Object]\"===o)&&null==s(\"\")}catch(s){}return!1})}s.exports=u?function isCallable(s){if(B(s))return!0;if(!s)return!1;if(\"function\"!=typeof s&&\"object\"!=typeof s)return!1;try{u(s,null,o)}catch(s){if(s!==i)return!1}return!w(s)&&x(s)}:function isCallable(s){if(B(s))return!0;if(!s)return!1;if(\"function\"!=typeof s&&\"object\"!=typeof s)return!1;if(j)return x(s);if(w(s))return!1;var o=C.call(s);return!(\"[object Function]\"!==o&&\"[object GeneratorFunction]\"!==o&&!/^\\[object HTML/.test(o))&&x(s)}},69675:s=>{\"use strict\";s.exports=TypeError},69884:(s,o,i)=>{var a=i(21791),u=i(37241);s.exports=function toPlainObject(s){return a(s,u(s))}},69982:(s,o,i)=>{\"use strict\";s.exports=i(29844)},70080:(s,o,i)=>{var a=i(26025),u=Array.prototype.splice;s.exports=function listCacheDelete(s){var o=this.__data__,i=a(o,s);return!(i<0)&&(i==o.length-1?o.pop():u.call(o,i,1),--this.size,!0)}},70414:s=>{\"use strict\";s.exports=Math.round},70453:(s,o,i)=>{\"use strict\";var a,u=i(79612),_=i(69383),w=i(41237),x=i(79290),C=i(79538),j=i(58068),L=i(69675),B=i(35345),$=i(71514),U=i(58968),V=i(6188),z=i(68002),Y=i(75880),Z=i(70414),ee=i(73093),ie=Function,getEvalledConstructor=function(s){try{return ie('\"use strict\"; return ('+s+\").constructor;\")()}catch(s){}},ae=i(75795),ce=i(30655),throwTypeError=function(){throw new L},le=ae?function(){try{return throwTypeError}catch(s){try{return ae(arguments,\"callee\").get}catch(s){return throwTypeError}}}():throwTypeError,pe=i(64039)(),de=i(93628),fe=i(71064),ye=i(48648),be=i(11002),_e=i(10076),Se={},we=\"undefined\"!=typeof Uint8Array&&de?de(Uint8Array):a,xe={__proto__:null,\"%AggregateError%\":\"undefined\"==typeof AggregateError?a:AggregateError,\"%Array%\":Array,\"%ArrayBuffer%\":\"undefined\"==typeof ArrayBuffer?a:ArrayBuffer,\"%ArrayIteratorPrototype%\":pe&&de?de([][Symbol.iterator]()):a,\"%AsyncFromSyncIteratorPrototype%\":a,\"%AsyncFunction%\":Se,\"%AsyncGenerator%\":Se,\"%AsyncGeneratorFunction%\":Se,\"%AsyncIteratorPrototype%\":Se,\"%Atomics%\":\"undefined\"==typeof Atomics?a:Atomics,\"%BigInt%\":\"undefined\"==typeof BigInt?a:BigInt,\"%BigInt64Array%\":\"undefined\"==typeof BigInt64Array?a:BigInt64Array,\"%BigUint64Array%\":\"undefined\"==typeof BigUint64Array?a:BigUint64Array,\"%Boolean%\":Boolean,\"%DataView%\":\"undefined\"==typeof DataView?a:DataView,\"%Date%\":Date,\"%decodeURI%\":decodeURI,\"%decodeURIComponent%\":decodeURIComponent,\"%encodeURI%\":encodeURI,\"%encodeURIComponent%\":encodeURIComponent,\"%Error%\":_,\"%eval%\":eval,\"%EvalError%\":w,\"%Float32Array%\":\"undefined\"==typeof Float32Array?a:Float32Array,\"%Float64Array%\":\"undefined\"==typeof Float64Array?a:Float64Array,\"%FinalizationRegistry%\":\"undefined\"==typeof FinalizationRegistry?a:FinalizationRegistry,\"%Function%\":ie,\"%GeneratorFunction%\":Se,\"%Int8Array%\":\"undefined\"==typeof Int8Array?a:Int8Array,\"%Int16Array%\":\"undefined\"==typeof Int16Array?a:Int16Array,\"%Int32Array%\":\"undefined\"==typeof Int32Array?a:Int32Array,\"%isFinite%\":isFinite,\"%isNaN%\":isNaN,\"%IteratorPrototype%\":pe&&de?de(de([][Symbol.iterator]())):a,\"%JSON%\":\"object\"==typeof JSON?JSON:a,\"%Map%\":\"undefined\"==typeof Map?a:Map,\"%MapIteratorPrototype%\":\"undefined\"!=typeof Map&&pe&&de?de((new Map)[Symbol.iterator]()):a,\"%Math%\":Math,\"%Number%\":Number,\"%Object%\":u,\"%Object.getOwnPropertyDescriptor%\":ae,\"%parseFloat%\":parseFloat,\"%parseInt%\":parseInt,\"%Promise%\":\"undefined\"==typeof Promise?a:Promise,\"%Proxy%\":\"undefined\"==typeof Proxy?a:Proxy,\"%RangeError%\":x,\"%ReferenceError%\":C,\"%Reflect%\":\"undefined\"==typeof Reflect?a:Reflect,\"%RegExp%\":RegExp,\"%Set%\":\"undefined\"==typeof Set?a:Set,\"%SetIteratorPrototype%\":\"undefined\"!=typeof Set&&pe&&de?de((new Set)[Symbol.iterator]()):a,\"%SharedArrayBuffer%\":\"undefined\"==typeof SharedArrayBuffer?a:SharedArrayBuffer,\"%String%\":String,\"%StringIteratorPrototype%\":pe&&de?de(\"\"[Symbol.iterator]()):a,\"%Symbol%\":pe?Symbol:a,\"%SyntaxError%\":j,\"%ThrowTypeError%\":le,\"%TypedArray%\":we,\"%TypeError%\":L,\"%Uint8Array%\":\"undefined\"==typeof Uint8Array?a:Uint8Array,\"%Uint8ClampedArray%\":\"undefined\"==typeof Uint8ClampedArray?a:Uint8ClampedArray,\"%Uint16Array%\":\"undefined\"==typeof Uint16Array?a:Uint16Array,\"%Uint32Array%\":\"undefined\"==typeof Uint32Array?a:Uint32Array,\"%URIError%\":B,\"%WeakMap%\":\"undefined\"==typeof WeakMap?a:WeakMap,\"%WeakRef%\":\"undefined\"==typeof WeakRef?a:WeakRef,\"%WeakSet%\":\"undefined\"==typeof WeakSet?a:WeakSet,\"%Function.prototype.call%\":_e,\"%Function.prototype.apply%\":be,\"%Object.defineProperty%\":ce,\"%Object.getPrototypeOf%\":fe,\"%Math.abs%\":$,\"%Math.floor%\":U,\"%Math.max%\":V,\"%Math.min%\":z,\"%Math.pow%\":Y,\"%Math.round%\":Z,\"%Math.sign%\":ee,\"%Reflect.getPrototypeOf%\":ye};if(de)try{null.error}catch(s){var Pe=de(de(s));xe[\"%Error.prototype%\"]=Pe}var Te=function doEval(s){var o;if(\"%AsyncFunction%\"===s)o=getEvalledConstructor(\"async function () {}\");else if(\"%GeneratorFunction%\"===s)o=getEvalledConstructor(\"function* () {}\");else if(\"%AsyncGeneratorFunction%\"===s)o=getEvalledConstructor(\"async function* () {}\");else if(\"%AsyncGenerator%\"===s){var i=doEval(\"%AsyncGeneratorFunction%\");i&&(o=i.prototype)}else if(\"%AsyncIteratorPrototype%\"===s){var a=doEval(\"%AsyncGenerator%\");a&&de&&(o=de(a.prototype))}return xe[s]=o,o},Re={__proto__:null,\"%ArrayBufferPrototype%\":[\"ArrayBuffer\",\"prototype\"],\"%ArrayPrototype%\":[\"Array\",\"prototype\"],\"%ArrayProto_entries%\":[\"Array\",\"prototype\",\"entries\"],\"%ArrayProto_forEach%\":[\"Array\",\"prototype\",\"forEach\"],\"%ArrayProto_keys%\":[\"Array\",\"prototype\",\"keys\"],\"%ArrayProto_values%\":[\"Array\",\"prototype\",\"values\"],\"%AsyncFunctionPrototype%\":[\"AsyncFunction\",\"prototype\"],\"%AsyncGenerator%\":[\"AsyncGeneratorFunction\",\"prototype\"],\"%AsyncGeneratorPrototype%\":[\"AsyncGeneratorFunction\",\"prototype\",\"prototype\"],\"%BooleanPrototype%\":[\"Boolean\",\"prototype\"],\"%DataViewPrototype%\":[\"DataView\",\"prototype\"],\"%DatePrototype%\":[\"Date\",\"prototype\"],\"%ErrorPrototype%\":[\"Error\",\"prototype\"],\"%EvalErrorPrototype%\":[\"EvalError\",\"prototype\"],\"%Float32ArrayPrototype%\":[\"Float32Array\",\"prototype\"],\"%Float64ArrayPrototype%\":[\"Float64Array\",\"prototype\"],\"%FunctionPrototype%\":[\"Function\",\"prototype\"],\"%Generator%\":[\"GeneratorFunction\",\"prototype\"],\"%GeneratorPrototype%\":[\"GeneratorFunction\",\"prototype\",\"prototype\"],\"%Int8ArrayPrototype%\":[\"Int8Array\",\"prototype\"],\"%Int16ArrayPrototype%\":[\"Int16Array\",\"prototype\"],\"%Int32ArrayPrototype%\":[\"Int32Array\",\"prototype\"],\"%JSONParse%\":[\"JSON\",\"parse\"],\"%JSONStringify%\":[\"JSON\",\"stringify\"],\"%MapPrototype%\":[\"Map\",\"prototype\"],\"%NumberPrototype%\":[\"Number\",\"prototype\"],\"%ObjectPrototype%\":[\"Object\",\"prototype\"],\"%ObjProto_toString%\":[\"Object\",\"prototype\",\"toString\"],\"%ObjProto_valueOf%\":[\"Object\",\"prototype\",\"valueOf\"],\"%PromisePrototype%\":[\"Promise\",\"prototype\"],\"%PromiseProto_then%\":[\"Promise\",\"prototype\",\"then\"],\"%Promise_all%\":[\"Promise\",\"all\"],\"%Promise_reject%\":[\"Promise\",\"reject\"],\"%Promise_resolve%\":[\"Promise\",\"resolve\"],\"%RangeErrorPrototype%\":[\"RangeError\",\"prototype\"],\"%ReferenceErrorPrototype%\":[\"ReferenceError\",\"prototype\"],\"%RegExpPrototype%\":[\"RegExp\",\"prototype\"],\"%SetPrototype%\":[\"Set\",\"prototype\"],\"%SharedArrayBufferPrototype%\":[\"SharedArrayBuffer\",\"prototype\"],\"%StringPrototype%\":[\"String\",\"prototype\"],\"%SymbolPrototype%\":[\"Symbol\",\"prototype\"],\"%SyntaxErrorPrototype%\":[\"SyntaxError\",\"prototype\"],\"%TypedArrayPrototype%\":[\"TypedArray\",\"prototype\"],\"%TypeErrorPrototype%\":[\"TypeError\",\"prototype\"],\"%Uint8ArrayPrototype%\":[\"Uint8Array\",\"prototype\"],\"%Uint8ClampedArrayPrototype%\":[\"Uint8ClampedArray\",\"prototype\"],\"%Uint16ArrayPrototype%\":[\"Uint16Array\",\"prototype\"],\"%Uint32ArrayPrototype%\":[\"Uint32Array\",\"prototype\"],\"%URIErrorPrototype%\":[\"URIError\",\"prototype\"],\"%WeakMapPrototype%\":[\"WeakMap\",\"prototype\"],\"%WeakSetPrototype%\":[\"WeakSet\",\"prototype\"]},$e=i(66743),qe=i(9957),ze=$e.call(_e,Array.prototype.concat),We=$e.call(be,Array.prototype.splice),He=$e.call(_e,String.prototype.replace),Ye=$e.call(_e,String.prototype.slice),Xe=$e.call(_e,RegExp.prototype.exec),Qe=/[^%.[\\]]+|\\[(?:(-?\\d+(?:\\.\\d+)?)|([\"'])((?:(?!\\2)[^\\\\]|\\\\.)*?)\\2)\\]|(?=(?:\\.|\\[\\])(?:\\.|\\[\\]|%$))/g,et=/\\\\(\\\\)?/g,tt=function getBaseIntrinsic(s,o){var i,a=s;if(qe(Re,a)&&(a=\"%\"+(i=Re[a])[0]+\"%\"),qe(xe,a)){var u=xe[a];if(u===Se&&(u=Te(a)),void 0===u&&!o)throw new L(\"intrinsic \"+s+\" exists, but is not available. Please file an issue!\");return{alias:i,name:a,value:u}}throw new j(\"intrinsic \"+s+\" does not exist!\")};s.exports=function GetIntrinsic(s,o){if(\"string\"!=typeof s||0===s.length)throw new L(\"intrinsic name must be a non-empty string\");if(arguments.length>1&&\"boolean\"!=typeof o)throw new L('\"allowMissing\" argument must be a boolean');if(null===Xe(/^%?[^%]*%?$/,s))throw new j(\"`%` may not be present anywhere but at the beginning and end of the intrinsic name\");var i=function stringToPath(s){var o=Ye(s,0,1),i=Ye(s,-1);if(\"%\"===o&&\"%\"!==i)throw new j(\"invalid intrinsic syntax, expected closing `%`\");if(\"%\"===i&&\"%\"!==o)throw new j(\"invalid intrinsic syntax, expected opening `%`\");var a=[];return He(s,Qe,(function(s,o,i,u){a[a.length]=i?He(u,et,\"$1\"):o||s})),a}(s),a=i.length>0?i[0]:\"\",u=tt(\"%\"+a+\"%\",o),_=u.name,w=u.value,x=!1,C=u.alias;C&&(a=C[0],We(i,ze([0,1],C)));for(var B=1,$=!0;B<i.length;B+=1){var U=i[B],V=Ye(U,0,1),z=Ye(U,-1);if(('\"'===V||\"'\"===V||\"`\"===V||'\"'===z||\"'\"===z||\"`\"===z)&&V!==z)throw new j(\"property names with quotes must have matching quotes\");if(\"constructor\"!==U&&$||(x=!0),qe(xe,_=\"%\"+(a+=\".\"+U)+\"%\"))w=xe[_];else if(null!=w){if(!(U in w)){if(!o)throw new L(\"base intrinsic for \"+s+\" exists, but the property is not available.\");return}if(ae&&B+1>=i.length){var Y=ae(w,U);w=($=!!Y)&&\"get\"in Y&&!(\"originalValue\"in Y.get)?Y.get:w[U]}else $=qe(w,U),w=w[U];$&&!x&&(xe[_]=w)}}return w}},70470:(s,o,i)=>{\"use strict\";var a=i(46028),u=i(25594);s.exports=function(s){var o=a(s,\"string\");return u(o)?o:o+\"\"}},70695:(s,o,i)=>{var a=i(78096),u=i(72428),_=i(56449),w=i(3656),x=i(30361),C=i(37167),j=Object.prototype.hasOwnProperty;s.exports=function arrayLikeKeys(s,o){var i=_(s),L=!i&&u(s),B=!i&&!L&&w(s),$=!i&&!L&&!B&&C(s),U=i||L||B||$,V=U?a(s.length,String):[],z=V.length;for(var Y in s)!o&&!j.call(s,Y)||U&&(\"length\"==Y||B&&(\"offset\"==Y||\"parent\"==Y)||$&&(\"buffer\"==Y||\"byteLength\"==Y||\"byteOffset\"==Y)||x(Y,z))||V.push(Y);return V}},70981:(s,o,i)=>{var a=i(75251),u=i(62060),_=i(32865),w=i(75948);s.exports=function setWrapToString(s,o,i){var x=o+\"\";return _(s,u(x,w(a(x),i)))}},71064:(s,o,i)=>{\"use strict\";var a=i(79612);s.exports=a.getPrototypeOf||null},71167:(s,o,i)=>{const a=i(10316);s.exports=class StringElement extends a{constructor(s,o,i){super(s,o,i),this.element=\"string\"}primitive(){return\"string\"}get length(){return this.content.length}}},71340:(s,o,i)=>{\"use strict\";var a=i(11091),u=i(29538);a({target:\"Object\",stat:!0,arity:2,forced:Object.assign!==u},{assign:u})},71514:s=>{\"use strict\";s.exports=Math.abs},71961:(s,o,i)=>{var a=i(49653);s.exports=function cloneTypedArray(s,o){var i=o?a(s.buffer):s.buffer;return new s.constructor(i,s.byteOffset,s.length)}},72428:(s,o,i)=>{var a=i(27534),u=i(40346),_=Object.prototype,w=_.hasOwnProperty,x=_.propertyIsEnumerable,C=a(function(){return arguments}())?a:function(s){return u(s)&&w.call(s,\"callee\")&&!x.call(s,\"callee\")};s.exports=C},72552:(s,o,i)=>{var a=i(51873),u=i(659),_=i(59350),w=a?a.toStringTag:void 0;s.exports=function baseGetTag(s){return null==s?void 0===s?\"[object Undefined]\":\"[object Null]\":w&&w in Object(s)?u(s):_(s)}},72903:(s,o,i)=>{var a=i(23805),u=i(55527),_=i(90181),w=Object.prototype.hasOwnProperty;s.exports=function baseKeysIn(s){if(!a(s))return _(s);var o=u(s),i=[];for(var x in s)(\"constructor\"!=x||!o&&w.call(s,x))&&i.push(x);return i}},72949:(s,o,i)=>{var a=i(12651);s.exports=function mapCacheSet(s,o){var i=a(this,s),u=i.size;return i.set(s,o),this.size+=i.size==u?0:1,this}},73093:(s,o,i)=>{\"use strict\";var a=i(94459);s.exports=function sign(s){return a(s)||0===s?s:s<0?-1:1}},73126:(s,o,i)=>{\"use strict\";var a=i(66743),u=i(69675),_=i(10076),w=i(13144);s.exports=function callBindBasic(s){if(s.length<1||\"function\"!=typeof s[0])throw new u(\"a function is required\");return w(a,_,s)}},73170:(s,o,i)=>{var a=i(16547),u=i(31769),_=i(30361),w=i(23805),x=i(77797);s.exports=function baseSet(s,o,i,C){if(!w(s))return s;for(var j=-1,L=(o=u(o,s)).length,B=L-1,$=s;null!=$&&++j<L;){var U=x(o[j]),V=i;if(\"__proto__\"===U||\"constructor\"===U||\"prototype\"===U)return s;if(j!=B){var z=$[U];void 0===(V=C?C(z,U,$):void 0)&&(V=w(z)?z:_(o[j+1])?[]:{})}a($,U,V),$=$[U]}return s}},73201:s=>{var o=/\\w*$/;s.exports=function cloneRegExp(s){var i=new s.constructor(s.source,o.exec(s));return i.lastIndex=s.lastIndex,i}},73402:s=>{function concat(...s){return s.map((s=>function source(s){return s?\"string\"==typeof s?s:s.source:null}(s))).join(\"\")}s.exports=function http(s){const o=\"HTTP/(2|1\\\\.[01])\",i={className:\"attribute\",begin:concat(\"^\",/[A-Za-z][A-Za-z0-9-]*/,\"(?=\\\\:\\\\s)\"),starts:{contains:[{className:\"punctuation\",begin:/: /,relevance:0,starts:{end:\"$\",relevance:0}}]}},a=[i,{begin:\"\\\\n\\\\n\",starts:{subLanguage:[],endsWithParent:!0}}];return{name:\"HTTP\",aliases:[\"https\"],illegal:/\\S/,contains:[{begin:\"^(?=\"+o+\" \\\\d{3})\",end:/$/,contains:[{className:\"meta\",begin:o},{className:\"number\",begin:\"\\\\b\\\\d{3}\\\\b\"}],starts:{end:/\\b\\B/,illegal:/\\S/,contains:a}},{begin:\"(?=^[A-Z]+ (.*?) \"+o+\"$)\",end:/$/,contains:[{className:\"string\",begin:\" \",end:\" \",excludeBegin:!0,excludeEnd:!0},{className:\"meta\",begin:o},{className:\"keyword\",begin:\"[A-Z]+\"}],starts:{end:/\\b\\B/,illegal:/\\S/,contains:a}},s.inherit(i,{relevance:0})]}}},73424:(s,o,i)=>{var a=i(16962),u=i(2874),_=Array.prototype.push;function baseAry(s,o){return 2==o?function(o,i){return s(o,i)}:function(o){return s(o)}}function cloneArray(s){for(var o=s?s.length:0,i=Array(o);o--;)i[o]=s[o];return i}function wrapImmutable(s,o){return function(){var i=arguments.length;if(i){for(var a=Array(i);i--;)a[i]=arguments[i];var u=a[0]=o.apply(void 0,a);return s.apply(void 0,a),u}}}s.exports=function baseConvert(s,o,i,w){var x=\"function\"==typeof o,C=o===Object(o);if(C&&(w=i,i=o,o=void 0),null==i)throw new TypeError;w||(w={});var j=!(\"cap\"in w)||w.cap,L=!(\"curry\"in w)||w.curry,B=!(\"fixed\"in w)||w.fixed,$=!(\"immutable\"in w)||w.immutable,U=!(\"rearg\"in w)||w.rearg,V=x?i:u,z=\"curry\"in w&&w.curry,Y=\"fixed\"in w&&w.fixed,Z=\"rearg\"in w&&w.rearg,ee=x?i.runInContext():void 0,ie=x?i:{ary:s.ary,assign:s.assign,clone:s.clone,curry:s.curry,forEach:s.forEach,isArray:s.isArray,isError:s.isError,isFunction:s.isFunction,isWeakMap:s.isWeakMap,iteratee:s.iteratee,keys:s.keys,rearg:s.rearg,toInteger:s.toInteger,toPath:s.toPath},ae=ie.ary,ce=ie.assign,le=ie.clone,pe=ie.curry,de=ie.forEach,fe=ie.isArray,ye=ie.isError,be=ie.isFunction,_e=ie.isWeakMap,Se=ie.keys,we=ie.rearg,xe=ie.toInteger,Pe=ie.toPath,Te=Se(a.aryMethod),Re={castArray:function(s){return function(){var o=arguments[0];return fe(o)?s(cloneArray(o)):s.apply(void 0,arguments)}},iteratee:function(s){return function(){var o=arguments[1],i=s(arguments[0],o),a=i.length;return j&&\"number\"==typeof o?(o=o>2?o-2:1,a&&a<=o?i:baseAry(i,o)):i}},mixin:function(s){return function(o){var i=this;if(!be(i))return s(i,Object(o));var a=[];return de(Se(o),(function(s){be(o[s])&&a.push([s,i.prototype[s]])})),s(i,Object(o)),de(a,(function(s){var o=s[1];be(o)?i.prototype[s[0]]=o:delete i.prototype[s[0]]})),i}},nthArg:function(s){return function(o){var i=o<0?1:xe(o)+1;return pe(s(o),i)}},rearg:function(s){return function(o,i){var a=i?i.length:0;return pe(s(o,i),a)}},runInContext:function(o){return function(i){return baseConvert(s,o(i),w)}}};function castCap(s,o){if(j){var i=a.iterateeRearg[s];if(i)return function iterateeRearg(s,o){return overArg(s,(function(s){var i=o.length;return function baseArity(s,o){return 2==o?function(o,i){return s.apply(void 0,arguments)}:function(o){return s.apply(void 0,arguments)}}(we(baseAry(s,i),o),i)}))}(o,i);var u=!x&&a.iterateeAry[s];if(u)return function iterateeAry(s,o){return overArg(s,(function(s){return\"function\"==typeof s?baseAry(s,o):s}))}(o,u)}return o}function castFixed(s,o,i){if(B&&(Y||!a.skipFixed[s])){var u=a.methodSpread[s],w=u&&u.start;return void 0===w?ae(o,i):function flatSpread(s,o){return function(){for(var i=arguments.length,a=i-1,u=Array(i);i--;)u[i]=arguments[i];var w=u[o],x=u.slice(0,o);return w&&_.apply(x,w),o!=a&&_.apply(x,u.slice(o+1)),s.apply(this,x)}}(o,w)}return o}function castRearg(s,o,i){return U&&i>1&&(Z||!a.skipRearg[s])?we(o,a.methodRearg[s]||a.aryRearg[i]):o}function cloneByPath(s,o){for(var i=-1,a=(o=Pe(o)).length,u=a-1,_=le(Object(s)),w=_;null!=w&&++i<a;){var x=o[i],C=w[x];null==C||be(C)||ye(C)||_e(C)||(w[x]=le(i==u?C:Object(C))),w=w[x]}return _}function createConverter(s,o){var i=a.aliasToReal[s]||s,u=a.remap[i]||i,_=w;return function(s){var a=x?ee:ie,w=x?ee[u]:o,C=ce(ce({},_),s);return baseConvert(a,i,w,C)}}function overArg(s,o){return function(){var i=arguments.length;if(!i)return s();for(var a=Array(i);i--;)a[i]=arguments[i];var u=U?0:i-1;return a[u]=o(a[u]),s.apply(void 0,a)}}function wrap(s,o,i){var u,_=a.aliasToReal[s]||s,w=o,x=Re[_];return x?w=x(o):$&&(a.mutate.array[_]?w=wrapImmutable(o,cloneArray):a.mutate.object[_]?w=wrapImmutable(o,function createCloner(s){return function(o){return s({},o)}}(o)):a.mutate.set[_]&&(w=wrapImmutable(o,cloneByPath))),de(Te,(function(s){return de(a.aryMethod[s],(function(o){if(_==o){var i=a.methodSpread[_],x=i&&i.afterRearg;return u=x?castFixed(_,castRearg(_,w,s),s):castRearg(_,castFixed(_,w,s),s),u=function castCurry(s,o,i){return z||L&&i>1?pe(o,i):o}(0,u=castCap(_,u),s),!1}})),!u})),u||(u=w),u==o&&(u=z?pe(u,1):function(){return o.apply(this,arguments)}),u.convert=createConverter(_,o),u.placeholder=o.placeholder=i,u}if(!C)return wrap(o,i,V);var $e=i,qe=[];return de(Te,(function(s){de(a.aryMethod[s],(function(s){var o=$e[a.remap[s]||s];o&&qe.push([s,wrap(s,o,$e)])}))})),de(Se($e),(function(s){var o=$e[s];if(\"function\"==typeof o){for(var i=qe.length;i--;)if(qe[i][0]==s)return;o.convert=createConverter(s,o),qe.push([s,o])}})),de(qe,(function(s){$e[s[0]]=s[1]})),$e.convert=function convertLib(s){return $e.runInContext.convert(s)(void 0)},$e.placeholder=$e,de(Se($e),(function(s){de(a.realToAlias[s]||[],(function(o){$e[o]=$e[s]}))})),$e}},73448:(s,o,i)=>{\"use strict\";var a=i(73948),u=i(29367),_=i(87136),w=i(93742),x=i(76264)(\"iterator\");s.exports=function(s){if(!_(s))return u(s,x)||u(s,\"@@iterator\")||w[a(s)]}},73648:(s,o,i)=>{\"use strict\";var a=i(39447),u=i(98828),_=i(49552);s.exports=!a&&!u((function(){return 7!==Object.defineProperty(_(\"div\"),\"a\",{get:function(){return 7}}).a}))},73948:(s,o,i)=>{\"use strict\";var a=i(52623),u=i(62250),_=i(45807),w=i(76264)(\"toStringTag\"),x=Object,C=\"Arguments\"===_(function(){return arguments}());s.exports=a?_:function(s){var o,i,a;return void 0===s?\"Undefined\":null===s?\"Null\":\"string\"==typeof(i=function(s,o){try{return s[o]}catch(s){}}(o=x(s),w))?i:C?_(o):\"Object\"===(a=_(o))&&u(o.callee)?\"Arguments\":a}},73992:(s,o)=>{\"use strict\";var i=Object.prototype.hasOwnProperty;function decode(s){try{return decodeURIComponent(s.replace(/\\+/g,\" \"))}catch(s){return null}}function encode(s){try{return encodeURIComponent(s)}catch(s){return null}}o.stringify=function querystringify(s,o){o=o||\"\";var a,u,_=[];for(u in\"string\"!=typeof o&&(o=\"?\"),s)if(i.call(s,u)){if((a=s[u])||null!=a&&!isNaN(a)||(a=\"\"),u=encode(u),a=encode(a),null===u||null===a)continue;_.push(u+\"=\"+a)}return _.length?o+_.join(\"&\"):\"\"},o.parse=function querystring(s){for(var o,i=/([^=?#&]+)=?([^&]*)/g,a={};o=i.exec(s);){var u=decode(o[1]),_=decode(o[2]);null===u||null===_||u in a||(a[u]=_)}return a}},74218:s=>{s.exports=function isKeyable(s){var o=typeof s;return\"string\"==o||\"number\"==o||\"symbol\"==o||\"boolean\"==o?\"__proto__\"!==s:null===s}},74239:(s,o,i)=>{\"use strict\";var a=i(87136),u=TypeError;s.exports=function(s){if(a(s))throw new u(\"Can't call method on \"+s);return s}},74284:(s,o,i)=>{\"use strict\";var a=i(39447),u=i(73648),_=i(58661),w=i(36624),x=i(70470),C=TypeError,j=Object.defineProperty,L=Object.getOwnPropertyDescriptor,B=\"enumerable\",$=\"configurable\",U=\"writable\";o.f=a?_?function defineProperty(s,o,i){if(w(s),o=x(o),w(i),\"function\"==typeof s&&\"prototype\"===o&&\"value\"in i&&U in i&&!i[U]){var a=L(s,o);a&&a[U]&&(s[o]=i.value,i={configurable:$ in i?i[$]:a[$],enumerable:B in i?i[B]:a[B],writable:!1})}return j(s,o,i)}:j:function defineProperty(s,o,i){if(w(s),o=x(o),w(i),u)try{return j(s,o,i)}catch(s){}if(\"get\"in i||\"set\"in i)throw new C(\"Accessors not supported\");return\"value\"in i&&(s[o]=i.value),s}},74335:s=>{s.exports=function overArg(s,o){return function(i){return s(o(i))}}},74372:(s,o,i)=>{\"use strict\";var a=i(69675),u=i(36556)(\"TypedArray.prototype.buffer\",!0),_=i(35680);s.exports=u||function typedArrayBuffer(s){if(!_(s))throw new a(\"Not a Typed Array\");return s.buffer}},74436:(s,o,i)=>{\"use strict\";var a=i(4993),u=i(34849),_=i(20575),createMethod=function(s){return function(o,i,w){var x=a(o),C=_(x);if(0===C)return!s&&-1;var j,L=u(w,C);if(s&&i!=i){for(;C>L;)if((j=x[L++])!=j)return!0}else for(;C>L;L++)if((s||L in x)&&x[L]===i)return s||L||0;return!s&&-1}};s.exports={includes:createMethod(!0),indexOf:createMethod(!1)}},74610:(s,o,i)=>{\"use strict\";s.exports=Transform;var a=i(86048).F,u=a.ERR_METHOD_NOT_IMPLEMENTED,_=a.ERR_MULTIPLE_CALLBACK,w=a.ERR_TRANSFORM_ALREADY_TRANSFORMING,x=a.ERR_TRANSFORM_WITH_LENGTH_0,C=i(25382);function afterTransform(s,o){var i=this._transformState;i.transforming=!1;var a=i.writecb;if(null===a)return this.emit(\"error\",new _);i.writechunk=null,i.writecb=null,null!=o&&this.push(o),a(s);var u=this._readableState;u.reading=!1,(u.needReadable||u.length<u.highWaterMark)&&this._read(u.highWaterMark)}function Transform(s){if(!(this instanceof Transform))return new Transform(s);C.call(this,s),this._transformState={afterTransform:afterTransform.bind(this),needTransform:!1,transforming:!1,writecb:null,writechunk:null,writeencoding:null},this._readableState.needReadable=!0,this._readableState.sync=!1,s&&(\"function\"==typeof s.transform&&(this._transform=s.transform),\"function\"==typeof s.flush&&(this._flush=s.flush)),this.on(\"prefinish\",prefinish)}function prefinish(){var s=this;\"function\"!=typeof this._flush||this._readableState.destroyed?done(this,null,null):this._flush((function(o,i){done(s,o,i)}))}function done(s,o,i){if(o)return s.emit(\"error\",o);if(null!=i&&s.push(i),s._writableState.length)throw new x;if(s._transformState.transforming)throw new w;return s.push(null)}i(56698)(Transform,C),Transform.prototype.push=function(s,o){return this._transformState.needTransform=!1,C.prototype.push.call(this,s,o)},Transform.prototype._transform=function(s,o,i){i(new u(\"_transform()\"))},Transform.prototype._write=function(s,o,i){var a=this._transformState;if(a.writecb=i,a.writechunk=s,a.writeencoding=o,!a.transforming){var u=this._readableState;(a.needTransform||u.needReadable||u.length<u.highWaterMark)&&this._read(u.highWaterMark)}},Transform.prototype._read=function(s){var o=this._transformState;null===o.writechunk||o.transforming?o.needTransform=!0:(o.transforming=!0,this._transform(o.writechunk,o.writeencoding,o.afterTransform))},Transform.prototype._destroy=function(s,o){C.prototype._destroy.call(this,s,(function(s){o(s)}))}},74733:(s,o,i)=>{var a=i(21791),u=i(95950);s.exports=function baseAssign(s,o){return s&&a(o,u(o),s)}},75147:(s,o,i)=>{const a=i(85105);s.exports=class JSON06Serialiser extends a{serialise(s){if(!(s instanceof this.namespace.elements.Element))throw new TypeError(`Given element \\`${s}\\` is not an Element instance`);let o;s._attributes&&s.attributes.get(\"variable\")&&(o=s.attributes.get(\"variable\"));const i={element:s.element};s._meta&&s._meta.length>0&&(i.meta=this.serialiseObject(s.meta));const a=\"enum\"===s.element||-1!==s.attributes.keys().indexOf(\"enumerations\");if(a){const o=this.enumSerialiseAttributes(s);o&&(i.attributes=o)}else if(s._attributes&&s._attributes.length>0){let{attributes:a}=s;a.get(\"metadata\")&&(a=a.clone(),a.set(\"meta\",a.get(\"metadata\")),a.remove(\"metadata\")),\"member\"===s.element&&o&&(a=a.clone(),a.remove(\"variable\")),a.length>0&&(i.attributes=this.serialiseObject(a))}if(a)i.content=this.enumSerialiseContent(s,i);else if(this[`${s.element}SerialiseContent`])i.content=this[`${s.element}SerialiseContent`](s,i);else if(void 0!==s.content){let a;o&&s.content.key?(a=s.content.clone(),a.key.attributes.set(\"variable\",o),a=this.serialiseContent(a)):a=this.serialiseContent(s.content),this.shouldSerialiseContent(s,a)&&(i.content=a)}else this.shouldSerialiseContent(s,s.content)&&s instanceof this.namespace.elements.Array&&(i.content=[]);return i}shouldSerialiseContent(s,o){return\"parseResult\"===s.element||\"httpRequest\"===s.element||\"httpResponse\"===s.element||\"category\"===s.element||\"link\"===s.element||void 0!==o&&(!Array.isArray(o)||0!==o.length)}refSerialiseContent(s,o){return delete o.attributes,{href:s.toValue(),path:s.path.toValue()}}sourceMapSerialiseContent(s){return s.toValue()}dataStructureSerialiseContent(s){return[this.serialiseContent(s.content)]}enumSerialiseAttributes(s){const o=s.attributes.clone(),i=o.remove(\"enumerations\")||new this.namespace.elements.Array([]),a=o.get(\"default\");let u=o.get(\"samples\")||new this.namespace.elements.Array([]);if(a&&a.content&&(a.content.attributes&&a.content.attributes.remove(\"typeAttributes\"),o.set(\"default\",new this.namespace.elements.Array([a.content]))),u.forEach((s=>{s.content&&s.content.element&&s.content.attributes.remove(\"typeAttributes\")})),s.content&&0!==i.length&&u.unshift(s.content),u=u.map((s=>s instanceof this.namespace.elements.Array?[s]:new this.namespace.elements.Array([s.content]))),u.length&&o.set(\"samples\",u),o.length>0)return this.serialiseObject(o)}enumSerialiseContent(s){if(s._attributes){const o=s.attributes.get(\"enumerations\");if(o&&o.length>0)return o.content.map((s=>{const o=s.clone();return o.attributes.remove(\"typeAttributes\"),this.serialise(o)}))}if(s.content){const o=s.content.clone();return o.attributes.remove(\"typeAttributes\"),[this.serialise(o)]}return[]}deserialise(s){if(\"string\"==typeof s)return new this.namespace.elements.String(s);if(\"number\"==typeof s)return new this.namespace.elements.Number(s);if(\"boolean\"==typeof s)return new this.namespace.elements.Boolean(s);if(null===s)return new this.namespace.elements.Null;if(Array.isArray(s))return new this.namespace.elements.Array(s.map(this.deserialise,this));const o=this.namespace.getElementClass(s.element),i=new o;i.element!==s.element&&(i.element=s.element),s.meta&&this.deserialiseObject(s.meta,i.meta),s.attributes&&this.deserialiseObject(s.attributes,i.attributes);const a=this.deserialiseContent(s.content);if(void 0===a&&null!==i.content||(i.content=a),\"enum\"===i.element){i.content&&i.attributes.set(\"enumerations\",i.content);let s=i.attributes.get(\"samples\");if(i.attributes.remove(\"samples\"),s){const a=s;s=new this.namespace.elements.Array,a.forEach((a=>{a.forEach((a=>{const u=new o(a);u.element=i.element,s.push(u)}))}));const u=s.shift();i.content=u?u.content:void 0,i.attributes.set(\"samples\",s)}else i.content=void 0;let a=i.attributes.get(\"default\");if(a&&a.length>0){a=a.get(0);const s=new o(a);s.element=i.element,i.attributes.set(\"default\",s)}}else if(\"dataStructure\"===i.element&&Array.isArray(i.content))[i.content]=i.content;else if(\"category\"===i.element){const s=i.attributes.get(\"meta\");s&&(i.attributes.set(\"metadata\",s),i.attributes.remove(\"meta\"))}else\"member\"===i.element&&i.key&&i.key._attributes&&i.key._attributes.getValue(\"variable\")&&(i.attributes.set(\"variable\",i.key.attributes.get(\"variable\")),i.key.attributes.remove(\"variable\"));return i}serialiseContent(s){if(s instanceof this.namespace.elements.Element)return this.serialise(s);if(s instanceof this.namespace.KeyValuePair){const o={key:this.serialise(s.key)};return s.value&&(o.value=this.serialise(s.value)),o}return s&&s.map?s.map(this.serialise,this):s}deserialiseContent(s){if(s){if(s.element)return this.deserialise(s);if(s.key){const o=new this.namespace.KeyValuePair(this.deserialise(s.key));return s.value&&(o.value=this.deserialise(s.value)),o}if(s.map)return s.map(this.deserialise,this)}return s}shouldRefract(s){return!!(s._attributes&&s.attributes.keys().length||s._meta&&s.meta.keys().length)||\"enum\"!==s.element&&(s.element!==s.primitive()||\"member\"===s.element)}convertKeyToRefract(s,o){return this.shouldRefract(o)?this.serialise(o):\"enum\"===o.element?this.serialiseEnum(o):\"array\"===o.element?o.map((o=>this.shouldRefract(o)||\"default\"===s?this.serialise(o):\"array\"===o.element||\"object\"===o.element||\"enum\"===o.element?o.children.map((s=>this.serialise(s))):o.toValue())):\"object\"===o.element?(o.content||[]).map(this.serialise,this):o.toValue()}serialiseEnum(s){return s.children.map((s=>this.serialise(s)))}serialiseObject(s){const o={};return s.forEach(((s,i)=>{if(s){const a=i.toValue();o[a]=this.convertKeyToRefract(a,s)}})),o}deserialiseObject(s,o){Object.keys(s).forEach((i=>{o.set(i,this.deserialise(s[i]))}))}}},75208:s=>{\"use strict\";var o,i=\"\";s.exports=function repeat(s,a){if(\"string\"!=typeof s)throw new TypeError(\"expected a string\");if(1===a)return s;if(2===a)return s+s;var u=s.length*a;if(o!==s||void 0===o)o=s,i=\"\";else if(i.length>=u)return i.substr(0,u);for(;u>i.length&&a>1;)1&a&&(i+=s),a>>=1,s+=s;return i=(i+=s).substr(0,u)}},75251:s=>{var o=/\\{\\n\\/\\* \\[wrapped with (.+)\\] \\*/,i=/,? & /;s.exports=function getWrapDetails(s){var a=s.match(o);return a?a[1].split(i):[]}},75288:s=>{s.exports=function eq(s,o){return s===o||s!=s&&o!=o}},75795:(s,o,i)=>{\"use strict\";var a=i(6549);if(a)try{a([],\"length\")}catch(s){a=null}s.exports=a},75817:s=>{\"use strict\";s.exports=function(s,o){return{enumerable:!(1&s),configurable:!(2&s),writable:!(4&s),value:o}}},75880:s=>{\"use strict\";s.exports=Math.pow},75896:(s,o,i)=>{\"use strict\";var a=i(65606);function emitErrorAndCloseNT(s,o){emitErrorNT(s,o),emitCloseNT(s)}function emitCloseNT(s){s._writableState&&!s._writableState.emitClose||s._readableState&&!s._readableState.emitClose||s.emit(\"close\")}function emitErrorNT(s,o){s.emit(\"error\",o)}s.exports={destroy:function destroy(s,o){var i=this,u=this._readableState&&this._readableState.destroyed,_=this._writableState&&this._writableState.destroyed;return u||_?(o?o(s):s&&(this._writableState?this._writableState.errorEmitted||(this._writableState.errorEmitted=!0,a.nextTick(emitErrorNT,this,s)):a.nextTick(emitErrorNT,this,s)),this):(this._readableState&&(this._readableState.destroyed=!0),this._writableState&&(this._writableState.destroyed=!0),this._destroy(s||null,(function(s){!o&&s?i._writableState?i._writableState.errorEmitted?a.nextTick(emitCloseNT,i):(i._writableState.errorEmitted=!0,a.nextTick(emitErrorAndCloseNT,i,s)):a.nextTick(emitErrorAndCloseNT,i,s):o?(a.nextTick(emitCloseNT,i),o(s)):a.nextTick(emitCloseNT,i)})),this)},undestroy:function undestroy(){this._readableState&&(this._readableState.destroyed=!1,this._readableState.reading=!1,this._readableState.ended=!1,this._readableState.endEmitted=!1),this._writableState&&(this._writableState.destroyed=!1,this._writableState.ended=!1,this._writableState.ending=!1,this._writableState.finalCalled=!1,this._writableState.prefinished=!1,this._writableState.finished=!1,this._writableState.errorEmitted=!1)},errorOrDestroy:function errorOrDestroy(s,o){var i=s._readableState,a=s._writableState;i&&i.autoDestroy||a&&a.autoDestroy?s.destroy(o):s.emit(\"error\",o)}}},75948:(s,o,i)=>{var a=i(83729),u=i(15325),_=[[\"ary\",128],[\"bind\",1],[\"bindKey\",2],[\"curry\",8],[\"curryRight\",16],[\"flip\",512],[\"partial\",32],[\"partialRight\",64],[\"rearg\",256]];s.exports=function updateWrapDetails(s,o){return a(_,(function(i){var a=\"_.\"+i[0];o&i[1]&&!u(s,a)&&s.push(a)})),s.sort()}},76024:(s,o,i)=>{\"use strict\";var a=i(41505),u=Function.prototype,_=u.apply,w=u.call;s.exports=\"object\"==typeof Reflect&&Reflect.apply||(a?w.bind(_):function(){return w.apply(_,arguments)})},76169:(s,o,i)=>{var a=i(49653);s.exports=function cloneDataView(s,o){var i=o?a(s.buffer):s.buffer;return new s.constructor(i,s.byteOffset,s.byteLength)}},76189:s=>{var o=Object.prototype.hasOwnProperty;s.exports=function initCloneArray(s){var i=s.length,a=new s.constructor(i);return i&&\"string\"==typeof s[0]&&o.call(s,\"index\")&&(a.index=s.index,a.input=s.input),a}},76264:(s,o,i)=>{\"use strict\";var a=i(45951),u=i(85816),_=i(49724),w=i(6499),x=i(19846),C=i(51175),j=a.Symbol,L=u(\"wks\"),B=C?j.for||j:j&&j.withoutSetter||w;s.exports=function(s){return _(L,s)||(L[s]=x&&_(j,s)?j[s]:B(\"Symbol.\"+s)),L[s]}},76545:(s,o,i)=>{var a=i(56110)(i(9325),\"Set\");s.exports=a},76578:s=>{\"use strict\";s.exports=[\"Float16Array\",\"Float32Array\",\"Float64Array\",\"Int8Array\",\"Int16Array\",\"Int32Array\",\"Uint8Array\",\"Uint8ClampedArray\",\"Uint16Array\",\"Uint32Array\",\"BigInt64Array\",\"BigUint64Array\"]},76959:s=>{s.exports=function strictIndexOf(s,o,i){for(var a=i-1,u=s.length;++a<u;)if(s[a]===o)return a;return-1}},77078:(s,o,i)=>{var a=i(91033),u=i(82819),_=i(37471),w=i(18073),x=i(11287),C=i(36306),j=i(9325);s.exports=function createCurry(s,o,i){var L=u(s);return function wrapper(){for(var u=arguments.length,B=Array(u),$=u,U=x(wrapper);$--;)B[$]=arguments[$];var V=u<3&&B[0]!==U&&B[u-1]!==U?[]:C(B,U);return(u-=V.length)<i?w(s,o,_,wrapper.placeholder,void 0,B,V,void 0,void 0,i-u):a(this&&this!==j&&this instanceof wrapper?L:s,this,B)}}},77199:(s,o,i)=>{var a=i(49653),u=i(76169),_=i(73201),w=i(93736),x=i(71961);s.exports=function initCloneByTag(s,o,i){var C=s.constructor;switch(o){case\"[object ArrayBuffer]\":return a(s);case\"[object Boolean]\":case\"[object Date]\":return new C(+s);case\"[object DataView]\":return u(s,i);case\"[object Float32Array]\":case\"[object Float64Array]\":case\"[object Int8Array]\":case\"[object Int16Array]\":case\"[object Int32Array]\":case\"[object Uint8Array]\":case\"[object Uint8ClampedArray]\":case\"[object Uint16Array]\":case\"[object Uint32Array]\":return x(s,i);case\"[object Map]\":case\"[object Set]\":return new C;case\"[object Number]\":case\"[object String]\":return new C(s);case\"[object RegExp]\":return _(s);case\"[object Symbol]\":return w(s)}}},77556:(s,o,i)=>{var a=i(51873),u=i(34932),_=i(56449),w=i(44394),x=a?a.prototype:void 0,C=x?x.toString:void 0;s.exports=function baseToString(s){if(\"string\"==typeof s)return s;if(_(s))return u(s,baseToString)+\"\";if(w(s))return C?C.call(s):\"\";var o=s+\"\";return\"0\"==o&&1/s==-1/0?\"-0\":o}},77731:(s,o,i)=>{var a=i(79920)(\"set\",i(63560));a.placeholder=i(2874),s.exports=a},77797:(s,o,i)=>{var a=i(44394);s.exports=function toKey(s){if(\"string\"==typeof s||a(s))return s;var o=s+\"\";return\"0\"==o&&1/s==-1/0?\"-0\":o}},78004:s=>{\"use strict\";class SubRange{constructor(s,o){this.low=s,this.high=o,this.length=1+o-s}overlaps(s){return!(this.high<s.low||this.low>s.high)}touches(s){return!(this.high+1<s.low||this.low-1>s.high)}add(s){return new SubRange(Math.min(this.low,s.low),Math.max(this.high,s.high))}subtract(s){return s.low<=this.low&&s.high>=this.high?[]:s.low>this.low&&s.high<this.high?[new SubRange(this.low,s.low-1),new SubRange(s.high+1,this.high)]:s.low<=this.low?[new SubRange(s.high+1,this.high)]:[new SubRange(this.low,s.low-1)]}toString(){return this.low==this.high?this.low.toString():this.low+\"-\"+this.high}}class DRange{constructor(s,o){this.ranges=[],this.length=0,null!=s&&this.add(s,o)}_update_length(){this.length=this.ranges.reduce(((s,o)=>s+o.length),0)}add(s,o){var _add=s=>{for(var o=0;o<this.ranges.length&&!s.touches(this.ranges[o]);)o++;for(var i=this.ranges.slice(0,o);o<this.ranges.length&&s.touches(this.ranges[o]);)s=s.add(this.ranges[o]),o++;i.push(s),this.ranges=i.concat(this.ranges.slice(o)),this._update_length()};return s instanceof DRange?s.ranges.forEach(_add):(null==o&&(o=s),_add(new SubRange(s,o))),this}subtract(s,o){var _subtract=s=>{for(var o=0;o<this.ranges.length&&!s.overlaps(this.ranges[o]);)o++;for(var i=this.ranges.slice(0,o);o<this.ranges.length&&s.overlaps(this.ranges[o]);)i=i.concat(this.ranges[o].subtract(s)),o++;this.ranges=i.concat(this.ranges.slice(o)),this._update_length()};return s instanceof DRange?s.ranges.forEach(_subtract):(null==o&&(o=s),_subtract(new SubRange(s,o))),this}intersect(s,o){var i=[],_intersect=s=>{for(var o=0;o<this.ranges.length&&!s.overlaps(this.ranges[o]);)o++;for(;o<this.ranges.length&&s.overlaps(this.ranges[o]);){var a=Math.max(this.ranges[o].low,s.low),u=Math.min(this.ranges[o].high,s.high);i.push(new SubRange(a,u)),o++}};return s instanceof DRange?s.ranges.forEach(_intersect):(null==o&&(o=s),_intersect(new SubRange(s,o))),this.ranges=i,this._update_length(),this}index(s){for(var o=0;o<this.ranges.length&&this.ranges[o].length<=s;)s-=this.ranges[o].length,o++;return this.ranges[o].low+s}toString(){return\"[ \"+this.ranges.join(\", \")+\" ]\"}clone(){return new DRange(this)}numbers(){return this.ranges.reduce(((s,o)=>{for(var i=o.low;i<=o.high;)s.push(i),i++;return s}),[])}subranges(){return this.ranges.map((s=>({low:s.low,high:s.high,length:1+s.high-s.low})))}}s.exports=DRange},78096:s=>{s.exports=function baseTimes(s,o){for(var i=-1,a=Array(s);++i<s;)a[i]=o(i);return a}},78418:(s,o,i)=>{\"use strict\";i(85160)},79192:(s,o,i)=>{\"use strict\";var a=i(51871),u=i(46285),_=i(74239),w=i(10043);s.exports=Object.setPrototypeOf||(\"__proto__\"in{}?function(){var s,o=!1,i={};try{(s=a(Object.prototype,\"__proto__\",\"set\"))(i,[]),o=i instanceof Array}catch(s){}return function setPrototypeOf(i,a){return _(i),w(a),u(i)?(o?s(i,a):i.__proto__=a,i):i}}():void 0)},79290:s=>{\"use strict\";s.exports=RangeError},79307:(s,o,i)=>{\"use strict\";var a=i(11091),u=i(44673);a({target:\"Function\",proto:!0,forced:Function.bind!==u},{bind:u})},79538:s=>{\"use strict\";s.exports=ReferenceError},79612:s=>{\"use strict\";s.exports=Object},79770:s=>{s.exports=function arrayFilter(s,o){for(var i=-1,a=null==s?0:s.length,u=0,_=[];++i<a;){var w=s[i];o(w,i,s)&&(_[u++]=w)}return _}},79838:()=>{},79920:(s,o,i)=>{var a=i(73424),u=i(47934);s.exports=function convert(s,o,i){return a(u,s,o,i)}},80079:(s,o,i)=>{var a=i(63702),u=i(70080),_=i(24739),w=i(48655),x=i(31175);function ListCache(s){var o=-1,i=null==s?0:s.length;for(this.clear();++o<i;){var a=s[o];this.set(a[0],a[1])}}ListCache.prototype.clear=a,ListCache.prototype.delete=u,ListCache.prototype.get=_,ListCache.prototype.has=w,ListCache.prototype.set=x,s.exports=ListCache},80218:(s,o,i)=>{var a=i(13222);s.exports=function toLower(s){return a(s).toLowerCase()}},80257:(s,o,i)=>{var a=i(30980),u=i(56017),_=i(23007);s.exports=function wrapperClone(s){if(s instanceof a)return s.clone();var o=new u(s.__wrapped__,s.__chain__);return o.__actions__=_(s.__actions__),o.__index__=s.__index__,o.__values__=s.__values__,o}},80345:(s,o,i)=>{\"use strict\";function ownKeys(s,o){var i=Object.keys(s);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(s);o&&(a=a.filter((function(o){return Object.getOwnPropertyDescriptor(s,o).enumerable}))),i.push.apply(i,a)}return i}function _objectSpread(s){for(var o=1;o<arguments.length;o++){var i=null!=arguments[o]?arguments[o]:{};o%2?ownKeys(Object(i),!0).forEach((function(o){_defineProperty(s,o,i[o])})):Object.getOwnPropertyDescriptors?Object.defineProperties(s,Object.getOwnPropertyDescriptors(i)):ownKeys(Object(i)).forEach((function(o){Object.defineProperty(s,o,Object.getOwnPropertyDescriptor(i,o))}))}return s}function _defineProperty(s,o,i){return(o=_toPropertyKey(o))in s?Object.defineProperty(s,o,{value:i,enumerable:!0,configurable:!0,writable:!0}):s[o]=i,s}function _defineProperties(s,o){for(var i=0;i<o.length;i++){var a=o[i];a.enumerable=a.enumerable||!1,a.configurable=!0,\"value\"in a&&(a.writable=!0),Object.defineProperty(s,_toPropertyKey(a.key),a)}}function _toPropertyKey(s){var o=function _toPrimitive(s,o){if(\"object\"!=typeof s||null===s)return s;var i=s[Symbol.toPrimitive];if(void 0!==i){var a=i.call(s,o||\"default\");if(\"object\"!=typeof a)return a;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(\"string\"===o?String:Number)(s)}(s,\"string\");return\"symbol\"==typeof o?o:String(o)}var a=i(48287).Buffer,u=i(15340).inspect,_=u&&u.custom||\"inspect\";s.exports=function(){function BufferList(){!function _classCallCheck(s,o){if(!(s instanceof o))throw new TypeError(\"Cannot call a class as a function\")}(this,BufferList),this.head=null,this.tail=null,this.length=0}return function _createClass(s,o,i){return o&&_defineProperties(s.prototype,o),i&&_defineProperties(s,i),Object.defineProperty(s,\"prototype\",{writable:!1}),s}(BufferList,[{key:\"push\",value:function push(s){var o={data:s,next:null};this.length>0?this.tail.next=o:this.head=o,this.tail=o,++this.length}},{key:\"unshift\",value:function unshift(s){var o={data:s,next:this.head};0===this.length&&(this.tail=o),this.head=o,++this.length}},{key:\"shift\",value:function shift(){if(0!==this.length){var s=this.head.data;return 1===this.length?this.head=this.tail=null:this.head=this.head.next,--this.length,s}}},{key:\"clear\",value:function clear(){this.head=this.tail=null,this.length=0}},{key:\"join\",value:function join(s){if(0===this.length)return\"\";for(var o=this.head,i=\"\"+o.data;o=o.next;)i+=s+o.data;return i}},{key:\"concat\",value:function concat(s){if(0===this.length)return a.alloc(0);for(var o,i,u,_=a.allocUnsafe(s>>>0),w=this.head,x=0;w;)o=w.data,i=_,u=x,a.prototype.copy.call(o,i,u),x+=w.data.length,w=w.next;return _}},{key:\"consume\",value:function consume(s,o){var i;return s<this.head.data.length?(i=this.head.data.slice(0,s),this.head.data=this.head.data.slice(s)):i=s===this.head.data.length?this.shift():o?this._getString(s):this._getBuffer(s),i}},{key:\"first\",value:function first(){return this.head.data}},{key:\"_getString\",value:function _getString(s){var o=this.head,i=1,a=o.data;for(s-=a.length;o=o.next;){var u=o.data,_=s>u.length?u.length:s;if(_===u.length?a+=u:a+=u.slice(0,s),0===(s-=_)){_===u.length?(++i,o.next?this.head=o.next:this.head=this.tail=null):(this.head=o,o.data=u.slice(_));break}++i}return this.length-=i,a}},{key:\"_getBuffer\",value:function _getBuffer(s){var o=a.allocUnsafe(s),i=this.head,u=1;for(i.data.copy(o),s-=i.data.length;i=i.next;){var _=i.data,w=s>_.length?_.length:s;if(_.copy(o,o.length-s,0,w),0===(s-=w)){w===_.length?(++u,i.next?this.head=i.next:this.head=this.tail=null):(this.head=i,i.data=_.slice(w));break}++u}return this.length-=u,o}},{key:_,value:function value(s,o){return u(this,_objectSpread(_objectSpread({},o),{},{depth:0,customInspect:!1}))}}]),BufferList}()},80376:s=>{\"use strict\";s.exports=[\"constructor\",\"hasOwnProperty\",\"isPrototypeOf\",\"propertyIsEnumerable\",\"toLocaleString\",\"toString\",\"valueOf\"]},80631:(s,o,i)=>{var a=i(28077),u=i(49326);s.exports=function hasIn(s,o){return null!=s&&u(s,o,a)}},80909:(s,o,i)=>{var a=i(30641),u=i(38329)(a);s.exports=u},80945:(s,o,i)=>{var a=i(80079),u=i(68223),_=i(53661);s.exports=function stackSet(s,o){var i=this.__data__;if(i instanceof a){var w=i.__data__;if(!u||w.length<199)return w.push([s,o]),this.size=++i.size,this;i=this.__data__=new _(w)}return i.set(s,o),this.size=i.size,this}},81042:(s,o,i)=>{var a=i(56110)(Object,\"create\");s.exports=a},81214:(s,o,i)=>{\"use strict\";function _typeof(s){return _typeof=\"function\"==typeof Symbol&&\"symbol\"==typeof Symbol.iterator?function(s){return typeof s}:function(s){return s&&\"function\"==typeof Symbol&&s.constructor===Symbol&&s!==Symbol.prototype?\"symbol\":typeof s},_typeof(s)}Object.defineProperty(o,\"__esModule\",{value:!0}),o.DebounceInput=void 0;var a=_interopRequireDefault(i(96540)),u=_interopRequireDefault(i(20181)),_=[\"element\",\"onChange\",\"value\",\"minLength\",\"debounceTimeout\",\"forceNotifyByEnter\",\"forceNotifyOnBlur\",\"onKeyDown\",\"onBlur\",\"inputRef\"];function _interopRequireDefault(s){return s&&s.__esModule?s:{default:s}}function _objectWithoutProperties(s,o){if(null==s)return{};var i,a,u=function _objectWithoutPropertiesLoose(s,o){if(null==s)return{};var i,a,u={},_=Object.keys(s);for(a=0;a<_.length;a++)i=_[a],o.indexOf(i)>=0||(u[i]=s[i]);return u}(s,o);if(Object.getOwnPropertySymbols){var _=Object.getOwnPropertySymbols(s);for(a=0;a<_.length;a++)i=_[a],o.indexOf(i)>=0||Object.prototype.propertyIsEnumerable.call(s,i)&&(u[i]=s[i])}return u}function ownKeys(s,o){var i=Object.keys(s);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(s);o&&(a=a.filter((function(o){return Object.getOwnPropertyDescriptor(s,o).enumerable}))),i.push.apply(i,a)}return i}function _objectSpread(s){for(var o=1;o<arguments.length;o++){var i=null!=arguments[o]?arguments[o]:{};o%2?ownKeys(Object(i),!0).forEach((function(o){_defineProperty(s,o,i[o])})):Object.getOwnPropertyDescriptors?Object.defineProperties(s,Object.getOwnPropertyDescriptors(i)):ownKeys(Object(i)).forEach((function(o){Object.defineProperty(s,o,Object.getOwnPropertyDescriptor(i,o))}))}return s}function _defineProperties(s,o){for(var i=0;i<o.length;i++){var a=o[i];a.enumerable=a.enumerable||!1,a.configurable=!0,\"value\"in a&&(a.writable=!0),Object.defineProperty(s,a.key,a)}}function _setPrototypeOf(s,o){return _setPrototypeOf=Object.setPrototypeOf||function _setPrototypeOf(s,o){return s.__proto__=o,s},_setPrototypeOf(s,o)}function _createSuper(s){var o=function _isNativeReflectConstruct(){if(\"undefined\"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if(\"function\"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(s){return!1}}();return function _createSuperInternal(){var i,a=_getPrototypeOf(s);if(o){var u=_getPrototypeOf(this).constructor;i=Reflect.construct(a,arguments,u)}else i=a.apply(this,arguments);return function _possibleConstructorReturn(s,o){if(o&&(\"object\"===_typeof(o)||\"function\"==typeof o))return o;if(void 0!==o)throw new TypeError(\"Derived constructors may only return object or undefined\");return _assertThisInitialized(s)}(this,i)}}function _assertThisInitialized(s){if(void 0===s)throw new ReferenceError(\"this hasn't been initialised - super() hasn't been called\");return s}function _getPrototypeOf(s){return _getPrototypeOf=Object.setPrototypeOf?Object.getPrototypeOf:function _getPrototypeOf(s){return s.__proto__||Object.getPrototypeOf(s)},_getPrototypeOf(s)}function _defineProperty(s,o,i){return o in s?Object.defineProperty(s,o,{value:i,enumerable:!0,configurable:!0,writable:!0}):s[o]=i,s}var w=function(s){!function _inherits(s,o){if(\"function\"!=typeof o&&null!==o)throw new TypeError(\"Super expression must either be null or a function\");s.prototype=Object.create(o&&o.prototype,{constructor:{value:s,writable:!0,configurable:!0}}),Object.defineProperty(s,\"prototype\",{writable:!1}),o&&_setPrototypeOf(s,o)}(DebounceInput,s);var o=_createSuper(DebounceInput);function DebounceInput(s){var i;!function _classCallCheck(s,o){if(!(s instanceof o))throw new TypeError(\"Cannot call a class as a function\")}(this,DebounceInput),_defineProperty(_assertThisInitialized(i=o.call(this,s)),\"onChange\",(function(s){s.persist();var o=i.state.value,a=i.props.minLength;i.setState({value:s.target.value},(function(){var u=i.state.value;u.length>=a?i.notify(s):o.length>u.length&&i.notify(_objectSpread(_objectSpread({},s),{},{target:_objectSpread(_objectSpread({},s.target),{},{value:\"\"})}))}))})),_defineProperty(_assertThisInitialized(i),\"onKeyDown\",(function(s){\"Enter\"===s.key&&i.forceNotify(s);var o=i.props.onKeyDown;o&&(s.persist(),o(s))})),_defineProperty(_assertThisInitialized(i),\"onBlur\",(function(s){i.forceNotify(s);var o=i.props.onBlur;o&&(s.persist(),o(s))})),_defineProperty(_assertThisInitialized(i),\"createNotifier\",(function(s){if(s<0)i.notify=function(){return null};else if(0===s)i.notify=i.doNotify;else{var o=(0,u.default)((function(s){i.isDebouncing=!1,i.doNotify(s)}),s);i.notify=function(s){i.isDebouncing=!0,o(s)},i.flush=function(){return o.flush()},i.cancel=function(){i.isDebouncing=!1,o.cancel()}}})),_defineProperty(_assertThisInitialized(i),\"doNotify\",(function(){i.props.onChange.apply(void 0,arguments)})),_defineProperty(_assertThisInitialized(i),\"forceNotify\",(function(s){var o=i.props.debounceTimeout;if(i.isDebouncing||!(o>0)){i.cancel&&i.cancel();var a=i.state.value,u=i.props.minLength;a.length>=u?i.doNotify(s):i.doNotify(_objectSpread(_objectSpread({},s),{},{target:_objectSpread(_objectSpread({},s.target),{},{value:a})}))}})),i.isDebouncing=!1,i.state={value:void 0===s.value||null===s.value?\"\":s.value};var a=i.props.debounceTimeout;return i.createNotifier(a),i}return function _createClass(s,o,i){return o&&_defineProperties(s.prototype,o),i&&_defineProperties(s,i),Object.defineProperty(s,\"prototype\",{writable:!1}),s}(DebounceInput,[{key:\"componentDidUpdate\",value:function componentDidUpdate(s){if(!this.isDebouncing){var o=this.props,i=o.value,a=o.debounceTimeout,u=s.debounceTimeout,_=s.value,w=this.state.value;void 0!==i&&_!==i&&w!==i&&this.setState({value:i}),a!==u&&this.createNotifier(a)}}},{key:\"componentWillUnmount\",value:function componentWillUnmount(){this.flush&&this.flush()}},{key:\"render\",value:function render(){var s,o,i=this.props,u=i.element,w=(i.onChange,i.value,i.minLength,i.debounceTimeout,i.forceNotifyByEnter),x=i.forceNotifyOnBlur,C=i.onKeyDown,j=i.onBlur,L=i.inputRef,B=_objectWithoutProperties(i,_),$=this.state.value;s=w?{onKeyDown:this.onKeyDown}:C?{onKeyDown:C}:{},o=x?{onBlur:this.onBlur}:j?{onBlur:j}:{};var U=L?{ref:L}:{};return a.default.createElement(u,_objectSpread(_objectSpread(_objectSpread(_objectSpread({},B),{},{onChange:this.onChange,value:$},s),o),U))}}]),DebounceInput}(a.default.PureComponent);o.DebounceInput=w,_defineProperty(w,\"defaultProps\",{element:\"input\",type:\"text\",onKeyDown:void 0,onBlur:void 0,value:void 0,minLength:0,debounceTimeout:100,forceNotifyByEnter:!0,forceNotifyOnBlur:!0,inputRef:void 0})},81919:(s,o,i)=>{\"use strict\";var a=i(48287).Buffer;function isSpecificValue(s){return s instanceof a||s instanceof Date||s instanceof RegExp}function cloneSpecificValue(s){if(s instanceof a){var o=a.alloc?a.alloc(s.length):new a(s.length);return s.copy(o),o}if(s instanceof Date)return new Date(s.getTime());if(s instanceof RegExp)return new RegExp(s);throw new Error(\"Unexpected situation\")}function deepCloneArray(s){var o=[];return s.forEach((function(s,i){\"object\"==typeof s&&null!==s?Array.isArray(s)?o[i]=deepCloneArray(s):isSpecificValue(s)?o[i]=cloneSpecificValue(s):o[i]=u({},s):o[i]=s})),o}function safeGetProperty(s,o){return\"__proto__\"===o?void 0:s[o]}var u=s.exports=function(){if(arguments.length<1||\"object\"!=typeof arguments[0])return!1;if(arguments.length<2)return arguments[0];var s,o,i=arguments[0];return Array.prototype.slice.call(arguments,1).forEach((function(a){\"object\"!=typeof a||null===a||Array.isArray(a)||Object.keys(a).forEach((function(_){return o=safeGetProperty(i,_),(s=safeGetProperty(a,_))===i?void 0:\"object\"!=typeof s||null===s?void(i[_]=s):Array.isArray(s)?void(i[_]=deepCloneArray(s)):isSpecificValue(s)?void(i[_]=cloneSpecificValue(s)):\"object\"!=typeof o||null===o||Array.isArray(o)?void(i[_]=u({},s)):void(i[_]=u(o,s))}))})),i}},82048:(s,o,i)=>{\"use strict\";var a=i(11091),u=i(88280),_=i(15972),w=i(79192),x=i(19595),C=i(58075),j=i(61626),L=i(75817),B=i(39259),$=i(85884),U=i(24823),V=i(32096),z=i(76264)(\"toStringTag\"),Y=Error,Z=[].push,ee=function AggregateError(s,o){var i,a=u(ie,this);w?i=w(new Y,a?_(this):ie):(i=a?this:C(ie),j(i,z,\"Error\")),void 0!==o&&j(i,\"message\",V(o)),$(i,ee,i.stack,1),arguments.length>2&&B(i,arguments[2]);var x=[];return U(s,Z,{that:x}),j(i,\"errors\",x),i};w?w(ee,Y):x(ee,Y,{name:!0});var ie=ee.prototype=C(Y.prototype,{constructor:L(1,ee),message:L(1,\"\"),name:L(1,\"AggregateError\")});a({global:!0,constructor:!0,arity:2},{AggregateError:ee})},82159:(s,o,i)=>{\"use strict\";var a=i(62250),u=i(4640),_=TypeError;s.exports=function(s){if(a(s))return s;throw new _(u(s)+\" is not a function\")}},82199:(s,o,i)=>{var a=i(14528),u=i(56449);s.exports=function baseGetAllKeys(s,o,i){var _=o(s);return u(s)?_:a(_,i(s))}},82261:(s,o,i)=>{\"use strict\";Object.defineProperty(o,\"__esModule\",{value:!0});var a=_interopRequireDefault(i(9404)),u=_interopRequireDefault(i(48590));function _interopRequireDefault(s){return s&&s.__esModule?s:{default:s}}o.default=function(s,o,i){var _=Object.keys(o);if(!_.length)return\"Store does not have a valid reducer. Make sure the argument passed to combineReducers is an object whose values are reducers.\";var w=(0,u.default)(i);if(a.default.isImmutable?!a.default.isImmutable(s):!a.default.Iterable.isIterable(s))return\"The \"+w+' is of unexpected type. Expected argument to be an instance of Immutable.Collection or Immutable.Record with the following properties: \"'+_.join('\", \"')+'\".';var x=s.toSeq().keySeq().toArray().filter((function(s){return!o.hasOwnProperty(s)}));return x.length>0?\"Unexpected \"+(1===x.length?\"property\":\"properties\")+' \"'+x.join('\", \"')+'\" found in '+w+'. Expected to find one of the known reducer property names instead: \"'+_.join('\", \"')+'\". Unexpected properties will be ignored.':null},s.exports=o.default},82682:(s,o,i)=>{\"use strict\";var a=i(69600),u=Object.prototype.toString,_=Object.prototype.hasOwnProperty;s.exports=function forEach(s,o,i){if(!a(o))throw new TypeError(\"iterator must be a function\");var w;arguments.length>=3&&(w=i),function isArray(s){return\"[object Array]\"===u.call(s)}(s)?function forEachArray(s,o,i){for(var a=0,u=s.length;a<u;a++)_.call(s,a)&&(null==i?o(s[a],a,s):o.call(i,s[a],a,s))}(s,o,w):\"string\"==typeof s?function forEachString(s,o,i){for(var a=0,u=s.length;a<u;a++)null==i?o(s.charAt(a),a,s):o.call(i,s.charAt(a),a,s)}(s,o,w):function forEachObject(s,o,i){for(var a in s)_.call(s,a)&&(null==i?o(s[a],a,s):o.call(i,s[a],a,s))}(s,o,w)}},82819:(s,o,i)=>{var a=i(39344),u=i(23805);s.exports=function createCtor(s){return function(){var o=arguments;switch(o.length){case 0:return new s;case 1:return new s(o[0]);case 2:return new s(o[0],o[1]);case 3:return new s(o[0],o[1],o[2]);case 4:return new s(o[0],o[1],o[2],o[3]);case 5:return new s(o[0],o[1],o[2],o[3],o[4]);case 6:return new s(o[0],o[1],o[2],o[3],o[4],o[5]);case 7:return new s(o[0],o[1],o[2],o[3],o[4],o[5],o[6])}var i=a(s.prototype),_=s.apply(i,o);return u(_)?_:i}}},82890:(s,o,i)=>{\"use strict\";var a=i(56698),u=i(90392),_=i(92861).Buffer,w=[1116352408,3609767458,1899447441,602891725,3049323471,3964484399,3921009573,2173295548,961987163,4081628472,1508970993,3053834265,2453635748,2937671579,2870763221,3664609560,3624381080,2734883394,310598401,1164996542,607225278,1323610764,1426881987,3590304994,1925078388,4068182383,2162078206,991336113,2614888103,633803317,3248222580,3479774868,3835390401,2666613458,4022224774,944711139,264347078,2341262773,604807628,2007800933,770255983,1495990901,1249150122,1856431235,1555081692,3175218132,1996064986,2198950837,2554220882,3999719339,2821834349,766784016,2952996808,2566594879,3210313671,3203337956,3336571891,1034457026,3584528711,2466948901,113926993,3758326383,338241895,168717936,666307205,1188179964,773529912,1546045734,1294757372,1522805485,1396182291,2643833823,1695183700,2343527390,1986661051,1014477480,2177026350,1206759142,2456956037,344077627,2730485921,1290863460,2820302411,3158454273,3259730800,3505952657,3345764771,106217008,3516065817,3606008344,3600352804,1432725776,4094571909,1467031594,275423344,851169720,430227734,3100823752,506948616,1363258195,659060556,3750685593,883997877,3785050280,958139571,3318307427,1322822218,3812723403,1537002063,2003034995,1747873779,3602036899,1955562222,1575990012,2024104815,1125592928,2227730452,2716904306,2361852424,442776044,2428436474,593698344,2756734187,3733110249,3204031479,2999351573,3329325298,3815920427,3391569614,3928383900,3515267271,566280711,3940187606,3454069534,4118630271,4000239992,116418474,1914138554,174292421,2731055270,289380356,3203993006,460393269,320620315,685471733,587496836,852142971,1086792851,1017036298,365543100,1126000580,2618297676,1288033470,3409855158,1501505948,4234509866,1607167915,987167468,1816402316,1246189591],x=new Array(160);function Sha512(){this.init(),this._w=x,u.call(this,128,112)}function Ch(s,o,i){return i^s&(o^i)}function maj(s,o,i){return s&o|i&(s|o)}function sigma0(s,o){return(s>>>28|o<<4)^(o>>>2|s<<30)^(o>>>7|s<<25)}function sigma1(s,o){return(s>>>14|o<<18)^(s>>>18|o<<14)^(o>>>9|s<<23)}function Gamma0(s,o){return(s>>>1|o<<31)^(s>>>8|o<<24)^s>>>7}function Gamma0l(s,o){return(s>>>1|o<<31)^(s>>>8|o<<24)^(s>>>7|o<<25)}function Gamma1(s,o){return(s>>>19|o<<13)^(o>>>29|s<<3)^s>>>6}function Gamma1l(s,o){return(s>>>19|o<<13)^(o>>>29|s<<3)^(s>>>6|o<<26)}function getCarry(s,o){return s>>>0<o>>>0?1:0}a(Sha512,u),Sha512.prototype.init=function(){return this._ah=1779033703,this._bh=3144134277,this._ch=1013904242,this._dh=2773480762,this._eh=1359893119,this._fh=2600822924,this._gh=528734635,this._hh=1541459225,this._al=4089235720,this._bl=2227873595,this._cl=4271175723,this._dl=1595750129,this._el=2917565137,this._fl=725511199,this._gl=4215389547,this._hl=327033209,this},Sha512.prototype._update=function(s){for(var o=this._w,i=0|this._ah,a=0|this._bh,u=0|this._ch,_=0|this._dh,x=0|this._eh,C=0|this._fh,j=0|this._gh,L=0|this._hh,B=0|this._al,$=0|this._bl,U=0|this._cl,V=0|this._dl,z=0|this._el,Y=0|this._fl,Z=0|this._gl,ee=0|this._hl,ie=0;ie<32;ie+=2)o[ie]=s.readInt32BE(4*ie),o[ie+1]=s.readInt32BE(4*ie+4);for(;ie<160;ie+=2){var ae=o[ie-30],ce=o[ie-30+1],le=Gamma0(ae,ce),pe=Gamma0l(ce,ae),de=Gamma1(ae=o[ie-4],ce=o[ie-4+1]),fe=Gamma1l(ce,ae),ye=o[ie-14],be=o[ie-14+1],_e=o[ie-32],Se=o[ie-32+1],we=pe+be|0,xe=le+ye+getCarry(we,pe)|0;xe=(xe=xe+de+getCarry(we=we+fe|0,fe)|0)+_e+getCarry(we=we+Se|0,Se)|0,o[ie]=xe,o[ie+1]=we}for(var Pe=0;Pe<160;Pe+=2){xe=o[Pe],we=o[Pe+1];var Te=maj(i,a,u),Re=maj(B,$,U),$e=sigma0(i,B),qe=sigma0(B,i),ze=sigma1(x,z),We=sigma1(z,x),He=w[Pe],Ye=w[Pe+1],Xe=Ch(x,C,j),Qe=Ch(z,Y,Z),et=ee+We|0,tt=L+ze+getCarry(et,ee)|0;tt=(tt=(tt=tt+Xe+getCarry(et=et+Qe|0,Qe)|0)+He+getCarry(et=et+Ye|0,Ye)|0)+xe+getCarry(et=et+we|0,we)|0;var rt=qe+Re|0,nt=$e+Te+getCarry(rt,qe)|0;L=j,ee=Z,j=C,Z=Y,C=x,Y=z,x=_+tt+getCarry(z=V+et|0,V)|0,_=u,V=U,u=a,U=$,a=i,$=B,i=tt+nt+getCarry(B=et+rt|0,et)|0}this._al=this._al+B|0,this._bl=this._bl+$|0,this._cl=this._cl+U|0,this._dl=this._dl+V|0,this._el=this._el+z|0,this._fl=this._fl+Y|0,this._gl=this._gl+Z|0,this._hl=this._hl+ee|0,this._ah=this._ah+i+getCarry(this._al,B)|0,this._bh=this._bh+a+getCarry(this._bl,$)|0,this._ch=this._ch+u+getCarry(this._cl,U)|0,this._dh=this._dh+_+getCarry(this._dl,V)|0,this._eh=this._eh+x+getCarry(this._el,z)|0,this._fh=this._fh+C+getCarry(this._fl,Y)|0,this._gh=this._gh+j+getCarry(this._gl,Z)|0,this._hh=this._hh+L+getCarry(this._hl,ee)|0},Sha512.prototype._hash=function(){var s=_.allocUnsafe(64);function writeInt64BE(o,i,a){s.writeInt32BE(o,a),s.writeInt32BE(i,a+4)}return writeInt64BE(this._ah,this._al,0),writeInt64BE(this._bh,this._bl,8),writeInt64BE(this._ch,this._cl,16),writeInt64BE(this._dh,this._dl,24),writeInt64BE(this._eh,this._el,32),writeInt64BE(this._fh,this._fl,40),writeInt64BE(this._gh,this._gl,48),writeInt64BE(this._hh,this._hl,56),s},s.exports=Sha512},83120:(s,o,i)=>{var a=i(14528),u=i(45891);s.exports=function baseFlatten(s,o,i,_,w){var x=-1,C=s.length;for(i||(i=u),w||(w=[]);++x<C;){var j=s[x];o>0&&i(j)?o>1?baseFlatten(j,o-1,i,_,w):a(w,j):_||(w[w.length]=j)}return w}},83141:(s,o,i)=>{\"use strict\";var a=i(92861).Buffer,u=a.isEncoding||function(s){switch((s=\"\"+s)&&s.toLowerCase()){case\"hex\":case\"utf8\":case\"utf-8\":case\"ascii\":case\"binary\":case\"base64\":case\"ucs2\":case\"ucs-2\":case\"utf16le\":case\"utf-16le\":case\"raw\":return!0;default:return!1}};function StringDecoder(s){var o;switch(this.encoding=function normalizeEncoding(s){var o=function _normalizeEncoding(s){if(!s)return\"utf8\";for(var o;;)switch(s){case\"utf8\":case\"utf-8\":return\"utf8\";case\"ucs2\":case\"ucs-2\":case\"utf16le\":case\"utf-16le\":return\"utf16le\";case\"latin1\":case\"binary\":return\"latin1\";case\"base64\":case\"ascii\":case\"hex\":return s;default:if(o)return;s=(\"\"+s).toLowerCase(),o=!0}}(s);if(\"string\"!=typeof o&&(a.isEncoding===u||!u(s)))throw new Error(\"Unknown encoding: \"+s);return o||s}(s),this.encoding){case\"utf16le\":this.text=utf16Text,this.end=utf16End,o=4;break;case\"utf8\":this.fillLast=utf8FillLast,o=4;break;case\"base64\":this.text=base64Text,this.end=base64End,o=3;break;default:return this.write=simpleWrite,void(this.end=simpleEnd)}this.lastNeed=0,this.lastTotal=0,this.lastChar=a.allocUnsafe(o)}function utf8CheckByte(s){return s<=127?0:s>>5==6?2:s>>4==14?3:s>>3==30?4:s>>6==2?-1:-2}function utf8FillLast(s){var o=this.lastTotal-this.lastNeed,i=function utf8CheckExtraBytes(s,o,i){if(128!=(192&o[0]))return s.lastNeed=0,\"�\";if(s.lastNeed>1&&o.length>1){if(128!=(192&o[1]))return s.lastNeed=1,\"�\";if(s.lastNeed>2&&o.length>2&&128!=(192&o[2]))return s.lastNeed=2,\"�\"}}(this,s);return void 0!==i?i:this.lastNeed<=s.length?(s.copy(this.lastChar,o,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal)):(s.copy(this.lastChar,o,0,s.length),void(this.lastNeed-=s.length))}function utf16Text(s,o){if((s.length-o)%2==0){var i=s.toString(\"utf16le\",o);if(i){var a=i.charCodeAt(i.length-1);if(a>=55296&&a<=56319)return this.lastNeed=2,this.lastTotal=4,this.lastChar[0]=s[s.length-2],this.lastChar[1]=s[s.length-1],i.slice(0,-1)}return i}return this.lastNeed=1,this.lastTotal=2,this.lastChar[0]=s[s.length-1],s.toString(\"utf16le\",o,s.length-1)}function utf16End(s){var o=s&&s.length?this.write(s):\"\";if(this.lastNeed){var i=this.lastTotal-this.lastNeed;return o+this.lastChar.toString(\"utf16le\",0,i)}return o}function base64Text(s,o){var i=(s.length-o)%3;return 0===i?s.toString(\"base64\",o):(this.lastNeed=3-i,this.lastTotal=3,1===i?this.lastChar[0]=s[s.length-1]:(this.lastChar[0]=s[s.length-2],this.lastChar[1]=s[s.length-1]),s.toString(\"base64\",o,s.length-i))}function base64End(s){var o=s&&s.length?this.write(s):\"\";return this.lastNeed?o+this.lastChar.toString(\"base64\",0,3-this.lastNeed):o}function simpleWrite(s){return s.toString(this.encoding)}function simpleEnd(s){return s&&s.length?this.write(s):\"\"}o.I=StringDecoder,StringDecoder.prototype.write=function(s){if(0===s.length)return\"\";var o,i;if(this.lastNeed){if(void 0===(o=this.fillLast(s)))return\"\";i=this.lastNeed,this.lastNeed=0}else i=0;return i<s.length?o?o+this.text(s,i):this.text(s,i):o||\"\"},StringDecoder.prototype.end=function utf8End(s){var o=s&&s.length?this.write(s):\"\";return this.lastNeed?o+\"�\":o},StringDecoder.prototype.text=function utf8Text(s,o){var i=function utf8CheckIncomplete(s,o,i){var a=o.length-1;if(a<i)return 0;var u=utf8CheckByte(o[a]);if(u>=0)return u>0&&(s.lastNeed=u-1),u;if(--a<i||-2===u)return 0;if(u=utf8CheckByte(o[a]),u>=0)return u>0&&(s.lastNeed=u-2),u;if(--a<i||-2===u)return 0;if(u=utf8CheckByte(o[a]),u>=0)return u>0&&(2===u?u=0:s.lastNeed=u-3),u;return 0}(this,s,o);if(!this.lastNeed)return s.toString(\"utf8\",o);this.lastTotal=i;var a=s.length-(i-this.lastNeed);return s.copy(this.lastChar,0,a),s.toString(\"utf8\",o,a)},StringDecoder.prototype.fillLast=function(s){if(this.lastNeed<=s.length)return s.copy(this.lastChar,this.lastTotal-this.lastNeed,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal);s.copy(this.lastChar,this.lastTotal-this.lastNeed,0,s.length),this.lastNeed-=s.length}},83221:s=>{s.exports=function createBaseFor(s){return function(o,i,a){for(var u=-1,_=Object(o),w=a(o),x=w.length;x--;){var C=w[s?x:++u];if(!1===i(_[C],C,_))break}return o}}},83349:(s,o,i)=>{var a=i(82199),u=i(86375),_=i(37241);s.exports=function getAllKeysIn(s){return a(s,_,u)}},83488:s=>{s.exports=function identity(s){return s}},83693:(s,o,i)=>{var a=i(64894),u=i(40346);s.exports=function isArrayLikeObject(s){return u(s)&&a(s)}},83729:s=>{s.exports=function arrayEach(s,o){for(var i=-1,a=null==s?0:s.length;++i<a&&!1!==o(s[i],i,s););return s}},84058:(s,o,i)=>{var a=i(14792),u=i(45539)((function(s,o,i){return o=o.toLowerCase(),s+(i?a(o):o)}));s.exports=u},84195:(s,o,i)=>{var a=i(66977),u=i(38816),_=u((function(s,o){return a(s,256,void 0,void 0,void 0,o)}));s.exports=_},84247:s=>{s.exports=function setToArray(s){var o=-1,i=Array(s.size);return s.forEach((function(s){i[++o]=s})),i}},84629:s=>{s.exports={}},84851:(s,o,i)=>{\"use strict\";s.exports=i(85401)},84977:(s,o,i)=>{\"use strict\";Object.defineProperty(o,\"__esModule\",{value:!0});var a=function _interopRequireDefault(s){return s&&s.__esModule?s:{default:s}}(i(9404)),u=i(55674);o.default=function(s){var o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:a.default.Map,i=Object.keys(s);return function(){var a=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o(),_=arguments[1];return a.withMutations((function(o){i.forEach((function(i){var a=(0,s[i])(o.get(i),_);(0,u.validateNextState)(a,i,_),o.set(i,a)}))}))}},s.exports=o.default},85015:(s,o,i)=>{var a=i(72552),u=i(56449),_=i(40346);s.exports=function isString(s){return\"string\"==typeof s||!u(s)&&_(s)&&\"[object String]\"==a(s)}},85087:(s,o,i)=>{var a=i(30980),u=i(37381),_=i(62284),w=i(53758);s.exports=function isLaziable(s){var o=_(s),i=w[o];if(\"function\"!=typeof i||!(o in a.prototype))return!1;if(s===i)return!0;var x=u(i);return!!x&&s===x[0]}},85105:s=>{s.exports=class JSONSerialiser{constructor(s){this.namespace=s||new this.Namespace}serialise(s){if(!(s instanceof this.namespace.elements.Element))throw new TypeError(`Given element \\`${s}\\` is not an Element instance`);const o={element:s.element};s._meta&&s._meta.length>0&&(o.meta=this.serialiseObject(s.meta)),s._attributes&&s._attributes.length>0&&(o.attributes=this.serialiseObject(s.attributes));const i=this.serialiseContent(s.content);return void 0!==i&&(o.content=i),o}deserialise(s){if(!s.element)throw new Error(\"Given value is not an object containing an element name\");const o=new(this.namespace.getElementClass(s.element));o.element!==s.element&&(o.element=s.element),s.meta&&this.deserialiseObject(s.meta,o.meta),s.attributes&&this.deserialiseObject(s.attributes,o.attributes);const i=this.deserialiseContent(s.content);return void 0===i&&null!==o.content||(o.content=i),o}serialiseContent(s){if(s instanceof this.namespace.elements.Element)return this.serialise(s);if(s instanceof this.namespace.KeyValuePair){const o={key:this.serialise(s.key)};return s.value&&(o.value=this.serialise(s.value)),o}if(s&&s.map){if(0===s.length)return;return s.map(this.serialise,this)}return s}deserialiseContent(s){if(s){if(s.element)return this.deserialise(s);if(s.key){const o=new this.namespace.KeyValuePair(this.deserialise(s.key));return s.value&&(o.value=this.deserialise(s.value)),o}if(s.map)return s.map(this.deserialise,this)}return s}serialiseObject(s){const o={};if(s.forEach(((s,i)=>{s&&(o[i.toValue()]=this.serialise(s))})),0!==Object.keys(o).length)return o}deserialiseObject(s,o){Object.keys(s).forEach((i=>{o.set(i,this.deserialise(s[i]))}))}}},85160:(s,o,i)=>{\"use strict\";var a=i(96540);var u=\"function\"==typeof Object.is?Object.is:function is(s,o){return s===o&&(0!==s||1/s==1/o)||s!=s&&o!=o},_=a.useSyncExternalStore,w=a.useRef,x=a.useEffect,C=a.useMemo,j=a.useDebugValue},85250:(s,o,i)=>{var a=i(37217),u=i(87805),_=i(86649),w=i(42824),x=i(23805),C=i(37241),j=i(14974);s.exports=function baseMerge(s,o,i,L,B){s!==o&&_(o,(function(_,C){if(B||(B=new a),x(_))w(s,o,C,i,baseMerge,L,B);else{var $=L?L(j(s,C),_,C+\"\",s,o,B):void 0;void 0===$&&($=_),u(s,C,$)}}),C)}},85401:(s,o,i)=>{\"use strict\";var a=i(462);s.exports=a},85463:s=>{s.exports=function baseIsNaN(s){return s!=s}},85558:s=>{s.exports=function baseReduce(s,o,i,a,u){return u(s,(function(s,u,_){i=a?(a=!1,s):o(i,s,u,_)})),i}},85582:(s,o,i)=>{\"use strict\";var a=i(92046),u=i(45951),_=i(62250),aFunction=function(s){return _(s)?s:void 0};s.exports=function(s,o){return arguments.length<2?aFunction(a[s])||aFunction(u[s]):a[s]&&a[s][o]||u[s]&&u[s][o]}},85587:(s,o,i)=>{\"use strict\";var a=i(26311),u=create(Error);function create(s){return FormattedError.displayName=s.displayName||s.name,FormattedError;function FormattedError(o){return o&&(o=a.apply(null,arguments)),new s(o)}}s.exports=u,u.eval=create(EvalError),u.range=create(RangeError),u.reference=create(ReferenceError),u.syntax=create(SyntaxError),u.type=create(TypeError),u.uri=create(URIError),u.create=create},85762:(s,o,i)=>{\"use strict\";var a=i(1907),u=Error,_=a(\"\".replace),w=String(new u(\"zxcasd\").stack),x=/\\n\\s*at [^:]*:[^\\n]*/,C=x.test(w);s.exports=function(s,o){if(C&&\"string\"==typeof s&&!u.prepareStackTrace)for(;o--;)s=_(s,x,\"\");return s}},85816:(s,o,i)=>{\"use strict\";var a=i(36128);s.exports=function(s,o){return a[s]||(a[s]=o||{})}},85884:(s,o,i)=>{\"use strict\";var a=i(61626),u=i(85762),_=i(23888),w=Error.captureStackTrace;s.exports=function(s,o,i,x){_&&(w?w(s,o):a(s,\"stack\",u(i,x)))}},86009:(s,o,i)=>{s=i.nmd(s);var a=i(34840),u=o&&!o.nodeType&&o,_=u&&s&&!s.nodeType&&s,w=_&&_.exports===u&&a.process,x=function(){try{var s=_&&_.require&&_.require(\"util\").types;return s||w&&w.binding&&w.binding(\"util\")}catch(s){}}();s.exports=x},86048:s=>{\"use strict\";var o={};function createErrorType(s,i,a){a||(a=Error);var u=function(s){function NodeError(o,a,u){return s.call(this,function getMessage(s,o,a){return\"string\"==typeof i?i:i(s,o,a)}(o,a,u))||this}return function _inheritsLoose(s,o){s.prototype=Object.create(o.prototype),s.prototype.constructor=s,s.__proto__=o}(NodeError,s),NodeError}(a);u.prototype.name=a.name,u.prototype.code=s,o[s]=u}function oneOf(s,o){if(Array.isArray(s)){var i=s.length;return s=s.map((function(s){return String(s)})),i>2?\"one of \".concat(o,\" \").concat(s.slice(0,i-1).join(\", \"),\", or \")+s[i-1]:2===i?\"one of \".concat(o,\" \").concat(s[0],\" or \").concat(s[1]):\"of \".concat(o,\" \").concat(s[0])}return\"of \".concat(o,\" \").concat(String(s))}createErrorType(\"ERR_INVALID_OPT_VALUE\",(function(s,o){return'The value \"'+o+'\" is invalid for option \"'+s+'\"'}),TypeError),createErrorType(\"ERR_INVALID_ARG_TYPE\",(function(s,o,i){var a,u;if(\"string\"==typeof o&&function startsWith(s,o,i){return s.substr(!i||i<0?0:+i,o.length)===o}(o,\"not \")?(a=\"must not be\",o=o.replace(/^not /,\"\")):a=\"must be\",function endsWith(s,o,i){return(void 0===i||i>s.length)&&(i=s.length),s.substring(i-o.length,i)===o}(s,\" argument\"))u=\"The \".concat(s,\" \").concat(a,\" \").concat(oneOf(o,\"type\"));else{var _=function includes(s,o,i){return\"number\"!=typeof i&&(i=0),!(i+o.length>s.length)&&-1!==s.indexOf(o,i)}(s,\".\")?\"property\":\"argument\";u='The \"'.concat(s,'\" ').concat(_,\" \").concat(a,\" \").concat(oneOf(o,\"type\"))}return u+=\". Received type \".concat(typeof i)}),TypeError),createErrorType(\"ERR_STREAM_PUSH_AFTER_EOF\",\"stream.push() after EOF\"),createErrorType(\"ERR_METHOD_NOT_IMPLEMENTED\",(function(s){return\"The \"+s+\" method is not implemented\"})),createErrorType(\"ERR_STREAM_PREMATURE_CLOSE\",\"Premature close\"),createErrorType(\"ERR_STREAM_DESTROYED\",(function(s){return\"Cannot call \"+s+\" after a stream was destroyed\"})),createErrorType(\"ERR_MULTIPLE_CALLBACK\",\"Callback called multiple times\"),createErrorType(\"ERR_STREAM_CANNOT_PIPE\",\"Cannot pipe, not readable\"),createErrorType(\"ERR_STREAM_WRITE_AFTER_END\",\"write after end\"),createErrorType(\"ERR_STREAM_NULL_VALUES\",\"May not write null values to stream\",TypeError),createErrorType(\"ERR_UNKNOWN_ENCODING\",(function(s){return\"Unknown encoding: \"+s}),TypeError),createErrorType(\"ERR_STREAM_UNSHIFT_AFTER_END_EVENT\",\"stream.unshift() after end event\"),s.exports.F=o},86215:function(s,o){var i,a,u;a=[],i=function(){\"use strict\";var isNativeSmoothScrollEnabledOn=function(s){return s&&\"getComputedStyle\"in window&&\"smooth\"===window.getComputedStyle(s)[\"scroll-behavior\"]};if(\"undefined\"==typeof window||!(\"document\"in window))return{};var makeScroller=function(s,o,i){var a;o=o||999,i||0===i||(i=9);var setScrollTimeoutId=function(s){a=s},stopScroll=function(){clearTimeout(a),setScrollTimeoutId(0)},getTopWithEdgeOffset=function(o){return Math.max(0,s.getTopOf(o)-i)},scrollToY=function(i,a,u){if(stopScroll(),0===a||a&&a<0||isNativeSmoothScrollEnabledOn(s.body))s.toY(i),u&&u();else{var _=s.getY(),w=Math.max(0,i)-_,x=(new Date).getTime();a=a||Math.min(Math.abs(w),o),function loopScroll(){setScrollTimeoutId(setTimeout((function(){var o=Math.min(1,((new Date).getTime()-x)/a),i=Math.max(0,Math.floor(_+w*(o<.5?2*o*o:o*(4-2*o)-1)));s.toY(i),o<1&&s.getHeight()+i<s.body.scrollHeight?loopScroll():(setTimeout(stopScroll,99),u&&u())}),9))}()}},scrollToElem=function(s,o,i){scrollToY(getTopWithEdgeOffset(s),o,i)},scrollIntoView=function(o,a,u){var _=o.getBoundingClientRect().height,w=s.getTopOf(o)+_,x=s.getHeight(),C=s.getY(),j=C+x;getTopWithEdgeOffset(o)<C||_+i>x?scrollToElem(o,a,u):w+i>j?scrollToY(w-x+i,a,u):u&&u()},scrollToCenterOf=function(o,i,a,u){scrollToY(Math.max(0,s.getTopOf(o)-s.getHeight()/2+(a||o.getBoundingClientRect().height/2)),i,u)};return{setup:function(s,a){return(0===s||s)&&(o=s),(0===a||a)&&(i=a),{defaultDuration:o,edgeOffset:i}},to:scrollToElem,toY:scrollToY,intoView:scrollIntoView,center:scrollToCenterOf,stop:stopScroll,moving:function(){return!!a},getY:s.getY,getTopOf:s.getTopOf}},s=document.documentElement,getDocY=function(){return window.scrollY||s.scrollTop},o=makeScroller({body:document.scrollingElement||document.body,toY:function(s){window.scrollTo(0,s)},getY:getDocY,getHeight:function(){return window.innerHeight||s.clientHeight},getTopOf:function(o){return o.getBoundingClientRect().top+getDocY()-s.offsetTop}});if(o.createScroller=function(o,i,a){return makeScroller({body:o,toY:function(s){o.scrollTop=s},getY:function(){return o.scrollTop},getHeight:function(){return Math.min(o.clientHeight,window.innerHeight||s.clientHeight)},getTopOf:function(s){return s.offsetTop}},i,a)},\"addEventListener\"in window&&!window.noZensmooth&&!isNativeSmoothScrollEnabledOn(document.body)){var i=\"history\"in window&&\"pushState\"in history,a=i&&\"scrollRestoration\"in history;a&&(history.scrollRestoration=\"auto\"),window.addEventListener(\"load\",(function(){a&&(setTimeout((function(){history.scrollRestoration=\"manual\"}),9),window.addEventListener(\"popstate\",(function(s){s.state&&\"zenscrollY\"in s.state&&o.toY(s.state.zenscrollY)}),!1)),window.location.hash&&setTimeout((function(){var s=o.setup().edgeOffset;if(s){var i=document.getElementById(window.location.href.split(\"#\")[1]);if(i){var a=Math.max(0,o.getTopOf(i)-s),u=o.getY()-a;0<=u&&u<9&&window.scrollTo(0,a)}}}),9)}),!1);var u=new RegExp(\"(^|\\\\s)noZensmooth(\\\\s|$)\");window.addEventListener(\"click\",(function(s){for(var _=s.target;_&&\"A\"!==_.tagName;)_=_.parentNode;if(!(!_||1!==s.which||s.shiftKey||s.metaKey||s.ctrlKey||s.altKey)){if(a){var w=history.state&&\"object\"==typeof history.state?history.state:{};w.zenscrollY=o.getY();try{history.replaceState(w,\"\")}catch(s){}}var x=_.getAttribute(\"href\")||\"\";if(0===x.indexOf(\"#\")&&!u.test(_.className)){var C=0,j=document.getElementById(x.substring(1));if(\"#\"!==x){if(!j)return;C=o.getTopOf(j)}s.preventDefault();var onDone=function(){window.location=x},L=o.setup().edgeOffset;L&&(C=Math.max(0,C-L),i&&(onDone=function(){history.pushState({},\"\",x)})),o.toY(C,null,onDone)}}}),!1)}return o}(),void 0===(u=\"function\"==typeof i?i.apply(o,a):i)||(s.exports=u)},86238:(s,o,i)=>{\"use strict\";var a=i(86048).F.ERR_STREAM_PREMATURE_CLOSE;function noop(){}s.exports=function eos(s,o,i){if(\"function\"==typeof o)return eos(s,null,o);o||(o={}),i=function once(s){var o=!1;return function(){if(!o){o=!0;for(var i=arguments.length,a=new Array(i),u=0;u<i;u++)a[u]=arguments[u];s.apply(this,a)}}}(i||noop);var u=o.readable||!1!==o.readable&&s.readable,_=o.writable||!1!==o.writable&&s.writable,w=function onlegacyfinish(){s.writable||C()},x=s._writableState&&s._writableState.finished,C=function onfinish(){_=!1,x=!0,u||i.call(s)},j=s._readableState&&s._readableState.endEmitted,L=function onend(){u=!1,j=!0,_||i.call(s)},B=function onerror(o){i.call(s,o)},$=function onclose(){var o;return u&&!j?(s._readableState&&s._readableState.ended||(o=new a),i.call(s,o)):_&&!x?(s._writableState&&s._writableState.ended||(o=new a),i.call(s,o)):void 0},U=function onrequest(){s.req.on(\"finish\",C)};return!function isRequest(s){return s.setHeader&&\"function\"==typeof s.abort}(s)?_&&!s._writableState&&(s.on(\"end\",w),s.on(\"close\",w)):(s.on(\"complete\",C),s.on(\"abort\",$),s.req?U():s.on(\"request\",U)),s.on(\"end\",L),s.on(\"finish\",C),!1!==o.error&&s.on(\"error\",B),s.on(\"close\",$),function(){s.removeListener(\"complete\",C),s.removeListener(\"abort\",$),s.removeListener(\"request\",U),s.req&&s.req.removeListener(\"finish\",C),s.removeListener(\"end\",w),s.removeListener(\"close\",w),s.removeListener(\"finish\",C),s.removeListener(\"end\",L),s.removeListener(\"error\",B),s.removeListener(\"close\",$)}}},86303:(s,o,i)=>{const a=i(10316);s.exports=class LinkElement extends a{constructor(s,o,i){super(s||[],o,i),this.element=\"link\"}get relation(){return this.attributes.get(\"relation\")}set relation(s){this.attributes.set(\"relation\",s)}get href(){return this.attributes.get(\"href\")}set href(s){this.attributes.set(\"href\",s)}}},86375:(s,o,i)=>{var a=i(14528),u=i(28879),_=i(4664),w=i(63345),x=Object.getOwnPropertySymbols?function(s){for(var o=[];s;)a(o,_(s)),s=u(s);return o}:w;s.exports=x},86649:(s,o,i)=>{var a=i(83221)();s.exports=a},86804:(s,o,i)=>{const a=i(10316),u=i(41067),_=i(71167),w=i(40239),x=i(12242),C=i(6233),j=i(87726),L=i(61045),B=i(86303),$=i(14540),U=i(92340),V=i(10866),z=i(55973);function refract(s){if(s instanceof a)return s;if(\"string\"==typeof s)return new _(s);if(\"number\"==typeof s)return new w(s);if(\"boolean\"==typeof s)return new x(s);if(null===s)return new u;if(Array.isArray(s))return new C(s.map(refract));if(\"object\"==typeof s){return new L(s)}return s}a.prototype.ObjectElement=L,a.prototype.RefElement=$,a.prototype.MemberElement=j,a.prototype.refract=refract,U.prototype.refract=refract,s.exports={Element:a,NullElement:u,StringElement:_,NumberElement:w,BooleanElement:x,ArrayElement:C,MemberElement:j,ObjectElement:L,LinkElement:B,RefElement:$,refract,ArraySlice:U,ObjectSlice:V,KeyValuePair:z}},87068:(s,o,i)=>{var a=i(37217),u=i(25911),_=i(21986),w=i(50689),x=i(5861),C=i(56449),j=i(3656),L=i(37167),B=\"[object Arguments]\",$=\"[object Array]\",U=\"[object Object]\",V=Object.prototype.hasOwnProperty;s.exports=function baseIsEqualDeep(s,o,i,z,Y,Z){var ee=C(s),ie=C(o),ae=ee?$:x(s),ce=ie?$:x(o),le=(ae=ae==B?U:ae)==U,pe=(ce=ce==B?U:ce)==U,de=ae==ce;if(de&&j(s)){if(!j(o))return!1;ee=!0,le=!1}if(de&&!le)return Z||(Z=new a),ee||L(s)?u(s,o,i,z,Y,Z):_(s,o,ae,i,z,Y,Z);if(!(1&i)){var fe=le&&V.call(s,\"__wrapped__\"),ye=pe&&V.call(o,\"__wrapped__\");if(fe||ye){var be=fe?s.value():s,_e=ye?o.value():o;return Z||(Z=new a),Y(be,_e,i,z,Z)}}return!!de&&(Z||(Z=new a),w(s,o,i,z,Y,Z))}},87136:s=>{\"use strict\";s.exports=function(s){return null==s}},87170:(s,o)=>{\"use strict\";o.f=Object.getOwnPropertySymbols},87296:(s,o,i)=>{var a,u=i(55481),_=(a=/[^.]+$/.exec(u&&u.keys&&u.keys.IE_PROTO||\"\"))?\"Symbol(src)_1.\"+a:\"\";s.exports=function isMasked(s){return!!_&&_ in s}},87586:(s,o,i)=>{const a=i(6205),u=i(10023),_={0:0,t:9,n:10,v:11,f:12,r:13};o.strToChars=function(s){return s=s.replace(/(\\[\\\\b\\])|(\\\\)?\\\\(?:u([A-F0-9]{4})|x([A-F0-9]{2})|(0?[0-7]{2})|c([@A-Z[\\\\\\]^?])|([0tnvfr]))/g,(function(s,o,i,a,u,w,x,C){if(i)return s;var j=o?8:a?parseInt(a,16):u?parseInt(u,16):w?parseInt(w,8):x?\"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^ ?\".indexOf(x):_[C],L=String.fromCharCode(j);return/[[\\]{}^$.|?*+()]/.test(L)&&(L=\"\\\\\"+L),L}))},o.tokenizeClass=(s,i)=>{for(var _,w,x=[],C=/\\\\(?:(w)|(d)|(s)|(W)|(D)|(S))|((?:(?:\\\\)(.)|([^\\]\\\\]))-(?:\\\\)?([^\\]]))|(\\])|(?:\\\\)?([^])/g;null!=(_=C.exec(s));)if(_[1])x.push(u.words());else if(_[2])x.push(u.ints());else if(_[3])x.push(u.whitespace());else if(_[4])x.push(u.notWords());else if(_[5])x.push(u.notInts());else if(_[6])x.push(u.notWhitespace());else if(_[7])x.push({type:a.RANGE,from:(_[8]||_[9]).charCodeAt(0),to:_[10].charCodeAt(0)});else{if(!(w=_[12]))return[x,C.lastIndex];x.push({type:a.CHAR,value:w.charCodeAt(0)})}o.error(i,\"Unterminated character class\")},o.error=(s,o)=>{throw new SyntaxError(\"Invalid regular expression: /\"+s+\"/: \"+o)}},87726:(s,o,i)=>{const a=i(55973),u=i(10316);s.exports=class MemberElement extends u{constructor(s,o,i,u){super(new a,i,u),this.element=\"member\",this.key=s,this.value=o}get key(){return this.content.key}set key(s){this.content.key=this.refract(s)}get value(){return this.content.value}set value(s){this.content.value=this.refract(s)}}},87730:(s,o,i)=>{var a=i(29172),u=i(27301),_=i(86009),w=_&&_.isMap,x=w?u(w):a;s.exports=x},87805:(s,o,i)=>{var a=i(43360),u=i(75288);s.exports=function assignMergeValue(s,o,i){(void 0!==i&&!u(s[o],i)||void 0===i&&!(o in s))&&a(s,o,i)}},87978:(s,o,i)=>{var a=i(60270),u=i(58156),_=i(80631),w=i(28586),x=i(30756),C=i(67197),j=i(77797);s.exports=function baseMatchesProperty(s,o){return w(s)&&x(o)?C(j(s),o):function(i){var w=u(i,s);return void 0===w&&w===o?_(i,s):a(o,w,3)}}},88280:(s,o,i)=>{\"use strict\";var a=i(1907);s.exports=a({}.isPrototypeOf)},88310:(s,o,i)=>{s.exports=Stream;var a=i(37007).EventEmitter;function Stream(){a.call(this)}i(56698)(Stream,a),Stream.Readable=i(45412),Stream.Writable=i(16708),Stream.Duplex=i(25382),Stream.Transform=i(74610),Stream.PassThrough=i(63600),Stream.finished=i(86238),Stream.pipeline=i(57758),Stream.Stream=Stream,Stream.prototype.pipe=function(s,o){var i=this;function ondata(o){s.writable&&!1===s.write(o)&&i.pause&&i.pause()}function ondrain(){i.readable&&i.resume&&i.resume()}i.on(\"data\",ondata),s.on(\"drain\",ondrain),s._isStdio||o&&!1===o.end||(i.on(\"end\",onend),i.on(\"close\",onclose));var u=!1;function onend(){u||(u=!0,s.end())}function onclose(){u||(u=!0,\"function\"==typeof s.destroy&&s.destroy())}function onerror(s){if(cleanup(),0===a.listenerCount(this,\"error\"))throw s}function cleanup(){i.removeListener(\"data\",ondata),s.removeListener(\"drain\",ondrain),i.removeListener(\"end\",onend),i.removeListener(\"close\",onclose),i.removeListener(\"error\",onerror),s.removeListener(\"error\",onerror),i.removeListener(\"end\",cleanup),i.removeListener(\"close\",cleanup),s.removeListener(\"close\",cleanup)}return i.on(\"error\",onerror),s.on(\"error\",onerror),i.on(\"end\",cleanup),i.on(\"close\",cleanup),s.on(\"close\",cleanup),s.emit(\"pipe\",i),s}},88984:(s,o,i)=>{var a=i(55527),u=i(3650),_=Object.prototype.hasOwnProperty;s.exports=function baseKeys(s){if(!a(s))return u(s);var o=[];for(var i in Object(s))_.call(s,i)&&\"constructor\"!=i&&o.push(i);return o}},89353:s=>{\"use strict\";var o=Object.prototype.toString,i=Math.max,a=function concatty(s,o){for(var i=[],a=0;a<s.length;a+=1)i[a]=s[a];for(var u=0;u<o.length;u+=1)i[u+s.length]=o[u];return i};s.exports=function bind(s){var u=this;if(\"function\"!=typeof u||\"[object Function]\"!==o.apply(u))throw new TypeError(\"Function.prototype.bind called on incompatible \"+u);for(var _,w=function slicy(s,o){for(var i=[],a=o||0,u=0;a<s.length;a+=1,u+=1)i[u]=s[a];return i}(arguments,1),x=i(0,u.length-w.length),C=[],j=0;j<x;j++)C[j]=\"$\"+j;if(_=Function(\"binder\",\"return function (\"+function(s,o){for(var i=\"\",a=0;a<s.length;a+=1)i+=s[a],a+1<s.length&&(i+=o);return i}(C,\",\")+\"){ return binder.apply(this,arguments); }\")((function(){if(this instanceof _){var o=u.apply(this,a(w,arguments));return Object(o)===o?o:this}return u.apply(s,a(w,arguments))})),u.prototype){var L=function Empty(){};L.prototype=u.prototype,_.prototype=new L,L.prototype=null}return _}},89593:(s,o,i)=>{\"use strict\";o.H=void 0;var a=function _interopRequireDefault(s){return s&&s.__esModule?s:{default:s}}(i(84977));o.H=a.default},89935:s=>{s.exports=function stubFalse(){return!1}},90160:(s,o,i)=>{\"use strict\";var a=i(73948),u=String;s.exports=function(s){if(\"Symbol\"===a(s))throw new TypeError(\"Cannot convert a Symbol value to a string\");return u(s)}},90179:(s,o,i)=>{var a=i(34932),u=i(9999),_=i(19931),w=i(31769),x=i(21791),C=i(53138),j=i(38816),L=i(83349),B=j((function(s,o){var i={};if(null==s)return i;var j=!1;o=a(o,(function(o){return o=w(o,s),j||(j=o.length>1),o})),x(s,L(s),i),j&&(i=u(i,7,C));for(var B=o.length;B--;)_(i,o[B]);return i}));s.exports=B},90181:s=>{s.exports=function nativeKeysIn(s){var o=[];if(null!=s)for(var i in Object(s))o.push(i);return o}},90289:(s,o,i)=>{var a=i(12651);s.exports=function mapCacheGet(s){return a(this,s).get(s)}},90392:(s,o,i)=>{\"use strict\";var a=i(92861).Buffer,u=i(15377);function Hash(s,o){this._block=a.alloc(s),this._finalSize=o,this._blockSize=s,this._len=0}Hash.prototype.update=function(s,o){s=u(s,o||\"utf8\");for(var i=this._block,a=this._blockSize,_=s.length,w=this._len,x=0;x<_;){for(var C=w%a,j=Math.min(_-x,a-C),L=0;L<j;L++)i[C+L]=s[x+L];x+=j,(w+=j)%a==0&&this._update(i)}return this._len+=_,this},Hash.prototype.digest=function(s){var o=this._len%this._blockSize;this._block[o]=128,this._block.fill(0,o+1),o>=this._finalSize&&(this._update(this._block),this._block.fill(0));var i=8*this._len;if(i<=4294967295)this._block.writeUInt32BE(i,this._blockSize-4);else{var a=(4294967295&i)>>>0,u=(i-a)/4294967296;this._block.writeUInt32BE(u,this._blockSize-8),this._block.writeUInt32BE(a,this._blockSize-4)}this._update(this._block);var _=this._hash();return s?_.toString(s):_},Hash.prototype._update=function(){throw new Error(\"_update must be implemented by subclass\")},s.exports=Hash},90916:(s,o,i)=>{var a=i(80909);s.exports=function baseSome(s,o){var i;return a(s,(function(s,a,u){return!(i=o(s,a,u))})),!!i}},90938:s=>{s.exports=function stackDelete(s){var o=this.__data__,i=o.delete(s);return this.size=o.size,i}},91033:s=>{s.exports=function apply(s,o,i){switch(i.length){case 0:return s.call(o);case 1:return s.call(o,i[0]);case 2:return s.call(o,i[0],i[1]);case 3:return s.call(o,i[0],i[1],i[2])}return s.apply(o,i)}},91596:s=>{var o=Math.max;s.exports=function composeArgs(s,i,a,u){for(var _=-1,w=s.length,x=a.length,C=-1,j=i.length,L=o(w-x,0),B=Array(j+L),$=!u;++C<j;)B[C]=i[C];for(;++_<x;)($||_<w)&&(B[a[_]]=s[_]);for(;L--;)B[C++]=s[_++];return B}},91599:(s,o,i)=>{\"use strict\";i(64502)},92046:s=>{\"use strict\";s.exports={}},92063:s=>{\"use strict\";s.exports=function required(s,o){if(o=o.split(\":\")[0],!(s=+s))return!1;switch(o){case\"http\":case\"ws\":return 80!==s;case\"https\":case\"wss\":return 443!==s;case\"ftp\":return 21!==s;case\"gopher\":return 70!==s;case\"file\":return!1}return 0!==s}},92271:(s,o,i)=>{var a=i(21791),u=i(4664);s.exports=function copySymbols(s,o){return a(s,u(s),o)}},92340:(s,o,i)=>{const a=i(6048);function coerceElementMatchingCallback(s){return\"string\"==typeof s?o=>o.element===s:s.constructor&&s.extend?o=>o instanceof s:s}class ArraySlice{constructor(s){this.elements=s||[]}toValue(){return this.elements.map((s=>s.toValue()))}map(s,o){return this.elements.map(s,o)}flatMap(s,o){return this.map(s,o).reduce(((s,o)=>s.concat(o)),[])}compactMap(s,o){const i=[];return this.forEach((a=>{const u=s.bind(o)(a);u&&i.push(u)})),i}filter(s,o){return s=coerceElementMatchingCallback(s),new ArraySlice(this.elements.filter(s,o))}reject(s,o){return s=coerceElementMatchingCallback(s),new ArraySlice(this.elements.filter(a(s),o))}find(s,o){return s=coerceElementMatchingCallback(s),this.elements.find(s,o)}forEach(s,o){this.elements.forEach(s,o)}reduce(s,o){return this.elements.reduce(s,o)}includes(s){return this.elements.some((o=>o.equals(s)))}shift(){return this.elements.shift()}unshift(s){this.elements.unshift(this.refract(s))}push(s){return this.elements.push(this.refract(s)),this}add(s){this.push(s)}get(s){return this.elements[s]}getValue(s){const o=this.elements[s];if(o)return o.toValue()}get length(){return this.elements.length}get isEmpty(){return 0===this.elements.length}get first(){return this.elements[0]}}\"undefined\"!=typeof Symbol&&(ArraySlice.prototype[Symbol.iterator]=function symbol(){return this.elements[Symbol.iterator]()}),s.exports=ArraySlice},92361:(s,o,i)=>{\"use strict\";var a=i(45807),u=i(1907);s.exports=function(s){if(\"Function\"===a(s))return u(s)}},92522:(s,o,i)=>{\"use strict\";var a=i(85816),u=i(6499),_=a(\"keys\");s.exports=function(s){return _[s]||(_[s]=u(s))}},92861:(s,o,i)=>{var a=i(48287),u=a.Buffer;function copyProps(s,o){for(var i in s)o[i]=s[i]}function SafeBuffer(s,o,i){return u(s,o,i)}u.from&&u.alloc&&u.allocUnsafe&&u.allocUnsafeSlow?s.exports=a:(copyProps(a,o),o.Buffer=SafeBuffer),SafeBuffer.prototype=Object.create(u.prototype),copyProps(u,SafeBuffer),SafeBuffer.from=function(s,o,i){if(\"number\"==typeof s)throw new TypeError(\"Argument must not be a number\");return u(s,o,i)},SafeBuffer.alloc=function(s,o,i){if(\"number\"!=typeof s)throw new TypeError(\"Argument must be a number\");var a=u(s);return void 0!==o?\"string\"==typeof i?a.fill(o,i):a.fill(o):a.fill(0),a},SafeBuffer.allocUnsafe=function(s){if(\"number\"!=typeof s)throw new TypeError(\"Argument must be a number\");return u(s)},SafeBuffer.allocUnsafeSlow=function(s){if(\"number\"!=typeof s)throw new TypeError(\"Argument must be a number\");return a.SlowBuffer(s)}},93243:(s,o,i)=>{var a=i(56110),u=function(){try{var s=a(Object,\"defineProperty\");return s({},\"\",{}),s}catch(s){}}();s.exports=u},93290:(s,o,i)=>{s=i.nmd(s);var a=i(9325),u=o&&!o.nodeType&&o,_=u&&s&&!s.nodeType&&s,w=_&&_.exports===u?a.Buffer:void 0,x=w?w.allocUnsafe:void 0;s.exports=function cloneBuffer(s,o){if(o)return s.slice();var i=s.length,a=x?x(i):new s.constructor(i);return s.copy(a),a}},93427:(s,o,i)=>{\"use strict\";var a=i(1907);s.exports=a([].slice)},93628:(s,o,i)=>{\"use strict\";var a=i(48648),u=i(71064),_=i(7176);s.exports=a?function getProto(s){return a(s)}:u?function getProto(s){if(!s||\"object\"!=typeof s&&\"function\"!=typeof s)throw new TypeError(\"getProto: not an object\");return u(s)}:_?function getProto(s){return _(s)}:null},93663:(s,o,i)=>{var a=i(41799),u=i(10776),_=i(67197);s.exports=function baseMatches(s){var o=u(s);return 1==o.length&&o[0][2]?_(o[0][0],o[0][1]):function(i){return i===s||a(i,s,o)}}},93700:(s,o,i)=>{\"use strict\";var a=i(19709);s.exports=a},93736:(s,o,i)=>{var a=i(51873),u=a?a.prototype:void 0,_=u?u.valueOf:void 0;s.exports=function cloneSymbol(s){return _?Object(_.call(s)):{}}},93742:s=>{\"use strict\";s.exports={}},94033:s=>{s.exports=function baseLodash(){}},94459:s=>{\"use strict\";s.exports=Number.isNaN||function isNaN(s){return s!=s}},94643:(s,o,i)=>{function config(s){try{if(!i.g.localStorage)return!1}catch(s){return!1}var o=i.g.localStorage[s];return null!=o&&\"true\"===String(o).toLowerCase()}s.exports=function deprecate(s,o){if(config(\"noDeprecation\"))return s;var i=!1;return function deprecated(){if(!i){if(config(\"throwDeprecation\"))throw new Error(o);config(\"traceDeprecation\")?console.trace(o):console.warn(o),i=!0}return s.apply(this,arguments)}}},95089:s=>{const o=\"[A-Za-z$_][0-9A-Za-z$_]*\",i=[\"as\",\"in\",\"of\",\"if\",\"for\",\"while\",\"finally\",\"var\",\"new\",\"function\",\"do\",\"return\",\"void\",\"else\",\"break\",\"catch\",\"instanceof\",\"with\",\"throw\",\"case\",\"default\",\"try\",\"switch\",\"continue\",\"typeof\",\"delete\",\"let\",\"yield\",\"const\",\"class\",\"debugger\",\"async\",\"await\",\"static\",\"import\",\"from\",\"export\",\"extends\"],a=[\"true\",\"false\",\"null\",\"undefined\",\"NaN\",\"Infinity\"],u=[].concat([\"setInterval\",\"setTimeout\",\"clearInterval\",\"clearTimeout\",\"require\",\"exports\",\"eval\",\"isFinite\",\"isNaN\",\"parseFloat\",\"parseInt\",\"decodeURI\",\"decodeURIComponent\",\"encodeURI\",\"encodeURIComponent\",\"escape\",\"unescape\"],[\"arguments\",\"this\",\"super\",\"console\",\"window\",\"document\",\"localStorage\",\"module\",\"global\"],[\"Intl\",\"DataView\",\"Number\",\"Math\",\"Date\",\"String\",\"RegExp\",\"Object\",\"Function\",\"Boolean\",\"Error\",\"Symbol\",\"Set\",\"Map\",\"WeakSet\",\"WeakMap\",\"Proxy\",\"Reflect\",\"JSON\",\"Promise\",\"Float64Array\",\"Int16Array\",\"Int32Array\",\"Int8Array\",\"Uint16Array\",\"Uint32Array\",\"Float32Array\",\"Array\",\"Uint8Array\",\"Uint8ClampedArray\",\"ArrayBuffer\",\"BigInt64Array\",\"BigUint64Array\",\"BigInt\"],[\"EvalError\",\"InternalError\",\"RangeError\",\"ReferenceError\",\"SyntaxError\",\"TypeError\",\"URIError\"]);function lookahead(s){return concat(\"(?=\",s,\")\")}function concat(...s){return s.map((s=>function source(s){return s?\"string\"==typeof s?s:s.source:null}(s))).join(\"\")}s.exports=function javascript(s){const _=o,w=\"<>\",x=\"</>\",C={begin:/<[A-Za-z0-9\\\\._:-]+/,end:/\\/[A-Za-z0-9\\\\._:-]+>|\\/>/,isTrulyOpeningTag:(s,o)=>{const i=s[0].length+s.index,a=s.input[i];\"<\"!==a?\">\"===a&&(((s,{after:o})=>{const i=\"</\"+s[0].slice(1);return-1!==s.input.indexOf(i,o)})(s,{after:i})||o.ignoreMatch()):o.ignoreMatch()}},j={$pattern:o,keyword:i,literal:a,built_in:u},L=\"[0-9](_?[0-9])*\",B=`\\\\.(${L})`,$=\"0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*\",U={className:\"number\",variants:[{begin:`(\\\\b(${$})((${B})|\\\\.)?|(${B}))[eE][+-]?(${L})\\\\b`},{begin:`\\\\b(${$})\\\\b((${B})\\\\b|\\\\.)?|(${B})\\\\b`},{begin:\"\\\\b(0|[1-9](_?[0-9])*)n\\\\b\"},{begin:\"\\\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\\\b\"},{begin:\"\\\\b0[bB][0-1](_?[0-1])*n?\\\\b\"},{begin:\"\\\\b0[oO][0-7](_?[0-7])*n?\\\\b\"},{begin:\"\\\\b0[0-7]+n?\\\\b\"}],relevance:0},V={className:\"subst\",begin:\"\\\\$\\\\{\",end:\"\\\\}\",keywords:j,contains:[]},z={begin:\"html`\",end:\"\",starts:{end:\"`\",returnEnd:!1,contains:[s.BACKSLASH_ESCAPE,V],subLanguage:\"xml\"}},Y={begin:\"css`\",end:\"\",starts:{end:\"`\",returnEnd:!1,contains:[s.BACKSLASH_ESCAPE,V],subLanguage:\"css\"}},Z={className:\"string\",begin:\"`\",end:\"`\",contains:[s.BACKSLASH_ESCAPE,V]},ee={className:\"comment\",variants:[s.COMMENT(/\\/\\*\\*(?!\\/)/,\"\\\\*/\",{relevance:0,contains:[{className:\"doctag\",begin:\"@[A-Za-z]+\",contains:[{className:\"type\",begin:\"\\\\{\",end:\"\\\\}\",relevance:0},{className:\"variable\",begin:_+\"(?=\\\\s*(-)|$)\",endsParent:!0,relevance:0},{begin:/(?=[^\\n])\\s/,relevance:0}]}]}),s.C_BLOCK_COMMENT_MODE,s.C_LINE_COMMENT_MODE]},ie=[s.APOS_STRING_MODE,s.QUOTE_STRING_MODE,z,Y,Z,U,s.REGEXP_MODE];V.contains=ie.concat({begin:/\\{/,end:/\\}/,keywords:j,contains:[\"self\"].concat(ie)});const ae=[].concat(ee,V.contains),ce=ae.concat([{begin:/\\(/,end:/\\)/,keywords:j,contains:[\"self\"].concat(ae)}]),le={className:\"params\",begin:/\\(/,end:/\\)/,excludeBegin:!0,excludeEnd:!0,keywords:j,contains:ce};return{name:\"Javascript\",aliases:[\"js\",\"jsx\",\"mjs\",\"cjs\"],keywords:j,exports:{PARAMS_CONTAINS:ce},illegal:/#(?![$_A-z])/,contains:[s.SHEBANG({label:\"shebang\",binary:\"node\",relevance:5}),{label:\"use_strict\",className:\"meta\",relevance:10,begin:/^\\s*['\"]use (strict|asm)['\"]/},s.APOS_STRING_MODE,s.QUOTE_STRING_MODE,z,Y,Z,ee,U,{begin:concat(/[{,\\n]\\s*/,lookahead(concat(/(((\\/\\/.*$)|(\\/\\*(\\*[^/]|[^*])*\\*\\/))\\s*)*/,_+\"\\\\s*:\"))),relevance:0,contains:[{className:\"attr\",begin:_+lookahead(\"\\\\s*:\"),relevance:0}]},{begin:\"(\"+s.RE_STARTERS_RE+\"|\\\\b(case|return|throw)\\\\b)\\\\s*\",keywords:\"return throw case\",contains:[ee,s.REGEXP_MODE,{className:\"function\",begin:\"(\\\\([^()]*(\\\\([^()]*(\\\\([^()]*\\\\)[^()]*)*\\\\)[^()]*)*\\\\)|\"+s.UNDERSCORE_IDENT_RE+\")\\\\s*=>\",returnBegin:!0,end:\"\\\\s*=>\",contains:[{className:\"params\",variants:[{begin:s.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\\(\\s*\\)/,skip:!0},{begin:/\\(/,end:/\\)/,excludeBegin:!0,excludeEnd:!0,keywords:j,contains:ce}]}]},{begin:/,/,relevance:0},{className:\"\",begin:/\\s/,end:/\\s*/,skip:!0},{variants:[{begin:w,end:x},{begin:C.begin,\"on:begin\":C.isTrulyOpeningTag,end:C.end}],subLanguage:\"xml\",contains:[{begin:C.begin,end:C.end,skip:!0,contains:[\"self\"]}]}],relevance:0},{className:\"function\",beginKeywords:\"function\",end:/[{;]/,excludeEnd:!0,keywords:j,contains:[\"self\",s.inherit(s.TITLE_MODE,{begin:_}),le],illegal:/%/},{beginKeywords:\"while if switch catch for\"},{className:\"function\",begin:s.UNDERSCORE_IDENT_RE+\"\\\\([^()]*(\\\\([^()]*(\\\\([^()]*\\\\)[^()]*)*\\\\)[^()]*)*\\\\)\\\\s*\\\\{\",returnBegin:!0,contains:[le,s.inherit(s.TITLE_MODE,{begin:_})]},{variants:[{begin:\"\\\\.\"+_},{begin:\"\\\\$\"+_}],relevance:0},{className:\"class\",beginKeywords:\"class\",end:/[{;=]/,excludeEnd:!0,illegal:/[:\"[\\]]/,contains:[{beginKeywords:\"extends\"},s.UNDERSCORE_TITLE_MODE]},{begin:/\\b(?=constructor)/,end:/[{;]/,excludeEnd:!0,contains:[s.inherit(s.TITLE_MODE,{begin:_}),\"self\",le]},{begin:\"(get|set)\\\\s+(?=\"+_+\"\\\\()\",end:/\\{/,keywords:\"get set\",contains:[s.inherit(s.TITLE_MODE,{begin:_}),{begin:/\\(\\)/},le]},{begin:/\\$[(.]/}]}}},95116:(s,o,i)=>{\"use strict\";var a,u,_,w=i(98828),x=i(62250),C=i(46285),j=i(58075),L=i(15972),B=i(68055),$=i(76264),U=i(7376),V=$(\"iterator\"),z=!1;[].keys&&(\"next\"in(_=[].keys())?(u=L(L(_)))!==Object.prototype&&(a=u):z=!0),!C(a)||w((function(){var s={};return a[V].call(s)!==s}))?a={}:U&&(a=j(a)),x(a[V])||B(a,V,(function(){return this})),s.exports={IteratorPrototype:a,BUGGY_SAFARI_ITERATORS:z}},95950:(s,o,i)=>{var a=i(70695),u=i(88984),_=i(64894);s.exports=function keys(s){return _(s)?a(s):u(s)}},96131:(s,o,i)=>{var a=i(2523),u=i(85463),_=i(76959);s.exports=function baseIndexOf(s,o,i){return o==o?_(s,o,i):a(s,u,i)}},96540:(s,o,i)=>{\"use strict\";s.exports=i(15287)},96605:(s,o,i)=>{\"use strict\";var a=i(11091),u=i(45951),_=i(76024),w=i(19358),x=\"WebAssembly\",C=u[x],j=7!==new Error(\"e\",{cause:7}).cause,exportGlobalErrorCauseWrapper=function(s,o){var i={};i[s]=w(s,o,j),a({global:!0,constructor:!0,arity:1,forced:j},i)},exportWebAssemblyErrorCauseWrapper=function(s,o){if(C&&C[s]){var i={};i[s]=w(x+\".\"+s,o,j),a({target:x,stat:!0,constructor:!0,arity:1,forced:j},i)}};exportGlobalErrorCauseWrapper(\"Error\",(function(s){return function Error(o){return _(s,this,arguments)}})),exportGlobalErrorCauseWrapper(\"EvalError\",(function(s){return function EvalError(o){return _(s,this,arguments)}})),exportGlobalErrorCauseWrapper(\"RangeError\",(function(s){return function RangeError(o){return _(s,this,arguments)}})),exportGlobalErrorCauseWrapper(\"ReferenceError\",(function(s){return function ReferenceError(o){return _(s,this,arguments)}})),exportGlobalErrorCauseWrapper(\"SyntaxError\",(function(s){return function SyntaxError(o){return _(s,this,arguments)}})),exportGlobalErrorCauseWrapper(\"TypeError\",(function(s){return function TypeError(o){return _(s,this,arguments)}})),exportGlobalErrorCauseWrapper(\"URIError\",(function(s){return function URIError(o){return _(s,this,arguments)}})),exportWebAssemblyErrorCauseWrapper(\"CompileError\",(function(s){return function CompileError(o){return _(s,this,arguments)}})),exportWebAssemblyErrorCauseWrapper(\"LinkError\",(function(s){return function LinkError(o){return _(s,this,arguments)}})),exportWebAssemblyErrorCauseWrapper(\"RuntimeError\",(function(s){return function RuntimeError(o){return _(s,this,arguments)}}))},96794:(s,o,i)=>{\"use strict\";var a=i(45951).navigator,u=a&&a.userAgent;s.exports=u?String(u):\"\"},96897:(s,o,i)=>{\"use strict\";var a=i(70453),u=i(30041),_=i(30592)(),w=i(75795),x=i(69675),C=a(\"%Math.floor%\");s.exports=function setFunctionLength(s,o){if(\"function\"!=typeof s)throw new x(\"`fn` is not a function\");if(\"number\"!=typeof o||o<0||o>4294967295||C(o)!==o)throw new x(\"`length` must be a positive 32-bit integer\");var i=arguments.length>2&&!!arguments[2],a=!0,j=!0;if(\"length\"in s&&w){var L=w(s,\"length\");L&&!L.configurable&&(a=!1),L&&!L.writable&&(j=!1)}return(a||j||!i)&&(_?u(s,\"length\",o,!0,!0):u(s,\"length\",o)),s}},98023:(s,o,i)=>{var a=i(72552),u=i(40346);s.exports=function isNumber(s){return\"number\"==typeof s||u(s)&&\"[object Number]\"==a(s)}},98828:s=>{\"use strict\";s.exports=function(s){try{return!!s()}catch(s){return!0}}},99363:(s,o,i)=>{\"use strict\";var a=i(4993),u=i(42156),_=i(93742),w=i(64932),x=i(74284).f,C=i(60183),j=i(59550),L=i(7376),B=i(39447),$=\"Array Iterator\",U=w.set,V=w.getterFor($);s.exports=C(Array,\"Array\",(function(s,o){U(this,{type:$,target:a(s),index:0,kind:o})}),(function(){var s=V(this),o=s.target,i=s.index++;if(!o||i>=o.length)return s.target=null,j(void 0,!0);switch(s.kind){case\"keys\":return j(i,!1);case\"values\":return j(o[i],!1)}return j([i,o[i]],!1)}),\"values\");var z=_.Arguments=_.Array;if(u(\"keys\"),u(\"values\"),u(\"entries\"),!L&&B&&\"values\"!==z.name)try{x(z,\"name\",{value:\"values\"})}catch(s){}},99374:(s,o,i)=>{var a=i(54128),u=i(23805),_=i(44394),w=/^[-+]0x[0-9a-f]+$/i,x=/^0b[01]+$/i,C=/^0o[0-7]+$/i,j=parseInt;s.exports=function toNumber(s){if(\"number\"==typeof s)return s;if(_(s))return NaN;if(u(s)){var o=\"function\"==typeof s.valueOf?s.valueOf():s;s=u(o)?o+\"\":o}if(\"string\"!=typeof s)return 0===s?s:+s;s=a(s);var i=x.test(s);return i||C.test(s)?j(s.slice(2),i?2:8):w.test(s)?NaN:+s}}},o={};function __webpack_require__(i){var a=o[i];if(void 0!==a)return a.exports;var u=o[i]={id:i,loaded:!1,exports:{}};return s[i].call(u.exports,u,u.exports,__webpack_require__),u.loaded=!0,u.exports}__webpack_require__.n=s=>{var o=s&&s.__esModule?()=>s.default:()=>s;return __webpack_require__.d(o,{a:o}),o},__webpack_require__.d=(s,o)=>{for(var i in o)__webpack_require__.o(o,i)&&!__webpack_require__.o(s,i)&&Object.defineProperty(s,i,{enumerable:!0,get:o[i]})},__webpack_require__.g=function(){if(\"object\"==typeof globalThis)return globalThis;try{return this||new Function(\"return this\")()}catch(s){if(\"object\"==typeof window)return window}}(),__webpack_require__.o=(s,o)=>Object.prototype.hasOwnProperty.call(s,o),__webpack_require__.r=s=>{\"undefined\"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(s,Symbol.toStringTag,{value:\"Module\"}),Object.defineProperty(s,\"__esModule\",{value:!0})},__webpack_require__.nmd=s=>(s.paths=[],s.children||(s.children=[]),s);var i={};return(()=>{\"use strict\";__webpack_require__.d(i,{default:()=>HT});var s={};__webpack_require__.r(s),__webpack_require__.d(s,{CLEAR:()=>at,CLEAR_BY:()=>ct,NEW_AUTH_ERR:()=>it,NEW_SPEC_ERR:()=>st,NEW_SPEC_ERR_BATCH:()=>ot,NEW_THROWN_ERR:()=>rt,NEW_THROWN_ERR_BATCH:()=>nt,clear:()=>clear,clearBy:()=>clearBy,newAuthErr:()=>newAuthErr,newSpecErr:()=>newSpecErr,newSpecErrBatch:()=>newSpecErrBatch,newThrownErr:()=>newThrownErr,newThrownErrBatch:()=>newThrownErrBatch});var o={};__webpack_require__.r(o),__webpack_require__.d(o,{AUTHORIZE:()=>Rt,AUTHORIZE_OAUTH2:()=>Lt,CONFIGURE_AUTH:()=>Ft,LOGOUT:()=>Dt,RESTORE_AUTHORIZATION:()=>Bt,SHOW_AUTH_POPUP:()=>Mt,authPopup:()=>authPopup,authorize:()=>authorize,authorizeAccessCodeWithBasicAuthentication:()=>authorizeAccessCodeWithBasicAuthentication,authorizeAccessCodeWithFormParams:()=>authorizeAccessCodeWithFormParams,authorizeApplication:()=>authorizeApplication,authorizeOauth2:()=>authorizeOauth2,authorizeOauth2WithPersistOption:()=>authorizeOauth2WithPersistOption,authorizePassword:()=>authorizePassword,authorizeRequest:()=>authorizeRequest,authorizeWithPersistOption:()=>authorizeWithPersistOption,configureAuth:()=>configureAuth,logout:()=>logout,logoutWithPersistOption:()=>logoutWithPersistOption,persistAuthorizationIfNeeded:()=>persistAuthorizationIfNeeded,preAuthorizeImplicit:()=>preAuthorizeImplicit,restoreAuthorization:()=>restoreAuthorization,showDefinitions:()=>showDefinitions});var a={};__webpack_require__.r(a),__webpack_require__.d(a,{authorized:()=>Jt,definitionsForRequirements:()=>definitionsForRequirements,definitionsToAuthorize:()=>Wt,getConfigs:()=>Ht,getDefinitionsByNames:()=>getDefinitionsByNames,isAuthorized:()=>isAuthorized,selectAuthPath:()=>selectAuthPath,shownDefinitions:()=>zt});var u={};__webpack_require__.r(u),__webpack_require__.d(u,{TOGGLE_CONFIGS:()=>gn,UPDATE_CONFIGS:()=>mn,downloadConfig:()=>downloadConfig,getConfigByUrl:()=>getConfigByUrl,loaded:()=>actions_loaded,toggle:()=>toggle,update:()=>update});var _={};__webpack_require__.r(_),__webpack_require__.d(_,{get:()=>get});var w={};__webpack_require__.r(w),__webpack_require__.d(w,{transform:()=>transform});var x={};__webpack_require__.r(x),__webpack_require__.d(x,{transform:()=>parameter_oneof_transform});var C={};__webpack_require__.r(C),__webpack_require__.d(C,{allErrors:()=>In,lastError:()=>Tn});var j={};__webpack_require__.r(j),__webpack_require__.d(j,{SHOW:()=>Fn,UPDATE_FILTER:()=>Dn,UPDATE_LAYOUT:()=>Rn,UPDATE_MODE:()=>Ln,changeMode:()=>changeMode,show:()=>actions_show,updateFilter:()=>updateFilter,updateLayout:()=>updateLayout});var L={};__webpack_require__.r(L),__webpack_require__.d(L,{current:()=>current,currentFilter:()=>currentFilter,isShown:()=>isShown,showSummary:()=>$n,whatMode:()=>whatMode});var B={};__webpack_require__.r(B),__webpack_require__.d(B,{taggedOperations:()=>taggedOperations});var $={};__webpack_require__.r($),__webpack_require__.d($,{getActiveLanguage:()=>Vn,getDefaultExpanded:()=>zn,getGenerators:()=>Un,getSnippetGenerators:()=>getSnippetGenerators});var U={};__webpack_require__.r(U),__webpack_require__.d(U,{JsonSchemaArrayItemFile:()=>JsonSchemaArrayItemFile,JsonSchemaArrayItemText:()=>JsonSchemaArrayItemText,JsonSchemaForm:()=>JsonSchemaForm,JsonSchema_array:()=>JsonSchema_array,JsonSchema_boolean:()=>JsonSchema_boolean,JsonSchema_object:()=>JsonSchema_object,JsonSchema_string:()=>JsonSchema_string});var V={};__webpack_require__.r(V),__webpack_require__.d(V,{allowTryItOutFor:()=>allowTryItOutFor,basePath:()=>Hs,canExecuteScheme:()=>canExecuteScheme,consumes:()=>Us,consumesOptionsFor:()=>consumesOptionsFor,contentTypeValues:()=>contentTypeValues,currentProducesFor:()=>currentProducesFor,definitions:()=>Js,externalDocs:()=>Ds,findDefinition:()=>findDefinition,getOAS3RequiredRequestBodyContentType:()=>getOAS3RequiredRequestBodyContentType,getParameter:()=>getParameter,hasHost:()=>ro,host:()=>Ks,info:()=>Rs,isMediaTypeSchemaPropertiesEqual:()=>isMediaTypeSchemaPropertiesEqual,isOAS3:()=>Ms,lastError:()=>Os,mutatedRequestFor:()=>mutatedRequestFor,mutatedRequests:()=>to,operationScheme:()=>operationScheme,operationWithMeta:()=>operationWithMeta,operations:()=>qs,operationsWithRootInherited:()=>Ys,operationsWithTags:()=>Qs,parameterInclusionSettingFor:()=>parameterInclusionSettingFor,parameterValues:()=>parameterValues,parameterWithMeta:()=>parameterWithMeta,parameterWithMetaByIdentity:()=>parameterWithMetaByIdentity,parametersIncludeIn:()=>parametersIncludeIn,parametersIncludeType:()=>parametersIncludeType,paths:()=>Bs,produces:()=>Vs,producesOptionsFor:()=>producesOptionsFor,requestFor:()=>requestFor,requests:()=>eo,responseFor:()=>responseFor,responses:()=>Zs,schemes:()=>Gs,security:()=>zs,securityDefinitions:()=>Ws,semver:()=>Fs,spec:()=>spec,specJS:()=>Is,specJson:()=>Ps,specJsonWithResolvedSubtrees:()=>Ns,specResolved:()=>Ts,specResolvedSubtree:()=>specResolvedSubtree,specSource:()=>js,specStr:()=>Cs,tagDetails:()=>tagDetails,taggedOperations:()=>selectors_taggedOperations,tags:()=>Xs,url:()=>As,validOperationMethods:()=>$s,validateBeforeExecute:()=>validateBeforeExecute,validationErrors:()=>validationErrors,version:()=>Ls});var z={};__webpack_require__.r(z),__webpack_require__.d(z,{CLEAR_REQUEST:()=>wo,CLEAR_RESPONSE:()=>Eo,CLEAR_VALIDATE_PARAMS:()=>xo,LOG_REQUEST:()=>So,SET_MUTATED_REQUEST:()=>_o,SET_REQUEST:()=>bo,SET_RESPONSE:()=>vo,SET_SCHEME:()=>Co,UPDATE_EMPTY_PARAM_INCLUSION:()=>go,UPDATE_JSON:()=>fo,UPDATE_OPERATION_META_VALUE:()=>ko,UPDATE_PARAM:()=>mo,UPDATE_RESOLVED:()=>Oo,UPDATE_RESOLVED_SUBTREE:()=>Ao,UPDATE_SPEC:()=>po,UPDATE_URL:()=>ho,VALIDATE_PARAMS:()=>yo,changeConsumesValue:()=>changeConsumesValue,changeParam:()=>changeParam,changeParamByIdentity:()=>changeParamByIdentity,changeProducesValue:()=>changeProducesValue,clearRequest:()=>clearRequest,clearResponse:()=>clearResponse,clearValidateParams:()=>clearValidateParams,execute:()=>actions_execute,executeRequest:()=>executeRequest,invalidateResolvedSubtreeCache:()=>invalidateResolvedSubtreeCache,logRequest:()=>logRequest,parseToJson:()=>parseToJson,requestResolvedSubtree:()=>requestResolvedSubtree,resolveSpec:()=>resolveSpec,setMutatedRequest:()=>setMutatedRequest,setRequest:()=>setRequest,setResponse:()=>setResponse,setScheme:()=>setScheme,updateEmptyParamInclusion:()=>updateEmptyParamInclusion,updateJsonSpec:()=>updateJsonSpec,updateResolved:()=>updateResolved,updateResolvedSubtree:()=>updateResolvedSubtree,updateSpec:()=>updateSpec,updateUrl:()=>updateUrl,validateParams:()=>validateParams});var Y={};__webpack_require__.r(Y),__webpack_require__.d(Y,{executeRequest:()=>wrap_actions_executeRequest,updateJsonSpec:()=>wrap_actions_updateJsonSpec,updateSpec:()=>wrap_actions_updateSpec,validateParams:()=>wrap_actions_validateParams});var Z={};__webpack_require__.r(Z),__webpack_require__.d(Z,{JsonPatchError:()=>Do,_areEquals:()=>_areEquals,applyOperation:()=>applyOperation,applyPatch:()=>applyPatch,applyReducer:()=>applyReducer,deepClone:()=>Lo,getValueByPointer:()=>getValueByPointer,validate:()=>validate,validator:()=>validator});var ee={};__webpack_require__.r(ee),__webpack_require__.d(ee,{compare:()=>compare,generate:()=>generate,observe:()=>observe,unobserve:()=>unobserve});var ie={};__webpack_require__.r(ie),__webpack_require__.d(ie,{hasElementSourceMap:()=>hasElementSourceMap,includesClasses:()=>includesClasses,includesSymbols:()=>includesSymbols,isAnnotationElement:()=>Bu,isArrayElement:()=>Ru,isBooleanElement:()=>Nu,isCommentElement:()=>$u,isElement:()=>ju,isLinkElement:()=>Lu,isMemberElement:()=>Du,isNullElement:()=>Tu,isNumberElement:()=>Iu,isObjectElement:()=>Mu,isParseResultElement:()=>qu,isPrimitiveElement:()=>isPrimitiveElement,isRefElement:()=>Fu,isSourceMapElement:()=>Uu,isStringElement:()=>Pu});var ae={};__webpack_require__.r(ae),__webpack_require__.d(ae,{isJSONReferenceElement:()=>Bd,isJSONSchemaElement:()=>Fd,isLinkDescriptionElement:()=>Ud,isMediaElement:()=>$d});var ce={};__webpack_require__.r(ce),__webpack_require__.d(ce,{isBooleanJsonSchemaElement:()=>isBooleanJsonSchemaElement,isCallbackElement:()=>Mm,isComponentsElement:()=>Rm,isContactElement:()=>Dm,isDiscriminatorElement:()=>pg,isExampleElement:()=>Lm,isExternalDocumentationElement:()=>Fm,isHeaderElement:()=>Bm,isInfoElement:()=>$m,isLicenseElement:()=>qm,isLinkElement:()=>Um,isMediaTypeElement:()=>og,isOpenApi3_0Element:()=>zm,isOpenapiElement:()=>Vm,isOperationElement:()=>Wm,isParameterElement:()=>Jm,isPathItemElement:()=>Hm,isPathsElement:()=>Km,isReferenceElement:()=>Gm,isRequestBodyElement:()=>Ym,isResponseElement:()=>Xm,isResponsesElement:()=>Qm,isSchemaElement:()=>Zm,isSecurityRequirementElement:()=>eg,isSecuritySchemeElement:()=>rg,isServerElement:()=>ng,isServerVariableElement:()=>sg,isServersElement:()=>lg});var le={};__webpack_require__.r(le),__webpack_require__.d(le,{isJSONReferenceElement:()=>Bd,isJSONSchemaElement:()=>v_,isLinkDescriptionElement:()=>b_,isMediaElement:()=>$d});var pe={};__webpack_require__.r(pe),__webpack_require__.d(pe,{isJSONReferenceElement:()=>Bd,isJSONSchemaElement:()=>j_,isLinkDescriptionElement:()=>P_});var de={};__webpack_require__.r(de),__webpack_require__.d(de,{isJSONSchemaElement:()=>Y_,isLinkDescriptionElement:()=>X_});var fe={};__webpack_require__.r(fe),__webpack_require__.d(fe,{isJSONSchemaElement:()=>aS,isLinkDescriptionElement:()=>cS});var ye={};__webpack_require__.r(ye),__webpack_require__.d(ye,{isBooleanJsonSchemaElement:()=>predicates_isBooleanJsonSchemaElement,isCallbackElement:()=>JS,isComponentsElement:()=>HS,isContactElement:()=>KS,isExampleElement:()=>GS,isExternalDocumentationElement:()=>YS,isHeaderElement:()=>XS,isInfoElement:()=>QS,isJsonSchemaDialectElement:()=>ZS,isLicenseElement:()=>eE,isLinkElement:()=>tE,isMediaTypeElement:()=>yE,isOpenApi3_1Element:()=>nE,isOpenapiElement:()=>rE,isOperationElement:()=>sE,isParameterElement:()=>oE,isPathItemElement:()=>iE,isPathItemElementExternal:()=>isPathItemElementExternal,isPathsElement:()=>aE,isReferenceElement:()=>cE,isReferenceElementExternal:()=>isReferenceElementExternal,isRequestBodyElement:()=>lE,isResponseElement:()=>uE,isResponsesElement:()=>pE,isSchemaElement:()=>hE,isSecurityRequirementElement:()=>dE,isSecuritySchemeElement:()=>fE,isServerElement:()=>mE,isServerVariableElement:()=>gE});var be={};__webpack_require__.r(be),__webpack_require__.d(be,{cookie:()=>cookie,header:()=>parameter_builders_header,path:()=>parameter_builders_path,query:()=>query});var _e={};__webpack_require__.r(_e),__webpack_require__.d(_e,{Button:()=>Button,Col:()=>Col,Collapse:()=>Collapse,Container:()=>Container,Input:()=>Input,Link:()=>layout_utils_Link,Row:()=>Row,Select:()=>Select,TextArea:()=>TextArea});var Se={};__webpack_require__.r(Se),__webpack_require__.d(Se,{basePath:()=>RP,consumes:()=>DP,definitions:()=>IP,findDefinition:()=>PP,hasHost:()=>TP,host:()=>MP,produces:()=>LP,schemes:()=>FP,securityDefinitions:()=>NP,validOperationMethods:()=>wrap_selectors_validOperationMethods});var we={};__webpack_require__.r(we),__webpack_require__.d(we,{definitionsToAuthorize:()=>BP});var xe={};__webpack_require__.r(xe),__webpack_require__.d(xe,{callbacksOperations:()=>UP,findSchema:()=>findSchema,isOAS3:()=>selectors_isOAS3,isOAS30:()=>selectors_isOAS30,isSwagger2:()=>selectors_isSwagger2,servers:()=>qP});var Pe={};__webpack_require__.r(Pe),__webpack_require__.d(Pe,{CLEAR_REQUEST_BODY_VALIDATE_ERROR:()=>cI,CLEAR_REQUEST_BODY_VALUE:()=>lI,SET_REQUEST_BODY_VALIDATE_ERROR:()=>aI,UPDATE_ACTIVE_EXAMPLES_MEMBER:()=>nI,UPDATE_REQUEST_BODY_INCLUSION:()=>rI,UPDATE_REQUEST_BODY_VALUE:()=>eI,UPDATE_REQUEST_BODY_VALUE_RETAIN_FLAG:()=>tI,UPDATE_REQUEST_CONTENT_TYPE:()=>sI,UPDATE_RESPONSE_CONTENT_TYPE:()=>oI,UPDATE_SELECTED_SERVER:()=>ZP,UPDATE_SERVER_VARIABLE_VALUE:()=>iI,clearRequestBodyValidateError:()=>clearRequestBodyValidateError,clearRequestBodyValue:()=>clearRequestBodyValue,initRequestBodyValidateError:()=>initRequestBodyValidateError,setActiveExamplesMember:()=>setActiveExamplesMember,setRequestBodyInclusion:()=>setRequestBodyInclusion,setRequestBodyValidateError:()=>setRequestBodyValidateError,setRequestBodyValue:()=>setRequestBodyValue,setRequestContentType:()=>setRequestContentType,setResponseContentType:()=>setResponseContentType,setRetainRequestBodyValueFlag:()=>setRetainRequestBodyValueFlag,setSelectedServer:()=>setSelectedServer,setServerVariableValue:()=>setServerVariableValue});var Te={};__webpack_require__.r(Te),__webpack_require__.d(Te,{activeExamplesMember:()=>vI,hasUserEditedBody:()=>mI,requestBodyErrors:()=>yI,requestBodyInclusionSetting:()=>gI,requestBodyValue:()=>dI,requestContentType:()=>bI,responseContentType:()=>_I,selectDefaultRequestBodyValue:()=>selectDefaultRequestBodyValue,selectedServer:()=>hI,serverEffectiveValue:()=>wI,serverVariableValue:()=>SI,serverVariables:()=>EI,shouldRetainRequestBodyValue:()=>fI,validOperationMethods:()=>kI,validateBeforeExecute:()=>xI,validateShallowRequired:()=>validateShallowRequired});var Re=__webpack_require__(96540);function formatProdErrorMessage(s){return`Minified Redux error #${s}; visit https://redux.js.org/Errors?code=${s} for the full message or use the non-minified dev environment for full errors. `}var $e=(()=>\"function\"==typeof Symbol&&Symbol.observable||\"@@observable\")(),randomString=()=>Math.random().toString(36).substring(7).split(\"\").join(\".\"),qe={INIT:`@@redux/INIT${randomString()}`,REPLACE:`@@redux/REPLACE${randomString()}`,PROBE_UNKNOWN_ACTION:()=>`@@redux/PROBE_UNKNOWN_ACTION${randomString()}`};function isPlainObject(s){if(\"object\"!=typeof s||null===s)return!1;let o=s;for(;null!==Object.getPrototypeOf(o);)o=Object.getPrototypeOf(o);return Object.getPrototypeOf(s)===o||null===Object.getPrototypeOf(s)}function createStore(s,o,i){if(\"function\"!=typeof s)throw new Error(formatProdErrorMessage(2));if(\"function\"==typeof o&&\"function\"==typeof i||\"function\"==typeof i&&\"function\"==typeof arguments[3])throw new Error(formatProdErrorMessage(0));if(\"function\"==typeof o&&void 0===i&&(i=o,o=void 0),void 0!==i){if(\"function\"!=typeof i)throw new Error(formatProdErrorMessage(1));return i(createStore)(s,o)}let a=s,u=o,_=new Map,w=_,x=0,C=!1;function ensureCanMutateNextListeners(){w===_&&(w=new Map,_.forEach(((s,o)=>{w.set(o,s)})))}function getState(){if(C)throw new Error(formatProdErrorMessage(3));return u}function subscribe(s){if(\"function\"!=typeof s)throw new Error(formatProdErrorMessage(4));if(C)throw new Error(formatProdErrorMessage(5));let o=!0;ensureCanMutateNextListeners();const i=x++;return w.set(i,s),function unsubscribe(){if(o){if(C)throw new Error(formatProdErrorMessage(6));o=!1,ensureCanMutateNextListeners(),w.delete(i),_=null}}}function dispatch(s){if(!isPlainObject(s))throw new Error(formatProdErrorMessage(7));if(void 0===s.type)throw new Error(formatProdErrorMessage(8));if(\"string\"!=typeof s.type)throw new Error(formatProdErrorMessage(17));if(C)throw new Error(formatProdErrorMessage(9));try{C=!0,u=a(u,s)}finally{C=!1}return(_=w).forEach((s=>{s()})),s}dispatch({type:qe.INIT});return{dispatch,subscribe,getState,replaceReducer:function replaceReducer(s){if(\"function\"!=typeof s)throw new Error(formatProdErrorMessage(10));a=s,dispatch({type:qe.REPLACE})},[$e]:function observable(){const s=subscribe;return{subscribe(o){if(\"object\"!=typeof o||null===o)throw new Error(formatProdErrorMessage(11));function observeState(){const s=o;s.next&&s.next(getState())}observeState();return{unsubscribe:s(observeState)}},[$e](){return this}}}}}function bindActionCreator(s,o){return function(...i){return o(s.apply(this,i))}}function compose(...s){return 0===s.length?s=>s:1===s.length?s[0]:s.reduce(((s,o)=>(...i)=>s(o(...i))))}var ze=__webpack_require__(9404),We=__webpack_require__.n(ze),He=__webpack_require__(81919),Ye=__webpack_require__.n(He),Xe=__webpack_require__(89593),Qe=__webpack_require__(20334),et=__webpack_require__(55364),tt=__webpack_require__.n(et);const rt=\"err_new_thrown_err\",nt=\"err_new_thrown_err_batch\",st=\"err_new_spec_err\",ot=\"err_new_spec_err_batch\",it=\"err_new_auth_err\",at=\"err_clear\",ct=\"err_clear_by\";function newThrownErr(s){return{type:rt,payload:(0,Qe.serializeError)(s)}}function newThrownErrBatch(s){return{type:nt,payload:s}}function newSpecErr(s){return{type:st,payload:s}}function newSpecErrBatch(s){return{type:ot,payload:s}}function newAuthErr(s){return{type:it,payload:s}}function clear(s={}){return{type:at,payload:s}}function clearBy(s=()=>!0){return{type:ct,payload:s}}const lt=function makeWindow(){var s={location:{},history:{},open:()=>{},close:()=>{},File:function(){},FormData:function(){}};if(\"undefined\"==typeof window)return s;try{s=window;for(var o of[\"File\",\"Blob\",\"FormData\"])o in window&&(s[o]=window[o])}catch(s){console.error(s)}return s}();__webpack_require__(84058),__webpack_require__(55808);var ut=__webpack_require__(50104),pt=__webpack_require__.n(ut),ht=__webpack_require__(7309),dt=__webpack_require__.n(ht),mt=__webpack_require__(42426),gt=__webpack_require__.n(mt),yt=__webpack_require__(75288),vt=__webpack_require__.n(yt),bt=__webpack_require__(1882),_t=__webpack_require__.n(bt),St=__webpack_require__(2205),Et=__webpack_require__.n(St),wt=__webpack_require__(53209),xt=__webpack_require__.n(wt),kt=__webpack_require__(62802),Ot=__webpack_require__.n(kt);const At=We().Set.of(\"type\",\"format\",\"items\",\"default\",\"maximum\",\"exclusiveMaximum\",\"minimum\",\"exclusiveMinimum\",\"maxLength\",\"minLength\",\"pattern\",\"maxItems\",\"minItems\",\"uniqueItems\",\"enum\",\"multipleOf\");function getParameterSchema(s,{isOAS3:o}={}){if(!We().Map.isMap(s))return{schema:We().Map(),parameterContentMediaType:null};if(!o)return\"body\"===s.get(\"in\")?{schema:s.get(\"schema\",We().Map()),parameterContentMediaType:null}:{schema:s.filter(((s,o)=>At.includes(o))),parameterContentMediaType:null};if(s.get(\"content\")){const o=s.get(\"content\",We().Map({})).keySeq().first();return{schema:s.getIn([\"content\",o,\"schema\"],We().Map()),parameterContentMediaType:o}}return{schema:s.get(\"schema\")?s.get(\"schema\",We().Map()):We().Map(),parameterContentMediaType:null}}var Ct=__webpack_require__(48287).Buffer;const jt=\"default\",isImmutable=s=>We().Iterable.isIterable(s),immutableToJS=s=>isImmutable(s)?s.toJS():s;function objectify(s){return isObject(s)?immutableToJS(s):{}}function fromJSOrdered(s){if(isImmutable(s))return s;if(s instanceof lt.File)return s;if(!isObject(s))return s;if(Array.isArray(s))return We().Seq(s).map(fromJSOrdered).toList();if(_t()(s.entries)){const o=function createObjWithHashedKeys(s){if(!_t()(s.entries))return s;const o={},i=\"_**[]\",a={};for(let u of s.entries())if(o[u[0]]||a[u[0]]&&a[u[0]].containsMultiple){if(!a[u[0]]){a[u[0]]={containsMultiple:!0,length:1},o[`${u[0]}${i}${a[u[0]].length}`]=o[u[0]],delete o[u[0]]}a[u[0]].length+=1,o[`${u[0]}${i}${a[u[0]].length}`]=u[1]}else o[u[0]]=u[1];return o}(s);return We().OrderedMap(o).map(fromJSOrdered)}return We().OrderedMap(s).map(fromJSOrdered)}function normalizeArray(s){return Array.isArray(s)?s:[s]}function isFn(s){return\"function\"==typeof s}function isObject(s){return!!s&&\"object\"==typeof s}function isFunc(s){return\"function\"==typeof s}function isArray(s){return Array.isArray(s)}const Pt=pt();function objMap(s,o){return Object.keys(s).reduce(((i,a)=>(i[a]=o(s[a],a),i)),{})}function objReduce(s,o){return Object.keys(s).reduce(((i,a)=>{let u=o(s[a],a);return u&&\"object\"==typeof u&&Object.assign(i,u),i}),{})}function systemThunkMiddleware(s){return({dispatch:o,getState:i})=>o=>i=>\"function\"==typeof i?i(s()):o(i)}function validateValueBySchema(s,o,i,a,u){if(!o)return[];let _=[],w=o.get(\"nullable\"),x=o.get(\"required\"),C=o.get(\"maximum\"),j=o.get(\"minimum\"),L=o.get(\"type\"),B=o.get(\"format\"),$=o.get(\"maxLength\"),U=o.get(\"minLength\"),V=o.get(\"uniqueItems\"),z=o.get(\"maxItems\"),Y=o.get(\"minItems\"),Z=o.get(\"pattern\");const ee=i||!0===x,ie=null!=s,ae=ee||ie&&\"array\"===L||!(!ee&&!ie),ce=w&&null===s;if(ee&&!ie&&!ce&&!a&&!L)return _.push(\"Required field is not provided\"),_;if(ce||!L||!ae)return[];let le=\"string\"===L&&s,pe=\"array\"===L&&Array.isArray(s)&&s.length,de=\"array\"===L&&We().List.isList(s)&&s.count();const fe=[le,pe,de,\"array\"===L&&\"string\"==typeof s&&s,\"file\"===L&&s instanceof lt.File,\"boolean\"===L&&(s||!1===s),\"number\"===L&&(s||0===s),\"integer\"===L&&(s||0===s),\"object\"===L&&\"object\"==typeof s&&null!==s,\"object\"===L&&\"string\"==typeof s&&s].some((s=>!!s));if(ee&&!fe&&!a)return _.push(\"Required field is not provided\"),_;if(\"object\"===L&&(null===u||\"application/json\"===u)){let i=s;if(\"string\"==typeof s)try{i=JSON.parse(s)}catch(s){return _.push(\"Parameter string value must be valid JSON\"),_}o&&o.has(\"required\")&&isFunc(x.isList)&&x.isList()&&x.forEach((s=>{void 0===i[s]&&_.push({propKey:s,error:\"Required property not found\"})})),o&&o.has(\"properties\")&&o.get(\"properties\").forEach(((s,o)=>{const w=validateValueBySchema(i[o],s,!1,a,u);_.push(...w.map((s=>({propKey:o,error:s}))))}))}if(Z){let o=((s,o)=>{if(!new RegExp(o).test(s))return\"Value must follow pattern \"+o})(s,Z);o&&_.push(o)}if(Y&&\"array\"===L){let o=((s,o)=>{if(!s&&o>=1||s&&s.length<o)return`Array must contain at least ${o} item${1===o?\"\":\"s\"}`})(s,Y);o&&_.push(o)}if(z&&\"array\"===L){let o=((s,o)=>{if(s&&s.length>o)return`Array must not contain more then ${o} item${1===o?\"\":\"s\"}`})(s,z);o&&_.push({needRemove:!0,error:o})}if(V&&\"array\"===L){let o=((s,o)=>{if(s&&(\"true\"===o||!0===o)){const o=(0,ze.fromJS)(s),i=o.toSet();if(s.length>i.size){let s=(0,ze.Set)();if(o.forEach(((i,a)=>{o.filter((s=>isFunc(s.equals)?s.equals(i):s===i)).size>1&&(s=s.add(a))})),0!==s.size)return s.map((s=>({index:s,error:\"No duplicates allowed.\"}))).toArray()}}})(s,V);o&&_.push(...o)}if($||0===$){let o=((s,o)=>{if(s.length>o)return`Value must be no longer than ${o} character${1!==o?\"s\":\"\"}`})(s,$);o&&_.push(o)}if(U){let o=((s,o)=>{if(s.length<o)return`Value must be at least ${o} character${1!==o?\"s\":\"\"}`})(s,U);o&&_.push(o)}if(C||0===C){let o=((s,o)=>{if(s>o)return`Value must be less than or equal to ${o}`})(s,C);o&&_.push(o)}if(j||0===j){let o=((s,o)=>{if(s<o)return`Value must be greater than or equal to ${o}`})(s,j);o&&_.push(o)}if(\"string\"===L){let o;if(o=\"date-time\"===B?(s=>{if(isNaN(Date.parse(s)))return\"Value must be a DateTime\"})(s):\"uuid\"===B?(s=>{if(s=s.toString().toLowerCase(),!/^[{(]?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[)}]?$/.test(s))return\"Value must be a Guid\"})(s):(s=>{if(s&&\"string\"!=typeof s)return\"Value must be a string\"})(s),!o)return _;_.push(o)}else if(\"boolean\"===L){let o=(s=>{if(\"true\"!==s&&\"false\"!==s&&!0!==s&&!1!==s)return\"Value must be a boolean\"})(s);if(!o)return _;_.push(o)}else if(\"number\"===L){let o=(s=>{if(!/^-?\\d+(\\.?\\d+)?$/.test(s))return\"Value must be a number\"})(s);if(!o)return _;_.push(o)}else if(\"integer\"===L){let o=(s=>{if(!/^-?\\d+$/.test(s))return\"Value must be an integer\"})(s);if(!o)return _;_.push(o)}else if(\"array\"===L){if(!pe&&!de)return _;s&&s.forEach(((s,i)=>{const w=validateValueBySchema(s,o.get(\"items\"),!1,a,u);_.push(...w.map((s=>({index:i,error:s}))))}))}else if(\"file\"===L){let o=(s=>{if(s&&!(s instanceof lt.File))return\"Value must be a file\"})(s);if(!o)return _;_.push(o)}return _}const utils_btoa=s=>{let o;return o=s instanceof Ct?s:Ct.from(s.toString(),\"utf-8\"),o.toString(\"base64\")},It={operationsSorter:{alpha:(s,o)=>s.get(\"path\").localeCompare(o.get(\"path\")),method:(s,o)=>s.get(\"method\").localeCompare(o.get(\"method\"))},tagsSorter:{alpha:(s,o)=>s.localeCompare(o)}},buildFormData=s=>{let o=[];for(let i in s){let a=s[i];void 0!==a&&\"\"!==a&&o.push([i,\"=\",encodeURIComponent(a).replace(/%20/g,\"+\")].join(\"\"))}return o.join(\"&\")},shallowEqualKeys=(s,o,i)=>!!dt()(i,(i=>vt()(s[i],o[i])));function requiresValidationURL(s){return!(!s||s.indexOf(\"localhost\")>=0||s.indexOf(\"127.0.0.1\")>=0||\"none\"===s)}const createDeepLinkPath=s=>\"string\"==typeof s||s instanceof String?s.trim().replace(/\\s/g,\"%20\"):\"\",escapeDeepLinkPath=s=>Et()(createDeepLinkPath(s).replace(/%20/g,\"_\")),isExtension=s=>/^x-/.test(s),getExtensions=s=>ze.Map.isMap(s)?s.filter(((s,o)=>isExtension(o))):Object.keys(s).filter((s=>isExtension(s))),getCommonExtensions=s=>s.filter(((s,o)=>/^pattern|maxLength|minLength|maximum|minimum/.test(o)));function deeplyStripKey(s,o,i=()=>!0){if(\"object\"!=typeof s||Array.isArray(s)||null===s||!o)return s;const a=Object.assign({},s);return Object.keys(a).forEach((s=>{s===o&&i(a[s],s)?delete a[s]:a[s]=deeplyStripKey(a[s],o,i)})),a}function stringify(s){if(\"string\"==typeof s)return s;if(s&&s.toJS&&(s=s.toJS()),\"object\"==typeof s&&null!==s)try{return JSON.stringify(s,null,2)}catch(o){return String(s)}return null==s?\"\":s.toString()}function paramToIdentifier(s,{returnAll:o=!1,allowHashes:i=!0}={}){if(!We().Map.isMap(s))throw new Error(\"paramToIdentifier: received a non-Im.Map parameter as input\");const a=s.get(\"name\"),u=s.get(\"in\");let _=[];return s&&s.hashCode&&u&&a&&i&&_.push(`${u}.${a}.hash-${s.hashCode()}`),u&&a&&_.push(`${u}.${a}`),_.push(a),o?_:_[0]||\"\"}function paramToValue(s,o){return paramToIdentifier(s,{returnAll:!0}).map((s=>o[s])).filter((s=>void 0!==s))[0]}function b64toB64UrlEncoded(s){return s.replace(/\\+/g,\"-\").replace(/\\//g,\"_\").replace(/=/g,\"\")}const isEmptyValue=s=>!s||!(!isImmutable(s)||!s.isEmpty()),idFn=s=>s;function createStoreWithMiddleware(s,o,i){let a=[systemThunkMiddleware(i)];return createStore(s,o,(lt.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__||compose)(function applyMiddleware(...s){return o=>(i,a)=>{const u=o(i,a);let dispatch=()=>{throw new Error(formatProdErrorMessage(15))};const _={getState:u.getState,dispatch:(s,...o)=>dispatch(s,...o)},w=s.map((s=>s(_)));return dispatch=compose(...w)(u.dispatch),{...u,dispatch}}}(...a)))}class Store{constructor(s={}){Ye()(this,{state:{},plugins:[],system:{configs:{},fn:{},components:{},rootInjects:{},statePlugins:{}},boundSystem:{},toolbox:{}},s),this.getSystem=this._getSystem.bind(this),this.store=function configureStore(s,o,i){return createStoreWithMiddleware(s,o,i)}(idFn,(0,ze.fromJS)(this.state),this.getSystem),this.buildSystem(!1),this.register(this.plugins)}getStore(){return this.store}register(s,o=!0){var i=combinePlugins(s,this.getSystem());systemExtend(this.system,i),o&&this.buildSystem();callAfterLoad.call(this.system,s,this.getSystem())&&this.buildSystem()}buildSystem(s=!0){let o=this.getStore().dispatch,i=this.getStore().getState;this.boundSystem=Object.assign({},this.getRootInjects(),this.getWrappedAndBoundActions(o),this.getWrappedAndBoundSelectors(i,this.getSystem),this.getStateThunks(i),this.getFn(),this.getConfigs()),s&&this.rebuildReducer()}_getSystem(){return this.boundSystem}getRootInjects(){return Object.assign({getSystem:this.getSystem,getStore:this.getStore.bind(this),getComponents:this.getComponents.bind(this),getState:this.getStore().getState,getConfigs:this._getConfigs.bind(this),Im:We(),React:Re},this.system.rootInjects||{})}_getConfigs(){return this.system.configs}getConfigs(){return{configs:this.system.configs}}setConfigs(s){this.system.configs=s}rebuildReducer(){this.store.replaceReducer(function buildReducer(s,o){return function allReducers(s,o){let i=Object.keys(s).reduce(((i,a)=>(i[a]=function makeReducer(s,o){return(i=new ze.Map,a)=>{if(!s)return i;let u=s[a.type];if(u){const s=wrapWithTryCatch(u,o)(i,a);return null===s?i:s}return i}}(s[a],o),i)),{});if(!Object.keys(i).length)return idFn;return(0,Xe.H)(i)}(objMap(s,(s=>s.reducers)),o)}(this.system.statePlugins,this.getSystem))}getType(s){let o=s[0].toUpperCase()+s.slice(1);return objReduce(this.system.statePlugins,((i,a)=>{let u=i[s];if(u)return{[a+o]:u}}))}getSelectors(){return this.getType(\"selectors\")}getActions(){return objMap(this.getType(\"actions\"),(s=>objReduce(s,((s,o)=>{if(isFn(s))return{[o]:s}}))))}getWrappedAndBoundActions(s){return objMap(this.getBoundActions(s),((s,o)=>{let i=this.system.statePlugins[o.slice(0,-7)].wrapActions;return i?objMap(s,((s,o)=>{let a=i[o];return a?(Array.isArray(a)||(a=[a]),a.reduce(((s,o)=>{let newAction=(...i)=>o(s,this.getSystem())(...i);if(!isFn(newAction))throw new TypeError(\"wrapActions needs to return a function that returns a new function (ie the wrapped action)\");return wrapWithTryCatch(newAction,this.getSystem)}),s||Function.prototype)):s})):s}))}getWrappedAndBoundSelectors(s,o){return objMap(this.getBoundSelectors(s,o),((o,i)=>{let a=[i.slice(0,-9)],u=this.system.statePlugins[a].wrapSelectors;return u?objMap(o,((o,i)=>{let _=u[i];return _?(Array.isArray(_)||(_=[_]),_.reduce(((o,i)=>{let wrappedSelector=(...u)=>i(o,this.getSystem())(s().getIn(a),...u);if(!isFn(wrappedSelector))throw new TypeError(\"wrapSelector needs to return a function that returns a new function (ie the wrapped action)\");return wrappedSelector}),o||Function.prototype)):o})):o}))}getStates(s){return Object.keys(this.system.statePlugins).reduce(((o,i)=>(o[i]=s.get(i),o)),{})}getStateThunks(s){return Object.keys(this.system.statePlugins).reduce(((o,i)=>(o[i]=()=>s().get(i),o)),{})}getFn(){return{fn:this.system.fn}}getComponents(s){const o=this.system.components[s];return Array.isArray(o)?o.reduce(((s,o)=>o(s,this.getSystem()))):void 0!==s?this.system.components[s]:this.system.components}getBoundSelectors(s,o){return objMap(this.getSelectors(),((i,a)=>{let u=[a.slice(0,-9)];return objMap(i,(i=>(...a)=>{let _=wrapWithTryCatch(i,this.getSystem).apply(null,[s().getIn(u),...a]);return\"function\"==typeof _&&(_=wrapWithTryCatch(_,this.getSystem)(o())),_}))}))}getBoundActions(s){s=s||this.getStore().dispatch;const o=this.getActions(),process=s=>\"function\"!=typeof s?objMap(s,(s=>process(s))):(...o)=>{var i=null;try{i=s(...o)}catch(s){i={type:rt,error:!0,payload:(0,Qe.serializeError)(s)}}finally{return i}};return objMap(o,(o=>function bindActionCreators(s,o){if(\"function\"==typeof s)return bindActionCreator(s,o);if(\"object\"!=typeof s||null===s)throw new Error(formatProdErrorMessage(16));const i={};for(const a in s){const u=s[a];\"function\"==typeof u&&(i[a]=bindActionCreator(u,o))}return i}(process(o),s)))}getMapStateToProps(){return()=>Object.assign({},this.getSystem())}getMapDispatchToProps(s){return o=>Ye()({},this.getWrappedAndBoundActions(o),this.getFn(),s)}}function combinePlugins(s,o){return isObject(s)&&!isArray(s)?tt()({},s):isFunc(s)?combinePlugins(s(o),o):isArray(s)?s.map((s=>combinePlugins(s,o))).reduce(systemExtend,{components:o.getComponents()}):{}}function callAfterLoad(s,o,{hasLoaded:i}={}){let a=i;return isObject(s)&&!isArray(s)&&\"function\"==typeof s.afterLoad&&(a=!0,wrapWithTryCatch(s.afterLoad,o.getSystem).call(this,o)),isFunc(s)?callAfterLoad.call(this,s(o),o,{hasLoaded:a}):isArray(s)?s.map((s=>callAfterLoad.call(this,s,o,{hasLoaded:a}))):a}function systemExtend(s={},o={}){if(!isObject(s))return{};if(!isObject(o))return s;o.wrapComponents&&(objMap(o.wrapComponents,((i,a)=>{const u=s.components&&s.components[a];u&&Array.isArray(u)?(s.components[a]=u.concat([i]),delete o.wrapComponents[a]):u&&(s.components[a]=[u,i],delete o.wrapComponents[a])})),Object.keys(o.wrapComponents).length||delete o.wrapComponents);const{statePlugins:i}=s;if(isObject(i))for(let s in i){const a=i[s];if(!isObject(a))continue;const{wrapActions:u,wrapSelectors:_}=a;if(isObject(u))for(let i in u){let a=u[i];Array.isArray(a)||(a=[a],u[i]=a),o&&o.statePlugins&&o.statePlugins[s]&&o.statePlugins[s].wrapActions&&o.statePlugins[s].wrapActions[i]&&(o.statePlugins[s].wrapActions[i]=u[i].concat(o.statePlugins[s].wrapActions[i]))}if(isObject(_))for(let i in _){let a=_[i];Array.isArray(a)||(a=[a],_[i]=a),o&&o.statePlugins&&o.statePlugins[s]&&o.statePlugins[s].wrapSelectors&&o.statePlugins[s].wrapSelectors[i]&&(o.statePlugins[s].wrapSelectors[i]=_[i].concat(o.statePlugins[s].wrapSelectors[i]))}}return Ye()(s,o)}function wrapWithTryCatch(s,o,{logErrors:i=!0}={}){return\"function\"!=typeof s?s:function(...a){try{return s.call(this,...a)}catch(s){if(i){const{uncaughtExceptionHandler:i}=o().getConfigs();\"function\"==typeof i?i(s):console.error(s)}return null}}}var Tt=__webpack_require__(61160),Nt=__webpack_require__.n(Tt);const Mt=\"show_popup\",Rt=\"authorize\",Dt=\"logout\",Lt=\"authorize_oauth2\",Ft=\"configure_auth\",Bt=\"restore_authorization\";function showDefinitions(s){return{type:Mt,payload:s}}function authorize(s){return{type:Rt,payload:s}}const authorizeWithPersistOption=s=>({authActions:o})=>{o.authorize(s),o.persistAuthorizationIfNeeded()};function logout(s){return{type:Dt,payload:s}}const logoutWithPersistOption=s=>({authActions:o})=>{o.logout(s),o.persistAuthorizationIfNeeded()},preAuthorizeImplicit=s=>({authActions:o,errActions:i})=>{let{auth:a,token:u,isValid:_}=s,{schema:w,name:x}=a,C=w.get(\"flow\");delete lt.swaggerUIRedirectOauth2,\"accessCode\"===C||_||i.newAuthErr({authId:x,source:\"auth\",level:\"warning\",message:\"Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server\"}),u.error?i.newAuthErr({authId:x,source:\"auth\",level:\"error\",message:JSON.stringify(u)}):o.authorizeOauth2WithPersistOption({auth:a,token:u})};function authorizeOauth2(s){return{type:Lt,payload:s}}const authorizeOauth2WithPersistOption=s=>({authActions:o})=>{o.authorizeOauth2(s),o.persistAuthorizationIfNeeded()},authorizePassword=s=>({authActions:o})=>{let{schema:i,name:a,username:u,password:_,passwordType:w,clientId:x,clientSecret:C}=s,j={grant_type:\"password\",scope:s.scopes.join(\" \"),username:u,password:_},L={};switch(w){case\"request-body\":!function setClientIdAndSecret(s,o,i){o&&Object.assign(s,{client_id:o});i&&Object.assign(s,{client_secret:i})}(j,x,C);break;case\"basic\":L.Authorization=\"Basic \"+utils_btoa(x+\":\"+C);break;default:console.warn(`Warning: invalid passwordType ${w} was passed, not including client id and secret`)}return o.authorizeRequest({body:buildFormData(j),url:i.get(\"tokenUrl\"),name:a,headers:L,query:{},auth:s})};const authorizeApplication=s=>({authActions:o})=>{let{schema:i,scopes:a,name:u,clientId:_,clientSecret:w}=s,x={Authorization:\"Basic \"+utils_btoa(_+\":\"+w)},C={grant_type:\"client_credentials\",scope:a.join(\" \")};return o.authorizeRequest({body:buildFormData(C),name:u,url:i.get(\"tokenUrl\"),auth:s,headers:x})},authorizeAccessCodeWithFormParams=({auth:s,redirectUrl:o})=>({authActions:i})=>{let{schema:a,name:u,clientId:_,clientSecret:w,codeVerifier:x}=s,C={grant_type:\"authorization_code\",code:s.code,client_id:_,client_secret:w,redirect_uri:o,code_verifier:x};return i.authorizeRequest({body:buildFormData(C),name:u,url:a.get(\"tokenUrl\"),auth:s})},authorizeAccessCodeWithBasicAuthentication=({auth:s,redirectUrl:o})=>({authActions:i})=>{let{schema:a,name:u,clientId:_,clientSecret:w,codeVerifier:x}=s,C={Authorization:\"Basic \"+utils_btoa(_+\":\"+w)},j={grant_type:\"authorization_code\",code:s.code,client_id:_,redirect_uri:o,code_verifier:x};return i.authorizeRequest({body:buildFormData(j),name:u,url:a.get(\"tokenUrl\"),auth:s,headers:C})},authorizeRequest=s=>({fn:o,getConfigs:i,authActions:a,errActions:u,oas3Selectors:_,specSelectors:w,authSelectors:x})=>{let C,{body:j,query:L={},headers:B={},name:$,url:U,auth:V}=s,{additionalQueryStringParams:z}=x.getConfigs()||{};if(w.isOAS3()){let s=_.serverEffectiveValue(_.selectedServer());C=Nt()(U,s,!0)}else C=Nt()(U,w.url(),!0);\"object\"==typeof z&&(C.query=Object.assign({},C.query,z));const Y=C.toString();let Z=Object.assign({Accept:\"application/json, text/plain, */*\",\"Content-Type\":\"application/x-www-form-urlencoded\",\"X-Requested-With\":\"XMLHttpRequest\"},B);o.fetch({url:Y,method:\"post\",headers:Z,query:L,body:j,requestInterceptor:i().requestInterceptor,responseInterceptor:i().responseInterceptor}).then((function(s){let o=JSON.parse(s.data),i=o&&(o.error||\"\"),_=o&&(o.parseError||\"\");s.ok?i||_?u.newAuthErr({authId:$,level:\"error\",source:\"auth\",message:JSON.stringify(o)}):a.authorizeOauth2WithPersistOption({auth:V,token:o}):u.newAuthErr({authId:$,level:\"error\",source:\"auth\",message:s.statusText})})).catch((s=>{let o=new Error(s).message;if(s.response&&s.response.data){const i=s.response.data;try{const s=\"string\"==typeof i?JSON.parse(i):i;s.error&&(o+=`, error: ${s.error}`),s.error_description&&(o+=`, description: ${s.error_description}`)}catch(s){}}u.newAuthErr({authId:$,level:\"error\",source:\"auth\",message:o})}))};function configureAuth(s){return{type:Ft,payload:s}}function restoreAuthorization(s){return{type:Bt,payload:s}}const persistAuthorizationIfNeeded=()=>({authSelectors:s,getConfigs:o})=>{if(!o().persistAuthorization)return;const i=s.authorized().toJS();localStorage.setItem(\"authorized\",JSON.stringify(i))},authPopup=(s,o)=>()=>{lt.swaggerUIRedirectOauth2=o,lt.open(s)},$t={[Mt]:(s,{payload:o})=>s.set(\"showDefinitions\",o),[Rt]:(s,{payload:o})=>{let i=(0,ze.fromJS)(o),a=s.get(\"authorized\")||(0,ze.Map)();return i.entrySeq().forEach((([o,i])=>{if(!isFunc(i.getIn))return s.set(\"authorized\",a);let u=i.getIn([\"schema\",\"type\"]);if(\"apiKey\"===u||\"http\"===u)a=a.set(o,i);else if(\"basic\"===u){let s=i.getIn([\"value\",\"username\"]),u=i.getIn([\"value\",\"password\"]);a=a.setIn([o,\"value\"],{username:s,header:\"Basic \"+utils_btoa(s+\":\"+u)}),a=a.setIn([o,\"schema\"],i.get(\"schema\"))}})),s.set(\"authorized\",a)},[Lt]:(s,{payload:o})=>{let i,{auth:a,token:u}=o;a.token=Object.assign({},u),i=(0,ze.fromJS)(a);let _=s.get(\"authorized\")||(0,ze.Map)();return _=_.set(i.get(\"name\"),i),s.set(\"authorized\",_)},[Dt]:(s,{payload:o})=>{let i=s.get(\"authorized\").withMutations((s=>{o.forEach((o=>{s.delete(o)}))}));return s.set(\"authorized\",i)},[Ft]:(s,{payload:o})=>s.set(\"configs\",o),[Bt]:(s,{payload:o})=>s.set(\"authorized\",(0,ze.fromJS)(o.authorized))};function assertIsFunction(s,o=\"expected a function, instead received \"+typeof s){if(\"function\"!=typeof s)throw new TypeError(o)}var ensureIsArray=s=>Array.isArray(s)?s:[s];function getDependencies(s){const o=Array.isArray(s[0])?s[0]:s;return function assertIsArrayOfFunctions(s,o=\"expected all items to be functions, instead received the following types: \"){if(!s.every((s=>\"function\"==typeof s))){const i=s.map((s=>\"function\"==typeof s?`function ${s.name||\"unnamed\"}()`:typeof s)).join(\", \");throw new TypeError(`${o}[${i}]`)}}(o,\"createSelector expects all input-selectors to be functions, but received the following types: \"),o}Symbol(),Object.getPrototypeOf({});var qt=\"undefined\"!=typeof WeakRef?WeakRef:class{constructor(s){this.value=s}deref(){return this.value}};function weakMapMemoize(s,o={}){let i={s:0,v:void 0,o:null,p:null};const{resultEqualityCheck:a}=o;let u,_=0;function memoized(){let o=i;const{length:w}=arguments;for(let s=0,i=w;s<i;s++){const i=arguments[s];if(\"function\"==typeof i||\"object\"==typeof i&&null!==i){let s=o.o;null===s&&(o.o=s=new WeakMap);const a=s.get(i);void 0===a?(o={s:0,v:void 0,o:null,p:null},s.set(i,o)):o=a}else{let s=o.p;null===s&&(o.p=s=new Map);const a=s.get(i);void 0===a?(o={s:0,v:void 0,o:null,p:null},s.set(i,o)):o=a}}const x=o;let C;if(1===o.s)C=o.v;else if(C=s.apply(null,arguments),_++,a){const s=u?.deref?.()??u;null!=s&&a(s,C)&&(C=s,0!==_&&_--);u=\"object\"==typeof C&&null!==C||\"function\"==typeof C?new qt(C):C}return x.s=1,x.v=C,C}return memoized.clearCache=()=>{i={s:0,v:void 0,o:null,p:null},memoized.resetResultsCount()},memoized.resultsCount=()=>_,memoized.resetResultsCount=()=>{_=0},memoized}function createSelectorCreator(s,...o){const i=\"function\"==typeof s?{memoize:s,memoizeOptions:o}:s,createSelector2=(...s)=>{let o,a=0,u=0,_={},w=s.pop();\"object\"==typeof w&&(_=w,w=s.pop()),assertIsFunction(w,`createSelector expects an output function after the inputs, but received: [${typeof w}]`);const x={...i,..._},{memoize:C,memoizeOptions:j=[],argsMemoize:L=weakMapMemoize,argsMemoizeOptions:B=[],devModeChecks:$={}}=x,U=ensureIsArray(j),V=ensureIsArray(B),z=getDependencies(s),Y=C((function recomputationWrapper(){return a++,w.apply(null,arguments)}),...U);const Z=L((function dependenciesChecker(){u++;const s=function collectInputSelectorResults(s,o){const i=[],{length:a}=s;for(let u=0;u<a;u++)i.push(s[u].apply(null,o));return i}(z,arguments);return o=Y.apply(null,s),o}),...V);return Object.assign(Z,{resultFunc:w,memoizedResultFunc:Y,dependencies:z,dependencyRecomputations:()=>u,resetDependencyRecomputations:()=>{u=0},lastResult:()=>o,recomputations:()=>a,resetRecomputations:()=>{a=0},memoize:C,argsMemoize:L})};return Object.assign(createSelector2,{withTypes:()=>createSelector2}),createSelector2}var Ut=createSelectorCreator(weakMapMemoize),Vt=Object.assign(((s,o=Ut)=>{!function assertIsObject(s,o=\"expected an object, instead received \"+typeof s){if(\"object\"!=typeof s)throw new TypeError(o)}(s,\"createStructuredSelector expects first argument to be an object where each property is a selector, instead received a \"+typeof s);const i=Object.keys(s);return o(i.map((o=>s[o])),((...s)=>s.reduce(((s,o,a)=>(s[i[a]]=o,s)),{})))}),{withTypes:()=>Vt});const state=s=>s,zt=Ut(state,(s=>s.get(\"showDefinitions\"))),Wt=Ut(state,(()=>({specSelectors:s})=>{let o=s.securityDefinitions()||(0,ze.Map)({}),i=(0,ze.List)();return o.entrySeq().forEach((([s,o])=>{let a=(0,ze.Map)();a=a.set(s,o),i=i.push(a)})),i})),selectAuthPath=(s,o)=>({specSelectors:s})=>(0,ze.List)(s.isOAS3()?[\"components\",\"securitySchemes\",o]:[\"securityDefinitions\",o]),getDefinitionsByNames=(s,o)=>({specSelectors:s})=>{console.warn(\"WARNING: getDefinitionsByNames is deprecated and will be removed in the next major version.\");let i=s.securityDefinitions(),a=(0,ze.List)();return o.valueSeq().forEach((s=>{let o=(0,ze.Map)();s.entrySeq().forEach((([s,a])=>{let u,_=i.get(s);\"oauth2\"===_.get(\"type\")&&a.size&&(u=_.get(\"scopes\"),u.keySeq().forEach((s=>{a.contains(s)||(u=u.delete(s))})),_=_.set(\"allowedScopes\",u)),o=o.set(s,_)})),a=a.push(o)})),a},definitionsForRequirements=(s,o=(0,ze.List)())=>({authSelectors:s})=>{const i=s.definitionsToAuthorize()||(0,ze.List)();let a=(0,ze.List)();return i.forEach((s=>{let i=o.find((o=>o.get(s.keySeq().first())));i&&(s.forEach(((o,a)=>{if(\"oauth2\"===o.get(\"type\")){const u=i.get(a);let _=o.get(\"scopes\");ze.List.isList(u)&&ze.Map.isMap(_)&&(_.keySeq().forEach((s=>{u.contains(s)||(_=_.delete(s))})),s=s.set(a,o.set(\"scopes\",_)))}})),a=a.push(s))})),a},Jt=Ut(state,(s=>s.get(\"authorized\")||(0,ze.Map)())),isAuthorized=(s,o)=>({authSelectors:s})=>{let i=s.authorized();return ze.List.isList(o)?!!o.toJS().filter((s=>-1===Object.keys(s).map((s=>!!i.get(s))).indexOf(!1))).length:null},Ht=Ut(state,(s=>s.get(\"configs\"))),execute=(s,{authSelectors:o,specSelectors:i})=>({path:a,method:u,operation:_,extras:w})=>{let x={authorized:o.authorized()&&o.authorized().toJS(),definitions:i.securityDefinitions()&&i.securityDefinitions().toJS(),specSecurity:i.security()&&i.security().toJS()};return s({path:a,method:u,operation:_,securities:x,...w})},loaded=(s,o)=>i=>{const{getConfigs:a,authActions:u}=o,_=a();if(s(i),_.persistAuthorization){const s=localStorage.getItem(\"authorized\");s&&u.restoreAuthorization({authorized:JSON.parse(s)})}},wrap_actions_authorize=(s,o)=>i=>{s(i);if(o.getConfigs().persistAuthorization)try{const[{schema:s,value:o}]=Object.values(i),a=(0,ze.fromJS)(s),u=\"apiKey\"===a.get(\"type\"),_=\"cookie\"===a.get(\"in\");u&&_&&(document.cookie=`${a.get(\"name\")}=${o}; SameSite=None; Secure`)}catch(s){console.error(\"Error persisting cookie based apiKey in document.cookie.\",s)}},wrap_actions_logout=(s,o)=>i=>{const a=o.getConfigs(),u=o.authSelectors.authorized();try{a.persistAuthorization&&Array.isArray(i)&&i.forEach((s=>{const o=u.get(s,{}),i=\"apiKey\"===o.getIn([\"schema\",\"type\"]),a=\"cookie\"===o.getIn([\"schema\",\"in\"]);if(i&&a){const s=o.getIn([\"schema\",\"name\"]);document.cookie=`${s}=; Max-Age=-99999999`}}))}catch(s){console.error(\"Error deleting cookie based apiKey from document.cookie.\",s)}s(i)};var Kt=__webpack_require__(90179),Gt=__webpack_require__.n(Kt);class LockAuthIcon extends Re.Component{mapStateToProps(s,o){return{state:s,ownProps:Gt()(o,Object.keys(o.getSystem()))}}render(){const{getComponent:s,ownProps:o}=this.props,i=s(\"LockIcon\");return Re.createElement(i,o)}}const Yt=LockAuthIcon;class UnlockAuthIcon extends Re.Component{mapStateToProps(s,o){return{state:s,ownProps:Gt()(o,Object.keys(o.getSystem()))}}render(){const{getComponent:s,ownProps:o}=this.props,i=s(\"UnlockIcon\");return Re.createElement(i,o)}}const Xt=UnlockAuthIcon;function auth(){return{afterLoad(s){this.rootInjects=this.rootInjects||{},this.rootInjects.initOAuth=s.authActions.configureAuth,this.rootInjects.preauthorizeApiKey=preauthorizeApiKey.bind(null,s),this.rootInjects.preauthorizeBasic=preauthorizeBasic.bind(null,s)},components:{LockAuthIcon:Yt,UnlockAuthIcon:Xt,LockAuthOperationIcon:Yt,UnlockAuthOperationIcon:Xt},statePlugins:{auth:{reducers:$t,actions:o,selectors:a,wrapActions:{authorize:wrap_actions_authorize,logout:wrap_actions_logout}},configs:{wrapActions:{loaded}},spec:{wrapActions:{execute}}}}}function preauthorizeBasic(s,o,i,a){const{authActions:{authorize:u},specSelectors:{specJson:_,isOAS3:w}}=s,x=w()?[\"components\",\"securitySchemes\"]:[\"securityDefinitions\"],C=_().getIn([...x,o]);return C?u({[o]:{value:{username:i,password:a},schema:C.toJS()}}):null}function preauthorizeApiKey(s,o,i){const{authActions:{authorize:a},specSelectors:{specJson:u,isOAS3:_}}=s,w=_()?[\"components\",\"securitySchemes\"]:[\"securityDefinitions\"],x=u().getIn([...w,o]);return x?a({[o]:{value:i,schema:x.toJS()}}):null}function isNothing(s){return null==s}var Qt=function repeat(s,o){var i,a=\"\";for(i=0;i<o;i+=1)a+=s;return a},Zt=function isNegativeZero(s){return 0===s&&Number.NEGATIVE_INFINITY===1/s},er={isNothing,isObject:function js_yaml_isObject(s){return\"object\"==typeof s&&null!==s},toArray:function toArray(s){return Array.isArray(s)?s:isNothing(s)?[]:[s]},repeat:Qt,isNegativeZero:Zt,extend:function extend(s,o){var i,a,u,_;if(o)for(i=0,a=(_=Object.keys(o)).length;i<a;i+=1)s[u=_[i]]=o[u];return s}};function formatError(s,o){var i=\"\",a=s.reason||\"(unknown reason)\";return s.mark?(s.mark.name&&(i+='in \"'+s.mark.name+'\" '),i+=\"(\"+(s.mark.line+1)+\":\"+(s.mark.column+1)+\")\",!o&&s.mark.snippet&&(i+=\"\\n\\n\"+s.mark.snippet),a+\" \"+i):a}function YAMLException$1(s,o){Error.call(this),this.name=\"YAMLException\",this.reason=s,this.mark=o,this.message=formatError(this,!1),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=(new Error).stack||\"\"}YAMLException$1.prototype=Object.create(Error.prototype),YAMLException$1.prototype.constructor=YAMLException$1,YAMLException$1.prototype.toString=function toString(s){return this.name+\": \"+formatError(this,s)};var tr=YAMLException$1;function getLine(s,o,i,a,u){var _=\"\",w=\"\",x=Math.floor(u/2)-1;return a-o>x&&(o=a-x+(_=\" ... \").length),i-a>x&&(i=a+x-(w=\" ...\").length),{str:_+s.slice(o,i).replace(/\\t/g,\"→\")+w,pos:a-o+_.length}}function padStart(s,o){return er.repeat(\" \",o-s.length)+s}var rr=function makeSnippet(s,o){if(o=Object.create(o||null),!s.buffer)return null;o.maxLength||(o.maxLength=79),\"number\"!=typeof o.indent&&(o.indent=1),\"number\"!=typeof o.linesBefore&&(o.linesBefore=3),\"number\"!=typeof o.linesAfter&&(o.linesAfter=2);for(var i,a=/\\r?\\n|\\r|\\0/g,u=[0],_=[],w=-1;i=a.exec(s.buffer);)_.push(i.index),u.push(i.index+i[0].length),s.position<=i.index&&w<0&&(w=u.length-2);w<0&&(w=u.length-1);var x,C,j=\"\",L=Math.min(s.line+o.linesAfter,_.length).toString().length,B=o.maxLength-(o.indent+L+3);for(x=1;x<=o.linesBefore&&!(w-x<0);x++)C=getLine(s.buffer,u[w-x],_[w-x],s.position-(u[w]-u[w-x]),B),j=er.repeat(\" \",o.indent)+padStart((s.line-x+1).toString(),L)+\" | \"+C.str+\"\\n\"+j;for(C=getLine(s.buffer,u[w],_[w],s.position,B),j+=er.repeat(\" \",o.indent)+padStart((s.line+1).toString(),L)+\" | \"+C.str+\"\\n\",j+=er.repeat(\"-\",o.indent+L+3+C.pos)+\"^\\n\",x=1;x<=o.linesAfter&&!(w+x>=_.length);x++)C=getLine(s.buffer,u[w+x],_[w+x],s.position-(u[w]-u[w+x]),B),j+=er.repeat(\" \",o.indent)+padStart((s.line+x+1).toString(),L)+\" | \"+C.str+\"\\n\";return j.replace(/\\n$/,\"\")},nr=[\"kind\",\"multi\",\"resolve\",\"construct\",\"instanceOf\",\"predicate\",\"represent\",\"representName\",\"defaultStyle\",\"styleAliases\"],sr=[\"scalar\",\"sequence\",\"mapping\"];var ir=function Type$1(s,o){if(o=o||{},Object.keys(o).forEach((function(o){if(-1===nr.indexOf(o))throw new tr('Unknown option \"'+o+'\" is met in definition of \"'+s+'\" YAML type.')})),this.options=o,this.tag=s,this.kind=o.kind||null,this.resolve=o.resolve||function(){return!0},this.construct=o.construct||function(s){return s},this.instanceOf=o.instanceOf||null,this.predicate=o.predicate||null,this.represent=o.represent||null,this.representName=o.representName||null,this.defaultStyle=o.defaultStyle||null,this.multi=o.multi||!1,this.styleAliases=function compileStyleAliases(s){var o={};return null!==s&&Object.keys(s).forEach((function(i){s[i].forEach((function(s){o[String(s)]=i}))})),o}(o.styleAliases||null),-1===sr.indexOf(this.kind))throw new tr('Unknown kind \"'+this.kind+'\" is specified for \"'+s+'\" YAML type.')};function compileList(s,o){var i=[];return s[o].forEach((function(s){var o=i.length;i.forEach((function(i,a){i.tag===s.tag&&i.kind===s.kind&&i.multi===s.multi&&(o=a)})),i[o]=s})),i}function Schema$1(s){return this.extend(s)}Schema$1.prototype.extend=function extend(s){var o=[],i=[];if(s instanceof ir)i.push(s);else if(Array.isArray(s))i=i.concat(s);else{if(!s||!Array.isArray(s.implicit)&&!Array.isArray(s.explicit))throw new tr(\"Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })\");s.implicit&&(o=o.concat(s.implicit)),s.explicit&&(i=i.concat(s.explicit))}o.forEach((function(s){if(!(s instanceof ir))throw new tr(\"Specified list of YAML types (or a single Type object) contains a non-Type object.\");if(s.loadKind&&\"scalar\"!==s.loadKind)throw new tr(\"There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.\");if(s.multi)throw new tr(\"There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.\")})),i.forEach((function(s){if(!(s instanceof ir))throw new tr(\"Specified list of YAML types (or a single Type object) contains a non-Type object.\")}));var a=Object.create(Schema$1.prototype);return a.implicit=(this.implicit||[]).concat(o),a.explicit=(this.explicit||[]).concat(i),a.compiledImplicit=compileList(a,\"implicit\"),a.compiledExplicit=compileList(a,\"explicit\"),a.compiledTypeMap=function compileMap(){var s,o,i={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}};function collectType(s){s.multi?(i.multi[s.kind].push(s),i.multi.fallback.push(s)):i[s.kind][s.tag]=i.fallback[s.tag]=s}for(s=0,o=arguments.length;s<o;s+=1)arguments[s].forEach(collectType);return i}(a.compiledImplicit,a.compiledExplicit),a};var ar=Schema$1,cr=new ir(\"tag:yaml.org,2002:str\",{kind:\"scalar\",construct:function(s){return null!==s?s:\"\"}}),lr=new ir(\"tag:yaml.org,2002:seq\",{kind:\"sequence\",construct:function(s){return null!==s?s:[]}}),ur=new ir(\"tag:yaml.org,2002:map\",{kind:\"mapping\",construct:function(s){return null!==s?s:{}}}),pr=new ar({explicit:[cr,lr,ur]});var dr=new ir(\"tag:yaml.org,2002:null\",{kind:\"scalar\",resolve:function resolveYamlNull(s){if(null===s)return!0;var o=s.length;return 1===o&&\"~\"===s||4===o&&(\"null\"===s||\"Null\"===s||\"NULL\"===s)},construct:function constructYamlNull(){return null},predicate:function isNull(s){return null===s},represent:{canonical:function(){return\"~\"},lowercase:function(){return\"null\"},uppercase:function(){return\"NULL\"},camelcase:function(){return\"Null\"},empty:function(){return\"\"}},defaultStyle:\"lowercase\"});var fr=new ir(\"tag:yaml.org,2002:bool\",{kind:\"scalar\",resolve:function resolveYamlBoolean(s){if(null===s)return!1;var o=s.length;return 4===o&&(\"true\"===s||\"True\"===s||\"TRUE\"===s)||5===o&&(\"false\"===s||\"False\"===s||\"FALSE\"===s)},construct:function constructYamlBoolean(s){return\"true\"===s||\"True\"===s||\"TRUE\"===s},predicate:function isBoolean(s){return\"[object Boolean]\"===Object.prototype.toString.call(s)},represent:{lowercase:function(s){return s?\"true\":\"false\"},uppercase:function(s){return s?\"TRUE\":\"FALSE\"},camelcase:function(s){return s?\"True\":\"False\"}},defaultStyle:\"lowercase\"});function isOctCode(s){return 48<=s&&s<=55}function isDecCode(s){return 48<=s&&s<=57}var mr=new ir(\"tag:yaml.org,2002:int\",{kind:\"scalar\",resolve:function resolveYamlInteger(s){if(null===s)return!1;var o,i,a=s.length,u=0,_=!1;if(!a)return!1;if(\"-\"!==(o=s[u])&&\"+\"!==o||(o=s[++u]),\"0\"===o){if(u+1===a)return!0;if(\"b\"===(o=s[++u])){for(u++;u<a;u++)if(\"_\"!==(o=s[u])){if(\"0\"!==o&&\"1\"!==o)return!1;_=!0}return _&&\"_\"!==o}if(\"x\"===o){for(u++;u<a;u++)if(\"_\"!==(o=s[u])){if(!(48<=(i=s.charCodeAt(u))&&i<=57||65<=i&&i<=70||97<=i&&i<=102))return!1;_=!0}return _&&\"_\"!==o}if(\"o\"===o){for(u++;u<a;u++)if(\"_\"!==(o=s[u])){if(!isOctCode(s.charCodeAt(u)))return!1;_=!0}return _&&\"_\"!==o}}if(\"_\"===o)return!1;for(;u<a;u++)if(\"_\"!==(o=s[u])){if(!isDecCode(s.charCodeAt(u)))return!1;_=!0}return!(!_||\"_\"===o)},construct:function constructYamlInteger(s){var o,i=s,a=1;if(-1!==i.indexOf(\"_\")&&(i=i.replace(/_/g,\"\")),\"-\"!==(o=i[0])&&\"+\"!==o||(\"-\"===o&&(a=-1),o=(i=i.slice(1))[0]),\"0\"===i)return 0;if(\"0\"===o){if(\"b\"===i[1])return a*parseInt(i.slice(2),2);if(\"x\"===i[1])return a*parseInt(i.slice(2),16);if(\"o\"===i[1])return a*parseInt(i.slice(2),8)}return a*parseInt(i,10)},predicate:function isInteger(s){return\"[object Number]\"===Object.prototype.toString.call(s)&&s%1==0&&!er.isNegativeZero(s)},represent:{binary:function(s){return s>=0?\"0b\"+s.toString(2):\"-0b\"+s.toString(2).slice(1)},octal:function(s){return s>=0?\"0o\"+s.toString(8):\"-0o\"+s.toString(8).slice(1)},decimal:function(s){return s.toString(10)},hexadecimal:function(s){return s>=0?\"0x\"+s.toString(16).toUpperCase():\"-0x\"+s.toString(16).toUpperCase().slice(1)}},defaultStyle:\"decimal\",styleAliases:{binary:[2,\"bin\"],octal:[8,\"oct\"],decimal:[10,\"dec\"],hexadecimal:[16,\"hex\"]}}),gr=new RegExp(\"^(?:[-+]?(?:[0-9][0-9_]*)(?:\\\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\\\\.(?:inf|Inf|INF)|\\\\.(?:nan|NaN|NAN))$\");var yr=/^[-+]?[0-9]+e/;var vr=new ir(\"tag:yaml.org,2002:float\",{kind:\"scalar\",resolve:function resolveYamlFloat(s){return null!==s&&!(!gr.test(s)||\"_\"===s[s.length-1])},construct:function constructYamlFloat(s){var o,i;return i=\"-\"===(o=s.replace(/_/g,\"\").toLowerCase())[0]?-1:1,\"+-\".indexOf(o[0])>=0&&(o=o.slice(1)),\".inf\"===o?1===i?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:\".nan\"===o?NaN:i*parseFloat(o,10)},predicate:function isFloat(s){return\"[object Number]\"===Object.prototype.toString.call(s)&&(s%1!=0||er.isNegativeZero(s))},represent:function representYamlFloat(s,o){var i;if(isNaN(s))switch(o){case\"lowercase\":return\".nan\";case\"uppercase\":return\".NAN\";case\"camelcase\":return\".NaN\"}else if(Number.POSITIVE_INFINITY===s)switch(o){case\"lowercase\":return\".inf\";case\"uppercase\":return\".INF\";case\"camelcase\":return\".Inf\"}else if(Number.NEGATIVE_INFINITY===s)switch(o){case\"lowercase\":return\"-.inf\";case\"uppercase\":return\"-.INF\";case\"camelcase\":return\"-.Inf\"}else if(er.isNegativeZero(s))return\"-0.0\";return i=s.toString(10),yr.test(i)?i.replace(\"e\",\".e\"):i},defaultStyle:\"lowercase\"}),br=pr.extend({implicit:[dr,fr,mr,vr]}),_r=br,Sr=new RegExp(\"^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$\"),Er=new RegExp(\"^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\\\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\\\.([0-9]*))?(?:[ \\\\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$\");var wr=new ir(\"tag:yaml.org,2002:timestamp\",{kind:\"scalar\",resolve:function resolveYamlTimestamp(s){return null!==s&&(null!==Sr.exec(s)||null!==Er.exec(s))},construct:function constructYamlTimestamp(s){var o,i,a,u,_,w,x,C,j=0,L=null;if(null===(o=Sr.exec(s))&&(o=Er.exec(s)),null===o)throw new Error(\"Date resolve error\");if(i=+o[1],a=+o[2]-1,u=+o[3],!o[4])return new Date(Date.UTC(i,a,u));if(_=+o[4],w=+o[5],x=+o[6],o[7]){for(j=o[7].slice(0,3);j.length<3;)j+=\"0\";j=+j}return o[9]&&(L=6e4*(60*+o[10]+ +(o[11]||0)),\"-\"===o[9]&&(L=-L)),C=new Date(Date.UTC(i,a,u,_,w,x,j)),L&&C.setTime(C.getTime()-L),C},instanceOf:Date,represent:function representYamlTimestamp(s){return s.toISOString()}});var xr=new ir(\"tag:yaml.org,2002:merge\",{kind:\"scalar\",resolve:function resolveYamlMerge(s){return\"<<\"===s||null===s}}),kr=\"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\\n\\r\";var Or=new ir(\"tag:yaml.org,2002:binary\",{kind:\"scalar\",resolve:function resolveYamlBinary(s){if(null===s)return!1;var o,i,a=0,u=s.length,_=kr;for(i=0;i<u;i++)if(!((o=_.indexOf(s.charAt(i)))>64)){if(o<0)return!1;a+=6}return a%8==0},construct:function constructYamlBinary(s){var o,i,a=s.replace(/[\\r\\n=]/g,\"\"),u=a.length,_=kr,w=0,x=[];for(o=0;o<u;o++)o%4==0&&o&&(x.push(w>>16&255),x.push(w>>8&255),x.push(255&w)),w=w<<6|_.indexOf(a.charAt(o));return 0===(i=u%4*6)?(x.push(w>>16&255),x.push(w>>8&255),x.push(255&w)):18===i?(x.push(w>>10&255),x.push(w>>2&255)):12===i&&x.push(w>>4&255),new Uint8Array(x)},predicate:function isBinary(s){return\"[object Uint8Array]\"===Object.prototype.toString.call(s)},represent:function representYamlBinary(s){var o,i,a=\"\",u=0,_=s.length,w=kr;for(o=0;o<_;o++)o%3==0&&o&&(a+=w[u>>18&63],a+=w[u>>12&63],a+=w[u>>6&63],a+=w[63&u]),u=(u<<8)+s[o];return 0===(i=_%3)?(a+=w[u>>18&63],a+=w[u>>12&63],a+=w[u>>6&63],a+=w[63&u]):2===i?(a+=w[u>>10&63],a+=w[u>>4&63],a+=w[u<<2&63],a+=w[64]):1===i&&(a+=w[u>>2&63],a+=w[u<<4&63],a+=w[64],a+=w[64]),a}}),Ar=Object.prototype.hasOwnProperty,Cr=Object.prototype.toString;var jr=new ir(\"tag:yaml.org,2002:omap\",{kind:\"sequence\",resolve:function resolveYamlOmap(s){if(null===s)return!0;var o,i,a,u,_,w=[],x=s;for(o=0,i=x.length;o<i;o+=1){if(a=x[o],_=!1,\"[object Object]\"!==Cr.call(a))return!1;for(u in a)if(Ar.call(a,u)){if(_)return!1;_=!0}if(!_)return!1;if(-1!==w.indexOf(u))return!1;w.push(u)}return!0},construct:function constructYamlOmap(s){return null!==s?s:[]}}),Pr=Object.prototype.toString;var Ir=new ir(\"tag:yaml.org,2002:pairs\",{kind:\"sequence\",resolve:function resolveYamlPairs(s){if(null===s)return!0;var o,i,a,u,_,w=s;for(_=new Array(w.length),o=0,i=w.length;o<i;o+=1){if(a=w[o],\"[object Object]\"!==Pr.call(a))return!1;if(1!==(u=Object.keys(a)).length)return!1;_[o]=[u[0],a[u[0]]]}return!0},construct:function constructYamlPairs(s){if(null===s)return[];var o,i,a,u,_,w=s;for(_=new Array(w.length),o=0,i=w.length;o<i;o+=1)a=w[o],u=Object.keys(a),_[o]=[u[0],a[u[0]]];return _}}),Tr=Object.prototype.hasOwnProperty;var Nr=new ir(\"tag:yaml.org,2002:set\",{kind:\"mapping\",resolve:function resolveYamlSet(s){if(null===s)return!0;var o,i=s;for(o in i)if(Tr.call(i,o)&&null!==i[o])return!1;return!0},construct:function constructYamlSet(s){return null!==s?s:{}}}),Mr=_r.extend({implicit:[wr,xr],explicit:[Or,jr,Ir,Nr]}),Rr=Object.prototype.hasOwnProperty,Dr=/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F-\\x84\\x86-\\x9F\\uFFFE\\uFFFF]|[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|(?:[^\\uD800-\\uDBFF]|^)[\\uDC00-\\uDFFF]/,Lr=/[\\x85\\u2028\\u2029]/,Fr=/[,\\[\\]\\{\\}]/,Br=/^(?:!|!!|![a-z\\-]+!)$/i,$r=/^(?:!|[^,\\[\\]\\{\\}])(?:%[0-9a-f]{2}|[0-9a-z\\-#;\\/\\?:@&=\\+\\$,_\\.!~\\*'\\(\\)\\[\\]])*$/i;function _class(s){return Object.prototype.toString.call(s)}function is_EOL(s){return 10===s||13===s}function is_WHITE_SPACE(s){return 9===s||32===s}function is_WS_OR_EOL(s){return 9===s||32===s||10===s||13===s}function is_FLOW_INDICATOR(s){return 44===s||91===s||93===s||123===s||125===s}function fromHexCode(s){var o;return 48<=s&&s<=57?s-48:97<=(o=32|s)&&o<=102?o-97+10:-1}function simpleEscapeSequence(s){return 48===s?\"\\0\":97===s?\"\u0007\":98===s?\"\\b\":116===s||9===s?\"\\t\":110===s?\"\\n\":118===s?\"\\v\":102===s?\"\\f\":114===s?\"\\r\":101===s?\"\u001b\":32===s?\" \":34===s?'\"':47===s?\"/\":92===s?\"\\\\\":78===s?\"\":95===s?\" \":76===s?\"\\u2028\":80===s?\"\\u2029\":\"\"}function charFromCodepoint(s){return s<=65535?String.fromCharCode(s):String.fromCharCode(55296+(s-65536>>10),56320+(s-65536&1023))}for(var qr=new Array(256),Ur=new Array(256),Vr=0;Vr<256;Vr++)qr[Vr]=simpleEscapeSequence(Vr)?1:0,Ur[Vr]=simpleEscapeSequence(Vr);function State$1(s,o){this.input=s,this.filename=o.filename||null,this.schema=o.schema||Mr,this.onWarning=o.onWarning||null,this.legacy=o.legacy||!1,this.json=o.json||!1,this.listener=o.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=s.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.firstTabInLine=-1,this.documents=[]}function generateError(s,o){var i={name:s.filename,buffer:s.input.slice(0,-1),position:s.position,line:s.line,column:s.position-s.lineStart};return i.snippet=rr(i),new tr(o,i)}function throwError(s,o){throw generateError(s,o)}function throwWarning(s,o){s.onWarning&&s.onWarning.call(null,generateError(s,o))}var zr={YAML:function handleYamlDirective(s,o,i){var a,u,_;null!==s.version&&throwError(s,\"duplication of %YAML directive\"),1!==i.length&&throwError(s,\"YAML directive accepts exactly one argument\"),null===(a=/^([0-9]+)\\.([0-9]+)$/.exec(i[0]))&&throwError(s,\"ill-formed argument of the YAML directive\"),u=parseInt(a[1],10),_=parseInt(a[2],10),1!==u&&throwError(s,\"unacceptable YAML version of the document\"),s.version=i[0],s.checkLineBreaks=_<2,1!==_&&2!==_&&throwWarning(s,\"unsupported YAML version of the document\")},TAG:function handleTagDirective(s,o,i){var a,u;2!==i.length&&throwError(s,\"TAG directive accepts exactly two arguments\"),a=i[0],u=i[1],Br.test(a)||throwError(s,\"ill-formed tag handle (first argument) of the TAG directive\"),Rr.call(s.tagMap,a)&&throwError(s,'there is a previously declared suffix for \"'+a+'\" tag handle'),$r.test(u)||throwError(s,\"ill-formed tag prefix (second argument) of the TAG directive\");try{u=decodeURIComponent(u)}catch(o){throwError(s,\"tag prefix is malformed: \"+u)}s.tagMap[a]=u}};function captureSegment(s,o,i,a){var u,_,w,x;if(o<i){if(x=s.input.slice(o,i),a)for(u=0,_=x.length;u<_;u+=1)9===(w=x.charCodeAt(u))||32<=w&&w<=1114111||throwError(s,\"expected valid JSON character\");else Dr.test(x)&&throwError(s,\"the stream contains non-printable characters\");s.result+=x}}function mergeMappings(s,o,i,a){var u,_,w,x;for(er.isObject(i)||throwError(s,\"cannot merge mappings; the provided source object is unacceptable\"),w=0,x=(u=Object.keys(i)).length;w<x;w+=1)_=u[w],Rr.call(o,_)||(o[_]=i[_],a[_]=!0)}function storeMappingPair(s,o,i,a,u,_,w,x,C){var j,L;if(Array.isArray(u))for(j=0,L=(u=Array.prototype.slice.call(u)).length;j<L;j+=1)Array.isArray(u[j])&&throwError(s,\"nested arrays are not supported inside keys\"),\"object\"==typeof u&&\"[object Object]\"===_class(u[j])&&(u[j]=\"[object Object]\");if(\"object\"==typeof u&&\"[object Object]\"===_class(u)&&(u=\"[object Object]\"),u=String(u),null===o&&(o={}),\"tag:yaml.org,2002:merge\"===a)if(Array.isArray(_))for(j=0,L=_.length;j<L;j+=1)mergeMappings(s,o,_[j],i);else mergeMappings(s,o,_,i);else s.json||Rr.call(i,u)||!Rr.call(o,u)||(s.line=w||s.line,s.lineStart=x||s.lineStart,s.position=C||s.position,throwError(s,\"duplicated mapping key\")),\"__proto__\"===u?Object.defineProperty(o,u,{configurable:!0,enumerable:!0,writable:!0,value:_}):o[u]=_,delete i[u];return o}function readLineBreak(s){var o;10===(o=s.input.charCodeAt(s.position))?s.position++:13===o?(s.position++,10===s.input.charCodeAt(s.position)&&s.position++):throwError(s,\"a line break is expected\"),s.line+=1,s.lineStart=s.position,s.firstTabInLine=-1}function skipSeparationSpace(s,o,i){for(var a=0,u=s.input.charCodeAt(s.position);0!==u;){for(;is_WHITE_SPACE(u);)9===u&&-1===s.firstTabInLine&&(s.firstTabInLine=s.position),u=s.input.charCodeAt(++s.position);if(o&&35===u)do{u=s.input.charCodeAt(++s.position)}while(10!==u&&13!==u&&0!==u);if(!is_EOL(u))break;for(readLineBreak(s),u=s.input.charCodeAt(s.position),a++,s.lineIndent=0;32===u;)s.lineIndent++,u=s.input.charCodeAt(++s.position)}return-1!==i&&0!==a&&s.lineIndent<i&&throwWarning(s,\"deficient indentation\"),a}function testDocumentSeparator(s){var o,i=s.position;return!(45!==(o=s.input.charCodeAt(i))&&46!==o||o!==s.input.charCodeAt(i+1)||o!==s.input.charCodeAt(i+2)||(i+=3,0!==(o=s.input.charCodeAt(i))&&!is_WS_OR_EOL(o)))}function writeFoldedLines(s,o){1===o?s.result+=\" \":o>1&&(s.result+=er.repeat(\"\\n\",o-1))}function readBlockSequence(s,o){var i,a,u=s.tag,_=s.anchor,w=[],x=!1;if(-1!==s.firstTabInLine)return!1;for(null!==s.anchor&&(s.anchorMap[s.anchor]=w),a=s.input.charCodeAt(s.position);0!==a&&(-1!==s.firstTabInLine&&(s.position=s.firstTabInLine,throwError(s,\"tab characters must not be used in indentation\")),45===a)&&is_WS_OR_EOL(s.input.charCodeAt(s.position+1));)if(x=!0,s.position++,skipSeparationSpace(s,!0,-1)&&s.lineIndent<=o)w.push(null),a=s.input.charCodeAt(s.position);else if(i=s.line,composeNode(s,o,3,!1,!0),w.push(s.result),skipSeparationSpace(s,!0,-1),a=s.input.charCodeAt(s.position),(s.line===i||s.lineIndent>o)&&0!==a)throwError(s,\"bad indentation of a sequence entry\");else if(s.lineIndent<o)break;return!!x&&(s.tag=u,s.anchor=_,s.kind=\"sequence\",s.result=w,!0)}function readTagProperty(s){var o,i,a,u,_=!1,w=!1;if(33!==(u=s.input.charCodeAt(s.position)))return!1;if(null!==s.tag&&throwError(s,\"duplication of a tag property\"),60===(u=s.input.charCodeAt(++s.position))?(_=!0,u=s.input.charCodeAt(++s.position)):33===u?(w=!0,i=\"!!\",u=s.input.charCodeAt(++s.position)):i=\"!\",o=s.position,_){do{u=s.input.charCodeAt(++s.position)}while(0!==u&&62!==u);s.position<s.length?(a=s.input.slice(o,s.position),u=s.input.charCodeAt(++s.position)):throwError(s,\"unexpected end of the stream within a verbatim tag\")}else{for(;0!==u&&!is_WS_OR_EOL(u);)33===u&&(w?throwError(s,\"tag suffix cannot contain exclamation marks\"):(i=s.input.slice(o-1,s.position+1),Br.test(i)||throwError(s,\"named tag handle cannot contain such characters\"),w=!0,o=s.position+1)),u=s.input.charCodeAt(++s.position);a=s.input.slice(o,s.position),Fr.test(a)&&throwError(s,\"tag suffix cannot contain flow indicator characters\")}a&&!$r.test(a)&&throwError(s,\"tag name cannot contain such characters: \"+a);try{a=decodeURIComponent(a)}catch(o){throwError(s,\"tag name is malformed: \"+a)}return _?s.tag=a:Rr.call(s.tagMap,i)?s.tag=s.tagMap[i]+a:\"!\"===i?s.tag=\"!\"+a:\"!!\"===i?s.tag=\"tag:yaml.org,2002:\"+a:throwError(s,'undeclared tag handle \"'+i+'\"'),!0}function readAnchorProperty(s){var o,i;if(38!==(i=s.input.charCodeAt(s.position)))return!1;for(null!==s.anchor&&throwError(s,\"duplication of an anchor property\"),i=s.input.charCodeAt(++s.position),o=s.position;0!==i&&!is_WS_OR_EOL(i)&&!is_FLOW_INDICATOR(i);)i=s.input.charCodeAt(++s.position);return s.position===o&&throwError(s,\"name of an anchor node must contain at least one character\"),s.anchor=s.input.slice(o,s.position),!0}function composeNode(s,o,i,a,u){var _,w,x,C,j,L,B,$,U,V=1,z=!1,Y=!1;if(null!==s.listener&&s.listener(\"open\",s),s.tag=null,s.anchor=null,s.kind=null,s.result=null,_=w=x=4===i||3===i,a&&skipSeparationSpace(s,!0,-1)&&(z=!0,s.lineIndent>o?V=1:s.lineIndent===o?V=0:s.lineIndent<o&&(V=-1)),1===V)for(;readTagProperty(s)||readAnchorProperty(s);)skipSeparationSpace(s,!0,-1)?(z=!0,x=_,s.lineIndent>o?V=1:s.lineIndent===o?V=0:s.lineIndent<o&&(V=-1)):x=!1;if(x&&(x=z||u),1!==V&&4!==i||($=1===i||2===i?o:o+1,U=s.position-s.lineStart,1===V?x&&(readBlockSequence(s,U)||function readBlockMapping(s,o,i){var a,u,_,w,x,C,j,L=s.tag,B=s.anchor,$={},U=Object.create(null),V=null,z=null,Y=null,Z=!1,ee=!1;if(-1!==s.firstTabInLine)return!1;for(null!==s.anchor&&(s.anchorMap[s.anchor]=$),j=s.input.charCodeAt(s.position);0!==j;){if(Z||-1===s.firstTabInLine||(s.position=s.firstTabInLine,throwError(s,\"tab characters must not be used in indentation\")),a=s.input.charCodeAt(s.position+1),_=s.line,63!==j&&58!==j||!is_WS_OR_EOL(a)){if(w=s.line,x=s.lineStart,C=s.position,!composeNode(s,i,2,!1,!0))break;if(s.line===_){for(j=s.input.charCodeAt(s.position);is_WHITE_SPACE(j);)j=s.input.charCodeAt(++s.position);if(58===j)is_WS_OR_EOL(j=s.input.charCodeAt(++s.position))||throwError(s,\"a whitespace character is expected after the key-value separator within a block mapping\"),Z&&(storeMappingPair(s,$,U,V,z,null,w,x,C),V=z=Y=null),ee=!0,Z=!1,u=!1,V=s.tag,z=s.result;else{if(!ee)return s.tag=L,s.anchor=B,!0;throwError(s,\"can not read an implicit mapping pair; a colon is missed\")}}else{if(!ee)return s.tag=L,s.anchor=B,!0;throwError(s,\"can not read a block mapping entry; a multiline key may not be an implicit key\")}}else 63===j?(Z&&(storeMappingPair(s,$,U,V,z,null,w,x,C),V=z=Y=null),ee=!0,Z=!0,u=!0):Z?(Z=!1,u=!0):throwError(s,\"incomplete explicit mapping pair; a key node is missed; or followed by a non-tabulated empty line\"),s.position+=1,j=a;if((s.line===_||s.lineIndent>o)&&(Z&&(w=s.line,x=s.lineStart,C=s.position),composeNode(s,o,4,!0,u)&&(Z?z=s.result:Y=s.result),Z||(storeMappingPair(s,$,U,V,z,Y,w,x,C),V=z=Y=null),skipSeparationSpace(s,!0,-1),j=s.input.charCodeAt(s.position)),(s.line===_||s.lineIndent>o)&&0!==j)throwError(s,\"bad indentation of a mapping entry\");else if(s.lineIndent<o)break}return Z&&storeMappingPair(s,$,U,V,z,null,w,x,C),ee&&(s.tag=L,s.anchor=B,s.kind=\"mapping\",s.result=$),ee}(s,U,$))||function readFlowCollection(s,o){var i,a,u,_,w,x,C,j,L,B,$,U,V=!0,z=s.tag,Y=s.anchor,Z=Object.create(null);if(91===(U=s.input.charCodeAt(s.position)))w=93,j=!1,_=[];else{if(123!==U)return!1;w=125,j=!0,_={}}for(null!==s.anchor&&(s.anchorMap[s.anchor]=_),U=s.input.charCodeAt(++s.position);0!==U;){if(skipSeparationSpace(s,!0,o),(U=s.input.charCodeAt(s.position))===w)return s.position++,s.tag=z,s.anchor=Y,s.kind=j?\"mapping\":\"sequence\",s.result=_,!0;V?44===U&&throwError(s,\"expected the node content, but found ','\"):throwError(s,\"missed comma between flow collection entries\"),$=null,x=C=!1,63===U&&is_WS_OR_EOL(s.input.charCodeAt(s.position+1))&&(x=C=!0,s.position++,skipSeparationSpace(s,!0,o)),i=s.line,a=s.lineStart,u=s.position,composeNode(s,o,1,!1,!0),B=s.tag,L=s.result,skipSeparationSpace(s,!0,o),U=s.input.charCodeAt(s.position),!C&&s.line!==i||58!==U||(x=!0,U=s.input.charCodeAt(++s.position),skipSeparationSpace(s,!0,o),composeNode(s,o,1,!1,!0),$=s.result),j?storeMappingPair(s,_,Z,B,L,$,i,a,u):x?_.push(storeMappingPair(s,null,Z,B,L,$,i,a,u)):_.push(L),skipSeparationSpace(s,!0,o),44===(U=s.input.charCodeAt(s.position))?(V=!0,U=s.input.charCodeAt(++s.position)):V=!1}throwError(s,\"unexpected end of the stream within a flow collection\")}(s,$)?Y=!0:(w&&function readBlockScalar(s,o){var i,a,u,_,w,x=1,C=!1,j=!1,L=o,B=0,$=!1;if(124===(_=s.input.charCodeAt(s.position)))a=!1;else{if(62!==_)return!1;a=!0}for(s.kind=\"scalar\",s.result=\"\";0!==_;)if(43===(_=s.input.charCodeAt(++s.position))||45===_)1===x?x=43===_?3:2:throwError(s,\"repeat of a chomping mode identifier\");else{if(!((u=48<=(w=_)&&w<=57?w-48:-1)>=0))break;0===u?throwError(s,\"bad explicit indentation width of a block scalar; it cannot be less than one\"):j?throwError(s,\"repeat of an indentation width identifier\"):(L=o+u-1,j=!0)}if(is_WHITE_SPACE(_)){do{_=s.input.charCodeAt(++s.position)}while(is_WHITE_SPACE(_));if(35===_)do{_=s.input.charCodeAt(++s.position)}while(!is_EOL(_)&&0!==_)}for(;0!==_;){for(readLineBreak(s),s.lineIndent=0,_=s.input.charCodeAt(s.position);(!j||s.lineIndent<L)&&32===_;)s.lineIndent++,_=s.input.charCodeAt(++s.position);if(!j&&s.lineIndent>L&&(L=s.lineIndent),is_EOL(_))B++;else{if(s.lineIndent<L){3===x?s.result+=er.repeat(\"\\n\",C?1+B:B):1===x&&C&&(s.result+=\"\\n\");break}for(a?is_WHITE_SPACE(_)?($=!0,s.result+=er.repeat(\"\\n\",C?1+B:B)):$?($=!1,s.result+=er.repeat(\"\\n\",B+1)):0===B?C&&(s.result+=\" \"):s.result+=er.repeat(\"\\n\",B):s.result+=er.repeat(\"\\n\",C?1+B:B),C=!0,j=!0,B=0,i=s.position;!is_EOL(_)&&0!==_;)_=s.input.charCodeAt(++s.position);captureSegment(s,i,s.position,!1)}}return!0}(s,$)||function readSingleQuotedScalar(s,o){var i,a,u;if(39!==(i=s.input.charCodeAt(s.position)))return!1;for(s.kind=\"scalar\",s.result=\"\",s.position++,a=u=s.position;0!==(i=s.input.charCodeAt(s.position));)if(39===i){if(captureSegment(s,a,s.position,!0),39!==(i=s.input.charCodeAt(++s.position)))return!0;a=s.position,s.position++,u=s.position}else is_EOL(i)?(captureSegment(s,a,u,!0),writeFoldedLines(s,skipSeparationSpace(s,!1,o)),a=u=s.position):s.position===s.lineStart&&testDocumentSeparator(s)?throwError(s,\"unexpected end of the document within a single quoted scalar\"):(s.position++,u=s.position);throwError(s,\"unexpected end of the stream within a single quoted scalar\")}(s,$)||function readDoubleQuotedScalar(s,o){var i,a,u,_,w,x,C;if(34!==(x=s.input.charCodeAt(s.position)))return!1;for(s.kind=\"scalar\",s.result=\"\",s.position++,i=a=s.position;0!==(x=s.input.charCodeAt(s.position));){if(34===x)return captureSegment(s,i,s.position,!0),s.position++,!0;if(92===x){if(captureSegment(s,i,s.position,!0),is_EOL(x=s.input.charCodeAt(++s.position)))skipSeparationSpace(s,!1,o);else if(x<256&&qr[x])s.result+=Ur[x],s.position++;else if((w=120===(C=x)?2:117===C?4:85===C?8:0)>0){for(u=w,_=0;u>0;u--)(w=fromHexCode(x=s.input.charCodeAt(++s.position)))>=0?_=(_<<4)+w:throwError(s,\"expected hexadecimal character\");s.result+=charFromCodepoint(_),s.position++}else throwError(s,\"unknown escape sequence\");i=a=s.position}else is_EOL(x)?(captureSegment(s,i,a,!0),writeFoldedLines(s,skipSeparationSpace(s,!1,o)),i=a=s.position):s.position===s.lineStart&&testDocumentSeparator(s)?throwError(s,\"unexpected end of the document within a double quoted scalar\"):(s.position++,a=s.position)}throwError(s,\"unexpected end of the stream within a double quoted scalar\")}(s,$)?Y=!0:!function readAlias(s){var o,i,a;if(42!==(a=s.input.charCodeAt(s.position)))return!1;for(a=s.input.charCodeAt(++s.position),o=s.position;0!==a&&!is_WS_OR_EOL(a)&&!is_FLOW_INDICATOR(a);)a=s.input.charCodeAt(++s.position);return s.position===o&&throwError(s,\"name of an alias node must contain at least one character\"),i=s.input.slice(o,s.position),Rr.call(s.anchorMap,i)||throwError(s,'unidentified alias \"'+i+'\"'),s.result=s.anchorMap[i],skipSeparationSpace(s,!0,-1),!0}(s)?function readPlainScalar(s,o,i){var a,u,_,w,x,C,j,L,B=s.kind,$=s.result;if(is_WS_OR_EOL(L=s.input.charCodeAt(s.position))||is_FLOW_INDICATOR(L)||35===L||38===L||42===L||33===L||124===L||62===L||39===L||34===L||37===L||64===L||96===L)return!1;if((63===L||45===L)&&(is_WS_OR_EOL(a=s.input.charCodeAt(s.position+1))||i&&is_FLOW_INDICATOR(a)))return!1;for(s.kind=\"scalar\",s.result=\"\",u=_=s.position,w=!1;0!==L;){if(58===L){if(is_WS_OR_EOL(a=s.input.charCodeAt(s.position+1))||i&&is_FLOW_INDICATOR(a))break}else if(35===L){if(is_WS_OR_EOL(s.input.charCodeAt(s.position-1)))break}else{if(s.position===s.lineStart&&testDocumentSeparator(s)||i&&is_FLOW_INDICATOR(L))break;if(is_EOL(L)){if(x=s.line,C=s.lineStart,j=s.lineIndent,skipSeparationSpace(s,!1,-1),s.lineIndent>=o){w=!0,L=s.input.charCodeAt(s.position);continue}s.position=_,s.line=x,s.lineStart=C,s.lineIndent=j;break}}w&&(captureSegment(s,u,_,!1),writeFoldedLines(s,s.line-x),u=_=s.position,w=!1),is_WHITE_SPACE(L)||(_=s.position+1),L=s.input.charCodeAt(++s.position)}return captureSegment(s,u,_,!1),!!s.result||(s.kind=B,s.result=$,!1)}(s,$,1===i)&&(Y=!0,null===s.tag&&(s.tag=\"?\")):(Y=!0,null===s.tag&&null===s.anchor||throwError(s,\"alias node should not have any properties\")),null!==s.anchor&&(s.anchorMap[s.anchor]=s.result)):0===V&&(Y=x&&readBlockSequence(s,U))),null===s.tag)null!==s.anchor&&(s.anchorMap[s.anchor]=s.result);else if(\"?\"===s.tag){for(null!==s.result&&\"scalar\"!==s.kind&&throwError(s,'unacceptable node kind for !<?> tag; it should be \"scalar\", not \"'+s.kind+'\"'),C=0,j=s.implicitTypes.length;C<j;C+=1)if((B=s.implicitTypes[C]).resolve(s.result)){s.result=B.construct(s.result),s.tag=B.tag,null!==s.anchor&&(s.anchorMap[s.anchor]=s.result);break}}else if(\"!\"!==s.tag){if(Rr.call(s.typeMap[s.kind||\"fallback\"],s.tag))B=s.typeMap[s.kind||\"fallback\"][s.tag];else for(B=null,C=0,j=(L=s.typeMap.multi[s.kind||\"fallback\"]).length;C<j;C+=1)if(s.tag.slice(0,L[C].tag.length)===L[C].tag){B=L[C];break}B||throwError(s,\"unknown tag !<\"+s.tag+\">\"),null!==s.result&&B.kind!==s.kind&&throwError(s,\"unacceptable node kind for !<\"+s.tag+'> tag; it should be \"'+B.kind+'\", not \"'+s.kind+'\"'),B.resolve(s.result,s.tag)?(s.result=B.construct(s.result,s.tag),null!==s.anchor&&(s.anchorMap[s.anchor]=s.result)):throwError(s,\"cannot resolve a node with !<\"+s.tag+\"> explicit tag\")}return null!==s.listener&&s.listener(\"close\",s),null!==s.tag||null!==s.anchor||Y}function readDocument(s){var o,i,a,u,_=s.position,w=!1;for(s.version=null,s.checkLineBreaks=s.legacy,s.tagMap=Object.create(null),s.anchorMap=Object.create(null);0!==(u=s.input.charCodeAt(s.position))&&(skipSeparationSpace(s,!0,-1),u=s.input.charCodeAt(s.position),!(s.lineIndent>0||37!==u));){for(w=!0,u=s.input.charCodeAt(++s.position),o=s.position;0!==u&&!is_WS_OR_EOL(u);)u=s.input.charCodeAt(++s.position);for(a=[],(i=s.input.slice(o,s.position)).length<1&&throwError(s,\"directive name must not be less than one character in length\");0!==u;){for(;is_WHITE_SPACE(u);)u=s.input.charCodeAt(++s.position);if(35===u){do{u=s.input.charCodeAt(++s.position)}while(0!==u&&!is_EOL(u));break}if(is_EOL(u))break;for(o=s.position;0!==u&&!is_WS_OR_EOL(u);)u=s.input.charCodeAt(++s.position);a.push(s.input.slice(o,s.position))}0!==u&&readLineBreak(s),Rr.call(zr,i)?zr[i](s,i,a):throwWarning(s,'unknown document directive \"'+i+'\"')}skipSeparationSpace(s,!0,-1),0===s.lineIndent&&45===s.input.charCodeAt(s.position)&&45===s.input.charCodeAt(s.position+1)&&45===s.input.charCodeAt(s.position+2)?(s.position+=3,skipSeparationSpace(s,!0,-1)):w&&throwError(s,\"directives end mark is expected\"),composeNode(s,s.lineIndent-1,4,!1,!0),skipSeparationSpace(s,!0,-1),s.checkLineBreaks&&Lr.test(s.input.slice(_,s.position))&&throwWarning(s,\"non-ASCII line breaks are interpreted as content\"),s.documents.push(s.result),s.position===s.lineStart&&testDocumentSeparator(s)?46===s.input.charCodeAt(s.position)&&(s.position+=3,skipSeparationSpace(s,!0,-1)):s.position<s.length-1&&throwError(s,\"end of the stream or a document separator is expected\")}function loadDocuments(s,o){o=o||{},0!==(s=String(s)).length&&(10!==s.charCodeAt(s.length-1)&&13!==s.charCodeAt(s.length-1)&&(s+=\"\\n\"),65279===s.charCodeAt(0)&&(s=s.slice(1)));var i=new State$1(s,o),a=s.indexOf(\"\\0\");for(-1!==a&&(i.position=a,throwError(i,\"null byte is not allowed in input\")),i.input+=\"\\0\";32===i.input.charCodeAt(i.position);)i.lineIndent+=1,i.position+=1;for(;i.position<i.length-1;)readDocument(i);return i.documents}var Wr={loadAll:function loadAll$1(s,o,i){null!==o&&\"object\"==typeof o&&void 0===i&&(i=o,o=null);var a=loadDocuments(s,i);if(\"function\"!=typeof o)return a;for(var u=0,_=a.length;u<_;u+=1)o(a[u])},load:function load$1(s,o){var i=loadDocuments(s,o);if(0!==i.length){if(1===i.length)return i[0];throw new tr(\"expected a single document in the stream, but found more\")}}},Jr=Object.prototype.toString,Hr=Object.prototype.hasOwnProperty,Kr=65279,Gr={0:\"\\\\0\",7:\"\\\\a\",8:\"\\\\b\",9:\"\\\\t\",10:\"\\\\n\",11:\"\\\\v\",12:\"\\\\f\",13:\"\\\\r\",27:\"\\\\e\",34:'\\\\\"',92:\"\\\\\\\\\",133:\"\\\\N\",160:\"\\\\_\",8232:\"\\\\L\",8233:\"\\\\P\"},Yr=[\"y\",\"Y\",\"yes\",\"Yes\",\"YES\",\"on\",\"On\",\"ON\",\"n\",\"N\",\"no\",\"No\",\"NO\",\"off\",\"Off\",\"OFF\"],Xr=/^[-+]?[0-9_]+(?::[0-9_]+)+(?:\\.[0-9_]*)?$/;function encodeHex(s){var o,i,a;if(o=s.toString(16).toUpperCase(),s<=255)i=\"x\",a=2;else if(s<=65535)i=\"u\",a=4;else{if(!(s<=4294967295))throw new tr(\"code point within a string may not be greater than 0xFFFFFFFF\");i=\"U\",a=8}return\"\\\\\"+i+er.repeat(\"0\",a-o.length)+o}function State(s){this.schema=s.schema||Mr,this.indent=Math.max(1,s.indent||2),this.noArrayIndent=s.noArrayIndent||!1,this.skipInvalid=s.skipInvalid||!1,this.flowLevel=er.isNothing(s.flowLevel)?-1:s.flowLevel,this.styleMap=function compileStyleMap(s,o){var i,a,u,_,w,x,C;if(null===o)return{};for(i={},u=0,_=(a=Object.keys(o)).length;u<_;u+=1)w=a[u],x=String(o[w]),\"!!\"===w.slice(0,2)&&(w=\"tag:yaml.org,2002:\"+w.slice(2)),(C=s.compiledTypeMap.fallback[w])&&Hr.call(C.styleAliases,x)&&(x=C.styleAliases[x]),i[w]=x;return i}(this.schema,s.styles||null),this.sortKeys=s.sortKeys||!1,this.lineWidth=s.lineWidth||80,this.noRefs=s.noRefs||!1,this.noCompatMode=s.noCompatMode||!1,this.condenseFlow=s.condenseFlow||!1,this.quotingType='\"'===s.quotingType?2:1,this.forceQuotes=s.forceQuotes||!1,this.replacer=\"function\"==typeof s.replacer?s.replacer:null,this.implicitTypes=this.schema.compiledImplicit,this.explicitTypes=this.schema.compiledExplicit,this.tag=null,this.result=\"\",this.duplicates=[],this.usedDuplicates=null}function indentString(s,o){for(var i,a=er.repeat(\" \",o),u=0,_=-1,w=\"\",x=s.length;u<x;)-1===(_=s.indexOf(\"\\n\",u))?(i=s.slice(u),u=x):(i=s.slice(u,_+1),u=_+1),i.length&&\"\\n\"!==i&&(w+=a),w+=i;return w}function generateNextLine(s,o){return\"\\n\"+er.repeat(\" \",s.indent*o)}function isWhitespace(s){return 32===s||9===s}function isPrintable(s){return 32<=s&&s<=126||161<=s&&s<=55295&&8232!==s&&8233!==s||57344<=s&&s<=65533&&s!==Kr||65536<=s&&s<=1114111}function isNsCharOrWhitespace(s){return isPrintable(s)&&s!==Kr&&13!==s&&10!==s}function isPlainSafe(s,o,i){var a=isNsCharOrWhitespace(s),u=a&&!isWhitespace(s);return(i?a:a&&44!==s&&91!==s&&93!==s&&123!==s&&125!==s)&&35!==s&&!(58===o&&!u)||isNsCharOrWhitespace(o)&&!isWhitespace(o)&&35===s||58===o&&u}function codePointAt(s,o){var i,a=s.charCodeAt(o);return a>=55296&&a<=56319&&o+1<s.length&&(i=s.charCodeAt(o+1))>=56320&&i<=57343?1024*(a-55296)+i-56320+65536:a}function needIndentIndicator(s){return/^\\n* /.test(s)}function chooseScalarStyle(s,o,i,a,u,_,w,x){var C,j=0,L=null,B=!1,$=!1,U=-1!==a,V=-1,z=function isPlainSafeFirst(s){return isPrintable(s)&&s!==Kr&&!isWhitespace(s)&&45!==s&&63!==s&&58!==s&&44!==s&&91!==s&&93!==s&&123!==s&&125!==s&&35!==s&&38!==s&&42!==s&&33!==s&&124!==s&&61!==s&&62!==s&&39!==s&&34!==s&&37!==s&&64!==s&&96!==s}(codePointAt(s,0))&&function isPlainSafeLast(s){return!isWhitespace(s)&&58!==s}(codePointAt(s,s.length-1));if(o||w)for(C=0;C<s.length;j>=65536?C+=2:C++){if(!isPrintable(j=codePointAt(s,C)))return 5;z=z&&isPlainSafe(j,L,x),L=j}else{for(C=0;C<s.length;j>=65536?C+=2:C++){if(10===(j=codePointAt(s,C)))B=!0,U&&($=$||C-V-1>a&&\" \"!==s[V+1],V=C);else if(!isPrintable(j))return 5;z=z&&isPlainSafe(j,L,x),L=j}$=$||U&&C-V-1>a&&\" \"!==s[V+1]}return B||$?i>9&&needIndentIndicator(s)?5:w?2===_?5:2:$?4:3:!z||w||u(s)?2===_?5:2:1}function writeScalar(s,o,i,a,u){s.dump=function(){if(0===o.length)return 2===s.quotingType?'\"\"':\"''\";if(!s.noCompatMode&&(-1!==Yr.indexOf(o)||Xr.test(o)))return 2===s.quotingType?'\"'+o+'\"':\"'\"+o+\"'\";var _=s.indent*Math.max(1,i),w=-1===s.lineWidth?-1:Math.max(Math.min(s.lineWidth,40),s.lineWidth-_),x=a||s.flowLevel>-1&&i>=s.flowLevel;switch(chooseScalarStyle(o,x,s.indent,w,(function testAmbiguity(o){return function testImplicitResolving(s,o){var i,a;for(i=0,a=s.implicitTypes.length;i<a;i+=1)if(s.implicitTypes[i].resolve(o))return!0;return!1}(s,o)}),s.quotingType,s.forceQuotes&&!a,u)){case 1:return o;case 2:return\"'\"+o.replace(/'/g,\"''\")+\"'\";case 3:return\"|\"+blockHeader(o,s.indent)+dropEndingNewline(indentString(o,_));case 4:return\">\"+blockHeader(o,s.indent)+dropEndingNewline(indentString(function foldString(s,o){var i,a,u=/(\\n+)([^\\n]*)/g,_=(x=s.indexOf(\"\\n\"),x=-1!==x?x:s.length,u.lastIndex=x,foldLine(s.slice(0,x),o)),w=\"\\n\"===s[0]||\" \"===s[0];var x;for(;a=u.exec(s);){var C=a[1],j=a[2];i=\" \"===j[0],_+=C+(w||i||\"\"===j?\"\":\"\\n\")+foldLine(j,o),w=i}return _}(o,w),_));case 5:return'\"'+function escapeString(s){for(var o,i=\"\",a=0,u=0;u<s.length;a>=65536?u+=2:u++)a=codePointAt(s,u),!(o=Gr[a])&&isPrintable(a)?(i+=s[u],a>=65536&&(i+=s[u+1])):i+=o||encodeHex(a);return i}(o)+'\"';default:throw new tr(\"impossible error: invalid scalar style\")}}()}function blockHeader(s,o){var i=needIndentIndicator(s)?String(o):\"\",a=\"\\n\"===s[s.length-1];return i+(a&&(\"\\n\"===s[s.length-2]||\"\\n\"===s)?\"+\":a?\"\":\"-\")+\"\\n\"}function dropEndingNewline(s){return\"\\n\"===s[s.length-1]?s.slice(0,-1):s}function foldLine(s,o){if(\"\"===s||\" \"===s[0])return s;for(var i,a,u=/ [^ ]/g,_=0,w=0,x=0,C=\"\";i=u.exec(s);)(x=i.index)-_>o&&(a=w>_?w:x,C+=\"\\n\"+s.slice(_,a),_=a+1),w=x;return C+=\"\\n\",s.length-_>o&&w>_?C+=s.slice(_,w)+\"\\n\"+s.slice(w+1):C+=s.slice(_),C.slice(1)}function writeBlockSequence(s,o,i,a){var u,_,w,x=\"\",C=s.tag;for(u=0,_=i.length;u<_;u+=1)w=i[u],s.replacer&&(w=s.replacer.call(i,String(u),w)),(writeNode(s,o+1,w,!0,!0,!1,!0)||void 0===w&&writeNode(s,o+1,null,!0,!0,!1,!0))&&(a&&\"\"===x||(x+=generateNextLine(s,o)),s.dump&&10===s.dump.charCodeAt(0)?x+=\"-\":x+=\"- \",x+=s.dump);s.tag=C,s.dump=x||\"[]\"}function detectType(s,o,i){var a,u,_,w,x,C;for(_=0,w=(u=i?s.explicitTypes:s.implicitTypes).length;_<w;_+=1)if(((x=u[_]).instanceOf||x.predicate)&&(!x.instanceOf||\"object\"==typeof o&&o instanceof x.instanceOf)&&(!x.predicate||x.predicate(o))){if(i?x.multi&&x.representName?s.tag=x.representName(o):s.tag=x.tag:s.tag=\"?\",x.represent){if(C=s.styleMap[x.tag]||x.defaultStyle,\"[object Function]\"===Jr.call(x.represent))a=x.represent(o,C);else{if(!Hr.call(x.represent,C))throw new tr(\"!<\"+x.tag+'> tag resolver accepts not \"'+C+'\" style');a=x.represent[C](o,C)}s.dump=a}return!0}return!1}function writeNode(s,o,i,a,u,_,w){s.tag=null,s.dump=i,detectType(s,i,!1)||detectType(s,i,!0);var x,C=Jr.call(s.dump),j=a;a&&(a=s.flowLevel<0||s.flowLevel>o);var L,B,$=\"[object Object]\"===C||\"[object Array]\"===C;if($&&(B=-1!==(L=s.duplicates.indexOf(i))),(null!==s.tag&&\"?\"!==s.tag||B||2!==s.indent&&o>0)&&(u=!1),B&&s.usedDuplicates[L])s.dump=\"*ref_\"+L;else{if($&&B&&!s.usedDuplicates[L]&&(s.usedDuplicates[L]=!0),\"[object Object]\"===C)a&&0!==Object.keys(s.dump).length?(!function writeBlockMapping(s,o,i,a){var u,_,w,x,C,j,L=\"\",B=s.tag,$=Object.keys(i);if(!0===s.sortKeys)$.sort();else if(\"function\"==typeof s.sortKeys)$.sort(s.sortKeys);else if(s.sortKeys)throw new tr(\"sortKeys must be a boolean or a function\");for(u=0,_=$.length;u<_;u+=1)j=\"\",a&&\"\"===L||(j+=generateNextLine(s,o)),x=i[w=$[u]],s.replacer&&(x=s.replacer.call(i,w,x)),writeNode(s,o+1,w,!0,!0,!0)&&((C=null!==s.tag&&\"?\"!==s.tag||s.dump&&s.dump.length>1024)&&(s.dump&&10===s.dump.charCodeAt(0)?j+=\"?\":j+=\"? \"),j+=s.dump,C&&(j+=generateNextLine(s,o)),writeNode(s,o+1,x,!0,C)&&(s.dump&&10===s.dump.charCodeAt(0)?j+=\":\":j+=\": \",L+=j+=s.dump));s.tag=B,s.dump=L||\"{}\"}(s,o,s.dump,u),B&&(s.dump=\"&ref_\"+L+s.dump)):(!function writeFlowMapping(s,o,i){var a,u,_,w,x,C=\"\",j=s.tag,L=Object.keys(i);for(a=0,u=L.length;a<u;a+=1)x=\"\",\"\"!==C&&(x+=\", \"),s.condenseFlow&&(x+='\"'),w=i[_=L[a]],s.replacer&&(w=s.replacer.call(i,_,w)),writeNode(s,o,_,!1,!1)&&(s.dump.length>1024&&(x+=\"? \"),x+=s.dump+(s.condenseFlow?'\"':\"\")+\":\"+(s.condenseFlow?\"\":\" \"),writeNode(s,o,w,!1,!1)&&(C+=x+=s.dump));s.tag=j,s.dump=\"{\"+C+\"}\"}(s,o,s.dump),B&&(s.dump=\"&ref_\"+L+\" \"+s.dump));else if(\"[object Array]\"===C)a&&0!==s.dump.length?(s.noArrayIndent&&!w&&o>0?writeBlockSequence(s,o-1,s.dump,u):writeBlockSequence(s,o,s.dump,u),B&&(s.dump=\"&ref_\"+L+s.dump)):(!function writeFlowSequence(s,o,i){var a,u,_,w=\"\",x=s.tag;for(a=0,u=i.length;a<u;a+=1)_=i[a],s.replacer&&(_=s.replacer.call(i,String(a),_)),(writeNode(s,o,_,!1,!1)||void 0===_&&writeNode(s,o,null,!1,!1))&&(\"\"!==w&&(w+=\",\"+(s.condenseFlow?\"\":\" \")),w+=s.dump);s.tag=x,s.dump=\"[\"+w+\"]\"}(s,o,s.dump),B&&(s.dump=\"&ref_\"+L+\" \"+s.dump));else{if(\"[object String]\"!==C){if(\"[object Undefined]\"===C)return!1;if(s.skipInvalid)return!1;throw new tr(\"unacceptable kind of an object to dump \"+C)}\"?\"!==s.tag&&writeScalar(s,s.dump,o,_,j)}null!==s.tag&&\"?\"!==s.tag&&(x=encodeURI(\"!\"===s.tag[0]?s.tag.slice(1):s.tag).replace(/!/g,\"%21\"),x=\"!\"===s.tag[0]?\"!\"+x:\"tag:yaml.org,2002:\"===x.slice(0,18)?\"!!\"+x.slice(18):\"!<\"+x+\">\",s.dump=x+\" \"+s.dump)}return!0}function getDuplicateReferences(s,o){var i,a,u=[],_=[];for(inspectNode(s,u,_),i=0,a=_.length;i<a;i+=1)o.duplicates.push(u[_[i]]);o.usedDuplicates=new Array(a)}function inspectNode(s,o,i){var a,u,_;if(null!==s&&\"object\"==typeof s)if(-1!==(u=o.indexOf(s)))-1===i.indexOf(u)&&i.push(u);else if(o.push(s),Array.isArray(s))for(u=0,_=s.length;u<_;u+=1)inspectNode(s[u],o,i);else for(u=0,_=(a=Object.keys(s)).length;u<_;u+=1)inspectNode(s[a[u]],o,i)}var Qr=function dump$1(s,o){var i=new State(o=o||{});i.noRefs||getDuplicateReferences(s,i);var a=s;return i.replacer&&(a=i.replacer.call({\"\":a},\"\",a)),writeNode(i,0,a,!0,!0)?i.dump+\"\\n\":\"\"};function renamed(s,o){return function(){throw new Error(\"Function yaml.\"+s+\" is removed in js-yaml 4. Use yaml.\"+o+\" instead, which is now safe by default.\")}}var Zr=ir,en=ar,tn=pr,rn=br,nn=_r,sn=Mr,on=Wr.load,an=Wr.loadAll,cn={dump:Qr}.dump,ln=tr,un={binary:Or,float:vr,map:ur,null:dr,pairs:Ir,set:Nr,timestamp:wr,bool:fr,int:mr,merge:xr,omap:jr,seq:lr,str:cr},pn=renamed(\"safeLoad\",\"load\"),hn=renamed(\"safeLoadAll\",\"loadAll\"),dn=renamed(\"safeDump\",\"dump\");const fn={Type:Zr,Schema:en,FAILSAFE_SCHEMA:tn,JSON_SCHEMA:rn,CORE_SCHEMA:nn,DEFAULT_SCHEMA:sn,load:on,loadAll:an,dump:cn,YAMLException:ln,types:un,safeLoad:pn,safeLoadAll:hn,safeDump:dn},mn=\"configs_update\",gn=\"configs_toggle\";function update(s,o){return{type:mn,payload:{[s]:o}}}function toggle(s){return{type:gn,payload:s}}const actions_loaded=()=>()=>{},downloadConfig=s=>o=>{const{fn:{fetch:i}}=o;return i(s)},getConfigByUrl=(s,o)=>i=>{const{specActions:a,configsActions:u}=i;if(s)return u.downloadConfig(s).then(next,next);function next(u){u instanceof Error||u.status>=400?(a.updateLoadingStatus(\"failedConfig\"),a.updateLoadingStatus(\"failedConfig\"),a.updateUrl(\"\"),console.error(u.statusText+\" \"+s.url),o(null)):o(((s,o)=>{try{return fn.load(s)}catch(s){return o&&o.errActions.newThrownErr(new Error(s)),{}}})(u.text,i))}},get=(s,o)=>s.getIn(Array.isArray(o)?o:[o]),yn={[mn]:(s,o)=>s.merge((0,ze.fromJS)(o.payload)),[gn]:(s,o)=>{const i=o.payload,a=s.get(i);return s.set(i,!a)}};function configsPlugin(){return{statePlugins:{configs:{reducers:yn,actions:u,selectors:_}}}}const setHash=s=>s?history.pushState(null,null,`#${s}`):window.location.hash=\"\";var vn=__webpack_require__(86215),bn=__webpack_require__.n(vn);const _n=\"layout_scroll_to\",Sn=\"layout_clear_scroll\";const En={fn:{getScrollParent:function getScrollParent(s,o){const i=document.documentElement;let a=getComputedStyle(s);const u=\"absolute\"===a.position,_=o?/(auto|scroll|hidden)/:/(auto|scroll)/;if(\"fixed\"===a.position)return i;for(let o=s;o=o.parentElement;)if(a=getComputedStyle(o),(!u||\"static\"!==a.position)&&_.test(a.overflow+a.overflowY+a.overflowX))return o;return i}},statePlugins:{layout:{actions:{scrollToElement:(s,o)=>i=>{try{o=o||i.fn.getScrollParent(s),bn().createScroller(o).to(s)}catch(s){console.error(s)}},scrollTo:s=>({type:_n,payload:Array.isArray(s)?s:[s]}),clearScrollTo:()=>({type:Sn}),readyToScroll:(s,o)=>i=>{const a=i.layoutSelectors.getScrollToKey();We().is(a,(0,ze.fromJS)(s))&&(i.layoutActions.scrollToElement(o),i.layoutActions.clearScrollTo())},parseDeepLinkHash:s=>({layoutActions:o,layoutSelectors:i,getConfigs:a})=>{if(a().deepLinking&&s){let a=s.slice(1);\"!\"===a[0]&&(a=a.slice(1)),\"/\"===a[0]&&(a=a.slice(1));const u=a.split(\"/\").map((s=>s||\"\")),_=i.isShownKeyFromUrlHashArray(u),[w,x=\"\",C=\"\"]=_;if(\"operations\"===w){const s=i.isShownKeyFromUrlHashArray([x]);x.indexOf(\"_\")>-1&&(console.warn(\"Warning: escaping deep link whitespace with `_` will be unsupported in v4.0, use `%20` instead.\"),o.show(s.map((s=>s.replace(/_/g,\" \"))),!0)),o.show(s,!0)}(x.indexOf(\"_\")>-1||C.indexOf(\"_\")>-1)&&(console.warn(\"Warning: escaping deep link whitespace with `_` will be unsupported in v4.0, use `%20` instead.\"),o.show(_.map((s=>s.replace(/_/g,\" \"))),!0)),o.show(_,!0),o.scrollTo(_)}}},selectors:{getScrollToKey:s=>s.get(\"scrollToKey\"),isShownKeyFromUrlHashArray(s,o){const[i,a]=o;return a?[\"operations\",i,a]:i?[\"operations-tag\",i]:[]},urlHashArrayFromIsShownKey(s,o){let[i,a,u]=o;return\"operations\"==i?[a,u]:\"operations-tag\"==i?[a]:[]}},reducers:{[_n]:(s,o)=>s.set(\"scrollToKey\",We().fromJS(o.payload)),[Sn]:s=>s.delete(\"scrollToKey\")},wrapActions:{show:(s,{getConfigs:o,layoutSelectors:i})=>(...a)=>{if(s(...a),o().deepLinking)try{let[s,o]=a;s=Array.isArray(s)?s:[s];const u=i.urlHashArrayFromIsShownKey(s);if(!u.length)return;const[_,w]=u;if(!o)return setHash(\"/\");2===u.length?setHash(createDeepLinkPath(`/${encodeURIComponent(_)}/${encodeURIComponent(w)}`)):1===u.length&&setHash(createDeepLinkPath(`/${encodeURIComponent(_)}`))}catch(s){console.error(s)}}}}}};var wn=__webpack_require__(2209),xn=__webpack_require__.n(wn);const operation_wrapper=(s,o)=>class OperationWrapper extends Re.Component{onLoad=s=>{const{operation:i}=this.props,{tag:a,operationId:u}=i.toObject();let{isShownKey:_}=i.toObject();_=_||[\"operations\",a,u],o.layoutActions.readyToScroll(_,s)};render(){return Re.createElement(\"span\",{ref:this.onLoad},Re.createElement(s,this.props))}},operation_tag_wrapper=(s,o)=>class OperationTagWrapper extends Re.Component{onLoad=s=>{const{tag:i}=this.props,a=[\"operations-tag\",i];o.layoutActions.readyToScroll(a,s)};render(){return Re.createElement(\"span\",{ref:this.onLoad},Re.createElement(s,this.props))}};function deep_linking(){return[En,{statePlugins:{configs:{wrapActions:{loaded:(s,o)=>(...i)=>{s(...i);const a=decodeURIComponent(window.location.hash);o.layoutActions.parseDeepLinkHash(a)}}}},wrapComponents:{operation:operation_wrapper,OperationTag:operation_tag_wrapper}}]}var kn=__webpack_require__(40860),On=__webpack_require__.n(kn);function transform(s){return s.map((s=>{let o=\"is not of a type(s)\",i=s.get(\"message\").indexOf(o);if(i>-1){let o=s.get(\"message\").slice(i+19).split(\",\");return s.set(\"message\",s.get(\"message\").slice(0,i)+function makeNewMessage(s){return s.reduce(((s,o,i,a)=>i===a.length-1&&a.length>1?s+\"or \"+o:a[i+1]&&a.length>2?s+o+\", \":a[i+1]?s+o+\" \":s+o),\"should be a\")}(o))}return s}))}var An=__webpack_require__(58156),Cn=__webpack_require__.n(An);function parameter_oneof_transform(s,{jsSpec:o}){return s}const jn=[w,x];function transformErrors(s){let o={jsSpec:{}},i=On()(jn,((s,i)=>{try{return i.transform(s,o).filter((s=>!!s))}catch(o){return console.error(\"Transformer error:\",o),s}}),s);return i.filter((s=>!!s)).map((s=>(!s.get(\"line\")&&s.get(\"path\"),s)))}let Pn={line:0,level:\"error\",message:\"Unknown error\"};const In=Ut((s=>s),(s=>s.get(\"errors\",(0,ze.List)()))),Tn=Ut(In,(s=>s.last()));function err(o){return{statePlugins:{err:{reducers:{[rt]:(s,{payload:o})=>{let i=Object.assign(Pn,o,{type:\"thrown\"});return s.update(\"errors\",(s=>(s||(0,ze.List)()).push((0,ze.fromJS)(i)))).update(\"errors\",(s=>transformErrors(s)))},[nt]:(s,{payload:o})=>(o=o.map((s=>(0,ze.fromJS)(Object.assign(Pn,s,{type:\"thrown\"})))),s.update(\"errors\",(s=>(s||(0,ze.List)()).concat((0,ze.fromJS)(o)))).update(\"errors\",(s=>transformErrors(s)))),[st]:(s,{payload:o})=>{let i=(0,ze.fromJS)(o);return i=i.set(\"type\",\"spec\"),s.update(\"errors\",(s=>(s||(0,ze.List)()).push((0,ze.fromJS)(i)).sortBy((s=>s.get(\"line\"))))).update(\"errors\",(s=>transformErrors(s)))},[ot]:(s,{payload:o})=>(o=o.map((s=>(0,ze.fromJS)(Object.assign(Pn,s,{type:\"spec\"})))),s.update(\"errors\",(s=>(s||(0,ze.List)()).concat((0,ze.fromJS)(o)))).update(\"errors\",(s=>transformErrors(s)))),[it]:(s,{payload:o})=>{let i=(0,ze.fromJS)(Object.assign({},o));return i=i.set(\"type\",\"auth\"),s.update(\"errors\",(s=>(s||(0,ze.List)()).push((0,ze.fromJS)(i)))).update(\"errors\",(s=>transformErrors(s)))},[at]:(s,{payload:o})=>{if(!o||!s.get(\"errors\"))return s;let i=s.get(\"errors\").filter((s=>s.keySeq().every((i=>{const a=s.get(i),u=o[i];return!u||a!==u}))));return s.merge({errors:i})},[ct]:(s,{payload:o})=>{if(!o||\"function\"!=typeof o)return s;let i=s.get(\"errors\").filter((s=>o(s)));return s.merge({errors:i})}},actions:s,selectors:C}}}}function opsFilter(s,o){return s.filter(((s,i)=>-1!==i.indexOf(o)))}function filter(){return{fn:{opsFilter}}}var Nn=__webpack_require__(7666),Mn=__webpack_require__.n(Nn);const arrow_up=({className:s=null,width:o=20,height:i=20,...a})=>Re.createElement(\"svg\",Mn()({xmlns:\"http://www.w3.org/2000/svg\",viewBox:\"0 0 20 20\",className:s,width:o,height:i,\"aria-hidden\":\"true\",focusable:\"false\"},a),Re.createElement(\"path\",{d:\"M 17.418 14.908 C 17.69 15.176 18.127 15.176 18.397 14.908 C 18.667 14.64 18.668 14.207 18.397 13.939 L 10.489 6.109 C 10.219 5.841 9.782 5.841 9.51 6.109 L 1.602 13.939 C 1.332 14.207 1.332 14.64 1.602 14.908 C 1.873 15.176 2.311 15.176 2.581 14.908 L 10 7.767 L 17.418 14.908 Z\"})),arrow_down=({className:s=null,width:o=20,height:i=20,...a})=>Re.createElement(\"svg\",Mn()({xmlns:\"http://www.w3.org/2000/svg\",viewBox:\"0 0 20 20\",className:s,width:o,height:i,\"aria-hidden\":\"true\",focusable:\"false\"},a),Re.createElement(\"path\",{d:\"M17.418 6.109c.272-.268.709-.268.979 0s.271.701 0 .969l-7.908 7.83c-.27.268-.707.268-.979 0l-7.908-7.83c-.27-.268-.27-.701 0-.969.271-.268.709-.268.979 0L10 13.25l7.418-7.141z\"})),arrow=({className:s=null,width:o=20,height:i=20,...a})=>Re.createElement(\"svg\",Mn()({xmlns:\"http://www.w3.org/2000/svg\",viewBox:\"0 0 20 20\",className:s,width:o,height:i,\"aria-hidden\":\"true\",focusable:\"false\"},a),Re.createElement(\"path\",{d:\"M13.25 10L6.109 2.58c-.268-.27-.268-.707 0-.979.268-.27.701-.27.969 0l7.83 7.908c.268.271.268.709 0 .979l-7.83 7.908c-.268.271-.701.27-.969 0-.268-.269-.268-.707 0-.979L13.25 10z\"})),components_close=({className:s=null,width:o=20,height:i=20,...a})=>Re.createElement(\"svg\",Mn()({xmlns:\"http://www.w3.org/2000/svg\",viewBox:\"0 0 20 20\",className:s,width:o,height:i,\"aria-hidden\":\"true\",focusable:\"false\"},a),Re.createElement(\"path\",{d:\"M14.348 14.849c-.469.469-1.229.469-1.697 0L10 11.819l-2.651 3.029c-.469.469-1.229.469-1.697 0-.469-.469-.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-.469-.469-.469-1.228 0-1.697.469-.469 1.228-.469 1.697 0L10 8.183l2.651-3.031c.469-.469 1.228-.469 1.697 0 .469.469.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c.469.469.469 1.229 0 1.698z\"})),copy=({className:s=null,width:o=15,height:i=16,...a})=>Re.createElement(\"svg\",Mn()({xmlns:\"http://www.w3.org/2000/svg\",viewBox:\"0 0 15 16\",className:s,width:o,height:i,\"aria-hidden\":\"true\",focusable:\"false\"},a),Re.createElement(\"g\",{transform:\"translate(2, -1)\"},Re.createElement(\"path\",{fill:\"#ffffff\",fillRule:\"evenodd\",d:\"M2 13h4v1H2v-1zm5-6H2v1h5V7zm2 3V8l-3 3 3 3v-2h5v-2H9zM4.5 9H2v1h2.5V9zM2 12h2.5v-1H2v1zm9 1h1v2c-.02.28-.11.52-.3.7-.19.18-.42.28-.7.3H1c-.55 0-1-.45-1-1V4c0-.55.45-1 1-1h3c0-1.11.89-2 2-2 1.11 0 2 .89 2 2h3c.55 0 1 .45 1 1v5h-1V6H1v9h10v-2zM2 5h8c0-.55-.45-1-1-1H8c-.55 0-1-.45-1-1s-.45-1-1-1-1 .45-1 1-.45 1-1 1H3c-.55 0-1 .45-1 1z\"}))),lock=({className:s=null,width:o=20,height:i=20,...a})=>Re.createElement(\"svg\",Mn()({xmlns:\"http://www.w3.org/2000/svg\",viewBox:\"0 0 20 20\",className:s,width:o,height:i,\"aria-hidden\":\"true\",focusable:\"false\"},a),Re.createElement(\"path\",{d:\"M15.8 8H14V5.6C14 2.703 12.665 1 10 1 7.334 1 6 2.703 6 5.6V8H4c-.553 0-1 .646-1 1.199V17c0 .549.428 1.139.951 1.307l1.197.387C5.672 18.861 6.55 19 7.1 19h5.8c.549 0 1.428-.139 1.951-.307l1.196-.387c.524-.167.953-.757.953-1.306V9.199C17 8.646 16.352 8 15.8 8zM12 8H8V5.199C8 3.754 8.797 3 10 3c1.203 0 2 .754 2 2.199V8z\"})),unlock=({className:s=null,width:o=20,height:i=20,...a})=>Re.createElement(\"svg\",Mn()({xmlns:\"http://www.w3.org/2000/svg\",viewBox:\"0 0 20 20\",className:s,width:o,height:i,\"aria-hidden\":\"true\",focusable:\"false\"},a),Re.createElement(\"path\",{d:\"M15.8 8H14V5.6C14 2.703 12.665 1 10 1 7.334 1 6 2.703 6 5.6V6h2v-.801C8 3.754 8.797 3 10 3c1.203 0 2 .754 2 2.199V8H4c-.553 0-1 .646-1 1.199V17c0 .549.428 1.139.951 1.307l1.197.387C5.672 18.861 6.55 19 7.1 19h5.8c.549 0 1.428-.139 1.951-.307l1.196-.387c.524-.167.953-.757.953-1.306V9.199C17 8.646 16.352 8 15.8 8z\"})),icons=()=>({components:{ArrowUpIcon:arrow_up,ArrowDownIcon:arrow_down,ArrowIcon:arrow,CloseIcon:components_close,CopyIcon:copy,LockIcon:lock,UnlockIcon:unlock}}),Rn=\"layout_update_layout\",Dn=\"layout_update_filter\",Ln=\"layout_update_mode\",Fn=\"layout_show\";function updateLayout(s){return{type:Rn,payload:s}}function updateFilter(s){return{type:Dn,payload:s}}function actions_show(s,o=!0){return s=normalizeArray(s),{type:Fn,payload:{thing:s,shown:o}}}function changeMode(s,o=\"\"){return s=normalizeArray(s),{type:Ln,payload:{thing:s,mode:o}}}const Bn={[Rn]:(s,o)=>s.set(\"layout\",o.payload),[Dn]:(s,o)=>s.set(\"filter\",o.payload),[Fn]:(s,o)=>{const i=o.payload.shown,a=(0,ze.fromJS)(o.payload.thing);return s.update(\"shown\",(0,ze.fromJS)({}),(s=>s.set(a,i)))},[Ln]:(s,o)=>{let i=o.payload.thing,a=o.payload.mode;return s.setIn([\"modes\"].concat(i),(a||\"\")+\"\")}},current=s=>s.get(\"layout\"),currentFilter=s=>s.get(\"filter\"),isShown=(s,o,i)=>(o=normalizeArray(o),s.get(\"shown\",(0,ze.fromJS)({})).get((0,ze.fromJS)(o),i)),whatMode=(s,o,i=\"\")=>(o=normalizeArray(o),s.getIn([\"modes\",...o],i)),$n=Ut((s=>s),(s=>!isShown(s,\"editor\"))),taggedOperations=(s,o)=>(i,...a)=>{let u=s(i,...a);const{fn:_,layoutSelectors:w,getConfigs:x}=o.getSystem(),C=x(),{maxDisplayedTags:j}=C;let L=w.currentFilter();return L&&!0!==L&&(u=_.opsFilter(u,L)),j>=0&&(u=u.slice(0,j)),u};function plugins_layout(){return{statePlugins:{layout:{reducers:Bn,actions:j,selectors:L},spec:{wrapSelectors:B}}}}function logs({configs:s}){const o={debug:0,info:1,log:2,warn:3,error:4},getLevel=s=>o[s]||-1;let{logLevel:i}=s,a=getLevel(i);function log(s,...o){getLevel(s)>=a&&console[s](...o)}return log.warn=log.bind(null,\"warn\"),log.error=log.bind(null,\"error\"),log.info=log.bind(null,\"info\"),log.debug=log.bind(null,\"debug\"),{rootInjects:{log}}}let qn=!1;function on_complete(){return{statePlugins:{spec:{wrapActions:{updateSpec:s=>(...o)=>(qn=!0,s(...o)),updateJsonSpec:(s,o)=>(...i)=>{const a=o.getConfigs().onComplete;return qn&&\"function\"==typeof a&&(setTimeout(a,0),qn=!1),s(...i)}}}}}}const extractKey=s=>{const o=\"_**[]\";return s.indexOf(o)<0?s:s.split(o)[0].trim()},escapeShell=s=>\"-d \"===s||/^[_\\/-]/g.test(s)?s:\"'\"+s.replace(/'/g,\"'\\\\''\")+\"'\",escapeCMD=s=>\"-d \"===(s=s.replace(/\\^/g,\"^^\").replace(/\\\\\"/g,'\\\\\\\\\"').replace(/\"/g,'\"\"').replace(/\\n/g,\"^\\n\"))?s.replace(/-d /g,\"-d ^\\n\"):/^[_\\/-]/g.test(s)?s:'\"'+s+'\"',escapePowershell=s=>{if(\"-d \"===s)return s;if(/\\n/.test(s)){return`@\"\\n${s.replace(/`/g,\"``\").replace(/\\$/g,\"`$\")}\\n\"@`}if(!/^[_\\/-]/.test(s)){return`'${s.replace(/'/g,\"''\")}'`}return s};const curlify=(s,o,i,a=\"\")=>{let u=!1,_=\"\";const addWords=(...s)=>_+=\" \"+s.map(o).join(\" \"),addWordsWithoutLeadingSpace=(...s)=>_+=s.map(o).join(\" \"),addNewLine=()=>_+=` ${i}`,addIndent=(s=1)=>_+=\"  \".repeat(s);let w=s.get(\"headers\");_+=\"curl\"+a;const x=s.get(\"curlOptions\");if(ze.List.isList(x)&&!x.isEmpty()&&addWords(...s.get(\"curlOptions\")),addWords(\"-X\",s.get(\"method\")),addNewLine(),addIndent(),addWordsWithoutLeadingSpace(`${s.get(\"url\")}`),w&&w.size)for(let o of s.get(\"headers\").entries()){addNewLine(),addIndent();let[s,i]=o;addWordsWithoutLeadingSpace(\"-H\",`${s}: ${i}`),u=u||/^content-type$/i.test(s)&&/^multipart\\/form-data$/i.test(i)}const C=s.get(\"body\");if(C)if(u&&[\"POST\",\"PUT\",\"PATCH\"].includes(s.get(\"method\")))for(let[s,o]of C.entrySeq()){let i=extractKey(s);addNewLine(),addIndent(),addWordsWithoutLeadingSpace(\"-F\"),o instanceof lt.File&&\"string\"==typeof o.valueOf()?addWords(`${i}=${o.data}${o.type?`;type=${o.type}`:\"\"}`):o instanceof lt.File?addWords(`${i}=@${o.name}${o.type?`;type=${o.type}`:\"\"}`):addWords(`${i}=${o}`)}else if(C instanceof lt.File)addNewLine(),addIndent(),addWordsWithoutLeadingSpace(`--data-binary '@${C.name}'`);else{addNewLine(),addIndent(),addWordsWithoutLeadingSpace(\"-d \");let o=C;ze.Map.isMap(o)?addWordsWithoutLeadingSpace(function getStringBodyOfMap(s){let o=[];for(let[i,a]of s.get(\"body\").entrySeq()){let s=extractKey(i);a instanceof lt.File?o.push(`  \"${s}\": {\\n    \"name\": \"${a.name}\"${a.type?`,\\n    \"type\": \"${a.type}\"`:\"\"}\\n  }`):o.push(`  \"${s}\": ${JSON.stringify(a,null,2).replace(/(\\r\\n|\\r|\\n)/g,\"\\n  \")}`)}return`{\\n${o.join(\",\\n\")}\\n}`}(s)):(\"string\"!=typeof o&&(o=JSON.stringify(o)),addWordsWithoutLeadingSpace(o))}else C||\"POST\"!==s.get(\"method\")||(addNewLine(),addIndent(),addWordsWithoutLeadingSpace(\"-d ''\"));return _},requestSnippetGenerator_curl_powershell=s=>curlify(s,escapePowershell,\"`\\n\",\".exe\"),requestSnippetGenerator_curl_bash=s=>curlify(s,escapeShell,\"\\\\\\n\"),requestSnippetGenerator_curl_cmd=s=>curlify(s,escapeCMD,\"^\\n\"),request_snippets_selectors_state=s=>s||(0,ze.Map)(),Un=Ut(request_snippets_selectors_state,(s=>{const o=s.get(\"languages\"),i=s.get(\"generators\",(0,ze.Map)());return!o||o.isEmpty()?i:i.filter(((s,i)=>o.includes(i)))})),getSnippetGenerators=s=>({fn:o})=>Un(s).map(((s,i)=>{const a=(s=>o[`requestSnippetGenerator_${s}`])(i);return\"function\"!=typeof a?null:s.set(\"fn\",a)})).filter((s=>s)),Vn=Ut(request_snippets_selectors_state,(s=>s.get(\"activeLanguage\"))),zn=Ut(request_snippets_selectors_state,(s=>s.get(\"defaultExpanded\")));var Wn=__webpack_require__(46942),Jn=__webpack_require__.n(Wn),Hn=__webpack_require__(59399);const Kn={cursor:\"pointer\",lineHeight:1,display:\"inline-flex\",backgroundColor:\"rgb(250, 250, 250)\",paddingBottom:\"0\",paddingTop:\"0\",border:\"1px solid rgb(51, 51, 51)\",borderRadius:\"4px 4px 0 0\",boxShadow:\"none\",borderBottom:\"none\"},Gn={cursor:\"pointer\",lineHeight:1,display:\"inline-flex\",backgroundColor:\"rgb(51, 51, 51)\",boxShadow:\"none\",border:\"1px solid rgb(51, 51, 51)\",paddingBottom:\"0\",paddingTop:\"0\",borderRadius:\"4px 4px 0 0\",marginTop:\"-5px\",marginRight:\"-5px\",marginLeft:\"-5px\",zIndex:\"9999\",borderBottom:\"none\"},request_snippets=({request:s,requestSnippetsSelectors:o,getComponent:i})=>{const a=(0,Re.useRef)(null),u=i(\"ArrowUpIcon\"),_=i(\"ArrowDownIcon\"),w=i(\"SyntaxHighlighter\",!0),[x,C]=(0,Re.useState)(o.getSnippetGenerators()?.keySeq().first()),[j,L]=(0,Re.useState)(o?.getDefaultExpanded()),B=o.getSnippetGenerators(),$=B.get(x),U=$.get(\"fn\")(s),handleSetIsExpanded=()=>{L(!j)},handleGetBtnStyle=s=>s===x?Gn:Kn,handlePreventYScrollingBeyondElement=s=>{const{target:o,deltaY:i}=s,{scrollHeight:a,offsetHeight:u,scrollTop:_}=o;a>u&&(0===_&&i<0||u+_>=a&&i>0)&&s.preventDefault()};return(0,Re.useEffect)((()=>{}),[]),(0,Re.useEffect)((()=>{const s=Array.from(a.current.childNodes).filter((s=>!!s.nodeType&&s.classList?.contains(\"curl-command\")));return s.forEach((s=>s.addEventListener(\"mousewheel\",handlePreventYScrollingBeyondElement,{passive:!1}))),()=>{s.forEach((s=>s.removeEventListener(\"mousewheel\",handlePreventYScrollingBeyondElement)))}}),[s]),Re.createElement(\"div\",{className:\"request-snippets\",ref:a},Re.createElement(\"div\",{style:{width:\"100%\",display:\"flex\",justifyContent:\"flex-start\",alignItems:\"center\",marginBottom:\"15px\"}},Re.createElement(\"h4\",{onClick:()=>handleSetIsExpanded(),style:{cursor:\"pointer\"}},\"Snippets\"),Re.createElement(\"button\",{onClick:()=>handleSetIsExpanded(),style:{border:\"none\",background:\"none\"},title:j?\"Collapse operation\":\"Expand operation\"},j?Re.createElement(_,{className:\"arrow\",width:\"10\",height:\"10\"}):Re.createElement(u,{className:\"arrow\",width:\"10\",height:\"10\"}))),j&&Re.createElement(\"div\",{className:\"curl-command\"},Re.createElement(\"div\",{style:{paddingLeft:\"15px\",paddingRight:\"10px\",width:\"100%\",display:\"flex\"}},B.entrySeq().map((([s,o])=>Re.createElement(\"div\",{className:Jn()(\"btn\",{active:s===x}),style:handleGetBtnStyle(s),key:s,onClick:()=>(s=>{x!==s&&C(s)})(s)},Re.createElement(\"h4\",{style:s===x?{color:\"white\"}:{}},o.get(\"title\")))))),Re.createElement(\"div\",{className:\"copy-to-clipboard\"},Re.createElement(Hn.CopyToClipboard,{text:U},Re.createElement(\"button\",null))),Re.createElement(\"div\",null,Re.createElement(w,{language:$.get(\"syntax\"),className:\"curl microlight\",renderPlainText:({children:s,PlainTextViewer:o})=>Re.createElement(o,{className:\"curl\"},s)},U))))},plugins_request_snippets=()=>({components:{RequestSnippets:request_snippets},fn:{requestSnippetGenerator_curl_bash,requestSnippetGenerator_curl_cmd,requestSnippetGenerator_curl_powershell},statePlugins:{requestSnippets:{selectors:$}}});class ModelCollapse extends Re.Component{static defaultProps={collapsedContent:\"{...}\",expanded:!1,title:null,onToggle:()=>{},hideSelfOnExpand:!1,specPath:We().List([])};constructor(s,o){super(s,o);let{expanded:i,collapsedContent:a}=this.props;this.state={expanded:i,collapsedContent:a||ModelCollapse.defaultProps.collapsedContent}}componentDidMount(){const{hideSelfOnExpand:s,expanded:o,modelName:i}=this.props;s&&o&&this.props.onToggle(i,o)}UNSAFE_componentWillReceiveProps(s){this.props.expanded!==s.expanded&&this.setState({expanded:s.expanded})}toggleCollapsed=()=>{this.props.onToggle&&this.props.onToggle(this.props.modelName,!this.state.expanded),this.setState({expanded:!this.state.expanded})};onLoad=s=>{if(s&&this.props.layoutSelectors){const o=this.props.layoutSelectors.getScrollToKey();We().is(o,this.props.specPath)&&this.toggleCollapsed(),this.props.layoutActions.readyToScroll(this.props.specPath,s.parentElement)}};render(){const{title:s,classes:o}=this.props;return this.state.expanded&&this.props.hideSelfOnExpand?Re.createElement(\"span\",{className:o||\"\"},this.props.children):Re.createElement(\"span\",{className:o||\"\",ref:this.onLoad},Re.createElement(\"button\",{\"aria-expanded\":this.state.expanded,className:\"model-box-control\",onClick:this.toggleCollapsed},s&&Re.createElement(\"span\",{className:\"pointer\"},s),Re.createElement(\"span\",{className:\"model-toggle\"+(this.state.expanded?\"\":\" collapsed\")}),!this.state.expanded&&Re.createElement(\"span\",null,this.state.collapsedContent)),this.state.expanded&&this.props.children)}}const useTabs=({initialTab:s,isExecute:o,schema:i,example:a})=>{const u=(0,Re.useMemo)((()=>({example:\"example\",model:\"model\"})),[]),_=(0,Re.useMemo)((()=>Object.keys(u)),[u]).includes(s)&&i&&!o?s:u.example,w=(s=>{const o=(0,Re.useRef)();return(0,Re.useEffect)((()=>{o.current=s})),o.current})(o),[x,C]=(0,Re.useState)(_),j=(0,Re.useCallback)((s=>{C(s.target.dataset.name)}),[]);return(0,Re.useEffect)((()=>{w&&!o&&a&&C(u.example)}),[w,o,a]),{activeTab:x,onTabChange:j,tabs:u}},model_example=({schema:s,example:o,isExecute:i=!1,specPath:a,includeWriteOnly:u=!1,includeReadOnly:_=!1,getComponent:w,getConfigs:x,specSelectors:C})=>{const{defaultModelRendering:j,defaultModelExpandDepth:L}=x(),B=w(\"ModelWrapper\"),$=w(\"HighlightCode\",!0),U=xt()(5).toString(\"base64\"),V=xt()(5).toString(\"base64\"),z=xt()(5).toString(\"base64\"),Y=xt()(5).toString(\"base64\"),Z=C.isOAS3(),{activeTab:ee,tabs:ie,onTabChange:ae}=useTabs({initialTab:j,isExecute:i,schema:s,example:o});return Re.createElement(\"div\",{className:\"model-example\"},Re.createElement(\"ul\",{className:\"tab\",role:\"tablist\"},Re.createElement(\"li\",{className:Jn()(\"tabitem\",{active:ee===ie.example}),role:\"presentation\"},Re.createElement(\"button\",{\"aria-controls\":V,\"aria-selected\":ee===ie.example,className:\"tablinks\",\"data-name\":\"example\",id:U,onClick:ae,role:\"tab\"},i?\"Edit Value\":\"Example Value\")),s&&Re.createElement(\"li\",{className:Jn()(\"tabitem\",{active:ee===ie.model}),role:\"presentation\"},Re.createElement(\"button\",{\"aria-controls\":Y,\"aria-selected\":ee===ie.model,className:Jn()(\"tablinks\",{inactive:i}),\"data-name\":\"model\",id:z,onClick:ae,role:\"tab\"},Z?\"Schema\":\"Model\"))),ee===ie.example&&Re.createElement(\"div\",{\"aria-hidden\":ee!==ie.example,\"aria-labelledby\":U,\"data-name\":\"examplePanel\",id:V,role:\"tabpanel\",tabIndex:\"0\"},o||Re.createElement($,null,\"(no example available\")),ee===ie.model&&Re.createElement(\"div\",{className:\"model-container\",\"aria-hidden\":ee===ie.example,\"aria-labelledby\":z,\"data-name\":\"modelPanel\",id:Y,role:\"tabpanel\",tabIndex:\"0\"},Re.createElement(B,{schema:s,getComponent:w,getConfigs:x,specSelectors:C,expandDepth:L,specPath:a,includeReadOnly:_,includeWriteOnly:u})))};class ModelWrapper extends Re.Component{onToggle=(s,o)=>{this.props.layoutActions&&this.props.layoutActions.show(this.props.fullPath,o)};render(){let{getComponent:s,getConfigs:o}=this.props;const i=s(\"Model\");let a;return this.props.layoutSelectors&&(a=this.props.layoutSelectors.isShown(this.props.fullPath)),Re.createElement(\"div\",{className:\"model-box\"},Re.createElement(i,Mn()({},this.props,{getConfigs:o,expanded:a,depth:1,onToggle:this.onToggle,expandDepth:this.props.expandDepth||0})))}}function _typeof(s){return _typeof=\"function\"==typeof Symbol&&\"symbol\"==typeof Symbol.iterator?function(s){return typeof s}:function(s){return s&&\"function\"==typeof Symbol&&s.constructor===Symbol&&s!==Symbol.prototype?\"symbol\":typeof s},_typeof(s)}function _defineProperties(s,o){for(var i=0;i<o.length;i++){var a=o[i];a.enumerable=a.enumerable||!1,a.configurable=!0,\"value\"in a&&(a.writable=!0),Object.defineProperty(s,a.key,a)}}function _defineProperty(s,o,i){return o in s?Object.defineProperty(s,o,{value:i,enumerable:!0,configurable:!0,writable:!0}):s[o]=i,s}function ownKeys(s,o){var i=Object.keys(s);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(s);o&&(a=a.filter((function(o){return Object.getOwnPropertyDescriptor(s,o).enumerable}))),i.push.apply(i,a)}return i}function _getPrototypeOf(s){return _getPrototypeOf=Object.setPrototypeOf?Object.getPrototypeOf:function _getPrototypeOf(s){return s.__proto__||Object.getPrototypeOf(s)},_getPrototypeOf(s)}function _setPrototypeOf(s,o){return _setPrototypeOf=Object.setPrototypeOf||function _setPrototypeOf(s,o){return s.__proto__=o,s},_setPrototypeOf(s,o)}function _possibleConstructorReturn(s,o){return!o||\"object\"!=typeof o&&\"function\"!=typeof o?function _assertThisInitialized(s){if(void 0===s)throw new ReferenceError(\"this hasn't been initialised - super() hasn't been called\");return s}(s):o}var Yn={};function react_immutable_pure_component_es_get(s,o,i){return function isInvalid(s){return null==s}(s)?i:function isMapLike(s){return null!==s&&\"object\"===_typeof(s)&&\"function\"==typeof s.get&&\"function\"==typeof s.has}(s)?s.has(o)?s.get(o):i:hasOwnProperty.call(s,o)?s[o]:i}function getIn(s,o,i){for(var a=0;a!==o.length;)if((s=react_immutable_pure_component_es_get(s,o[a++],Yn))===Yn)return i;return s}function check(s){var o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},a=function createChecker(s,o){return function(i){if(\"string\"==typeof i)return(0,ze.is)(o[i],s[i]);if(Array.isArray(i))return(0,ze.is)(getIn(o,i),getIn(s,i));throw new TypeError(\"Invalid key: expected Array or string: \"+i)}}(o,i),u=s||Object.keys(function _objectSpread2(s){for(var o=1;o<arguments.length;o++){var i=null!=arguments[o]?arguments[o]:{};o%2?ownKeys(i,!0).forEach((function(o){_defineProperty(s,o,i[o])})):Object.getOwnPropertyDescriptors?Object.defineProperties(s,Object.getOwnPropertyDescriptors(i)):ownKeys(i).forEach((function(o){Object.defineProperty(s,o,Object.getOwnPropertyDescriptor(i,o))}))}return s}({},i,{},o));return u.every(a)}const Xn=function(s){function ImmutablePureComponent(){return function _classCallCheck(s,o){if(!(s instanceof o))throw new TypeError(\"Cannot call a class as a function\")}(this,ImmutablePureComponent),_possibleConstructorReturn(this,_getPrototypeOf(ImmutablePureComponent).apply(this,arguments))}return function _inherits(s,o){if(\"function\"!=typeof o&&null!==o)throw new TypeError(\"Super expression must either be null or a function\");s.prototype=Object.create(o&&o.prototype,{constructor:{value:s,writable:!0,configurable:!0}}),o&&_setPrototypeOf(s,o)}(ImmutablePureComponent,s),function _createClass(s,o,i){return o&&_defineProperties(s.prototype,o),i&&_defineProperties(s,i),s}(ImmutablePureComponent,[{key:\"shouldComponentUpdate\",value:function shouldComponentUpdate(s){var o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return!check(this.updateOnProps,this.props,s,\"updateOnProps\")||!check(this.updateOnStates,this.state,o,\"updateOnStates\")}}]),ImmutablePureComponent}(Re.Component);var Qn,Zn=__webpack_require__(5556),es=__webpack_require__.n(Zn);function _extends(){return _extends=Object.assign?Object.assign.bind():function(s){for(var o=1;o<arguments.length;o++){var i=arguments[o];for(var a in i)({}).hasOwnProperty.call(i,a)&&(s[a]=i[a])}return s},_extends.apply(null,arguments)}const rolling_load=s=>Re.createElement(\"svg\",_extends({xmlns:\"http://www.w3.org/2000/svg\",width:200,height:200,className:\"rolling-load_svg__lds-rolling\",preserveAspectRatio:\"xMidYMid\",style:{backgroundImage:\"none\",backgroundPosition:\"initial initial\",backgroundRepeat:\"initial initial\"},viewBox:\"0 0 100 100\"},s),Qn||(Qn=Re.createElement(\"circle\",{cx:50,cy:50,r:35,fill:\"none\",stroke:\"#555\",strokeDasharray:\"164.93361431346415 56.97787143782138\",strokeWidth:10},Re.createElement(\"animateTransform\",{attributeName:\"transform\",begin:\"0s\",calcMode:\"linear\",dur:\"1s\",keyTimes:\"0;1\",repeatCount:\"indefinite\",type:\"rotate\",values:\"0 50 50;360 50 50\"})))),decodeRefName=s=>{const o=s.replace(/~1/g,\"/\").replace(/~0/g,\"~\");try{return decodeURIComponent(o)}catch{return o}};class Model extends Xn{static propTypes={schema:xn().map.isRequired,getComponent:es().func.isRequired,getConfigs:es().func.isRequired,specSelectors:es().object.isRequired,name:es().string,displayName:es().string,isRef:es().bool,required:es().bool,expandDepth:es().number,depth:es().number,specPath:xn().list.isRequired,includeReadOnly:es().bool,includeWriteOnly:es().bool};getModelName=s=>-1!==s.indexOf(\"#/definitions/\")?decodeRefName(s.replace(/^.*#\\/definitions\\//,\"\")):-1!==s.indexOf(\"#/components/schemas/\")?decodeRefName(s.replace(/^.*#\\/components\\/schemas\\//,\"\")):void 0;getRefSchema=s=>{let{specSelectors:o}=this.props;return o.findDefinition(s)};render(){let{getComponent:s,getConfigs:o,specSelectors:i,schema:a,required:u,name:_,isRef:w,specPath:x,displayName:C,includeReadOnly:j,includeWriteOnly:L}=this.props;const B=s(\"ObjectModel\"),$=s(\"ArrayModel\"),U=s(\"PrimitiveModel\");let V=\"object\",z=a&&a.get(\"$$ref\"),Y=a&&a.get(\"$ref\");if(!_&&z&&(_=this.getModelName(z)),Y){const s=this.getModelName(Y),o=this.getRefSchema(s);ze.Map.isMap(o)?(a=o.mergeDeep(a),z||(a=a.set(\"$$ref\",Y),z=Y)):ze.Map.isMap(a)&&1===a.size&&(a=null,_=Y)}if(!a)return Re.createElement(\"span\",{className:\"model model-title\"},Re.createElement(\"span\",{className:\"model-title__text\"},C||_),!Y&&Re.createElement(rolling_load,{height:\"20px\",width:\"20px\"}));const Z=i.isOAS3()&&a.get(\"deprecated\");switch(w=void 0!==w?w:!!z,V=a&&a.get(\"type\")||V,V){case\"object\":return Re.createElement(B,Mn()({className:\"object\"},this.props,{specPath:x,getConfigs:o,schema:a,name:_,deprecated:Z,isRef:w,includeReadOnly:j,includeWriteOnly:L}));case\"array\":return Re.createElement($,Mn()({className:\"array\"},this.props,{getConfigs:o,schema:a,name:_,deprecated:Z,required:u,includeReadOnly:j,includeWriteOnly:L}));default:return Re.createElement(U,Mn()({},this.props,{getComponent:s,getConfigs:o,schema:a,name:_,deprecated:Z,required:u}))}}}class Models extends Re.Component{getSchemaBasePath=()=>this.props.specSelectors.isOAS3()?[\"components\",\"schemas\"]:[\"definitions\"];getCollapsedContent=()=>\" \";handleToggle=(s,o)=>{const{layoutActions:i}=this.props;i.show([...this.getSchemaBasePath(),s],o),o&&this.props.specActions.requestResolvedSubtree([...this.getSchemaBasePath(),s])};onLoadModels=s=>{s&&this.props.layoutActions.readyToScroll(this.getSchemaBasePath(),s)};onLoadModel=s=>{if(s){const o=s.getAttribute(\"data-name\");this.props.layoutActions.readyToScroll([...this.getSchemaBasePath(),o],s)}};render(){let{specSelectors:s,getComponent:o,layoutSelectors:i,layoutActions:a,getConfigs:u}=this.props,_=s.definitions(),{docExpansion:w,defaultModelsExpandDepth:x}=u();if(!_.size||x<0)return null;const C=this.getSchemaBasePath();let j=i.isShown(C,x>0&&\"none\"!==w);const L=s.isOAS3(),B=o(\"ModelWrapper\"),$=o(\"Collapse\"),U=o(\"ModelCollapse\"),V=o(\"JumpToPath\",!0),z=o(\"ArrowUpIcon\"),Y=o(\"ArrowDownIcon\");return Re.createElement(\"section\",{className:j?\"models is-open\":\"models\",ref:this.onLoadModels},Re.createElement(\"h4\",null,Re.createElement(\"button\",{\"aria-expanded\":j,className:\"models-control\",onClick:()=>a.show(C,!j)},Re.createElement(\"span\",null,L?\"Schemas\":\"Models\"),j?Re.createElement(z,null):Re.createElement(Y,null))),Re.createElement($,{isOpened:j},_.entrySeq().map((([_])=>{const w=[...C,_],j=We().List(w),L=s.specResolvedSubtree(w),$=s.specJson().getIn(w),z=ze.Map.isMap(L)?L:We().Map(),Y=ze.Map.isMap($)?$:We().Map(),Z=z.get(\"title\")||Y.get(\"title\")||_,ee=i.isShown(w,!1);ee&&0===z.size&&Y.size>0&&this.props.specActions.requestResolvedSubtree(w);const ie=Re.createElement(B,{name:_,expandDepth:x,schema:z||We().Map(),displayName:Z,fullPath:w,specPath:j,getComponent:o,specSelectors:s,getConfigs:u,layoutSelectors:i,layoutActions:a,includeReadOnly:!0,includeWriteOnly:!0}),ae=Re.createElement(\"span\",{className:\"model-box\"},Re.createElement(\"span\",{className:\"model model-title\"},Z));return Re.createElement(\"div\",{id:`model-${_}`,className:\"model-container\",key:`models-section-${_}`,\"data-name\":_,ref:this.onLoadModel},Re.createElement(\"span\",{className:\"models-jump-to-path\"},Re.createElement(V,{path:j})),Re.createElement(U,{classes:\"model-box\",collapsedContent:this.getCollapsedContent(_),onToggle:this.handleToggle,title:ae,displayName:Z,modelName:_,specPath:j,layoutSelectors:i,layoutActions:a,hideSelfOnExpand:!0,expanded:x>0&&ee},ie))})).toArray()))}}const enum_model=({value:s,getComponent:o})=>{let i=o(\"ModelCollapse\"),a=Re.createElement(\"span\",null,\"Array [ \",s.count(),\" ]\");return Re.createElement(\"span\",{className:\"prop-enum\"},\"Enum:\",Re.createElement(\"br\",null),Re.createElement(i,{collapsedContent:a},\"[ \",s.map(String).join(\", \"),\" ]\"))};function isAbsoluteUrl(s){return s.match(/^(?:[a-z]+:)?\\/\\//i)}function buildBaseUrl(s,o){return s?isAbsoluteUrl(s)?function addProtocol(s){return s.match(/^\\/\\//i)?`${window.location.protocol}${s}`:s}(s):new URL(s,o).href:o}function safeBuildUrl(s,o,{selectedServer:i=\"\"}={}){try{return function buildUrl(s,o,{selectedServer:i=\"\"}={}){if(!s)return;if(isAbsoluteUrl(s))return s;const a=buildBaseUrl(i,o);return isAbsoluteUrl(a)?new URL(s,a).href:new URL(s,window.location.href).href}(s,o,{selectedServer:i})}catch{return}}function sanitizeUrl(s){if(\"string\"!=typeof s||\"\"===s.trim())return\"\";const o=s.trim(),i=\"about:blank\";try{const s=`https://base${String(Math.random()).slice(2)}`,a=new URL(o,s),u=a.protocol.slice(0,-1);return[\"javascript\",\"data\",\"vbscript\"].includes(u.toLowerCase())?i:a.origin===s?o.startsWith(\"/\")?`${a.pathname}${a.search}${a.hash}`:o.startsWith(\"./\")?`.${a.pathname}${a.search}${a.hash}`:o.startsWith(\"../\")?`..${a.pathname}${a.search}${a.hash}`:`${a.pathname.substring(1)}${a.search}${a.hash}`:String(a)}catch{return i}}class ObjectModel extends Re.Component{render(){let{schema:s,name:o,displayName:i,isRef:a,getComponent:u,getConfigs:_,depth:w,onToggle:x,expanded:C,specPath:j,...L}=this.props,{specSelectors:B,expandDepth:$,includeReadOnly:U,includeWriteOnly:V}=L;const{isOAS3:z}=B,Y=w>2||2===w&&\"items\"!==j.last();if(!s)return null;const{showExtensions:Z}=_(),ee=Z?getExtensions(s):(0,ze.List)();let ie=s.get(\"description\"),ae=s.get(\"properties\"),ce=s.get(\"additionalProperties\"),le=s.get(\"title\")||i||o,pe=s.get(\"required\"),de=s.filter(((s,o)=>-1!==[\"maxProperties\",\"minProperties\",\"nullable\",\"example\"].indexOf(o))),fe=s.get(\"deprecated\"),ye=s.getIn([\"externalDocs\",\"url\"]),be=s.getIn([\"externalDocs\",\"description\"]);const _e=u(\"JumpToPath\",!0),Se=u(\"Markdown\",!0),we=u(\"Model\"),xe=u(\"ModelCollapse\"),Pe=u(\"Property\"),Te=u(\"Link\"),$e=u(\"ModelExtensions\"),JumpToPathSection=()=>Re.createElement(\"span\",{className:\"model-jump-to-path\"},Re.createElement(_e,{path:j})),qe=Re.createElement(\"span\",null,Re.createElement(\"span\",null,\"{\"),\"...\",Re.createElement(\"span\",null,\"}\"),a?Re.createElement(JumpToPathSection,null):\"\"),We=B.isOAS3()?s.get(\"allOf\"):null,He=B.isOAS3()?s.get(\"anyOf\"):null,Ye=B.isOAS3()?s.get(\"oneOf\"):null,Xe=B.isOAS3()?s.get(\"not\"):null,Qe=le&&Re.createElement(\"span\",{className:\"model-title\"},a&&s.get(\"$$ref\")&&Re.createElement(\"span\",{className:Jn()(\"model-hint\",{\"model-hint--embedded\":Y})},s.get(\"$$ref\")),Re.createElement(\"span\",{className:\"model-title__text\"},le));return Re.createElement(\"span\",{className:\"model\"},Re.createElement(xe,{modelName:o,title:Qe,onToggle:x,expanded:!!C||w<=$,collapsedContent:qe},Re.createElement(\"span\",{className:\"brace-open object\"},\"{\"),a?Re.createElement(JumpToPathSection,null):null,Re.createElement(\"span\",{className:\"inner-object\"},Re.createElement(\"table\",{className:\"model\"},Re.createElement(\"tbody\",null,ie?Re.createElement(\"tr\",{className:\"description\"},Re.createElement(\"td\",null,\"description:\"),Re.createElement(\"td\",null,Re.createElement(Se,{source:ie}))):null,ye&&Re.createElement(\"tr\",{className:\"external-docs\"},Re.createElement(\"td\",null,\"externalDocs:\"),Re.createElement(\"td\",null,Re.createElement(Te,{target:\"_blank\",href:sanitizeUrl(ye)},be||ye))),fe?Re.createElement(\"tr\",{className:\"property\"},Re.createElement(\"td\",null,\"deprecated:\"),Re.createElement(\"td\",null,\"true\")):null,ae&&ae.size?ae.entrySeq().filter((([,s])=>(!s.get(\"readOnly\")||U)&&(!s.get(\"writeOnly\")||V))).map((([s,i])=>{let a=z()&&i.get(\"deprecated\"),x=ze.List.isList(pe)&&pe.contains(s),C=[\"property-row\"];return a&&C.push(\"deprecated\"),x&&C.push(\"required\"),Re.createElement(\"tr\",{key:s,className:C.join(\" \")},Re.createElement(\"td\",null,s,x&&Re.createElement(\"span\",{className:\"star\"},\"*\")),Re.createElement(\"td\",null,Re.createElement(we,Mn()({key:`object-${o}-${s}_${i}`},L,{required:x,getComponent:u,specPath:j.push(\"properties\",s),getConfigs:_,schema:i,depth:w+1}))))})).toArray():null,0===ee.size?null:Re.createElement(Re.Fragment,null,Re.createElement(\"tr\",null,Re.createElement(\"td\",null,\" \")),Re.createElement($e,{extensions:ee,propClass:\"extension\"})),ce&&ce.size?Re.createElement(\"tr\",null,Re.createElement(\"td\",null,\"< * >:\"),Re.createElement(\"td\",null,Re.createElement(we,Mn()({},L,{required:!1,getComponent:u,specPath:j.push(\"additionalProperties\"),getConfigs:_,schema:ce,depth:w+1})))):null,We?Re.createElement(\"tr\",null,Re.createElement(\"td\",null,\"allOf ->\"),Re.createElement(\"td\",null,We.map(((s,o)=>Re.createElement(\"div\",{key:o},Re.createElement(we,Mn()({},L,{required:!1,getComponent:u,specPath:j.push(\"allOf\",o),getConfigs:_,schema:s,depth:w+1}))))))):null,He?Re.createElement(\"tr\",null,Re.createElement(\"td\",null,\"anyOf ->\"),Re.createElement(\"td\",null,He.map(((s,o)=>Re.createElement(\"div\",{key:o},Re.createElement(we,Mn()({},L,{required:!1,getComponent:u,specPath:j.push(\"anyOf\",o),getConfigs:_,schema:s,depth:w+1}))))))):null,Ye?Re.createElement(\"tr\",null,Re.createElement(\"td\",null,\"oneOf ->\"),Re.createElement(\"td\",null,Ye.map(((s,o)=>Re.createElement(\"div\",{key:o},Re.createElement(we,Mn()({},L,{required:!1,getComponent:u,specPath:j.push(\"oneOf\",o),getConfigs:_,schema:s,depth:w+1}))))))):null,Xe?Re.createElement(\"tr\",null,Re.createElement(\"td\",null,\"not ->\"),Re.createElement(\"td\",null,Re.createElement(\"div\",null,Re.createElement(we,Mn()({},L,{required:!1,getComponent:u,specPath:j.push(\"not\"),getConfigs:_,schema:Xe,depth:w+1}))))):null))),Re.createElement(\"span\",{className:\"brace-close\"},\"}\")),de.size?de.entrySeq().map((([s,o])=>Re.createElement(Pe,{key:`${s}-${o}`,propKey:s,propVal:o,propClass:\"property\"}))):null)}}class ArrayModel extends Re.Component{render(){let{getComponent:s,getConfigs:o,schema:i,depth:a,expandDepth:u,name:_,displayName:w,specPath:x}=this.props,C=i.get(\"description\"),j=i.get(\"items\"),L=i.get(\"title\")||w||_,B=i.filter(((s,o)=>-1===[\"type\",\"items\",\"description\",\"$$ref\",\"externalDocs\"].indexOf(o))),$=i.getIn([\"externalDocs\",\"url\"]),U=i.getIn([\"externalDocs\",\"description\"]);const V=s(\"Markdown\",!0),z=s(\"ModelCollapse\"),Y=s(\"Model\"),Z=s(\"Property\"),ee=s(\"Link\"),ie=L&&Re.createElement(\"span\",{className:\"model-title\"},Re.createElement(\"span\",{className:\"model-title__text\"},L));return Re.createElement(\"span\",{className:\"model\"},Re.createElement(z,{title:ie,expanded:a<=u,collapsedContent:\"[...]\"},\"[\",B.size?B.entrySeq().map((([s,o])=>Re.createElement(Z,{key:`${s}-${o}`,propKey:s,propVal:o,propClass:\"property\"}))):null,C?Re.createElement(V,{source:C}):B.size?Re.createElement(\"div\",{className:\"markdown\"}):null,$&&Re.createElement(\"div\",{className:\"external-docs\"},Re.createElement(ee,{target:\"_blank\",href:sanitizeUrl($)},U||$)),Re.createElement(\"span\",null,Re.createElement(Y,Mn()({},this.props,{getConfigs:o,specPath:x.push(\"items\"),name:null,schema:j,required:!1,depth:a+1}))),\"]\"))}}const ts=\"property primitive\";class Primitive extends Re.Component{render(){let{schema:s,getComponent:o,getConfigs:i,name:a,displayName:u,depth:_,expandDepth:w}=this.props;const{showExtensions:x}=i();if(!s||!s.get)return Re.createElement(\"div\",null);let C=s.get(\"type\"),j=s.get(\"format\"),L=s.get(\"xml\"),B=s.get(\"enum\"),$=s.get(\"title\")||u||a,U=s.get(\"description\");const V=getExtensions(s);let z=s.filter(((s,o)=>-1===[\"enum\",\"type\",\"format\",\"description\",\"$$ref\",\"externalDocs\"].indexOf(o))).filterNot(((s,o)=>V.has(o))),Y=s.getIn([\"externalDocs\",\"url\"]),Z=s.getIn([\"externalDocs\",\"description\"]);const ee=o(\"Markdown\",!0),ie=o(\"EnumModel\"),ae=o(\"Property\"),ce=o(\"ModelCollapse\"),le=o(\"Link\"),pe=o(\"ModelExtensions\"),de=$&&Re.createElement(\"span\",{className:\"model-title\"},Re.createElement(\"span\",{className:\"model-title__text\"},$));return Re.createElement(\"span\",{className:\"model\"},Re.createElement(ce,{title:de,expanded:_<=w,collapsedContent:\"[...]\"},Re.createElement(\"span\",{className:\"prop\"},a&&_>1&&Re.createElement(\"span\",{className:\"prop-name\"},$),Re.createElement(\"span\",{className:\"prop-type\"},C),j&&Re.createElement(\"span\",{className:\"prop-format\"},\"($\",j,\")\"),z.size?z.entrySeq().map((([s,o])=>Re.createElement(ae,{key:`${s}-${o}`,propKey:s,propVal:o,propClass:ts}))):null,x&&V.size>0?Re.createElement(pe,{extensions:V,propClass:`${ts} extension`}):null,U?Re.createElement(ee,{source:U}):null,Y&&Re.createElement(\"div\",{className:\"external-docs\"},Re.createElement(le,{target:\"_blank\",href:sanitizeUrl(Y)},Z||Y)),L&&L.size?Re.createElement(\"span\",null,Re.createElement(\"br\",null),Re.createElement(\"span\",{className:ts},\"xml:\"),L.entrySeq().map((([s,o])=>Re.createElement(\"span\",{key:`${s}-${o}`,className:ts},Re.createElement(\"br\",null),\"   \",s,\": \",String(o)))).toArray()):null,B&&Re.createElement(ie,{value:B,getComponent:o}))))}}class Schemes extends Re.Component{UNSAFE_componentWillMount(){let{schemes:s}=this.props;this.setScheme(s.first())}UNSAFE_componentWillReceiveProps(s){this.props.currentScheme&&s.schemes.includes(this.props.currentScheme)||this.setScheme(s.schemes.first())}onChange=s=>{this.setScheme(s.target.value)};setScheme=s=>{let{path:o,method:i,specActions:a}=this.props;a.setScheme(s,o,i)};render(){let{schemes:s,currentScheme:o}=this.props;return Re.createElement(\"label\",{htmlFor:\"schemes\"},Re.createElement(\"span\",{className:\"schemes-title\"},\"Schemes\"),Re.createElement(\"select\",{onChange:this.onChange,value:o,id:\"schemes\"},s.valueSeq().map((s=>Re.createElement(\"option\",{value:s,key:s},s))).toArray()))}}class SchemesContainer extends Re.Component{render(){const{specActions:s,specSelectors:o,getComponent:i}=this.props,a=o.operationScheme(),u=o.schemes(),_=i(\"schemes\");return u&&u.size?Re.createElement(_,{currentScheme:a,schemes:u,specActions:s}):null}}var rs=__webpack_require__(24677),ns=__webpack_require__.n(rs);const ss={value:\"\",onChange:()=>{},schema:{},keyName:\"\",required:!1,errors:(0,ze.List)()};class JsonSchemaForm extends Re.Component{static defaultProps=ss;componentDidMount(){const{dispatchInitialValue:s,value:o,onChange:i}=this.props;s?i(o):!1===s&&i(\"\")}render(){let{schema:s,errors:o,value:i,onChange:a,getComponent:u,fn:_,disabled:w}=this.props;const x=s&&s.get?s.get(\"format\"):null,C=s&&s.get?s.get(\"type\"):null,j=_.getSchemaObjectType(s),L=_.isFileUploadIntended(s);let getComponentSilently=s=>u(s,!1,{failSilently:!0}),B=C?getComponentSilently(x?`JsonSchema_${C}_${x}`:`JsonSchema_${C}`):u(\"JsonSchema_string\");return L||!ze.List.isList(C)||\"array\"!==j&&\"object\"!==j||(B=u(\"JsonSchema_object\")),B||(B=u(\"JsonSchema_string\")),Re.createElement(B,Mn()({},this.props,{errors:o,fn:_,getComponent:u,value:i,onChange:a,schema:s,disabled:w}))}}class JsonSchema_string extends Re.Component{static defaultProps=ss;onChange=s=>{const o=this.props.schema&&\"file\"===this.props.schema.get(\"type\")?s.target.files[0]:s.target.value;this.props.onChange(o,this.props.keyName)};onEnumChange=s=>this.props.onChange(s);render(){let{getComponent:s,value:o,schema:i,errors:a,required:u,description:_,disabled:w}=this.props;const x=i&&i.get?i.get(\"enum\"):null,C=i&&i.get?i.get(\"format\"):null,j=i&&i.get?i.get(\"type\"):null,L=i&&i.get?i.get(\"in\"):null;if(o?(isImmutable(o)||\"object\"==typeof o)&&(o=stringify(o)):o=\"\",a=a.toJS?a.toJS():[],x){const i=s(\"Select\");return Re.createElement(i,{className:a.length?\"invalid\":\"\",title:a.length?a:\"\",allowedValues:[...x],value:o,allowEmptyValue:!u,disabled:w,onChange:this.onEnumChange})}const B=w||L&&\"formData\"===L&&!(\"FormData\"in window),$=s(\"Input\");return j&&\"file\"===j?Re.createElement($,{type:\"file\",className:a.length?\"invalid\":\"\",title:a.length?a:\"\",onChange:this.onChange,disabled:B}):Re.createElement(ns(),{type:C&&\"password\"===C?\"password\":\"text\",className:a.length?\"invalid\":\"\",title:a.length?a:\"\",value:o,minLength:0,debounceTimeout:350,placeholder:_,onChange:this.onChange,disabled:B})}}class JsonSchema_array extends Re.PureComponent{static defaultProps=ss;constructor(s,o){super(s,o),this.state={value:valueOrEmptyList(s.value),schema:s.schema}}UNSAFE_componentWillReceiveProps(s){const o=valueOrEmptyList(s.value);o!==this.state.value&&this.setState({value:o}),s.schema!==this.state.schema&&this.setState({schema:s.schema})}onChange=()=>{this.props.onChange(this.state.value)};onItemChange=(s,o)=>{this.setState((({value:i})=>({value:i.set(o,s)})),this.onChange)};removeItem=s=>{this.setState((({value:o})=>({value:o.delete(s)})),this.onChange)};addItem=()=>{const{fn:s}=this.props;let o=valueOrEmptyList(this.state.value);this.setState((()=>({value:o.push(s.getSampleSchema(this.state.schema.get(\"items\"),!1,{includeWriteOnly:!0}))})),this.onChange)};onEnumChange=s=>{this.setState((()=>({value:s})),this.onChange)};render(){let{getComponent:s,required:o,schema:i,errors:a,fn:u,disabled:_}=this.props;a=a.toJS?a.toJS():Array.isArray(a)?a:[];const w=a.filter((s=>\"string\"==typeof s)),x=a.filter((s=>void 0!==s.needRemove)).map((s=>s.error)),C=this.state.value,j=!!(C&&C.count&&C.count()>0),L=i.getIn([\"items\",\"enum\"]),B=i.get(\"items\"),$=u.getSchemaObjectType(B),U=u.getSchemaObjectTypeLabel(B),V=i.getIn([\"items\",\"format\"]),z=i.get(\"items\");let Y,Z=!1,ee=\"file\"===$||\"string\"===$&&\"binary\"===V;if($&&V?Y=s(`JsonSchema_${$}_${V}`):\"boolean\"!==$&&\"array\"!==$&&\"object\"!==$||(Y=s(`JsonSchema_${$}`)),!ze.List.isList(B?.get(\"type\"))||\"array\"!==$&&\"object\"!==$||(Y=s(\"JsonSchema_object\")),Y||ee||(Z=!0),L){const i=s(\"Select\");return Re.createElement(i,{className:a.length?\"invalid\":\"\",title:a.length?a:\"\",multiple:!0,value:C,disabled:_,allowedValues:L,allowEmptyValue:!o,onChange:this.onEnumChange})}const ie=s(\"Button\");return Re.createElement(\"div\",{className:\"json-schema-array\"},j?C.map(((o,i)=>{const w=(0,ze.fromJS)([...a.filter((s=>s.index===i)).map((s=>s.error))]);return Re.createElement(\"div\",{key:i,className:\"json-schema-form-item\"},ee?Re.createElement(JsonSchemaArrayItemFile,{value:o,onChange:s=>this.onItemChange(s,i),disabled:_,errors:w,getComponent:s}):Z?Re.createElement(JsonSchemaArrayItemText,{value:o,onChange:s=>this.onItemChange(s,i),disabled:_,errors:w}):Re.createElement(Y,Mn()({},this.props,{value:o,onChange:s=>this.onItemChange(s,i),disabled:_,errors:w,schema:z,getComponent:s,fn:u})),_?null:Re.createElement(ie,{className:`btn btn-sm json-schema-form-item-remove ${x.length?\"invalid\":null}`,title:x.length?x:\"\",onClick:()=>this.removeItem(i)},\" - \"))})):null,_?null:Re.createElement(ie,{className:`btn btn-sm json-schema-form-item-add ${w.length?\"invalid\":null}`,title:w.length?w:\"\",onClick:this.addItem},\"Add \",U,\" item\"))}}class JsonSchemaArrayItemText extends Re.Component{static defaultProps=ss;onChange=s=>{const o=s.target.value;this.props.onChange(o,this.props.keyName)};render(){let{value:s,errors:o,description:i,disabled:a}=this.props;return s?(isImmutable(s)||\"object\"==typeof s)&&(s=stringify(s)):s=\"\",o=o.toJS?o.toJS():[],Re.createElement(ns(),{type:\"text\",className:o.length?\"invalid\":\"\",title:o.length?o:\"\",value:s,minLength:0,debounceTimeout:350,placeholder:i,onChange:this.onChange,disabled:a})}}class JsonSchemaArrayItemFile extends Re.Component{static defaultProps=ss;onFileChange=s=>{const o=s.target.files[0];this.props.onChange(o,this.props.keyName)};render(){let{getComponent:s,errors:o,disabled:i}=this.props;const a=s(\"Input\"),u=i||!(\"FormData\"in window);return Re.createElement(a,{type:\"file\",className:o.length?\"invalid\":\"\",title:o.length?o:\"\",onChange:this.onFileChange,disabled:u})}}class JsonSchema_boolean extends Re.Component{static defaultProps=ss;onEnumChange=s=>this.props.onChange(s);render(){let{getComponent:s,value:o,errors:i,schema:a,required:u,disabled:_}=this.props;i=i.toJS?i.toJS():[];let w=a&&a.get?a.get(\"enum\"):null,x=!w||!u,C=!w&&[\"true\",\"false\"];const j=s(\"Select\");return Re.createElement(j,{className:i.length?\"invalid\":\"\",title:i.length?i:\"\",value:String(o),disabled:_,allowedValues:w?[...w]:C,allowEmptyValue:x,onChange:this.onEnumChange})}}const stringifyObjectErrors=s=>s.map((s=>{const o=void 0!==s.propKey?s.propKey:s.index;let i=\"string\"==typeof s?s:\"string\"==typeof s.error?s.error:null;if(!o&&i)return i;let a=s.error,u=`/${s.propKey}`;for(;\"object\"==typeof a;){const s=void 0!==a.propKey?a.propKey:a.index;if(void 0===s)break;if(u+=`/${s}`,!a.error)break;a=a.error}return`${u}: ${a}`}));class JsonSchema_object extends Re.PureComponent{constructor(){super()}static defaultProps=ss;onChange=s=>{this.props.onChange(s)};handleOnChange=s=>{const o=s.target.value;this.onChange(o)};render(){let{getComponent:s,value:o,errors:i,disabled:a}=this.props;const u=s(\"TextArea\");return i=i.toJS?i.toJS():Array.isArray(i)?i:[],Re.createElement(\"div\",null,Re.createElement(u,{className:Jn()({invalid:i.length}),title:i.length?stringifyObjectErrors(i).join(\", \"):\"\",value:stringify(o),disabled:a,onChange:this.handleOnChange}))}}function valueOrEmptyList(s){return ze.List.isList(s)?s:Array.isArray(s)?(0,ze.fromJS)(s):(0,ze.List)()}const ModelExtensions=({extensions:s,propClass:o=\"\"})=>s.entrySeq().map((([s,i])=>{const a=immutableToJS(i)??null;return Re.createElement(\"tr\",{key:s,className:o},Re.createElement(\"td\",null,s),Re.createElement(\"td\",null,JSON.stringify(a)))})).toArray();var os=__webpack_require__(11331),as=__webpack_require__.n(os);const hasSchemaType=(s,o)=>{const i=ze.Map.isMap(s);if(!i&&!as()(s))return!1;const a=i?s.get(\"type\"):s.type;return o===a||Array.isArray(o)&&o.includes(a)},getType=(s,o=new WeakSet)=>{if(null==s)return\"any\";if(o.has(s))return\"any\";o.add(s);const{type:i,items:a}=s;return Object.hasOwn(s,\"items\")?(()=>{if(a)return`array<${getType(a,o)}>`;return\"array<any>\"})():i},getSchemaObjectTypeLabel=s=>getType(immutableToJS(s)),json_schema_5=()=>({components:{modelExample:model_example,ModelWrapper,ModelCollapse,Model,Models,EnumModel:enum_model,ObjectModel,ArrayModel,PrimitiveModel:Primitive,ModelExtensions,schemes:Schemes,SchemesContainer,...U},fn:{hasSchemaType,getSchemaObjectTypeLabel}});var cs=__webpack_require__(19123),ls=__webpack_require__.n(cs),us=__webpack_require__(41859),ps=__webpack_require__.n(us),hs=__webpack_require__(62193),ds=__webpack_require__.n(hs);const shallowArrayEquals=s=>o=>Array.isArray(s)&&Array.isArray(o)&&s.length===o.length&&s.every(((s,i)=>s===o[i])),list=(...s)=>s;class Cache extends Map{delete(s){const o=Array.from(this.keys()).find(shallowArrayEquals(s));return super.delete(o)}get(s){const o=Array.from(this.keys()).find(shallowArrayEquals(s));return super.get(o)}has(s){return-1!==Array.from(this.keys()).findIndex(shallowArrayEquals(s))}}const utils_memoizeN=(s,o=list)=>{const{Cache:i}=pt();pt().Cache=Cache;const a=pt()(s,o);return pt().Cache=i,a},fs={string:s=>s.pattern?(s=>{try{const o=/(?<=(?<!\\\\)\\{)(\\d{3,})(?=\\})|(?<=(?<!\\\\)\\{\\d*,)(\\d{3,})(?=\\})|(?<=(?<!\\\\)\\{)(\\d{3,})(?=,\\d*\\})/g,i=s.replace(o,\"100\"),a=new(ps())(i);return a.max=100,a.gen()}catch(s){return\"string\"}})(s.pattern):\"string\",string_email:()=>\"user@example.com\",\"string_date-time\":()=>(new Date).toISOString(),string_date:()=>(new Date).toISOString().substring(0,10),string_time:()=>(new Date).toISOString().substring(11),string_uuid:()=>\"3fa85f64-5717-4562-b3fc-2c963f66afa6\",string_hostname:()=>\"example.com\",string_ipv4:()=>\"198.51.100.42\",string_ipv6:()=>\"2001:0db8:5b96:0000:0000:426f:8e17:642a\",number:()=>0,number_float:()=>0,integer:()=>0,boolean:s=>\"boolean\"!=typeof s.default||s.default},primitive=s=>{s=objectify(s);let{type:o,format:i}=s,a=fs[`${o}_${i}`]||fs[o];return isFunc(a)?a(s):\"Unknown Type: \"+s.type},sanitizeRef=s=>deeplyStripKey(s,\"$$ref\",(s=>\"string\"==typeof s&&s.indexOf(\"#\")>-1)),ms=[\"maxProperties\",\"minProperties\"],gs=[\"minItems\",\"maxItems\"],ys=[\"minimum\",\"maximum\",\"exclusiveMinimum\",\"exclusiveMaximum\"],vs=[\"minLength\",\"maxLength\"],mergeJsonSchema=(s,o,i={})=>{const a={...s};if([\"example\",\"default\",\"enum\",\"xml\",\"type\",...ms,...gs,...ys,...vs].forEach((s=>(s=>{void 0===a[s]&&void 0!==o[s]&&(a[s]=o[s])})(s))),void 0!==o.required&&Array.isArray(o.required)&&(void 0!==a.required&&a.required.length||(a.required=[]),o.required.forEach((s=>{a.required.includes(s)||a.required.push(s)}))),o.properties){a.properties||(a.properties={});let s=objectify(o.properties);for(let u in s)Object.prototype.hasOwnProperty.call(s,u)&&(s[u]&&s[u].deprecated||s[u]&&s[u].readOnly&&!i.includeReadOnly||s[u]&&s[u].writeOnly&&!i.includeWriteOnly||a.properties[u]||(a.properties[u]=s[u],!o.required&&Array.isArray(o.required)&&-1!==o.required.indexOf(u)&&(a.required?a.required.push(u):a.required=[u])))}return o.items&&(a.items||(a.items={}),a.items=mergeJsonSchema(a.items,o.items,i)),a},sampleFromSchemaGeneric=(s,o={},i=void 0,a=!1)=>{s&&isFunc(s.toJS)&&(s=s.toJS());let u=void 0!==i||s&&void 0!==s.example||s&&void 0!==s.default;const _=!u&&s&&s.oneOf&&s.oneOf.length>0,w=!u&&s&&s.anyOf&&s.anyOf.length>0;if(!u&&(_||w)){const i=objectify(_?s.oneOf[0]:s.anyOf[0]);if(!(s=mergeJsonSchema(s,i,o)).xml&&i.xml&&(s.xml=i.xml),void 0!==s.example&&void 0!==i.example)u=!0;else if(i.properties){s.properties||(s.properties={});let a=objectify(i.properties);for(let u in a)Object.prototype.hasOwnProperty.call(a,u)&&(a[u]&&a[u].deprecated||a[u]&&a[u].readOnly&&!o.includeReadOnly||a[u]&&a[u].writeOnly&&!o.includeWriteOnly||s.properties[u]||(s.properties[u]=a[u],!i.required&&Array.isArray(i.required)&&-1!==i.required.indexOf(u)&&(s.required?s.required.push(u):s.required=[u])))}}const x={};let{xml:C,type:j,example:L,properties:B,additionalProperties:$,items:U}=s||{},{includeReadOnly:V,includeWriteOnly:z}=o;C=C||{};let Y,{name:Z,prefix:ee,namespace:ie}=C,ae={};if(a&&(Z=Z||\"notagname\",Y=(ee?ee+\":\":\"\")+Z,ie)){x[ee?\"xmlns:\"+ee:\"xmlns\"]=ie}a&&(ae[Y]=[]);const schemaHasAny=o=>o.some((o=>Object.prototype.hasOwnProperty.call(s,o)));s&&!j&&(B||$||schemaHasAny(ms)?j=\"object\":U||schemaHasAny(gs)?j=\"array\":schemaHasAny(ys)?(j=\"number\",s.type=\"number\"):u||s.enum||(j=\"string\",s.type=\"string\"));const handleMinMaxItems=o=>{if(null!=s?.maxItems&&(o=o.slice(0,s?.maxItems)),null!=s?.minItems){let i=0;for(;o.length<s?.minItems;)o.push(o[i++%o.length])}return o},ce=objectify(B);let le,pe=0;const hasExceededMaxProperties=()=>s&&null!==s.maxProperties&&void 0!==s.maxProperties&&pe>=s.maxProperties,canAddProperty=o=>!s||null===s.maxProperties||void 0===s.maxProperties||!hasExceededMaxProperties()&&(!(o=>!(s&&s.required&&s.required.length&&s.required.includes(o)))(o)||s.maxProperties-pe-(()=>{if(!s||!s.required)return 0;let o=0;return a?s.required.forEach((s=>o+=void 0===ae[s]?0:1)):s.required.forEach((s=>o+=void 0===ae[Y]?.find((o=>void 0!==o[s]))?0:1)),s.required.length-o})()>0);if(le=a?(i,u=void 0)=>{if(s&&ce[i]){if(ce[i].xml=ce[i].xml||{},ce[i].xml.attribute){const s=Array.isArray(ce[i].enum)?ce[i].enum[0]:void 0,o=ce[i].example,a=ce[i].default;return void(x[ce[i].xml.name||i]=void 0!==o?o:void 0!==a?a:void 0!==s?s:primitive(ce[i]))}ce[i].xml.name=ce[i].xml.name||i}else ce[i]||!1===$||(ce[i]={xml:{name:i}});let _=sampleFromSchemaGeneric(s&&ce[i]||void 0,o,u,a);canAddProperty(i)&&(pe++,Array.isArray(_)?ae[Y]=ae[Y].concat(_):ae[Y].push(_))}:(i,u)=>{if(canAddProperty(i)){if(Object.prototype.hasOwnProperty.call(s,\"discriminator\")&&s.discriminator&&Object.prototype.hasOwnProperty.call(s.discriminator,\"mapping\")&&s.discriminator.mapping&&Object.prototype.hasOwnProperty.call(s,\"$$ref\")&&s.$$ref&&s.discriminator.propertyName===i){for(let o in s.discriminator.mapping)if(-1!==s.$$ref.search(s.discriminator.mapping[o])){ae[i]=o;break}}else ae[i]=sampleFromSchemaGeneric(ce[i],o,u,a);pe++}},u){let u;if(u=sanitizeRef(void 0!==i?i:void 0!==L?L:s.default),!a){if(\"number\"==typeof u&&\"string\"===j)return`${u}`;if(\"string\"!=typeof u||\"string\"===j)return u;try{return JSON.parse(u)}catch(s){return u}}if(s||(j=Array.isArray(u)?\"array\":typeof u),\"array\"===j){if(!Array.isArray(u)){if(\"string\"==typeof u)return u;u=[u]}const i=s?s.items:void 0;i&&(i.xml=i.xml||C||{},i.xml.name=i.xml.name||C.name);let _=u.map((s=>sampleFromSchemaGeneric(i,o,s,a)));return _=handleMinMaxItems(_),C.wrapped?(ae[Y]=_,ds()(x)||ae[Y].push({_attr:x})):ae=_,ae}if(\"object\"===j){if(\"string\"==typeof u)return u;for(let o in u)Object.prototype.hasOwnProperty.call(u,o)&&(s&&ce[o]&&ce[o].readOnly&&!V||s&&ce[o]&&ce[o].writeOnly&&!z||(s&&ce[o]&&ce[o].xml&&ce[o].xml.attribute?x[ce[o].xml.name||o]=u[o]:le(o,u[o])));return ds()(x)||ae[Y].push({_attr:x}),ae}return ae[Y]=ds()(x)?u:[{_attr:x},u],ae}if(\"object\"===j){for(let s in ce)Object.prototype.hasOwnProperty.call(ce,s)&&(ce[s]&&ce[s].deprecated||ce[s]&&ce[s].readOnly&&!V||ce[s]&&ce[s].writeOnly&&!z||le(s));if(a&&x&&ae[Y].push({_attr:x}),hasExceededMaxProperties())return ae;if(!0===$)a?ae[Y].push({additionalProp:\"Anything can be here\"}):ae.additionalProp1={},pe++;else if($){const i=objectify($),u=sampleFromSchemaGeneric(i,o,void 0,a);if(a&&i.xml&&i.xml.name&&\"notagname\"!==i.xml.name)ae[Y].push(u);else{const o=i[\"x-additionalPropertiesName\"]||\"additionalProp\",_=null!==s.minProperties&&void 0!==s.minProperties&&pe<s.minProperties?s.minProperties-pe:3;for(let s=1;s<=_;s++){if(hasExceededMaxProperties())return ae;if(a){const i={};i[o+s]=u.notagname,ae[Y].push(i)}else ae[o+s]=u;pe++}}}return ae}if(\"array\"===j){if(!U)return;let i;if(a&&(U.xml=U.xml||s?.xml||{},U.xml.name=U.xml.name||C.name),Array.isArray(U.anyOf))i=U.anyOf.map((s=>sampleFromSchemaGeneric(mergeJsonSchema(s,U,o),o,void 0,a)));else if(Array.isArray(U.oneOf))i=U.oneOf.map((s=>sampleFromSchemaGeneric(mergeJsonSchema(s,U,o),o,void 0,a)));else{if(!(!a||a&&C.wrapped))return sampleFromSchemaGeneric(U,o,void 0,a);i=[sampleFromSchemaGeneric(U,o,void 0,a)]}return i=handleMinMaxItems(i),a&&C.wrapped?(ae[Y]=i,ds()(x)||ae[Y].push({_attr:x}),ae):i}let de;if(s&&Array.isArray(s.enum))de=normalizeArray(s.enum)[0];else{if(!s)return;if(de=primitive(s),\"number\"==typeof de){let o=s.minimum;null!=o&&(s.exclusiveMinimum&&o++,de=o);let i=s.maximum;null!=i&&(s.exclusiveMaximum&&i--,de=i)}if(\"string\"==typeof de&&(null!==s.maxLength&&void 0!==s.maxLength&&(de=de.slice(0,s.maxLength)),null!==s.minLength&&void 0!==s.minLength)){let o=0;for(;de.length<s.minLength;)de+=de[o++%de.length]}}if(\"file\"!==j)return a?(ae[Y]=ds()(x)?de:[{_attr:x},de],ae):de},inferSchema=s=>(s.schema&&(s=s.schema),s.properties&&(s.type=\"object\"),s),createXMLExample=(s,o,i)=>{const a=sampleFromSchemaGeneric(s,o,i,!0);if(a)return\"string\"==typeof a?a:ls()(a,{declaration:!0,indent:\"\\t\"})},sampleFromSchema=(s,o,i)=>sampleFromSchemaGeneric(s,o,i,!1),resolver=(s,o,i)=>[s,JSON.stringify(o),JSON.stringify(i)],bs=utils_memoizeN(createXMLExample,resolver),_s=utils_memoizeN(sampleFromSchema,resolver),getSchemaObjectType=s=>immutableToJS(s)?.type??\"string\",Ss=[{when:/json/,shouldStringifyTypes:[\"string\"]}],Es=[\"object\"],get_json_sample_schema=s=>(o,i,a,u)=>{const{fn:_}=s(),w=_.memoizedSampleFromSchema(o,i,u),x=typeof w,C=Ss.reduce(((s,o)=>o.when.test(a)?[...s,...o.shouldStringifyTypes]:s),Es);return gt()(C,(s=>s===x))?JSON.stringify(w,null,2):w},get_yaml_sample_schema=s=>(o,i,a,u)=>{const{fn:_}=s(),w=_.getJsonSampleSchema(o,i,a,u);let x;try{x=fn.dump(fn.load(w),{lineWidth:-1},{schema:rn}),\"\\n\"===x[x.length-1]&&(x=x.slice(0,x.length-1))}catch(s){return console.error(s),\"error: could not generate yaml example\"}return x.replace(/\\t/g,\"  \")},get_xml_sample_schema=s=>(o,i,a)=>{const{fn:u}=s();if(o&&!o.xml&&(o.xml={}),o&&!o.xml.name){if(!o.$$ref&&(o.type||o.items||o.properties||o.additionalProperties))return'<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n\\x3c!-- XML example cannot be generated; root element name is undefined --\\x3e';if(o.$$ref){let s=o.$$ref.match(/\\S*\\/(\\S+)$/);o.xml.name=s[1]}}return u.memoizedCreateXMLExample(o,i,a)},get_sample_schema=s=>(o,i=\"\",a={},u=void 0)=>{const{fn:_}=s();return\"function\"==typeof o?.toJS&&(o=o.toJS()),\"function\"==typeof u?.toJS&&(u=u.toJS()),/xml/.test(i)?_.getXmlSampleSchema(o,a,u):/(yaml|yml)/.test(i)?_.getYamlSampleSchema(o,a,i,u):_.getJsonSampleSchema(o,a,i,u)},json_schema_5_samples=({getSystem:s})=>{const o=get_json_sample_schema(s),i=get_yaml_sample_schema(s),a=get_xml_sample_schema(s),u=get_sample_schema(s);return{fn:{jsonSchema5:{inferSchema,sampleFromSchema,sampleFromSchemaGeneric,createXMLExample,memoizedSampleFromSchema:_s,memoizedCreateXMLExample:bs,getJsonSampleSchema:o,getYamlSampleSchema:i,getXmlSampleSchema:a,getSampleSchema:u,mergeJsonSchema},inferSchema,sampleFromSchema,sampleFromSchemaGeneric,createXMLExample,memoizedSampleFromSchema:_s,memoizedCreateXMLExample:bs,getJsonSampleSchema:o,getYamlSampleSchema:i,getXmlSampleSchema:a,getSampleSchema:u,mergeJsonSchema,getSchemaObjectType}}};var ws=__webpack_require__(37334),xs=__webpack_require__.n(ws);const ks=[\"get\",\"put\",\"post\",\"delete\",\"options\",\"head\",\"patch\",\"trace\"],spec_selectors_state=s=>s||(0,ze.Map)(),Os=Ut(spec_selectors_state,(s=>s.get(\"lastError\"))),As=Ut(spec_selectors_state,(s=>s.get(\"url\"))),Cs=Ut(spec_selectors_state,(s=>s.get(\"spec\")||\"\")),js=Ut(spec_selectors_state,(s=>s.get(\"specSource\")||\"not-editor\")),Ps=Ut(spec_selectors_state,(s=>s.get(\"json\",(0,ze.Map)()))),Is=Ut(Ps,(s=>s.toJS())),Ts=Ut(spec_selectors_state,(s=>s.get(\"resolved\",(0,ze.Map)()))),specResolvedSubtree=(s,o)=>s.getIn([\"resolvedSubtrees\",...o],void 0),mergerFn=(s,o)=>ze.Map.isMap(s)&&ze.Map.isMap(o)?o.get(\"$$ref\")?o:(0,ze.OrderedMap)().mergeWith(mergerFn,s,o):o,Ns=Ut(spec_selectors_state,(s=>(0,ze.OrderedMap)().mergeWith(mergerFn,s.get(\"json\"),s.get(\"resolvedSubtrees\")))),spec=s=>Ps(s),Ms=Ut(spec,(()=>!1)),Rs=Ut(spec,(s=>returnSelfOrNewMap(s&&s.get(\"info\")))),Ds=Ut(spec,(s=>returnSelfOrNewMap(s&&s.get(\"externalDocs\")))),Ls=Ut(Rs,(s=>s&&s.get(\"version\"))),Fs=Ut(Ls,(s=>/v?([0-9]*)\\.([0-9]*)\\.([0-9]*)/i.exec(s).slice(1))),Bs=Ut(Ns,(s=>s.get(\"paths\"))),$s=xs()([\"get\",\"put\",\"post\",\"delete\",\"options\",\"head\",\"patch\"]),qs=Ut(Bs,(s=>{let o=(0,ze.List)();return!ze.Map.isMap(s)||s.isEmpty()||s.forEach(((s,i)=>{if(!s||!s.forEach)return{};s.forEach(((s,a)=>{ks.indexOf(a)<0||(o=o.push((0,ze.fromJS)({path:i,method:a,operation:s,id:`${a}-${i}`})))}))})),o})),Us=Ut(spec,(s=>(0,ze.Set)(s.get(\"consumes\")))),Vs=Ut(spec,(s=>(0,ze.Set)(s.get(\"produces\")))),zs=Ut(spec,(s=>s.get(\"security\",(0,ze.List)()))),Ws=Ut(spec,(s=>s.get(\"securityDefinitions\"))),findDefinition=(s,o)=>{const i=s.getIn([\"resolvedSubtrees\",\"definitions\",o],null),a=s.getIn([\"json\",\"definitions\",o],null);return i||a||null},Js=Ut(spec,(s=>{const o=s.get(\"definitions\");return ze.Map.isMap(o)?o:(0,ze.Map)()})),Hs=Ut(spec,(s=>s.get(\"basePath\"))),Ks=Ut(spec,(s=>s.get(\"host\"))),Gs=Ut(spec,(s=>s.get(\"schemes\",(0,ze.Map)()))),Ys=Ut([qs,Us,Vs],((s,o,i)=>s.map((s=>s.update(\"operation\",(s=>ze.Map.isMap(s)?s.withMutations((s=>(s.get(\"consumes\")||s.update(\"consumes\",(s=>(0,ze.Set)(s).merge(o))),s.get(\"produces\")||s.update(\"produces\",(s=>(0,ze.Set)(s).merge(i))),s))):(0,ze.Map)())))))),Xs=Ut(spec,(s=>{const o=s.get(\"tags\",(0,ze.List)());return ze.List.isList(o)?o.filter((s=>ze.Map.isMap(s))):(0,ze.List)()})),tagDetails=(s,o)=>(Xs(s)||(0,ze.List)()).filter(ze.Map.isMap).find((s=>s.get(\"name\")===o),(0,ze.Map)()),Qs=Ut(Ys,Xs,((s,o)=>s.reduce(((s,o)=>{let i=(0,ze.Set)(o.getIn([\"operation\",\"tags\"]));return i.count()<1?s.update(\"default\",(0,ze.List)(),(s=>s.push(o))):i.reduce(((s,i)=>s.update(i,(0,ze.List)(),(s=>s.push(o)))),s)}),o.reduce(((s,o)=>s.set(o.get(\"name\"),(0,ze.List)())),(0,ze.OrderedMap)())))),selectors_taggedOperations=s=>({getConfigs:o})=>{let{tagsSorter:i,operationsSorter:a}=o();return Qs(s).sortBy(((s,o)=>o),((s,o)=>{let a=\"function\"==typeof i?i:It.tagsSorter[i];return a?a(s,o):null})).map(((o,i)=>{let u=\"function\"==typeof a?a:It.operationsSorter[a],_=u?o.sort(u):o;return(0,ze.Map)({tagDetails:tagDetails(s,i),operations:_})}))},Zs=Ut(spec_selectors_state,(s=>s.get(\"responses\",(0,ze.Map)()))),eo=Ut(spec_selectors_state,(s=>s.get(\"requests\",(0,ze.Map)()))),to=Ut(spec_selectors_state,(s=>s.get(\"mutatedRequests\",(0,ze.Map)()))),responseFor=(s,o,i)=>Zs(s).getIn([o,i],null),requestFor=(s,o,i)=>eo(s).getIn([o,i],null),mutatedRequestFor=(s,o,i)=>to(s).getIn([o,i],null),allowTryItOutFor=()=>!0,parameterWithMetaByIdentity=(s,o,i)=>{const a=Ns(s).getIn([\"paths\",...o,\"parameters\"],(0,ze.OrderedMap)()),u=s.getIn([\"meta\",\"paths\",...o,\"parameters\"],(0,ze.OrderedMap)());return a.map((s=>{const o=u.get(`${i.get(\"in\")}.${i.get(\"name\")}`),a=u.get(`${i.get(\"in\")}.${i.get(\"name\")}.hash-${i.hashCode()}`);return(0,ze.OrderedMap)().merge(s,o,a)})).find((s=>s.get(\"in\")===i.get(\"in\")&&s.get(\"name\")===i.get(\"name\")),(0,ze.OrderedMap)())},parameterInclusionSettingFor=(s,o,i,a)=>{const u=`${a}.${i}`;return s.getIn([\"meta\",\"paths\",...o,\"parameter_inclusions\",u],!1)},parameterWithMeta=(s,o,i,a)=>{const u=Ns(s).getIn([\"paths\",...o,\"parameters\"],(0,ze.OrderedMap)()).find((s=>s.get(\"in\")===a&&s.get(\"name\")===i),(0,ze.OrderedMap)());return parameterWithMetaByIdentity(s,o,u)},operationWithMeta=(s,o,i)=>{const a=Ns(s).getIn([\"paths\",o,i],(0,ze.OrderedMap)()),u=s.getIn([\"meta\",\"paths\",o,i],(0,ze.OrderedMap)()),_=a.get(\"parameters\",(0,ze.List)()).map((a=>parameterWithMetaByIdentity(s,[o,i],a)));return(0,ze.OrderedMap)().merge(a,u).set(\"parameters\",_)};function getParameter(s,o,i,a){return o=o||[],s.getIn([\"meta\",\"paths\",...o,\"parameters\"],(0,ze.fromJS)([])).find((s=>ze.Map.isMap(s)&&s.get(\"name\")===i&&s.get(\"in\")===a))||(0,ze.Map)()}const ro=Ut(spec,(s=>{const o=s.get(\"host\");return\"string\"==typeof o&&o.length>0&&\"/\"!==o[0]}));function parameterValues(s,o,i){return o=o||[],operationWithMeta(s,...o).get(\"parameters\",(0,ze.List)()).reduce(((s,o)=>{let a=i&&\"body\"===o.get(\"in\")?o.get(\"value_xml\"):o.get(\"value\");return ze.List.isList(a)&&(a=a.filter((s=>\"\"!==s))),s.set(paramToIdentifier(o,{allowHashes:!1}),a)}),(0,ze.fromJS)({}))}function parametersIncludeIn(s,o=\"\"){if(ze.List.isList(s))return s.some((s=>ze.Map.isMap(s)&&s.get(\"in\")===o))}function parametersIncludeType(s,o=\"\"){if(ze.List.isList(s))return s.some((s=>ze.Map.isMap(s)&&s.get(\"type\")===o))}function contentTypeValues(s,o){o=o||[];let i=Ns(s).getIn([\"paths\",...o],(0,ze.fromJS)({})),a=s.getIn([\"meta\",\"paths\",...o],(0,ze.fromJS)({})),u=currentProducesFor(s,o);const _=i.get(\"parameters\")||new ze.List,w=a.get(\"consumes_value\")?a.get(\"consumes_value\"):parametersIncludeType(_,\"file\")?\"multipart/form-data\":parametersIncludeType(_,\"formData\")?\"application/x-www-form-urlencoded\":void 0;return(0,ze.fromJS)({requestContentType:w,responseContentType:u})}function currentProducesFor(s,o){o=o||[];const i=Ns(s).getIn([\"paths\",...o],null);if(null===i)return;const a=s.getIn([\"meta\",\"paths\",...o,\"produces_value\"],null),u=i.getIn([\"produces\",0],null);return a||u||\"application/json\"}function producesOptionsFor(s,o){o=o||[];const i=Ns(s),a=i.getIn([\"paths\",...o],null);if(null===a)return;const[u]=o,_=a.get(\"produces\",null),w=i.getIn([\"paths\",u,\"produces\"],null),x=i.getIn([\"produces\"],null);return _||w||x}function consumesOptionsFor(s,o){o=o||[];const i=Ns(s),a=i.getIn([\"paths\",...o],null);if(null===a)return;const[u]=o,_=a.get(\"consumes\",null),w=i.getIn([\"paths\",u,\"consumes\"],null),x=i.getIn([\"consumes\"],null);return _||w||x}const operationScheme=(s,o,i)=>{let a=s.get(\"url\").match(/^([a-z][a-z0-9+\\-.]*):/),u=Array.isArray(a)?a[1]:null;return s.getIn([\"scheme\",o,i])||s.getIn([\"scheme\",\"_defaultScheme\"])||u||\"\"},canExecuteScheme=(s,o,i)=>[\"http\",\"https\"].indexOf(operationScheme(s,o,i))>-1,validationErrors=(s,o)=>{o=o||[];const i=s.getIn([\"meta\",\"paths\",...o,\"parameters\"],(0,ze.fromJS)([])),a=[];if(0===i.length)return a;const getErrorsWithPaths=(s,o=[])=>{const getNestedErrorsWithPaths=(s,o)=>{const i=[...o,s.get(\"propKey\")||s.get(\"index\")];return ze.Map.isMap(s.get(\"error\"))?getErrorsWithPaths(s.get(\"error\"),i):{error:s.get(\"error\"),path:i}};return ze.List.isList(s)?s.map((s=>ze.Map.isMap(s)?getNestedErrorsWithPaths(s,o):{error:s,path:o})):getNestedErrorsWithPaths(s,o)};return i.forEach(((s,o)=>{const i=o.split(\".\").slice(1,-1).join(\".\"),u=s.get(\"errors\");if(u&&u.count()){getErrorsWithPaths(u).forEach((({error:s,path:o})=>{a.push(((s,o,i)=>`For '${i}'${(o=o.reduce(((s,o)=>\"number\"==typeof o?`${s}[${o}]`:s?`${s}.${o}`:o),\"\"))?` at path '${o}'`:\"\"}: ${s}.`)(s,o,i))}))}})),a},validateBeforeExecute=(s,o)=>0===validationErrors(s,o).length,getOAS3RequiredRequestBodyContentType=(s,o)=>{let i={requestBody:!1,requestContentType:{}},a=s.getIn([\"resolvedSubtrees\",\"paths\",...o,\"requestBody\"],(0,ze.fromJS)([]));return a.size<1||(a.getIn([\"required\"])&&(i.requestBody=a.getIn([\"required\"])),a.getIn([\"content\"]).entrySeq().forEach((s=>{const o=s[0];if(s[1].getIn([\"schema\",\"required\"])){const a=s[1].getIn([\"schema\",\"required\"]).toJS();i.requestContentType[o]=a}}))),i},isMediaTypeSchemaPropertiesEqual=(s,o,i,a)=>{if((i||a)&&i===a)return!0;let u=s.getIn([\"resolvedSubtrees\",\"paths\",...o,\"requestBody\",\"content\"],(0,ze.fromJS)([]));if(u.size<2||!i||!a)return!1;let _=u.getIn([i,\"schema\",\"properties\"],(0,ze.fromJS)([])),w=u.getIn([a,\"schema\",\"properties\"],(0,ze.fromJS)([]));return!!_.equals(w)};function returnSelfOrNewMap(s){return ze.Map.isMap(s)?s:new ze.Map}var no=__webpack_require__(85015),so=__webpack_require__.n(no),oo=__webpack_require__(38221),io=__webpack_require__.n(oo),ao=__webpack_require__(63560),co=__webpack_require__.n(ao),lo=__webpack_require__(56367),uo=__webpack_require__.n(lo);const po=\"spec_update_spec\",ho=\"spec_update_url\",fo=\"spec_update_json\",mo=\"spec_update_param\",go=\"spec_update_empty_param_inclusion\",yo=\"spec_validate_param\",vo=\"spec_set_response\",bo=\"spec_set_request\",_o=\"spec_set_mutated_request\",So=\"spec_log_request\",Eo=\"spec_clear_response\",wo=\"spec_clear_request\",xo=\"spec_clear_validate_param\",ko=\"spec_update_operation_meta_value\",Oo=\"spec_update_resolved\",Ao=\"spec_update_resolved_subtree\",Co=\"set_scheme\",toStr=s=>so()(s)?s:\"\";function updateSpec(s){const o=toStr(s).replace(/\\t/g,\"  \");if(\"string\"==typeof s)return{type:po,payload:o}}function updateResolved(s){return{type:Oo,payload:s}}function updateUrl(s){return{type:ho,payload:s}}function updateJsonSpec(s){return{type:fo,payload:s}}const parseToJson=s=>({specActions:o,specSelectors:i,errActions:a})=>{let{specStr:u}=i,_=null;try{s=s||u(),a.clear({source:\"parser\"}),_=fn.load(s,{schema:rn})}catch(s){return console.error(s),a.newSpecErr({source:\"parser\",level:\"error\",message:s.reason,line:s.mark&&s.mark.line?s.mark.line+1:void 0})}return _&&\"object\"==typeof _?o.updateJsonSpec(_):o.updateJsonSpec({})};let jo=!1;const resolveSpec=(s,o)=>({specActions:i,specSelectors:a,errActions:u,fn:{fetch:_,resolve:w,AST:x={}},getConfigs:C})=>{jo||(console.warn(\"specActions.resolveSpec is deprecated since v3.10.0 and will be removed in v4.0.0; use requestResolvedSubtree instead!\"),jo=!0);const{modelPropertyMacro:j,parameterMacro:L,requestInterceptor:B,responseInterceptor:$}=C();void 0===s&&(s=a.specJson()),void 0===o&&(o=a.url());let U=x.getLineNumberForPath?x.getLineNumberForPath:()=>{},V=a.specStr();return w({fetch:_,spec:s,baseDoc:String(new URL(o,document.baseURI)),modelPropertyMacro:j,parameterMacro:L,requestInterceptor:B,responseInterceptor:$}).then((({spec:s,errors:o})=>{if(u.clear({type:\"thrown\"}),Array.isArray(o)&&o.length>0){let s=o.map((s=>(console.error(s),s.line=s.fullPath?U(V,s.fullPath):null,s.path=s.fullPath?s.fullPath.join(\".\"):null,s.level=\"error\",s.type=\"thrown\",s.source=\"resolver\",Object.defineProperty(s,\"message\",{enumerable:!0,value:s.message}),s)));u.newThrownErrBatch(s)}return i.updateResolved(s)}))};let Po=[];const Io=io()((()=>{const s=Po.reduce(((s,{path:o,system:i})=>(s.has(i)||s.set(i,[]),s.get(i).push(o),s)),new Map);Po=[],s.forEach((async(s,o)=>{if(!o)return void console.error(\"debResolveSubtrees: don't have a system to operate on, aborting.\");if(!o.fn.resolveSubtree)return void console.error(\"Error: Swagger-Client did not provide a `resolveSubtree` method, doing nothing.\");const{errActions:i,errSelectors:a,fn:{resolveSubtree:u,fetch:_,AST:w={}},specSelectors:x,specActions:C}=o,j=w.getLineNumberForPath??xs()(void 0),L=x.specStr(),{modelPropertyMacro:B,parameterMacro:$,requestInterceptor:U,responseInterceptor:V}=o.getConfigs();try{const o=await s.reduce((async(s,o)=>{let{resultMap:w,specWithCurrentSubtrees:C}=await s;const{errors:z,spec:Y}=await u(C,o,{baseDoc:String(new URL(x.url(),document.baseURI)),modelPropertyMacro:B,parameterMacro:$,requestInterceptor:U,responseInterceptor:V});if(a.allErrors().size&&i.clearBy((s=>\"thrown\"!==s.get(\"type\")||\"resolver\"!==s.get(\"source\")||!s.get(\"fullPath\")?.every(((s,i)=>s===o[i]||void 0===o[i])))),Array.isArray(z)&&z.length>0){let s=z.map((s=>(s.line=s.fullPath?j(L,s.fullPath):null,s.path=s.fullPath?s.fullPath.join(\".\"):null,s.level=\"error\",s.type=\"thrown\",s.source=\"resolver\",Object.defineProperty(s,\"message\",{enumerable:!0,value:s.message}),s)));i.newThrownErrBatch(s)}return Y&&x.isOAS3()&&\"components\"===o[0]&&\"securitySchemes\"===o[1]&&await Promise.all(Object.values(Y).filter((s=>\"openIdConnect\"===s?.type)).map((async s=>{const o={url:s.openIdConnectUrl,requestInterceptor:U,responseInterceptor:V};try{const i=await _(o);i instanceof Error||i.status>=400?console.error(i.statusText+\" \"+o.url):s.openIdConnectData=JSON.parse(i.text)}catch(s){console.error(s)}}))),co()(w,o,Y),C=uo()(o,Y,C),{resultMap:w,specWithCurrentSubtrees:C}}),Promise.resolve({resultMap:(x.specResolvedSubtree([])||(0,ze.Map)()).toJS(),specWithCurrentSubtrees:x.specJS()}));C.updateResolvedSubtree([],o.resultMap)}catch(s){console.error(s)}}))}),35),requestResolvedSubtree=s=>o=>{Po.find((({path:i,system:a})=>a===o&&i.toString()===s.toString()))||(Po.push({path:s,system:o}),Io())};function changeParam(s,o,i,a,u){return{type:mo,payload:{path:s,value:a,paramName:o,paramIn:i,isXml:u}}}function changeParamByIdentity(s,o,i,a){return{type:mo,payload:{path:s,param:o,value:i,isXml:a}}}const updateResolvedSubtree=(s,o)=>({type:Ao,payload:{path:s,value:o}}),invalidateResolvedSubtreeCache=()=>({type:Ao,payload:{path:[],value:(0,ze.Map)()}}),validateParams=(s,o)=>({type:yo,payload:{pathMethod:s,isOAS3:o}}),updateEmptyParamInclusion=(s,o,i,a)=>({type:go,payload:{pathMethod:s,paramName:o,paramIn:i,includeEmptyValue:a}});function clearValidateParams(s){return{type:xo,payload:{pathMethod:s}}}function changeConsumesValue(s,o){return{type:ko,payload:{path:s,value:o,key:\"consumes_value\"}}}function changeProducesValue(s,o){return{type:ko,payload:{path:s,value:o,key:\"produces_value\"}}}const setResponse=(s,o,i)=>({payload:{path:s,method:o,res:i},type:vo}),setRequest=(s,o,i)=>({payload:{path:s,method:o,req:i},type:bo}),setMutatedRequest=(s,o,i)=>({payload:{path:s,method:o,req:i},type:_o}),logRequest=s=>({payload:s,type:So}),executeRequest=s=>({fn:o,specActions:i,specSelectors:a,getConfigs:u,oas3Selectors:_})=>{let{pathName:w,method:x,operation:C}=s,{requestInterceptor:j,responseInterceptor:L}=u(),B=C.toJS();if(C&&C.get(\"parameters\")&&C.get(\"parameters\").filter((s=>s&&!0===s.get(\"allowEmptyValue\"))).forEach((o=>{if(a.parameterInclusionSettingFor([w,x],o.get(\"name\"),o.get(\"in\"))){s.parameters=s.parameters||{};const i=paramToValue(o,s.parameters);(!i||i&&0===i.size)&&(s.parameters[o.get(\"name\")]=\"\")}})),s.contextUrl=Nt()(a.url()).toString(),B&&B.operationId?s.operationId=B.operationId:B&&w&&x&&(s.operationId=o.opId(B,w,x)),a.isOAS3()){const o=`${w}:${x}`;s.server=_.selectedServer(o)||_.selectedServer();const i=_.serverVariables({server:s.server,namespace:o}).toJS(),a=_.serverVariables({server:s.server}).toJS();s.serverVariables=Object.keys(i).length?i:a,s.requestContentType=_.requestContentType(w,x),s.responseContentType=_.responseContentType(w,x)||\"*/*\";const u=_.requestBodyValue(w,x),C=_.requestBodyInclusionSetting(w,x);u&&u.toJS?s.requestBody=u.map((s=>ze.Map.isMap(s)?s.get(\"value\"):s)).filter(((s,o)=>(Array.isArray(s)?0!==s.length:!isEmptyValue(s))||C.get(o))).toJS():s.requestBody=u}let $=Object.assign({},s);$=o.buildRequest($),i.setRequest(s.pathName,s.method,$);s.requestInterceptor=async o=>{let a=await j.apply(void 0,[o]),u=Object.assign({},a);return i.setMutatedRequest(s.pathName,s.method,u),a},s.responseInterceptor=L;const U=Date.now();return o.execute(s).then((o=>{o.duration=Date.now()-U,i.setResponse(s.pathName,s.method,o)})).catch((o=>{\"Failed to fetch\"===o.message&&(o.name=\"\",o.message='**Failed to fetch.**  \\n**Possible Reasons:** \\n  - CORS \\n  - Network Failure \\n  - URL scheme must be \"http\" or \"https\" for CORS request.'),i.setResponse(s.pathName,s.method,{error:!0,err:o})}))},actions_execute=({path:s,method:o,...i}={})=>a=>{let{fn:{fetch:u},specSelectors:_,specActions:w}=a,x=_.specJsonWithResolvedSubtrees().toJS(),C=_.operationScheme(s,o),{requestContentType:j,responseContentType:L}=_.contentTypeValues([s,o]).toJS(),B=/xml/i.test(j),$=_.parameterValues([s,o],B).toJS();return w.executeRequest({...i,fetch:u,spec:x,pathName:s,method:o,parameters:$,requestContentType:j,scheme:C,responseContentType:L})};function clearResponse(s,o){return{type:Eo,payload:{path:s,method:o}}}function clearRequest(s,o){return{type:wo,payload:{path:s,method:o}}}function setScheme(s,o,i){return{type:Co,payload:{scheme:s,path:o,method:i}}}const To={[po]:(s,o)=>\"string\"==typeof o.payload?s.set(\"spec\",o.payload):s,[ho]:(s,o)=>s.set(\"url\",o.payload+\"\"),[fo]:(s,o)=>s.set(\"json\",fromJSOrdered(o.payload)),[Oo]:(s,o)=>s.setIn([\"resolved\"],fromJSOrdered(o.payload)),[Ao]:(s,o)=>{const{value:i,path:a}=o.payload;return s.setIn([\"resolvedSubtrees\",...a],fromJSOrdered(i))},[mo]:(s,{payload:o})=>{let{path:i,paramName:a,paramIn:u,param:_,value:w,isXml:x}=o,C=_?paramToIdentifier(_):`${u}.${a}`;const j=x?\"value_xml\":\"value\";return s.setIn([\"meta\",\"paths\",...i,\"parameters\",C,j],(0,ze.fromJS)(w))},[go]:(s,{payload:o})=>{let{pathMethod:i,paramName:a,paramIn:u,includeEmptyValue:_}=o;if(!a||!u)return console.warn(\"Warning: UPDATE_EMPTY_PARAM_INCLUSION could not generate a paramKey.\"),s;const w=`${u}.${a}`;return s.setIn([\"meta\",\"paths\",...i,\"parameter_inclusions\",w],_)},[yo]:(s,{payload:{pathMethod:o,isOAS3:i}})=>{const a=Ns(s).getIn([\"paths\",...o]),u=parameterValues(s,o).toJS();return s.updateIn([\"meta\",\"paths\",...o,\"parameters\"],(0,ze.fromJS)({}),(_=>a.get(\"parameters\",(0,ze.List)()).reduce(((a,_)=>{const w=paramToValue(_,u),x=parameterInclusionSettingFor(s,o,_.get(\"name\"),_.get(\"in\")),C=((s,o,{isOAS3:i=!1,bypassRequiredCheck:a=!1}={})=>{let u=s.get(\"required\"),{schema:_,parameterContentMediaType:w}=getParameterSchema(s,{isOAS3:i});return validateValueBySchema(o,_,u,a,w)})(_,w,{bypassRequiredCheck:x,isOAS3:i});return a.setIn([paramToIdentifier(_),\"errors\"],(0,ze.fromJS)(C))}),_)))},[xo]:(s,{payload:{pathMethod:o}})=>s.updateIn([\"meta\",\"paths\",...o,\"parameters\"],(0,ze.fromJS)([]),(s=>s.map((s=>s.set(\"errors\",(0,ze.fromJS)([])))))),[vo]:(s,{payload:{res:o,path:i,method:a}})=>{let u;u=o.error?Object.assign({error:!0,name:o.err.name,message:o.err.message,statusCode:o.err.statusCode},o.err.response):o,u.headers=u.headers||{};let _=s.setIn([\"responses\",i,a],fromJSOrdered(u));return lt.Blob&&u.data instanceof lt.Blob&&(_=_.setIn([\"responses\",i,a,\"text\"],u.data)),_},[bo]:(s,{payload:{req:o,path:i,method:a}})=>s.setIn([\"requests\",i,a],fromJSOrdered(o)),[_o]:(s,{payload:{req:o,path:i,method:a}})=>s.setIn([\"mutatedRequests\",i,a],fromJSOrdered(o)),[ko]:(s,{payload:{path:o,value:i,key:a}})=>{let u=[\"paths\",...o],_=[\"meta\",\"paths\",...o];return s.getIn([\"json\",...u])||s.getIn([\"resolved\",...u])||s.getIn([\"resolvedSubtrees\",...u])?s.setIn([..._,a],(0,ze.fromJS)(i)):s},[Eo]:(s,{payload:{path:o,method:i}})=>s.deleteIn([\"responses\",o,i]),[wo]:(s,{payload:{path:o,method:i}})=>s.deleteIn([\"requests\",o,i]),[Co]:(s,{payload:{scheme:o,path:i,method:a}})=>i&&a?s.setIn([\"scheme\",i,a],o):i||a?void 0:s.setIn([\"scheme\",\"_defaultScheme\"],o)},wrap_actions_updateSpec=(s,{specActions:o})=>(...i)=>{s(...i),o.parseToJson(...i)},wrap_actions_updateJsonSpec=(s,{specActions:o})=>(...i)=>{s(...i),o.invalidateResolvedSubtreeCache();const[a]=i,u=Cn()(a,[\"paths\"])||{};Object.keys(u).forEach((s=>{const i=Cn()(u,[s]);as()(i)&&i.$ref&&o.requestResolvedSubtree([\"paths\",s])})),o.requestResolvedSubtree([\"components\",\"securitySchemes\"])},wrap_actions_executeRequest=(s,{specActions:o})=>i=>(o.logRequest(i),s(i)),wrap_actions_validateParams=(s,{specSelectors:o})=>i=>s(i,o.isOAS3()),plugins_spec=()=>({statePlugins:{spec:{wrapActions:{...Y},reducers:{...To},actions:{...z},selectors:{...V}}}});var No=function(){var extendStatics=function(s,o){return extendStatics=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(s,o){s.__proto__=o}||function(s,o){for(var i in o)o.hasOwnProperty(i)&&(s[i]=o[i])},extendStatics(s,o)};return function(s,o){function __(){this.constructor=s}extendStatics(s,o),s.prototype=null===o?Object.create(o):(__.prototype=o.prototype,new __)}}(),Mo=Object.prototype.hasOwnProperty;function module_helpers_hasOwnProperty(s,o){return Mo.call(s,o)}function _objectKeys(s){if(Array.isArray(s)){for(var o=new Array(s.length),i=0;i<o.length;i++)o[i]=\"\"+i;return o}if(Object.keys)return Object.keys(s);var a=[];for(var u in s)module_helpers_hasOwnProperty(s,u)&&a.push(u);return a}function _deepClone(s){switch(typeof s){case\"object\":return JSON.parse(JSON.stringify(s));case\"undefined\":return null;default:return s}}function helpers_isInteger(s){for(var o,i=0,a=s.length;i<a;){if(!((o=s.charCodeAt(i))>=48&&o<=57))return!1;i++}return!0}function escapePathComponent(s){return-1===s.indexOf(\"/\")&&-1===s.indexOf(\"~\")?s:s.replace(/~/g,\"~0\").replace(/\\//g,\"~1\")}function unescapePathComponent(s){return s.replace(/~1/g,\"/\").replace(/~0/g,\"~\")}function hasUndefined(s){if(void 0===s)return!0;if(s)if(Array.isArray(s)){for(var o=0,i=s.length;o<i;o++)if(hasUndefined(s[o]))return!0}else if(\"object\"==typeof s)for(var a=_objectKeys(s),u=a.length,_=0;_<u;_++)if(hasUndefined(s[a[_]]))return!0;return!1}function patchErrorMessageFormatter(s,o){var i=[s];for(var a in o){var u=\"object\"==typeof o[a]?JSON.stringify(o[a],null,2):o[a];void 0!==u&&i.push(a+\": \"+u)}return i.join(\"\\n\")}var Ro=function(s){function PatchError(o,i,a,u,_){var w=this.constructor,x=s.call(this,patchErrorMessageFormatter(o,{name:i,index:a,operation:u,tree:_}))||this;return x.name=i,x.index=a,x.operation=u,x.tree=_,Object.setPrototypeOf(x,w.prototype),x.message=patchErrorMessageFormatter(o,{name:i,index:a,operation:u,tree:_}),x}return No(PatchError,s),PatchError}(Error),Do=Ro,Lo=_deepClone,Fo={add:function(s,o,i){return s[o]=this.value,{newDocument:i}},remove:function(s,o,i){var a=s[o];return delete s[o],{newDocument:i,removed:a}},replace:function(s,o,i){var a=s[o];return s[o]=this.value,{newDocument:i,removed:a}},move:function(s,o,i){var a=getValueByPointer(i,this.path);a&&(a=_deepClone(a));var u=applyOperation(i,{op:\"remove\",path:this.from}).removed;return applyOperation(i,{op:\"add\",path:this.path,value:u}),{newDocument:i,removed:a}},copy:function(s,o,i){var a=getValueByPointer(i,this.from);return applyOperation(i,{op:\"add\",path:this.path,value:_deepClone(a)}),{newDocument:i}},test:function(s,o,i){return{newDocument:i,test:_areEquals(s[o],this.value)}},_get:function(s,o,i){return this.value=s[o],{newDocument:i}}},Bo={add:function(s,o,i){return helpers_isInteger(o)?s.splice(o,0,this.value):s[o]=this.value,{newDocument:i,index:o}},remove:function(s,o,i){return{newDocument:i,removed:s.splice(o,1)[0]}},replace:function(s,o,i){var a=s[o];return s[o]=this.value,{newDocument:i,removed:a}},move:Fo.move,copy:Fo.copy,test:Fo.test,_get:Fo._get};function getValueByPointer(s,o){if(\"\"==o)return s;var i={op:\"_get\",path:o};return applyOperation(s,i),i.value}function applyOperation(s,o,i,a,u,_){if(void 0===i&&(i=!1),void 0===a&&(a=!0),void 0===u&&(u=!0),void 0===_&&(_=0),i&&(\"function\"==typeof i?i(o,0,s,o.path):validator(o,0)),\"\"===o.path){var w={newDocument:s};if(\"add\"===o.op)return w.newDocument=o.value,w;if(\"replace\"===o.op)return w.newDocument=o.value,w.removed=s,w;if(\"move\"===o.op||\"copy\"===o.op)return w.newDocument=getValueByPointer(s,o.from),\"move\"===o.op&&(w.removed=s),w;if(\"test\"===o.op){if(w.test=_areEquals(s,o.value),!1===w.test)throw new Do(\"Test operation failed\",\"TEST_OPERATION_FAILED\",_,o,s);return w.newDocument=s,w}if(\"remove\"===o.op)return w.removed=s,w.newDocument=null,w;if(\"_get\"===o.op)return o.value=s,w;if(i)throw new Do(\"Operation `op` property is not one of operations defined in RFC-6902\",\"OPERATION_OP_INVALID\",_,o,s);return w}a||(s=_deepClone(s));var x=(o.path||\"\").split(\"/\"),C=s,j=1,L=x.length,B=void 0,$=void 0,U=void 0;for(U=\"function\"==typeof i?i:validator;;){if(($=x[j])&&-1!=$.indexOf(\"~\")&&($=unescapePathComponent($)),u&&(\"__proto__\"==$||\"prototype\"==$&&j>0&&\"constructor\"==x[j-1]))throw new TypeError(\"JSON-Patch: modifying `__proto__` or `constructor/prototype` prop is banned for security reasons, if this was on purpose, please set `banPrototypeModifications` flag false and pass it to this function. More info in fast-json-patch README\");if(i&&void 0===B&&(void 0===C[$]?B=x.slice(0,j).join(\"/\"):j==L-1&&(B=o.path),void 0!==B&&U(o,0,s,B)),j++,Array.isArray(C)){if(\"-\"===$)$=C.length;else{if(i&&!helpers_isInteger($))throw new Do(\"Expected an unsigned base-10 integer value, making the new referenced value the array element with the zero-based index\",\"OPERATION_PATH_ILLEGAL_ARRAY_INDEX\",_,o,s);helpers_isInteger($)&&($=~~$)}if(j>=L){if(i&&\"add\"===o.op&&$>C.length)throw new Do(\"The specified index MUST NOT be greater than the number of elements in the array\",\"OPERATION_VALUE_OUT_OF_BOUNDS\",_,o,s);if(!1===(w=Bo[o.op].call(o,C,$,s)).test)throw new Do(\"Test operation failed\",\"TEST_OPERATION_FAILED\",_,o,s);return w}}else if(j>=L){if(!1===(w=Fo[o.op].call(o,C,$,s)).test)throw new Do(\"Test operation failed\",\"TEST_OPERATION_FAILED\",_,o,s);return w}if(C=C[$],i&&j<L&&(!C||\"object\"!=typeof C))throw new Do(\"Cannot perform operation at the desired path\",\"OPERATION_PATH_UNRESOLVABLE\",_,o,s)}}function applyPatch(s,o,i,a,u){if(void 0===a&&(a=!0),void 0===u&&(u=!0),i&&!Array.isArray(o))throw new Do(\"Patch sequence must be an array\",\"SEQUENCE_NOT_AN_ARRAY\");a||(s=_deepClone(s));for(var _=new Array(o.length),w=0,x=o.length;w<x;w++)_[w]=applyOperation(s,o[w],i,!0,u,w),s=_[w].newDocument;return _.newDocument=s,_}function applyReducer(s,o,i){var a=applyOperation(s,o);if(!1===a.test)throw new Do(\"Test operation failed\",\"TEST_OPERATION_FAILED\",i,o,s);return a.newDocument}function validator(s,o,i,a){if(\"object\"!=typeof s||null===s||Array.isArray(s))throw new Do(\"Operation is not an object\",\"OPERATION_NOT_AN_OBJECT\",o,s,i);if(!Fo[s.op])throw new Do(\"Operation `op` property is not one of operations defined in RFC-6902\",\"OPERATION_OP_INVALID\",o,s,i);if(\"string\"!=typeof s.path)throw new Do(\"Operation `path` property is not a string\",\"OPERATION_PATH_INVALID\",o,s,i);if(0!==s.path.indexOf(\"/\")&&s.path.length>0)throw new Do('Operation `path` property must start with \"/\"',\"OPERATION_PATH_INVALID\",o,s,i);if((\"move\"===s.op||\"copy\"===s.op)&&\"string\"!=typeof s.from)throw new Do(\"Operation `from` property is not present (applicable in `move` and `copy` operations)\",\"OPERATION_FROM_REQUIRED\",o,s,i);if((\"add\"===s.op||\"replace\"===s.op||\"test\"===s.op)&&void 0===s.value)throw new Do(\"Operation `value` property is not present (applicable in `add`, `replace` and `test` operations)\",\"OPERATION_VALUE_REQUIRED\",o,s,i);if((\"add\"===s.op||\"replace\"===s.op||\"test\"===s.op)&&hasUndefined(s.value))throw new Do(\"Operation `value` property is not present (applicable in `add`, `replace` and `test` operations)\",\"OPERATION_VALUE_CANNOT_CONTAIN_UNDEFINED\",o,s,i);if(i)if(\"add\"==s.op){var u=s.path.split(\"/\").length,_=a.split(\"/\").length;if(u!==_+1&&u!==_)throw new Do(\"Cannot perform an `add` operation at the desired path\",\"OPERATION_PATH_CANNOT_ADD\",o,s,i)}else if(\"replace\"===s.op||\"remove\"===s.op||\"_get\"===s.op){if(s.path!==a)throw new Do(\"Cannot perform the operation at a path that does not exist\",\"OPERATION_PATH_UNRESOLVABLE\",o,s,i)}else if(\"move\"===s.op||\"copy\"===s.op){var w=validate([{op:\"_get\",path:s.from,value:void 0}],i);if(w&&\"OPERATION_PATH_UNRESOLVABLE\"===w.name)throw new Do(\"Cannot perform the operation from a path that does not exist\",\"OPERATION_FROM_UNRESOLVABLE\",o,s,i)}}function validate(s,o,i){try{if(!Array.isArray(s))throw new Do(\"Patch sequence must be an array\",\"SEQUENCE_NOT_AN_ARRAY\");if(o)applyPatch(_deepClone(o),_deepClone(s),i||!0);else{i=i||validator;for(var a=0;a<s.length;a++)i(s[a],a,o,void 0)}}catch(s){if(s instanceof Do)return s;throw s}}function _areEquals(s,o){if(s===o)return!0;if(s&&o&&\"object\"==typeof s&&\"object\"==typeof o){var i,a,u,_=Array.isArray(s),w=Array.isArray(o);if(_&&w){if((a=s.length)!=o.length)return!1;for(i=a;0!=i--;)if(!_areEquals(s[i],o[i]))return!1;return!0}if(_!=w)return!1;var x=Object.keys(s);if((a=x.length)!==Object.keys(o).length)return!1;for(i=a;0!=i--;)if(!o.hasOwnProperty(x[i]))return!1;for(i=a;0!=i--;)if(!_areEquals(s[u=x[i]],o[u]))return!1;return!0}return s!=s&&o!=o}var $o=new WeakMap,qo=function qo(s){this.observers=new Map,this.obj=s},Uo=function Uo(s,o){this.callback=s,this.observer=o};function unobserve(s,o){o.unobserve()}function observe(s,o){var i,a=function getMirror(s){return $o.get(s)}(s);if(a){var u=function getObserverFromMirror(s,o){return s.observers.get(o)}(a,o);i=u&&u.observer}else a=new qo(s),$o.set(s,a);if(i)return i;if(i={},a.value=_deepClone(s),o){i.callback=o,i.next=null;var dirtyCheck=function(){generate(i)},fastCheck=function(){clearTimeout(i.next),i.next=setTimeout(dirtyCheck)};\"undefined\"!=typeof window&&(window.addEventListener(\"mouseup\",fastCheck),window.addEventListener(\"keyup\",fastCheck),window.addEventListener(\"mousedown\",fastCheck),window.addEventListener(\"keydown\",fastCheck),window.addEventListener(\"change\",fastCheck))}return i.patches=[],i.object=s,i.unobserve=function(){generate(i),clearTimeout(i.next),function removeObserverFromMirror(s,o){s.observers.delete(o.callback)}(a,i),\"undefined\"!=typeof window&&(window.removeEventListener(\"mouseup\",fastCheck),window.removeEventListener(\"keyup\",fastCheck),window.removeEventListener(\"mousedown\",fastCheck),window.removeEventListener(\"keydown\",fastCheck),window.removeEventListener(\"change\",fastCheck))},a.observers.set(o,new Uo(o,i)),i}function generate(s,o){void 0===o&&(o=!1);var i=$o.get(s.object);_generate(i.value,s.object,s.patches,\"\",o),s.patches.length&&applyPatch(i.value,s.patches);var a=s.patches;return a.length>0&&(s.patches=[],s.callback&&s.callback(a)),a}function _generate(s,o,i,a,u){if(o!==s){\"function\"==typeof o.toJSON&&(o=o.toJSON());for(var _=_objectKeys(o),w=_objectKeys(s),x=!1,C=w.length-1;C>=0;C--){var j=s[B=w[C]];if(!module_helpers_hasOwnProperty(o,B)||void 0===o[B]&&void 0!==j&&!1===Array.isArray(o))Array.isArray(s)===Array.isArray(o)?(u&&i.push({op:\"test\",path:a+\"/\"+escapePathComponent(B),value:_deepClone(j)}),i.push({op:\"remove\",path:a+\"/\"+escapePathComponent(B)}),x=!0):(u&&i.push({op:\"test\",path:a,value:s}),i.push({op:\"replace\",path:a,value:o}),!0);else{var L=o[B];\"object\"==typeof j&&null!=j&&\"object\"==typeof L&&null!=L&&Array.isArray(j)===Array.isArray(L)?_generate(j,L,i,a+\"/\"+escapePathComponent(B),u):j!==L&&(u&&i.push({op:\"test\",path:a+\"/\"+escapePathComponent(B),value:_deepClone(j)}),i.push({op:\"replace\",path:a+\"/\"+escapePathComponent(B),value:_deepClone(L)}))}}if(x||_.length!=w.length)for(C=0;C<_.length;C++){var B;module_helpers_hasOwnProperty(s,B=_[C])||void 0===o[B]||i.push({op:\"add\",path:a+\"/\"+escapePathComponent(B),value:_deepClone(o[B])})}}}function compare(s,o,i){void 0===i&&(i=!1);var a=[];return _generate(s,o,a,\"\",i),a}Object.assign({},Z,ee,{JsonPatchError:Ro,deepClone:_deepClone,escapePathComponent,unescapePathComponent});var Vo=__webpack_require__(14744),zo=__webpack_require__.n(Vo);const Wo={add:function add(s,o){return{op:\"add\",path:s,value:o}},replace,remove:function remove(s){return{op:\"remove\",path:s}},merge:function lib_merge(s,o){return{type:\"mutation\",op:\"merge\",path:s,value:o}},mergeDeep:function mergeDeep(s,o){return{type:\"mutation\",op:\"mergeDeep\",path:s,value:o}},context:function context(s,o){return{type:\"context\",path:s,value:o}},getIn:function lib_getIn(s,o){return o.reduce(((s,o)=>void 0!==o&&s?s[o]:s),s)},applyPatch:function lib_applyPatch(s,o,i){if(i=i||{},\"merge\"===(o={...o,path:o.path&&normalizeJSONPath(o.path)}).op){const i=getInByJsonPath(s,o.path);Object.assign(i,o.value),applyPatch(s,[replace(o.path,i)])}else if(\"mergeDeep\"===o.op){const i=getInByJsonPath(s,o.path),a=zo()(i,o.value,{customMerge:s=>{if(\"enum\"===s)return(s,o)=>Array.isArray(s)&&Array.isArray(o)?[...new Set([...s,...o])]:zo()(s,o)}});s=applyPatch(s,[replace(o.path,a)]).newDocument}else if(\"add\"===o.op&&\"\"===o.path&&lib_isObject(o.value)){applyPatch(s,Object.keys(o.value).reduce(((s,i)=>(s.push({op:\"add\",path:`/${normalizeJSONPath(i)}`,value:o.value[i]}),s)),[]))}else if(\"replace\"===o.op&&\"\"===o.path){let{value:a}=o;i.allowMetaPatches&&o.meta&&isAdditiveMutation(o)&&(Array.isArray(o.value)||lib_isObject(o.value))&&(a={...a,...o.meta}),s=a}else if(applyPatch(s,[o]),i.allowMetaPatches&&o.meta&&isAdditiveMutation(o)&&(Array.isArray(o.value)||lib_isObject(o.value))){const i={...getInByJsonPath(s,o.path),...o.meta};applyPatch(s,[replace(o.path,i)])}return s},parentPathMatch:function parentPathMatch(s,o){if(!Array.isArray(o))return!1;for(let i=0,a=o.length;i<a;i+=1)if(o[i]!==s[i])return!1;return!0},flatten,fullyNormalizeArray:function fullyNormalizeArray(s){return cleanArray(flatten(lib_normalizeArray(s)))},normalizeArray:lib_normalizeArray,isPromise:function isPromise(s){return lib_isObject(s)&&lib_isFunction(s.then)},forEachNew:function forEachNew(s,o){try{return forEachNewPatch(s,forEach,o)}catch(s){return s}},forEachNewPrimitive:function forEachNewPrimitive(s,o){try{return forEachNewPatch(s,forEachPrimitive,o)}catch(s){return s}},isJsonPatch,isContextPatch:function isContextPatch(s){return isPatch(s)&&\"context\"===s.type},isPatch,isMutation,isAdditiveMutation,isGenerator:function isGenerator(s){return\"[object GeneratorFunction]\"===Object.prototype.toString.call(s)},isFunction:lib_isFunction,isObject:lib_isObject,isError:function lib_isError(s){return s instanceof Error}};function normalizeJSONPath(s){return Array.isArray(s)?s.length<1?\"\":`/${s.map((s=>(s+\"\").replace(/~/g,\"~0\").replace(/\\//g,\"~1\"))).join(\"/\")}`:s}function replace(s,o,i){return{op:\"replace\",path:s,value:o,meta:i}}function forEachNewPatch(s,o,i){return cleanArray(flatten(s.filter(isAdditiveMutation).map((s=>o(s.value,i,s.path)))||[]))}function forEachPrimitive(s,o,i){return i=i||[],Array.isArray(s)?s.map(((s,a)=>forEachPrimitive(s,o,i.concat(a)))):lib_isObject(s)?Object.keys(s).map((a=>forEachPrimitive(s[a],o,i.concat(a)))):o(s,i[i.length-1],i)}function forEach(s,o,i){let a=[];if((i=i||[]).length>0){const u=o(s,i[i.length-1],i);u&&(a=a.concat(u))}if(Array.isArray(s)){const u=s.map(((s,a)=>forEach(s,o,i.concat(a))));u&&(a=a.concat(u))}else if(lib_isObject(s)){const u=Object.keys(s).map((a=>forEach(s[a],o,i.concat(a))));u&&(a=a.concat(u))}return a=flatten(a),a}function lib_normalizeArray(s){return Array.isArray(s)?s:[s]}function flatten(s){return[].concat(...s.map((s=>Array.isArray(s)?flatten(s):s)))}function cleanArray(s){return s.filter((s=>void 0!==s))}function lib_isObject(s){return s&&\"object\"==typeof s}function lib_isFunction(s){return s&&\"function\"==typeof s}function isJsonPatch(s){if(isPatch(s)){const{op:o}=s;return\"add\"===o||\"remove\"===o||\"replace\"===o}return!1}function isMutation(s){return isJsonPatch(s)||isPatch(s)&&\"mutation\"===s.type}function isAdditiveMutation(s){return isMutation(s)&&(\"add\"===s.op||\"replace\"===s.op||\"merge\"===s.op||\"mergeDeep\"===s.op)}function isPatch(s){return s&&\"object\"==typeof s}function getInByJsonPath(s,o){try{return getValueByPointer(s,o)}catch(s){return console.error(s),{}}}var Jo=__webpack_require__(48675);const Ho=class ApiDOMAggregateError extends Jo{constructor(s,o,i){if(super(s,o,i),this.name=this.constructor.name,\"string\"==typeof o&&(this.message=o),\"function\"==typeof Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error(o).stack,null!=i&&\"object\"==typeof i&&Object.hasOwn(i,\"cause\")&&!(\"cause\"in this)){const{cause:s}=i;this.cause=s,s instanceof Error&&\"stack\"in s&&(this.stack=`${this.stack}\\nCAUSE: ${s.stack}`)}}};class ApiDOMError extends Error{static[Symbol.hasInstance](s){return super[Symbol.hasInstance](s)||Function.prototype[Symbol.hasInstance].call(Ho,s)}constructor(s,o){if(super(s,o),this.name=this.constructor.name,\"string\"==typeof s&&(this.message=s),\"function\"==typeof Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error(s).stack,null!=o&&\"object\"==typeof o&&Object.hasOwn(o,\"cause\")&&!(\"cause\"in this)){const{cause:s}=o;this.cause=s,s instanceof Error&&\"stack\"in s&&(this.stack=`${this.stack}\\nCAUSE: ${s.stack}`)}}}const Ko=ApiDOMError;const Go=class ApiDOMStructuredError extends Ko{constructor(s,o){if(super(s,o),null!=o&&\"object\"==typeof o){const{cause:s,...i}=o;Object.assign(this,i)}}};var Yo=__webpack_require__(65606);function _isPlaceholder(s){return null!=s&&\"object\"==typeof s&&!0===s[\"@@functional/placeholder\"]}function _curry1(s){return function f1(o){return 0===arguments.length||_isPlaceholder(o)?f1:s.apply(this,arguments)}}function _curry2(s){return function f2(o,i){switch(arguments.length){case 0:return f2;case 1:return _isPlaceholder(o)?f2:_curry1((function(i){return s(o,i)}));default:return _isPlaceholder(o)&&_isPlaceholder(i)?f2:_isPlaceholder(o)?_curry1((function(o){return s(o,i)})):_isPlaceholder(i)?_curry1((function(i){return s(o,i)})):s(o,i)}}}function _curry3(s){return function f3(o,i,a){switch(arguments.length){case 0:return f3;case 1:return _isPlaceholder(o)?f3:_curry2((function(i,a){return s(o,i,a)}));case 2:return _isPlaceholder(o)&&_isPlaceholder(i)?f3:_isPlaceholder(o)?_curry2((function(o,a){return s(o,i,a)})):_isPlaceholder(i)?_curry2((function(i,a){return s(o,i,a)})):_curry1((function(a){return s(o,i,a)}));default:return _isPlaceholder(o)&&_isPlaceholder(i)&&_isPlaceholder(a)?f3:_isPlaceholder(o)&&_isPlaceholder(i)?_curry2((function(o,i){return s(o,i,a)})):_isPlaceholder(o)&&_isPlaceholder(a)?_curry2((function(o,a){return s(o,i,a)})):_isPlaceholder(i)&&_isPlaceholder(a)?_curry2((function(i,a){return s(o,i,a)})):_isPlaceholder(o)?_curry1((function(o){return s(o,i,a)})):_isPlaceholder(i)?_curry1((function(i){return s(o,i,a)})):_isPlaceholder(a)?_curry1((function(a){return s(o,i,a)})):s(o,i,a)}}}const Xo=Number.isInteger||function _isInteger(s){return(s|0)===s};function _isString(s){return\"[object String]\"===Object.prototype.toString.call(s)}function _nth(s,o){var i=s<0?o.length+s:s;return _isString(o)?o.charAt(i):o[i]}function _path(s,o){for(var i=o,a=0;a<s.length;a+=1){if(null==i)return;var u=s[a];i=Xo(u)?_nth(u,i):i[u]}return i}const Qo=_curry3((function pathSatisfies(s,o,i){return s(_path(o,i))}));function _cloneRegExp(s){return new RegExp(s.source,s.flags?s.flags:(s.global?\"g\":\"\")+(s.ignoreCase?\"i\":\"\")+(s.multiline?\"m\":\"\")+(s.sticky?\"y\":\"\")+(s.unicode?\"u\":\"\")+(s.dotAll?\"s\":\"\"))}function _arrayFromIterator(s){for(var o,i=[];!(o=s.next()).done;)i.push(o.value);return i}function _includesWith(s,o,i){for(var a=0,u=i.length;a<u;){if(s(o,i[a]))return!0;a+=1}return!1}function _has(s,o){return Object.prototype.hasOwnProperty.call(o,s)}const Zo=\"function\"==typeof Object.is?Object.is:function _objectIs(s,o){return s===o?0!==s||1/s==1/o:s!=s&&o!=o};var _i=Object.prototype.toString;const Ei=function(){return\"[object Arguments]\"===_i.call(arguments)?function _isArguments(s){return\"[object Arguments]\"===_i.call(s)}:function _isArguments(s){return _has(\"callee\",s)}}();var Oi=!{toString:null}.propertyIsEnumerable(\"toString\"),Pi=[\"constructor\",\"valueOf\",\"isPrototypeOf\",\"toString\",\"propertyIsEnumerable\",\"hasOwnProperty\",\"toLocaleString\"],Mi=function(){return arguments.propertyIsEnumerable(\"length\")}(),Ri=function contains(s,o){for(var i=0;i<s.length;){if(s[i]===o)return!0;i+=1}return!1},Wi=\"function\"!=typeof Object.keys||Mi?_curry1((function keys(s){if(Object(s)!==s)return[];var o,i,a=[],u=Mi&&Ei(s);for(o in s)!_has(o,s)||u&&\"length\"===o||(a[a.length]=o);if(Oi)for(i=Pi.length-1;i>=0;)_has(o=Pi[i],s)&&!Ri(a,o)&&(a[a.length]=o),i-=1;return a})):_curry1((function keys(s){return Object(s)!==s?[]:Object.keys(s)}));const ea=Wi;const ra=_curry1((function type(s){return null===s?\"Null\":void 0===s?\"Undefined\":Object.prototype.toString.call(s).slice(8,-1)}));function _uniqContentEquals(s,o,i,a){var u=_arrayFromIterator(s);function eq(s,o){return _equals(s,o,i.slice(),a.slice())}return!_includesWith((function(s,o){return!_includesWith(eq,o,s)}),_arrayFromIterator(o),u)}function _equals(s,o,i,a){if(Zo(s,o))return!0;var u=ra(s);if(u!==ra(o))return!1;if(\"function\"==typeof s[\"fantasy-land/equals\"]||\"function\"==typeof o[\"fantasy-land/equals\"])return\"function\"==typeof s[\"fantasy-land/equals\"]&&s[\"fantasy-land/equals\"](o)&&\"function\"==typeof o[\"fantasy-land/equals\"]&&o[\"fantasy-land/equals\"](s);if(\"function\"==typeof s.equals||\"function\"==typeof o.equals)return\"function\"==typeof s.equals&&s.equals(o)&&\"function\"==typeof o.equals&&o.equals(s);switch(u){case\"Arguments\":case\"Array\":case\"Object\":if(\"function\"==typeof s.constructor&&\"Promise\"===function _functionName(s){var o=String(s).match(/^function (\\w*)/);return null==o?\"\":o[1]}(s.constructor))return s===o;break;case\"Boolean\":case\"Number\":case\"String\":if(typeof s!=typeof o||!Zo(s.valueOf(),o.valueOf()))return!1;break;case\"Date\":if(!Zo(s.valueOf(),o.valueOf()))return!1;break;case\"Error\":return s.name===o.name&&s.message===o.message;case\"RegExp\":if(s.source!==o.source||s.global!==o.global||s.ignoreCase!==o.ignoreCase||s.multiline!==o.multiline||s.sticky!==o.sticky||s.unicode!==o.unicode)return!1}for(var _=i.length-1;_>=0;){if(i[_]===s)return a[_]===o;_-=1}switch(u){case\"Map\":return s.size===o.size&&_uniqContentEquals(s.entries(),o.entries(),i.concat([s]),a.concat([o]));case\"Set\":return s.size===o.size&&_uniqContentEquals(s.values(),o.values(),i.concat([s]),a.concat([o]));case\"Arguments\":case\"Array\":case\"Object\":case\"Boolean\":case\"Number\":case\"String\":case\"Date\":case\"Error\":case\"RegExp\":case\"Int8Array\":case\"Uint8Array\":case\"Uint8ClampedArray\":case\"Int16Array\":case\"Uint16Array\":case\"Int32Array\":case\"Uint32Array\":case\"Float32Array\":case\"Float64Array\":case\"ArrayBuffer\":break;default:return!1}var w=ea(s);if(w.length!==ea(o).length)return!1;var x=i.concat([s]),C=a.concat([o]);for(_=w.length-1;_>=0;){var j=w[_];if(!_has(j,o)||!_equals(o[j],s[j],x,C))return!1;_-=1}return!0}const na=_curry2((function equals(s,o){return _equals(s,o,[],[])}));function _includes(s,o){return function _indexOf(s,o,i){var a,u;if(\"function\"==typeof s.indexOf)switch(typeof o){case\"number\":if(0===o){for(a=1/o;i<s.length;){if(0===(u=s[i])&&1/u===a)return i;i+=1}return-1}if(o!=o){for(;i<s.length;){if(\"number\"==typeof(u=s[i])&&u!=u)return i;i+=1}return-1}return s.indexOf(o,i);case\"string\":case\"boolean\":case\"function\":case\"undefined\":return s.indexOf(o,i);case\"object\":if(null===o)return s.indexOf(o,i)}for(;i<s.length;){if(na(s[i],o))return i;i+=1}return-1}(o,s,0)>=0}function _map(s,o){for(var i=0,a=o.length,u=Array(a);i<a;)u[i]=s(o[i]),i+=1;return u}function _quote(s){return'\"'+s.replace(/\\\\/g,\"\\\\\\\\\").replace(/[\\b]/g,\"\\\\b\").replace(/\\f/g,\"\\\\f\").replace(/\\n/g,\"\\\\n\").replace(/\\r/g,\"\\\\r\").replace(/\\t/g,\"\\\\t\").replace(/\\v/g,\"\\\\v\").replace(/\\0/g,\"\\\\0\").replace(/\"/g,'\\\\\"')+'\"'}var ia=function pad(s){return(s<10?\"0\":\"\")+s};const aa=\"function\"==typeof Date.prototype.toISOString?function _toISOString(s){return s.toISOString()}:function _toISOString(s){return s.getUTCFullYear()+\"-\"+ia(s.getUTCMonth()+1)+\"-\"+ia(s.getUTCDate())+\"T\"+ia(s.getUTCHours())+\":\"+ia(s.getUTCMinutes())+\":\"+ia(s.getUTCSeconds())+\".\"+(s.getUTCMilliseconds()/1e3).toFixed(3).slice(2,5)+\"Z\"};function _complement(s){return function(){return!s.apply(this,arguments)}}function _arrayReduce(s,o,i){for(var a=0,u=i.length;a<u;)o=s(o,i[a]),a+=1;return o}const ca=Array.isArray||function _isArray(s){return null!=s&&s.length>=0&&\"[object Array]\"===Object.prototype.toString.call(s)};function _dispatchable(s,o,i){return function(){if(0===arguments.length)return i();var a=arguments[arguments.length-1];if(!ca(a)){for(var u=0;u<s.length;){if(\"function\"==typeof a[s[u]])return a[s[u]].apply(a,Array.prototype.slice.call(arguments,0,-1));u+=1}if(function _isTransformer(s){return null!=s&&\"function\"==typeof s[\"@@transducer/step\"]}(a))return o.apply(null,Array.prototype.slice.call(arguments,0,-1))(a)}return i.apply(this,arguments)}}function _isObject(s){return\"[object Object]\"===Object.prototype.toString.call(s)}const _xfBase_init=function(){return this.xf[\"@@transducer/init\"]()},_xfBase_result=function(s){return this.xf[\"@@transducer/result\"](s)};var la=function(){function XFilter(s,o){this.xf=o,this.f=s}return XFilter.prototype[\"@@transducer/init\"]=_xfBase_init,XFilter.prototype[\"@@transducer/result\"]=_xfBase_result,XFilter.prototype[\"@@transducer/step\"]=function(s,o){return this.f(o)?this.xf[\"@@transducer/step\"](s,o):s},XFilter}();function _xfilter(s){return function(o){return new la(s,o)}}var ua=_curry2(_dispatchable([\"fantasy-land/filter\",\"filter\"],_xfilter,(function(s,o){return _isObject(o)?_arrayReduce((function(i,a){return s(o[a])&&(i[a]=o[a]),i}),{},ea(o)):function _filter(s,o){for(var i=0,a=o.length,u=[];i<a;)s(o[i])&&(u[u.length]=o[i]),i+=1;return u}(s,o)})));const da=ua;const ma=_curry2((function reject(s,o){return da(_complement(s),o)}));function _toString_toString(s,o){var i=function recur(i){var a=o.concat([s]);return _includes(i,a)?\"<Circular>\":_toString_toString(i,a)},mapPairs=function(s,o){return _map((function(o){return _quote(o)+\": \"+i(s[o])}),o.slice().sort())};switch(Object.prototype.toString.call(s)){case\"[object Arguments]\":return\"(function() { return arguments; }(\"+_map(i,s).join(\", \")+\"))\";case\"[object Array]\":return\"[\"+_map(i,s).concat(mapPairs(s,ma((function(s){return/^\\d+$/.test(s)}),ea(s)))).join(\", \")+\"]\";case\"[object Boolean]\":return\"object\"==typeof s?\"new Boolean(\"+i(s.valueOf())+\")\":s.toString();case\"[object Date]\":return\"new Date(\"+(isNaN(s.valueOf())?i(NaN):_quote(aa(s)))+\")\";case\"[object Map]\":return\"new Map(\"+i(Array.from(s))+\")\";case\"[object Null]\":return\"null\";case\"[object Number]\":return\"object\"==typeof s?\"new Number(\"+i(s.valueOf())+\")\":1/s==-1/0?\"-0\":s.toString(10);case\"[object Set]\":return\"new Set(\"+i(Array.from(s).sort())+\")\";case\"[object String]\":return\"object\"==typeof s?\"new String(\"+i(s.valueOf())+\")\":_quote(s);case\"[object Undefined]\":return\"undefined\";default:if(\"function\"==typeof s.toString){var a=s.toString();if(\"[object Object]\"!==a)return a}return\"{\"+mapPairs(s,ea(s)).join(\", \")+\"}\"}}const ga=_curry1((function toString(s){return _toString_toString(s,[])}));var ya=_curry2((function test(s,o){if(!function _isRegExp(s){return\"[object RegExp]\"===Object.prototype.toString.call(s)}(s))throw new TypeError(\"‘test’ requires a value of type RegExp as its first argument; received \"+ga(s));return _cloneRegExp(s).test(o)}));const va=ya;function _arity(s,o){switch(s){case 0:return function(){return o.apply(this,arguments)};case 1:return function(s){return o.apply(this,arguments)};case 2:return function(s,i){return o.apply(this,arguments)};case 3:return function(s,i,a){return o.apply(this,arguments)};case 4:return function(s,i,a,u){return o.apply(this,arguments)};case 5:return function(s,i,a,u,_){return o.apply(this,arguments)};case 6:return function(s,i,a,u,_,w){return o.apply(this,arguments)};case 7:return function(s,i,a,u,_,w,x){return o.apply(this,arguments)};case 8:return function(s,i,a,u,_,w,x,C){return o.apply(this,arguments)};case 9:return function(s,i,a,u,_,w,x,C,j){return o.apply(this,arguments)};case 10:return function(s,i,a,u,_,w,x,C,j,L){return o.apply(this,arguments)};default:throw new Error(\"First argument to _arity must be a non-negative integer no greater than ten\")}}function _pipe(s,o){return function(){return o.call(this,s.apply(this,arguments))}}const ba=_curry1((function isArrayLike(s){return!!ca(s)||!!s&&(\"object\"==typeof s&&(!_isString(s)&&(0===s.length||s.length>0&&(s.hasOwnProperty(0)&&s.hasOwnProperty(s.length-1)))))}));var _a=\"undefined\"!=typeof Symbol?Symbol.iterator:\"@@iterator\";function _createReduce(s,o,i){return function _reduce(a,u,_){if(ba(_))return s(a,u,_);if(null==_)return u;if(\"function\"==typeof _[\"fantasy-land/reduce\"])return o(a,u,_,\"fantasy-land/reduce\");if(null!=_[_a])return i(a,u,_[_a]());if(\"function\"==typeof _.next)return i(a,u,_);if(\"function\"==typeof _.reduce)return o(a,u,_,\"reduce\");throw new TypeError(\"reduce: list must be array or iterable\")}}function _xArrayReduce(s,o,i){for(var a=0,u=i.length;a<u;){if((o=s[\"@@transducer/step\"](o,i[a]))&&o[\"@@transducer/reduced\"]){o=o[\"@@transducer/value\"];break}a+=1}return s[\"@@transducer/result\"](o)}const Ea=_curry2((function bind(s,o){return _arity(s.length,(function(){return s.apply(o,arguments)}))}));function _xIterableReduce(s,o,i){for(var a=i.next();!a.done;){if((o=s[\"@@transducer/step\"](o,a.value))&&o[\"@@transducer/reduced\"]){o=o[\"@@transducer/value\"];break}a=i.next()}return s[\"@@transducer/result\"](o)}function _xMethodReduce(s,o,i,a){return s[\"@@transducer/result\"](i[a](Ea(s[\"@@transducer/step\"],s),o))}const wa=_createReduce(_xArrayReduce,_xMethodReduce,_xIterableReduce);var xa=function(){function XWrap(s){this.f=s}return XWrap.prototype[\"@@transducer/init\"]=function(){throw new Error(\"init not implemented on XWrap\")},XWrap.prototype[\"@@transducer/result\"]=function(s){return s},XWrap.prototype[\"@@transducer/step\"]=function(s,o){return this.f(s,o)},XWrap}();function _xwrap(s){return new xa(s)}var ka=_curry3((function(s,o,i){return wa(\"function\"==typeof s?_xwrap(s):s,o,i)}));const Aa=ka;function _checkForMethod(s,o){return function(){var i=arguments.length;if(0===i)return o();var a=arguments[i-1];return ca(a)||\"function\"!=typeof a[s]?o.apply(this,arguments):a[s].apply(a,Array.prototype.slice.call(arguments,0,i-1))}}var Ca=_curry3(_checkForMethod(\"slice\",(function slice(s,o,i){return Array.prototype.slice.call(i,s,o)})));const ja=Ca;const Ia=_curry1(_checkForMethod(\"tail\",ja(1,1/0)));function pipe(){if(0===arguments.length)throw new Error(\"pipe requires at least one argument\");return _arity(arguments[0].length,Aa(_pipe,arguments[0],Ia(arguments)))}const Na=_curry2((function defaultTo(s,o){return null==o||o!=o?s:o}));const Da=_curry2((function prop(s,o){if(null!=o)return Xo(s)?_nth(s,o):o[s]}));const La=_curry3((function propOr(s,o,i){return Na(s,Da(o,i))}));var Fa=_curry1((function(s){return _nth(-1,s)}));const Ba=Fa;function _curryN(s,o,i){return function(){for(var a=[],u=0,_=s,w=0,x=!1;w<o.length||u<arguments.length;){var C;w<o.length&&(!_isPlaceholder(o[w])||u>=arguments.length)?C=o[w]:(C=arguments[u],u+=1),a[w]=C,_isPlaceholder(C)?x=!0:_-=1,w+=1}return!x&&_<=0?i.apply(this,a):_arity(Math.max(0,_),_curryN(s,a,i))}}const $a=_curry2((function curryN(s,o){return 1===s?_curry1(o):_arity(s,_curryN(s,[],o))}));const za=_curry1((function curry(s){return $a(s.length,s)}));function _isFunction(s){var o=Object.prototype.toString.call(s);return\"[object Function]\"===o||\"[object AsyncFunction]\"===o||\"[object GeneratorFunction]\"===o||\"[object AsyncGeneratorFunction]\"===o}const Ja=_curry2((function invoker(s,o){return $a(s+1,(function(){var i=arguments[s];if(null!=i&&_isFunction(i[o]))return i[o].apply(i,Array.prototype.slice.call(arguments,0,s));throw new TypeError(ga(i)+' does not have a method named \"'+o+'\"')}))}));const Ha=Ja(1,\"split\");function dropLastWhile(s,o){for(var i=o.length-1;i>=0&&s(o[i]);)i-=1;return ja(0,i+1,o)}var Ga=function(){function XDropLastWhile(s,o){this.f=s,this.retained=[],this.xf=o}return XDropLastWhile.prototype[\"@@transducer/init\"]=_xfBase_init,XDropLastWhile.prototype[\"@@transducer/result\"]=function(s){return this.retained=null,this.xf[\"@@transducer/result\"](s)},XDropLastWhile.prototype[\"@@transducer/step\"]=function(s,o){return this.f(o)?this.retain(s,o):this.flush(s,o)},XDropLastWhile.prototype.flush=function(s,o){return s=wa(this.xf,s,this.retained),this.retained=[],this.xf[\"@@transducer/step\"](s,o)},XDropLastWhile.prototype.retain=function(s,o){return this.retained.push(o),s},XDropLastWhile}();function _xdropLastWhile(s){return function(o){return new Ga(s,o)}}const ec=_curry2(_dispatchable([],_xdropLastWhile,dropLastWhile));const rc=Ja(1,\"join\");const sc=_curry1((function flip(s){return $a(s.length,(function(o,i){var a=Array.prototype.slice.call(arguments,0);return a[0]=i,a[1]=o,s.apply(this,a)}))}))(_curry2(_includes));const oc=za((function(s,o){return pipe(Ha(\"\"),ec(sc(s)),rc(\"\"))(o)}));function _iterableReduce(s,o,i){for(var a=i.next();!a.done;)o=s(o,a.value),a=i.next();return o}function _methodReduce(s,o,i,a){return i[a](s,o)}const ic=_createReduce(_arrayReduce,_methodReduce,_iterableReduce);var ac=function(){function XMap(s,o){this.xf=o,this.f=s}return XMap.prototype[\"@@transducer/init\"]=_xfBase_init,XMap.prototype[\"@@transducer/result\"]=_xfBase_result,XMap.prototype[\"@@transducer/step\"]=function(s,o){return this.xf[\"@@transducer/step\"](s,this.f(o))},XMap}();const cc=_curry2(_dispatchable([\"fantasy-land/map\",\"map\"],(function _xmap(s){return function(o){return new ac(s,o)}}),(function map(s,o){switch(Object.prototype.toString.call(o)){case\"[object Function]\":return $a(o.length,(function(){return s.call(this,o.apply(this,arguments))}));case\"[object Object]\":return _arrayReduce((function(i,a){return i[a]=s(o[a]),i}),{},ea(o));default:return _map(s,o)}})));const lc=_curry2((function ap(s,o){return\"function\"==typeof o[\"fantasy-land/ap\"]?o[\"fantasy-land/ap\"](s):\"function\"==typeof s.ap?s.ap(o):\"function\"==typeof s?function(i){return s(i)(o(i))}:ic((function(s,i){return function _concat(s,o){var i;o=o||[];var a=(s=s||[]).length,u=o.length,_=[];for(i=0;i<a;)_[_.length]=s[i],i+=1;for(i=0;i<u;)_[_.length]=o[i],i+=1;return _}(s,cc(i,o))}),[],s)}));const pc=_curry2((function liftN(s,o){var i=$a(s,o);return $a(s,(function(){return _arrayReduce(lc,cc(i,arguments[0]),Array.prototype.slice.call(arguments,1))}))}));const hc=_curry1((function lift(s){return pc(s.length,s)}));const dc=hc(_curry1((function not(s){return!s})));const fc=_curry1((function always(s){return function(){return s}}));const gc=fc(void 0);const bc=na(gc());const _c=dc(bc);const Ec=_curry2((function max(s,o){if(s===o)return o;function safeMax(s,o){if(s>o!=o>s)return o>s?o:s}var i=safeMax(s,o);if(void 0!==i)return i;var a=safeMax(typeof s,typeof o);if(void 0!==a)return a===typeof s?s:o;var u=ga(s),_=safeMax(u,ga(o));return void 0!==_&&_===u?s:o}));var kc=_curry2((function pluck(s,o){return cc(Da(s),o)}));const Oc=kc;const jc=_curry1((function anyPass(s){return $a(Aa(Ec,0,Oc(\"length\",s)),(function(){for(var o=0,i=s.length;o<i;){if(s[o].apply(this,arguments))return!0;o+=1}return!1}))}));var identical=function(s,o){switch(arguments.length){case 0:return identical;case 1:return function unaryIdentical(o){return 0===arguments.length?unaryIdentical:Zo(s,o)};default:return Zo(s,o)}};const Pc=identical;const Ic=$a(1,pipe(ra,Pc(\"GeneratorFunction\")));const Nc=$a(1,pipe(ra,Pc(\"AsyncFunction\")));const Mc=jc([pipe(ra,Pc(\"Function\")),Ic,Nc]);var Rc=_curry3((function replace(s,o,i){return i.replace(s,o)}));const Lc=Rc;const Fc=$a(1,pipe(ra,Pc(\"RegExp\")));const qc=_curry3((function when(s,o,i){return s(i)?o(i):i}));const Jc=$a(1,pipe(ra,Pc(\"String\")));const Hc=qc(Jc,Lc(/[.*+?^${}()|[\\]\\\\-]/g,\"\\\\$&\"));var Kc=function checkValue(s,o){if(\"string\"!=typeof s&&!(s instanceof String))throw TypeError(\"`\".concat(o,\"` must be a string\"))};const Gc=function replaceAll(s,o,i){!function checkArguments(s,o,i){if(null==i||null==s||null==o)throw TypeError(\"Input values must not be `null` or `undefined`\")}(s,o,i),Kc(i,\"str\"),Kc(o,\"replaceValue\"),function checkSearchValue(s){if(!(\"string\"==typeof s||s instanceof String||s instanceof RegExp))throw TypeError(\"`searchValue` must be a string or an regexp\")}(s);var a=new RegExp(Fc(s)?s:Hc(s),\"g\");return Lc(a,o,i)};var Qc=$a(3,Gc),tl=Ja(2,\"replaceAll\");const sl=Mc(String.prototype.replaceAll)?tl:Qc,isWindows=()=>Qo(va(/^win/),[\"platform\"],Yo),getProtocol=s=>{try{const o=new URL(s);return oc(\":\",o.protocol)}catch{return}},ul=(pipe(getProtocol,_c),s=>{if(Yo.browser)return!1;const o=getProtocol(s);return bc(o)||\"file\"===o||/^[a-zA-Z]$/.test(o)}),isHttpUrl=s=>{const o=getProtocol(s);return\"http\"===o||\"https\"===o},toFileSystemPath=(s,o)=>{const i=[/%23/g,\"#\",/%24/g,\"$\",/%26/g,\"&\",/%2C/g,\",\",/%40/g,\"@\"],a=La(!1,\"keepFileProtocol\",o),u=La(isWindows,\"isWindows\",o);let _=decodeURI(s);for(let s=0;s<i.length;s+=2)_=_.replace(i[s],i[s+1]);let w=\"file://\"===_.substring(0,7).toLowerCase();return w&&(_=\"/\"===_[7]?_.substring(8):_.substring(7),u()&&\"/\"===_[1]&&(_=`${_[0]}:${_.substring(1)}`),a?_=`file:///${_}`:(w=!1,_=u()?_:`/${_}`)),u()&&!w&&(_=sl(\"/\",\"\\\\\",_),\":\\\\\"===_.substring(1,3)&&(_=_[0].toUpperCase()+_.substring(1))),_},getHash=s=>{const o=s.indexOf(\"#\");return-1!==o?s.substring(o):\"#\"},stripHash=s=>{const o=s.indexOf(\"#\");let i=s;return o>=0&&(i=s.substring(0,o)),i},url_cwd=()=>{if(Yo.browser)return stripHash(globalThis.location.href);const s=Yo.cwd(),o=Ba(s);return[\"/\",\"\\\\\"].includes(o)?s:s+(isWindows()?\"\\\\\":\"/\")},resolve=(s,o)=>{const i=new URL(o,new URL(s,\"resolve://\"));if(\"resolve:\"===i.protocol){const{pathname:s,search:o,hash:a}=i;return s+o+a}return i.toString()},sanitize=s=>{if(ul(s))return(s=>{const o=[/\\?/g,\"%3F\",/#/g,\"%23\"];let i=s;isWindows()&&(i=i.replace(/\\\\/g,\"/\")),i=encodeURI(i);for(let s=0;s<o.length;s+=2)i=i.replace(o[s],o[s+1]);return i})(toFileSystemPath(s));try{return new URL(s).toString()}catch{return encodeURI(decodeURI(s)).replace(/%5B/g,\"[\").replace(/%5D/g,\"]\")}},unsanitize=s=>ul(s)?toFileSystemPath(s):decodeURI(s),{fetch:yl,Response:vl,Headers:_l,Request:Sl,FormData:El,File:wl,Blob:xl}=globalThis;function _array_like_to_array(s,o){(null==o||o>s.length)&&(o=s.length);for(var i=0,a=new Array(o);i<o;i++)a[i]=s[i];return a}function legacy_defineProperties(s,o){for(var i=0;i<o.length;i++){var a=o[i];a.enumerable=a.enumerable||!1,a.configurable=!0,\"value\"in a&&(a.writable=!0),Object.defineProperty(s,a.key,a)}}function _instanceof(s,o){return null!=o&&\"undefined\"!=typeof Symbol&&o[Symbol.hasInstance]?!!o[Symbol.hasInstance](s):s instanceof o}function _sliced_to_array(s,o){return function _array_with_holes(s){if(Array.isArray(s))return s}(s)||function _iterable_to_array_limit(s,o){var i=null==s?null:\"undefined\"!=typeof Symbol&&s[Symbol.iterator]||s[\"@@iterator\"];if(null!=i){var a,u,_=[],w=!0,x=!1;try{for(i=i.call(s);!(w=(a=i.next()).done)&&(_.push(a.value),!o||_.length!==o);w=!0);}catch(s){x=!0,u=s}finally{try{w||null==i.return||i.return()}finally{if(x)throw u}}return _}}(s,o)||function _unsupported_iterable_to_array(s,o){if(!s)return;if(\"string\"==typeof s)return _array_like_to_array(s,o);var i=Object.prototype.toString.call(s).slice(8,-1);\"Object\"===i&&s.constructor&&(i=s.constructor.name);if(\"Map\"===i||\"Set\"===i)return Array.from(i);if(\"Arguments\"===i||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(i))return _array_like_to_array(s,o)}(s,o)||function _non_iterable_rest(){throw new TypeError(\"Invalid attempt to destructure non-iterable instance.\\\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\")}()}function _type_of(s){return s&&\"undefined\"!=typeof Symbol&&s.constructor===Symbol?\"symbol\":typeof s}void 0===globalThis.fetch&&(globalThis.fetch=yl),void 0===globalThis.Headers&&(globalThis.Headers=_l),void 0===globalThis.Request&&(globalThis.Request=Sl),void 0===globalThis.Response&&(globalThis.Response=vl),void 0===globalThis.FormData&&(globalThis.FormData=El),void 0===globalThis.File&&(globalThis.File=wl),void 0===globalThis.Blob&&(globalThis.Blob=xl);var __typeError=function(s){throw TypeError(s)},__accessCheck=function(s,o,i){return o.has(s)||__typeError(\"Cannot \"+i)},__privateGet=function(s,o,i){return __accessCheck(s,o,\"read from private field\"),i?i.call(s):o.get(s)},__privateAdd=function(s,o,i){return o.has(s)?__typeError(\"Cannot add the same private member more than once\"):_instanceof(o,WeakSet)?o.add(s):o.set(s,i)},__privateSet=function(s,o,i,a){return __accessCheck(s,o,\"write to private field\"),a?a.call(s,i):o.set(s,i),i},to_string=function(s){return Object.prototype.toString.call(s)},is_typed_array=function(s){return ArrayBuffer.isView(s)&&!_instanceof(s,DataView)},kl=Array.isArray,Ol=Object.getOwnPropertyDescriptor,Al=Object.prototype.propertyIsEnumerable,Cl=Object.getOwnPropertySymbols,Pl=Object.prototype.hasOwnProperty;function own_enumerable_keys(s){for(var o=Object.keys(s),i=Cl(s),a=0;a<i.length;a++)Al.call(s,i[a])&&o.push(i[a]);return o}function is_writable(s,o){var i;return!(null===(i=Ol(s,o))||void 0===i?void 0:i.writable)}function legacy_copy(s,o){if(\"object\"===(void 0===s?\"undefined\":_type_of(s))&&null!==s){var i;if(kl(s))i=[];else if(\"[object Date]\"===to_string(s))i=new Date(s.getTime?s.getTime():s);else if(function(s){return\"[object RegExp]\"===to_string(s)}(s))i=new RegExp(s);else if(function(s){return\"[object Error]\"===to_string(s)}(s))i={message:s.message};else if(function(s){return\"[object Boolean]\"===to_string(s)}(s)||function(s){return\"[object Number]\"===to_string(s)}(s)||function(s){return\"[object String]\"===to_string(s)}(s))i=Object(s);else{if(is_typed_array(s))return s.slice();i=Object.create(Object.getPrototypeOf(s))}var a=o.includeSymbols?own_enumerable_keys:Object.keys,u=!0,_=!1,w=void 0;try{for(var x,C=a(s)[Symbol.iterator]();!(u=(x=C.next()).done);u=!0){var j=x.value;i[j]=s[j]}}catch(s){_=!0,w=s}finally{try{u||null==C.return||C.return()}finally{if(_)throw w}}return i}return s}var Il,Tl,Nl={includeSymbols:!1,immutable:!1};function walk(s,o){var i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:Nl,a=[],u=[],_=!0,w=i.includeSymbols?own_enumerable_keys:Object.keys,x=!!i.immutable;return function walker(s){var C=x?legacy_copy(s,i):s,j={},L=!0,B={node:C,node_:s,path:[].concat(a),parent:u[u.length-1],parents:u,key:a[a.length-1],isRoot:0===a.length,level:a.length,circular:void 0,isLeaf:!1,notLeaf:!0,notRoot:!0,isFirst:!1,isLast:!1,update:function update(s){var o=arguments.length>1&&void 0!==arguments[1]&&arguments[1];B.isRoot||(B.parent.node[B.key]=s),B.node=s,o&&(L=!1)},delete:function _delete(s){delete B.parent.node[B.key],s&&(L=!1)},remove:function remove(s){kl(B.parent.node)?B.parent.node.splice(B.key,1):delete B.parent.node[B.key],s&&(L=!1)},keys:null,before:function before(s){j.before=s},after:function after(s){j.after=s},pre:function pre(s){j.pre=s},post:function post(s){j.post=s},stop:function stop(){_=!1},block:function block(){L=!1}};if(!_)return B;function update_state(){if(\"object\"===_type_of(B.node)&&null!==B.node){B.keys&&B.node_===B.node||(B.keys=w(B.node)),B.isLeaf=0===B.keys.length;for(var o=0;o<u.length;o++)if(u[o].node_===s){B.circular=u[o];break}}else B.isLeaf=!0,B.keys=null;B.notLeaf=!B.isLeaf,B.notRoot=!B.isRoot}update_state();var $=o.call(B,B.node);if(void 0!==$&&B.update&&B.update($),j.before&&j.before.call(B,B.node),!L)return B;if(\"object\"===_type_of(B.node)&&null!==B.node&&!B.circular){var U;u.push(B),update_state();var V=!0,z=!1,Y=void 0;try{for(var Z,ee=Object.entries(null!==(U=B.keys)&&void 0!==U?U:[])[Symbol.iterator]();!(V=(Z=ee.next()).done);V=!0){var ie,ae=_sliced_to_array(Z.value,2),ce=ae[0],le=ae[1];a.push(le),j.pre&&j.pre.call(B,B.node[le],le);var pe=walker(B.node[le]);x&&Pl.call(B.node,le)&&!is_writable(B.node,le)&&(B.node[le]=pe.node),pe.isLast=!!(null===(ie=B.keys)||void 0===ie?void 0:ie.length)&&+ce==B.keys.length-1,pe.isFirst=0==+ce,j.post&&j.post.call(B,pe),a.pop()}}catch(s){z=!0,Y=s}finally{try{V||null==ee.return||ee.return()}finally{if(z)throw Y}}u.pop()}return j.after&&j.after.call(B,B.node),B}(s).node}var Ml=function(){function Traverse(s){var o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:Nl;!function _class_call_check(s,o){if(!(s instanceof o))throw new TypeError(\"Cannot call a class as a function\")}(this,Traverse),__privateAdd(this,Il),__privateAdd(this,Tl),__privateSet(this,Il,s),__privateSet(this,Tl,o)}return function _create_class(s,o,i){return o&&legacy_defineProperties(s.prototype,o),i&&legacy_defineProperties(s,i),s}(Traverse,[{key:\"get\",value:function get(s){for(var o=__privateGet(this,Il),i=0;o&&i<s.length;i++){var a=s[i];if(!Pl.call(o,a)||!__privateGet(this,Tl).includeSymbols&&\"symbol\"===(void 0===a?\"undefined\":_type_of(a)))return;o=o[a]}return o}},{key:\"has\",value:function has(s){for(var o=__privateGet(this,Il),i=0;o&&i<s.length;i++){var a=s[i];if(!Pl.call(o,a)||!__privateGet(this,Tl).includeSymbols&&\"symbol\"===(void 0===a?\"undefined\":_type_of(a)))return!1;o=o[a]}return!0}},{key:\"set\",value:function set(s,o){var i=__privateGet(this,Il),a=0;for(a=0;a<s.length-1;a++){var u=s[a];Pl.call(i,u)||(i[u]={}),i=i[u]}return i[s[a]]=o,o}},{key:\"map\",value:function map(s){return walk(__privateGet(this,Il),s,{immutable:!0,includeSymbols:!!__privateGet(this,Tl).includeSymbols})}},{key:\"forEach\",value:function forEach(s){return __privateSet(this,Il,walk(__privateGet(this,Il),s,__privateGet(this,Tl))),__privateGet(this,Il)}},{key:\"reduce\",value:function reduce(s,o){var i=1===arguments.length,a=i?__privateGet(this,Il):o;return this.forEach((function(o){this.isRoot&&i||(a=s.call(this,a,o))})),a}},{key:\"paths\",value:function paths(){var s=[];return this.forEach((function(){s.push(this.path)})),s}},{key:\"nodes\",value:function nodes(){var s=[];return this.forEach((function(){s.push(this.node)})),s}},{key:\"clone\",value:function clone(){var s=[],o=[],i=__privateGet(this,Tl);return is_typed_array(__privateGet(this,Il))?__privateGet(this,Il).slice():function clone(a){for(var u=0;u<s.length;u++)if(s[u]===a)return o[u];if(\"object\"===(void 0===a?\"undefined\":_type_of(a))&&null!==a){var _=legacy_copy(a,i);s.push(a),o.push(_);var w=i.includeSymbols?own_enumerable_keys:Object.keys,x=!0,C=!1,j=void 0;try{for(var L,B=w(a)[Symbol.iterator]();!(x=(L=B.next()).done);x=!0){var $=L.value;_[$]=clone(a[$])}}catch(s){C=!0,j=s}finally{try{x||null==B.return||B.return()}finally{if(C)throw j}}return s.pop(),o.pop(),_}return a}(__privateGet(this,Il))}}]),Traverse}();Il=new WeakMap,Tl=new WeakMap;var traverse=function(s,o){return new Ml(s,o)};traverse.get=function(s,o,i){return new Ml(s,i).get(o)},traverse.set=function(s,o,i,a){return new Ml(s,a).set(o,i)},traverse.has=function(s,o,i){return new Ml(s,i).has(o)},traverse.map=function(s,o,i){return new Ml(s,i).map(o)},traverse.forEach=function(s,o,i){return new Ml(s,i).forEach(o)},traverse.reduce=function(s,o,i,a){return new Ml(s,a).reduce(o,i)},traverse.paths=function(s,o){return new Ml(s,o).paths()},traverse.nodes=function(s,o){return new Ml(s,o).nodes()},traverse.clone=function(s,o){return new Ml(s,o).clone()};var Rl=traverse;const Dl=\"application/json, application/yaml\",Ll=\"https://swagger.io\",Fl=Object.freeze({url:\"/\"}),Bl=3e3,$l=[\"properties\"],Ul=[\"properties\"],Vl=[\"definitions\",\"parameters\",\"responses\",\"securityDefinitions\",\"components/schemas\",\"components/responses\",\"components/parameters\",\"components/securitySchemes\"],zl=[\"schema/example\",\"items/example\"];function isFreelyNamed(s){const o=s[s.length-1],i=s[s.length-2],a=s.join(\"/\");return $l.indexOf(o)>-1&&-1===Ul.indexOf(i)||Vl.indexOf(a)>-1||zl.some((s=>a.indexOf(s)>-1))}function absolutifyPointer(s,o){const[i,a]=s.split(\"#\"),u=null!=o?o:\"\",_=null!=i?i:\"\";let w;if(isHttpUrl(u))w=resolve(u,_);else{const s=resolve(Ll,u),o=resolve(s,_).replace(Ll,\"\");w=_.startsWith(\"/\")?o:o.substring(1)}return a?`${w}#${a}`:w}const Wl=/^([a-z]+:\\/\\/|\\/\\/)/i;class JSONRefError extends Go{}const Jl={},Hl=new WeakMap,Kl=[s=>\"paths\"===s[0]&&\"responses\"===s[3]&&\"examples\"===s[5],s=>\"paths\"===s[0]&&\"responses\"===s[3]&&\"content\"===s[5]&&\"example\"===s[7],s=>\"paths\"===s[0]&&\"responses\"===s[3]&&\"content\"===s[5]&&\"examples\"===s[7]&&\"value\"===s[9],s=>\"paths\"===s[0]&&\"requestBody\"===s[3]&&\"content\"===s[4]&&\"example\"===s[6],s=>\"paths\"===s[0]&&\"requestBody\"===s[3]&&\"content\"===s[4]&&\"examples\"===s[6]&&\"value\"===s[8],s=>\"paths\"===s[0]&&\"parameters\"===s[2]&&\"example\"===s[4],s=>\"paths\"===s[0]&&\"parameters\"===s[3]&&\"example\"===s[5],s=>\"paths\"===s[0]&&\"parameters\"===s[2]&&\"examples\"===s[4]&&\"value\"===s[6],s=>\"paths\"===s[0]&&\"parameters\"===s[3]&&\"examples\"===s[5]&&\"value\"===s[7],s=>\"paths\"===s[0]&&\"parameters\"===s[2]&&\"content\"===s[4]&&\"example\"===s[6],s=>\"paths\"===s[0]&&\"parameters\"===s[2]&&\"content\"===s[4]&&\"examples\"===s[6]&&\"value\"===s[8],s=>\"paths\"===s[0]&&\"parameters\"===s[3]&&\"content\"===s[4]&&\"example\"===s[7],s=>\"paths\"===s[0]&&\"parameters\"===s[3]&&\"content\"===s[5]&&\"examples\"===s[7]&&\"value\"===s[9]],Gl={key:\"$ref\",plugin:(s,o,i,a)=>{const u=a.getInstance(),_=i.slice(0,-1);if(isFreelyNamed(_)||(s=>Kl.some((o=>o(s))))(_))return;const{baseDoc:w}=a.getContext(i);if(\"string\"!=typeof s)return new JSONRefError(\"$ref: must be a string (JSON-Ref)\",{$ref:s,baseDoc:w,fullPath:i});const x=refs_split(s),C=x[0],j=x[1]||\"\";let L,B,$;try{L=w||C?absoluteify(C,w):null}catch(o){return wrapError(o,{pointer:j,$ref:s,basePath:L,fullPath:i})}if(function pointerAlreadyInPath(s,o,i,a){let u=Hl.get(a);u||(u={},Hl.set(a,u));const _=function arrayToJsonPointer(s){if(0===s.length)return\"\";return`/${s.map(escapeJsonPointerToken).join(\"/\")}`}(i),w=`${o||\"<specmap-base>\"}#${s}`,x=_.replace(/allOf\\/\\d+\\/?/g,\"\"),C=a.contextTree.get([]).baseDoc;if(o===C&&pointerIsAParent(x,s))return!0;let j=\"\";const L=i.some((s=>(j=`${j}/${escapeJsonPointerToken(s)}`,u[j]&&u[j].some((s=>pointerIsAParent(s,w)||pointerIsAParent(w,s))))));if(L)return!0;return void(u[x]=(u[x]||[]).concat(w))}(j,L,_,a)&&!u.useCircularStructures){const o=absolutifyPointer(s,L);return s===o?null:Wo.replace(i,o)}if(null==L?($=jsonPointerToArray(j),B=a.get($),void 0===B&&(B=new JSONRefError(`Could not resolve reference: ${s}`,{pointer:j,$ref:s,baseDoc:w,fullPath:i}))):(B=extractFromDoc(L,j),B=null!=B.__value?B.__value:B.catch((o=>{throw wrapError(o,{pointer:j,$ref:s,baseDoc:w,fullPath:i})}))),B instanceof Error)return[Wo.remove(i),B];const U=absolutifyPointer(s,L),V=Wo.replace(_,B,{$$ref:U});if(L&&L!==w)return[V,Wo.context(_,{baseDoc:L})];try{if(!function patchValueAlreadyInPath(s,o){const i=[s];return o.path.reduce(((s,o)=>(i.push(s[o]),s[o])),s),pointToAncestor(o.value);function pointToAncestor(s){return Wo.isObject(s)&&(i.indexOf(s)>=0||Object.keys(s).some((o=>pointToAncestor(s[o]))))}}(a.state,V)||u.useCircularStructures)return V}catch(s){return null}}},Yl=Object.assign(Gl,{docCache:Jl,absoluteify,clearCache:function clearCache(s){void 0!==s?delete Jl[s]:Object.keys(Jl).forEach((s=>{delete Jl[s]}))},JSONRefError,wrapError,getDoc,split:refs_split,extractFromDoc,fetchJSON:function fetchJSON(s){return fetch(s,{headers:{Accept:Dl},loadSpec:!0}).then((s=>s.text())).then((s=>fn.load(s)))},extract,jsonPointerToArray,unescapeJsonPointerToken}),Xl=Yl;function absoluteify(s,o){if(!Wl.test(s)){if(!o)throw new JSONRefError(`Tried to resolve a relative URL, without having a basePath. path: '${s}' basePath: '${o}'`);return resolve(o,s)}return s}function wrapError(s,o){let i;return i=s&&s.response&&s.response.body?`${s.response.body.code} ${s.response.body.message}`:s.message,new JSONRefError(`Could not resolve reference: ${i}`,{...o,cause:s})}function refs_split(s){return(s+\"\").split(\"#\")}function extractFromDoc(s,o){const i=Jl[s];if(i&&!Wo.isPromise(i))try{const s=extract(o,i);return Object.assign(Promise.resolve(s),{__value:s})}catch(s){return Promise.reject(s)}return getDoc(s).then((s=>extract(o,s)))}function getDoc(s){const o=Jl[s];return o?Wo.isPromise(o)?o:Promise.resolve(o):(Jl[s]=Yl.fetchJSON(s).then((o=>(Jl[s]=o,o))),Jl[s])}function extract(s,o){const i=jsonPointerToArray(s);if(i.length<1)return o;const a=Wo.getIn(o,i);if(void 0===a)throw new JSONRefError(`Could not resolve pointer: ${s} does not exist in document`,{pointer:s});return a}function jsonPointerToArray(s){if(\"string\"!=typeof s)throw new TypeError(\"Expected a string, got a \"+typeof s);return\"/\"===s[0]&&(s=s.substr(1)),\"\"===s?[]:s.split(\"/\").map(unescapeJsonPointerToken)}function unescapeJsonPointerToken(s){if(\"string\"!=typeof s)return s;return new URLSearchParams(`=${s.replace(/~1/g,\"/\").replace(/~0/g,\"~\")}`).get(\"\")}function escapeJsonPointerToken(s){return new URLSearchParams([[\"\",s.replace(/~/g,\"~0\").replace(/\\//g,\"~1\")]]).toString().slice(1)}const pointerBoundaryChar=s=>!s||\"/\"===s||\"#\"===s;function pointerIsAParent(s,o){if(pointerBoundaryChar(o))return!0;const i=s.charAt(o.length),a=o.slice(-1);return 0===s.indexOf(o)&&(!i||\"/\"===i||\"#\"===i)&&\"#\"!==a}const Ql={key:\"allOf\",plugin:(s,o,i,a,u)=>{if(u.meta&&u.meta.$$ref)return;const _=i.slice(0,-1);if(isFreelyNamed(_))return;if(!Array.isArray(s)){const s=new TypeError(\"allOf must be an array\");return s.fullPath=i,s}let w=!1,x=u.value;if(_.forEach((s=>{x&&(x=x[s])})),x={...x},0===Object.keys(x).length)return;delete x.allOf;const C=[];return C.push(a.replace(_,{})),s.forEach(((s,o)=>{if(!a.isObject(s)){if(w)return null;w=!0;const s=new TypeError(\"Elements in allOf must be objects\");return s.fullPath=i,C.push(s)}C.push(a.mergeDeep(_,s));const u=function generateAbsoluteRefPatches(s,o,{specmap:i,getBaseUrlForNodePath:a=s=>i.getContext([...o,...s]).baseDoc,targetKeys:u=[\"$ref\",\"$$ref\"]}={}){const _=[];return Rl(s).forEach((function callback(){if(u.includes(this.key)&&\"string\"==typeof this.node){const s=this.path,u=o.concat(this.path),w=absolutifyPointer(this.node,a(s));_.push(i.replace(u,w))}})),_}(s,i.slice(0,-1),{getBaseUrlForNodePath:s=>a.getContext([...i,o,...s]).baseDoc,specmap:a});C.push(...u)})),x.example&&C.push(a.remove([].concat(_,\"example\"))),C.push(a.mergeDeep(_,x)),x.$$ref||C.push(a.remove([].concat(_,\"$$ref\"))),C}},Zl={key:\"parameters\",plugin:(s,o,i,a)=>{if(Array.isArray(s)&&s.length){const o=Object.assign([],s),u=i.slice(0,-1),_={...Wo.getIn(a.spec,u)};for(let u=0;u<s.length;u+=1){const w=s[u];try{o[u].default=a.parameterMacro(_,w)}catch(s){const o=new Error(s);return o.fullPath=i,o}}return Wo.replace(i,o)}return Wo.replace(i,s)}},eu={key:\"properties\",plugin:(s,o,i,a)=>{const u={...s};for(const o in s)try{u[o].default=a.modelPropertyMacro(u[o])}catch(s){const o=new Error(s);return o.fullPath=i,o}return Wo.replace(i,u)}};class ContextTree{constructor(s){this.root=context_tree_createNode(s||{})}set(s,o){const i=this.getParent(s,!0);if(!i)return void context_tree_updateNode(this.root,o,null);const a=s[s.length-1],{children:u}=i;u[a]?context_tree_updateNode(u[a],o,i):u[a]=context_tree_createNode(o,i)}get(s){if((s=s||[]).length<1)return this.root.value;let o,i,a=this.root;for(let u=0;u<s.length&&(i=s[u],o=a.children,o[i]);u+=1)a=o[i];return a&&a.protoValue}getParent(s,o){return!s||s.length<1?null:s.length<2?this.root:s.slice(0,-1).reduce(((s,i)=>{if(!s)return s;const{children:a}=s;return!a[i]&&o&&(a[i]=context_tree_createNode(null,s)),a[i]}),this.root)}}function context_tree_createNode(s,o){return context_tree_updateNode({children:{}},s,o)}function context_tree_updateNode(s,o,i){return s.value=o||{},s.protoValue=i?{...i.protoValue,...s.value}:s.value,Object.keys(s.children).forEach((o=>{const i=s.children[o];s.children[o]=context_tree_updateNode(i,i.value,s)})),s}const specmap_noop=()=>{};class SpecMap{static getPluginName(s){return s.pluginName}static getPatchesOfType(s,o){return s.filter(o)}constructor(s){Object.assign(this,{spec:\"\",debugLevel:\"info\",plugins:[],pluginHistory:{},errors:[],mutations:[],promisedPatches:[],state:{},patches:[],context:{},contextTree:new ContextTree,showDebug:!1,allPatches:[],pluginProp:\"specMap\",libMethods:Object.assign(Object.create(this),Wo,{getInstance:()=>this}),allowMetaPatches:!1},s),this.get=this._get.bind(this),this.getContext=this._getContext.bind(this),this.hasRun=this._hasRun.bind(this),this.wrappedPlugins=this.plugins.map(this.wrapPlugin.bind(this)).filter(Wo.isFunction),this.patches.push(Wo.add([],this.spec)),this.patches.push(Wo.context([],this.context)),this.updatePatches(this.patches)}debug(s,...o){this.debugLevel===s&&console.log(...o)}verbose(s,...o){\"verbose\"===this.debugLevel&&console.log(`[${s}]   `,...o)}wrapPlugin(s,o){const{pathDiscriminator:i}=this;let a,u=null;return s[this.pluginProp]?(u=s,a=s[this.pluginProp]):Wo.isFunction(s)?a=s:Wo.isObject(s)&&(a=function createKeyBasedPlugin(s){const isSubPath=(s,o)=>!Array.isArray(s)||s.every(((s,i)=>s===o[i]));return function*generator(o,a){const u={};for(const[s,i]of o.filter(Wo.isAdditiveMutation).entries()){if(!(s<Bl))return;yield*traverse(i.value,i.path,i)}function*traverse(o,_,w){if(Wo.isObject(o)){const x=_.length-1,C=_[x],j=_.indexOf(\"properties\"),L=\"properties\"===C&&x===j,B=a.allowMetaPatches&&u[o.$$ref];for(const x of Object.keys(o)){const C=o[x],j=_.concat(x),$=Wo.isObject(C),U=o.$$ref;if(B||$&&(a.allowMetaPatches&&U&&isSubPath(i,j)&&(u[U]=!0),yield*traverse(C,j,w)),!L&&x===s.key){const o=isSubPath(i,_);i&&!o||(yield s.plugin(C,x,j,a,w))}}}else s.key===_[_.length-1]&&(yield s.plugin(o,s.key,_,a))}}}(s)),Object.assign(a.bind(u),{pluginName:s.name||o,isGenerator:Wo.isGenerator(a)})}nextPlugin(){return this.wrappedPlugins.find((s=>this.getMutationsForPlugin(s).length>0))}nextPromisedPatch(){if(this.promisedPatches.length>0)return Promise.race(this.promisedPatches.map((s=>s.value)))}getPluginHistory(s){const o=this.constructor.getPluginName(s);return this.pluginHistory[o]||[]}getPluginRunCount(s){return this.getPluginHistory(s).length}getPluginHistoryTip(s){const o=this.getPluginHistory(s);return o&&o[o.length-1]||{}}getPluginMutationIndex(s){const o=this.getPluginHistoryTip(s).mutationIndex;return\"number\"!=typeof o?-1:o}updatePluginHistory(s,o){const i=this.constructor.getPluginName(s);this.pluginHistory[i]=this.pluginHistory[i]||[],this.pluginHistory[i].push(o)}updatePatches(s){Wo.normalizeArray(s).forEach((s=>{if(s instanceof Error)this.errors.push(s);else try{if(!Wo.isObject(s))return void this.debug(\"updatePatches\",\"Got a non-object patch\",s);if(this.showDebug&&this.allPatches.push(s),Wo.isPromise(s.value))return this.promisedPatches.push(s),void this.promisedPatchThen(s);if(Wo.isContextPatch(s))return void this.setContext(s.path,s.value);Wo.isMutation(s)&&this.updateMutations(s)}catch(s){console.error(s),this.errors.push(s)}}))}updateMutations(s){\"object\"==typeof s.value&&!Array.isArray(s.value)&&this.allowMetaPatches&&(s.value={...s.value});const o=Wo.applyPatch(this.state,s,{allowMetaPatches:this.allowMetaPatches});o&&(this.mutations.push(s),this.state=o)}removePromisedPatch(s){const o=this.promisedPatches.indexOf(s);o<0?this.debug(\"Tried to remove a promisedPatch that isn't there!\"):this.promisedPatches.splice(o,1)}promisedPatchThen(s){return s.value=s.value.then((o=>{const i={...s,value:o};this.removePromisedPatch(s),this.updatePatches(i)})).catch((o=>{this.removePromisedPatch(s),this.updatePatches(o)})),s.value}getMutations(s,o){return s=s||0,\"number\"!=typeof o&&(o=this.mutations.length),this.mutations.slice(s,o)}getCurrentMutations(){return this.getMutationsForPlugin(this.getCurrentPlugin())}getMutationsForPlugin(s){const o=this.getPluginMutationIndex(s);return this.getMutations(o+1)}getCurrentPlugin(){return this.currentPlugin}getLib(){return this.libMethods}_get(s){return Wo.getIn(this.state,s)}_getContext(s){return this.contextTree.get(s)}setContext(s,o){return this.contextTree.set(s,o)}_hasRun(s){return this.getPluginRunCount(this.getCurrentPlugin())>(s||0)}dispatch(){const s=this,o=this.nextPlugin();if(!o){const s=this.nextPromisedPatch();if(s)return s.then((()=>this.dispatch())).catch((()=>this.dispatch()));const o={spec:this.state,errors:this.errors};return this.showDebug&&(o.patches=this.allPatches),Promise.resolve(o)}if(s.pluginCount=s.pluginCount||new WeakMap,s.pluginCount.set(o,(s.pluginCount.get(o)||0)+1),s.pluginCount[o]>100)return Promise.resolve({spec:s.state,errors:s.errors.concat(new Error(\"We've reached a hard limit of 100 plugin runs\"))});if(o!==this.currentPlugin&&this.promisedPatches.length){const s=this.promisedPatches.map((s=>s.value));return Promise.all(s.map((s=>s.then(specmap_noop,specmap_noop)))).then((()=>this.dispatch()))}return function executePlugin(){s.currentPlugin=o;const i=s.getCurrentMutations(),a=s.mutations.length-1;try{if(o.isGenerator)for(const a of o(i,s.getLib()))updatePatches(a);else{updatePatches(o(i,s.getLib()))}}catch(s){console.error(s),updatePatches([Object.assign(Object.create(s),{plugin:o})])}finally{s.updatePluginHistory(o,{mutationIndex:a})}return s.dispatch()}();function updatePatches(i){i&&(i=Wo.fullyNormalizeArray(i),s.updatePatches(i,o))}}}const tu={refs:Xl,allOf:Ql,parameters:Zl,properties:eu};function makeFetchJSON(s,o={}){const{requestInterceptor:i,responseInterceptor:a}=o,u=s.withCredentials?\"include\":\"same-origin\";return o=>s({url:o,loadSpec:!0,requestInterceptor:i,responseInterceptor:a,headers:{Accept:Dl},credentials:u}).then((s=>s.body))}function isFile(s,o){return o||\"undefined\"==typeof navigator||(o=navigator),o&&\"ReactNative\"===o.product?!(!s||\"object\"!=typeof s||\"string\"!=typeof s.uri):\"undefined\"!=typeof File&&s instanceof File||(\"undefined\"!=typeof Blob&&s instanceof Blob||(!!ArrayBuffer.isView(s)||null!==s&&\"object\"==typeof s&&\"function\"==typeof s.pipe))}function isArrayOfFile(s,o){return Array.isArray(s)&&s.some((s=>isFile(s,o)))}class FileWithData extends File{constructor(s,o=\"\",i={}){super([s],o,i),this.data=s}valueOf(){return this.data}toString(){return this.valueOf()}}const isRfc3986Reserved=s=>\":/?#[]@!$&'()*+,;=\".indexOf(s)>-1,isRfc3986Unreserved=s=>/^[a-z0-9\\-._~]+$/i.test(s);function encodeCharacters(s,o=\"reserved\"){return[...s].map((s=>{if(isRfc3986Unreserved(s))return s;if(isRfc3986Reserved(s)&&\"unsafe\"===o)return s;const i=new TextEncoder;return Array.from(i.encode(s)).map((s=>`0${s.toString(16).toUpperCase()}`.slice(-2))).map((s=>`%${s}`)).join(\"\")})).join(\"\")}function stylize(s){const{value:o}=s;return Array.isArray(o)?function encodeArray({key:s,value:o,style:i,explode:a,escape:u}){if(\"simple\"===i)return o.map((s=>valueEncoder(s,u))).join(\",\");if(\"label\"===i)return`.${o.map((s=>valueEncoder(s,u))).join(\".\")}`;if(\"matrix\"===i)return o.map((s=>valueEncoder(s,u))).reduce(((o,i)=>!o||a?`${o||\"\"};${s}=${i}`:`${o},${i}`),\"\");if(\"form\"===i){const i=a?`&${s}=`:\",\";return o.map((s=>valueEncoder(s,u))).join(i)}if(\"spaceDelimited\"===i){const i=a?`${s}=`:\"\";return o.map((s=>valueEncoder(s,u))).join(` ${i}`)}if(\"pipeDelimited\"===i){const i=a?`${s}=`:\"\";return o.map((s=>valueEncoder(s,u))).join(`|${i}`)}return}(s):\"object\"==typeof o?function encodeObject({key:s,value:o,style:i,explode:a,escape:u}){const _=Object.keys(o);if(\"simple\"===i)return _.reduce(((s,i)=>{const _=valueEncoder(o[i],u);return`${s?`${s},`:\"\"}${i}${a?\"=\":\",\"}${_}`}),\"\");if(\"label\"===i)return _.reduce(((s,i)=>{const _=valueEncoder(o[i],u);return`${s?`${s}.`:\".\"}${i}${a?\"=\":\".\"}${_}`}),\"\");if(\"matrix\"===i&&a)return _.reduce(((s,i)=>`${s?`${s};`:\";\"}${i}=${valueEncoder(o[i],u)}`),\"\");if(\"matrix\"===i)return _.reduce(((i,a)=>{const _=valueEncoder(o[a],u);return`${i?`${i},`:`;${s}=`}${a},${_}`}),\"\");if(\"form\"===i)return _.reduce(((s,i)=>{const _=valueEncoder(o[i],u);return`${s?`${s}${a?\"&\":\",\"}`:\"\"}${i}${a?\"=\":\",\"}${_}`}),\"\");return}(s):function encodePrimitive({key:s,value:o,style:i,escape:a}){if(\"simple\"===i)return valueEncoder(o,a);if(\"label\"===i)return`.${valueEncoder(o,a)}`;if(\"matrix\"===i)return`;${s}=${valueEncoder(o,a)}`;if(\"form\"===i)return valueEncoder(o,a);if(\"deepObject\"===i)return valueEncoder(o,a);return}(s)}function valueEncoder(s,o=!1){return Array.isArray(s)||null!==s&&\"object\"==typeof s?s=JSON.stringify(s):\"number\"!=typeof s&&\"boolean\"!=typeof s||(s=String(s)),o&&\"string\"==typeof s&&s.length>0?encodeCharacters(s,o):null!=s?s:\"\"}const ru={form:\",\",spaceDelimited:\"%20\",pipeDelimited:\"|\"},nu={csv:\",\",ssv:\"%20\",tsv:\"%09\",pipes:\"|\"};function formatKeyValue(s,o,i=!1){const{collectionFormat:a,allowEmptyValue:u,serializationOption:_,encoding:w}=o,x=\"object\"!=typeof o||Array.isArray(o)?o:o.value,C=i?s=>s.toString():s=>encodeURIComponent(s),j=C(s);if(void 0===x&&u)return[[j,\"\"]];if(isFile(x)||isArrayOfFile(x))return[[j,x]];if(_)return formatKeyValueBySerializationOption(s,x,i,_);if(w){if([typeof w.style,typeof w.explode,typeof w.allowReserved].some((s=>\"undefined\"!==s))){const{style:o,explode:a,allowReserved:u}=w;return formatKeyValueBySerializationOption(s,x,i,{style:o,explode:a,allowReserved:u})}if(\"string\"==typeof w.contentType){if(w.contentType.startsWith(\"application/json\")){const s=C(\"string\"==typeof x?x:JSON.stringify(x));return[[j,new FileWithData(s,\"blob\",{type:w.contentType})]]}const s=C(String(x));return[[j,new FileWithData(s,\"blob\",{type:w.contentType})]]}return\"object\"!=typeof x?[[j,C(x)]]:Array.isArray(x)&&x.every((s=>\"object\"!=typeof s))?[[j,x.map(C).join(\",\")]]:[[j,C(JSON.stringify(x))]]}return\"object\"!=typeof x?[[j,C(x)]]:Array.isArray(x)?\"multi\"===a?[[j,x.map(C)]]:[[j,x.map(C).join(nu[a||\"csv\"])]]:[[j,\"\"]]}function formatKeyValueBySerializationOption(s,o,i,a){const u=a.style||\"form\",_=void 0===a.explode?\"form\"===u:a.explode,w=!i&&(a&&a.allowReserved?\"unsafe\":\"reserved\"),encodeFn=s=>valueEncoder(s,w),x=i?s=>s:s=>encodeFn(s);return\"object\"!=typeof o?[[x(s),encodeFn(o)]]:Array.isArray(o)?_?[[x(s),o.map(encodeFn)]]:[[x(s),o.map(encodeFn).join(ru[u])]]:\"deepObject\"===u?Object.keys(o).map((i=>[x(`${s}[${i}]`),encodeFn(o[i])])):_?Object.keys(o).map((s=>[x(s),encodeFn(o[s])])):[[x(s),Object.keys(o).map((s=>[`${x(s)},${encodeFn(o[s])}`])).join(\",\")]]}function encodeFormOrQuery(s){return((s,{encode:o=!0}={})=>{const buildNestedParams=(s,o,i)=>(Array.isArray(i)?i.reduce(((i,a)=>buildNestedParams(s,o,a)),s):i instanceof Date?s.append(o,i.toISOString()):\"object\"==typeof i?Object.entries(i).reduce(((i,[a,u])=>buildNestedParams(s,`${o}[${a}]`,u)),s):s.append(o,i),s),i=Object.entries(s).reduce(((s,[o,i])=>buildNestedParams(s,o,i)),new URLSearchParams),a=String(i);return o?a:decodeURIComponent(a)})(Object.keys(s).reduce(((o,i)=>{for(const[a,u]of formatKeyValue(i,s[i]))o[a]=u instanceof FileWithData?u.valueOf():u;return o}),{}),{encode:!1})}function serializeRequest(s={}){const{url:o=\"\",query:i,form:a}=s;if(a){const o=Object.keys(a).some((s=>{const{value:o}=a[s];return isFile(o)||isArrayOfFile(o)})),i=s.headers[\"content-type\"]||s.headers[\"Content-Type\"];if(o||/multipart\\/form-data/i.test(i)){const o=function request_buildFormData(s){return Object.entries(s).reduce(((s,[o,i])=>{for(const[a,u]of formatKeyValue(o,i,!0))if(Array.isArray(u))for(const o of u)if(ArrayBuffer.isView(o)){const i=new Blob([o]);s.append(a,i)}else s.append(a,o);else if(ArrayBuffer.isView(u)){const o=new Blob([u]);s.append(a,o)}else s.append(a,u);return s}),new FormData)}(s.form);s.formdata=o,s.body=o}else s.body=encodeFormOrQuery(a);delete s.form}if(i){const[a,u]=o.split(\"?\");let _=\"\";if(u){const s=new URLSearchParams(u);Object.keys(i).forEach((o=>s.delete(o))),_=String(s)}const w=((...s)=>{const o=s.filter((s=>s)).join(\"&\");return o?`?${o}`:\"\"})(_,encodeFormOrQuery(i));s.url=a+w,delete s.query}return s}function serializeHeaders(s={}){return\"function\"!=typeof s.entries?{}:Array.from(s.entries()).reduce(((s,[o,i])=>(s[o]=function serializeHeaderValue(s){return s.includes(\", \")?s.split(\", \"):s}(i),s)),{})}function serializeResponse(s,o,{loadSpec:i=!1}={}){const a={ok:s.ok,url:s.url||o,status:s.status,statusText:s.statusText,headers:serializeHeaders(s.headers)},u=a.headers[\"content-type\"],_=i||((s=\"\")=>/(json|xml|yaml|text)\\b/.test(s))(u);return(_?s.text:s.blob||s.buffer).call(s).then((s=>{if(a.text=s,a.data=s,_)try{const o=function parseBody(s,o){if(o){if(0===o.indexOf(\"application/json\")||o.indexOf(\"+json\")>0)return JSON.parse(s);if(0===o.indexOf(\"application/xml\")||o.indexOf(\"+xml\")>0)return s}return fn.load(s)}(s,u);a.body=o,a.obj=o}catch(s){a.parseError=s}return a}))}async function http_http(s,o={}){\"object\"==typeof s&&(s=(o=s).url),o.headers=o.headers||{},(o=serializeRequest(o)).headers&&Object.keys(o.headers).forEach((s=>{const i=o.headers[s];\"string\"==typeof i&&(o.headers[s]=i.replace(/\\n+/g,\" \"))})),o.requestInterceptor&&(o=await o.requestInterceptor(o)||o);const i=o.headers[\"content-type\"]||o.headers[\"Content-Type\"];let a;/multipart\\/form-data/i.test(i)&&(delete o.headers[\"content-type\"],delete o.headers[\"Content-Type\"]);try{a=await(o.userFetch||fetch)(o.url,o),a=await serializeResponse(a,s,o),o.responseInterceptor&&(a=await o.responseInterceptor(a)||a)}catch(s){if(!a)throw s;const o=new Error(a.statusText||`response status is ${a.status}`);throw o.status=a.status,o.statusCode=a.status,o.responseError=s,o}if(!a.ok){const s=new Error(a.statusText||`response status is ${a.status}`);throw s.status=a.status,s.statusCode=a.status,s.response=a,s}return a}const options_retrievalURI=s=>{var o,i;const{baseDoc:a,url:u}=s,_=null!==(o=null!=a?a:u)&&void 0!==o?o:\"\";return\"string\"==typeof(null===(i=globalThis.document)||void 0===i?void 0:i.baseURI)?String(new URL(_,globalThis.document.baseURI)):_},options_httpClient=s=>{const{fetch:o,http:i}=s;return o||i||http_http};async function resolveGenericStrategy(s){const{spec:o,mode:i,allowMetaPatches:a=!0,pathDiscriminator:u,modelPropertyMacro:_,parameterMacro:w,requestInterceptor:x,responseInterceptor:C,skipNormalization:j=!1,useCircularStructures:L,strategies:B}=s,$=options_retrievalURI(s),U=options_httpClient(s),V=B.find((s=>s.match(o)));return async function doResolve(s){$&&(tu.refs.docCache[$]=s);tu.refs.fetchJSON=makeFetchJSON(U,{requestInterceptor:x,responseInterceptor:C});const o=[tu.refs];\"function\"==typeof w&&o.push(tu.parameters);\"function\"==typeof _&&o.push(tu.properties);\"strict\"!==i&&o.push(tu.allOf);const B=await function mapSpec(s){return new SpecMap(s).dispatch()}({spec:s,context:{baseDoc:$},plugins:o,allowMetaPatches:a,pathDiscriminator:u,parameterMacro:w,modelPropertyMacro:_,useCircularStructures:L});j||(B.spec=V.normalize(B.spec));return B}(o)}const su=_curry2((function and(s,o){return s&&o}));const ou=_curry2((function both(s,o){return _isFunction(s)?function _both(){return s.apply(this,arguments)&&o.apply(this,arguments)}:hc(su)(s,o)}));const iu=na(null);const au=dc(iu);function isOfTypeObject_typeof(s){return isOfTypeObject_typeof=\"function\"==typeof Symbol&&\"symbol\"==typeof Symbol.iterator?function(s){return typeof s}:function(s){return s&&\"function\"==typeof Symbol&&s.constructor===Symbol&&s!==Symbol.prototype?\"symbol\":typeof s},isOfTypeObject_typeof(s)}const cu=function isOfTypeObject(s){return\"object\"===isOfTypeObject_typeof(s)};const lu=$a(1,ou(au,cu));var uu=pipe(ra,Pc(\"Object\")),pu=pipe(ga,na(ga(Object))),hu=Qo(ou(Mc,pu),[\"constructor\"]),du=$a(1,(function(s){if(!lu(s)||!uu(s))return!1;var o=Object.getPrototypeOf(s);return!!iu(o)||hu(o)}));const fu=du,replace_special_chars_with_underscore=s=>s.replace(/\\W/gi,\"_\");function opId(s,o,i=\"\",{v2OperationIdCompatibilityMode:a}={}){if(!s||\"object\"!=typeof s)return null;return(s.operationId||\"\").replace(/\\s/g,\"\").length?replace_special_chars_with_underscore(s.operationId):function idFromPathMethod(s,o,{v2OperationIdCompatibilityMode:i}={}){if(i){let i=`${o.toLowerCase()}_${s}`.replace(/[\\s!@#$%^&*()_+=[{\\]};:<>|./?,\\\\'\"\"-]/g,\"_\");return i=i||`${s.substring(1)}_${o}`,i.replace(/((_){2,})/g,\"_\").replace(/^(_)*/g,\"\").replace(/([_])*$/g,\"\")}return`${o.toLowerCase()}${replace_special_chars_with_underscore(s)}`}(o,i,{v2OperationIdCompatibilityMode:a})}function normalize_normalize(s){const{spec:o}=s,{paths:i}=o,a={};if(!i||o.$$normalized)return s;for(const s in i){const u=i[s];if(null==u||![\"object\",\"function\"].includes(typeof u))continue;const _=u.parameters;for(const i in u){const w=u[i];if(null==w||![\"object\",\"function\"].includes(typeof w))continue;const x=opId(w,s,i);if(x){a[x]?a[x].push(w):a[x]=[w];const s=a[x];if(s.length>1)s.forEach(((s,o)=>{s.__originalOperationId=s.__originalOperationId||s.operationId,s.operationId=`${x}${o+1}`}));else if(void 0!==w.operationId){const o=s[0];o.__originalOperationId=o.__originalOperationId||w.operationId,o.operationId=x}}if(\"parameters\"!==i){const s=[],i={};for(const a in o)\"produces\"!==a&&\"consumes\"!==a&&\"security\"!==a||(i[a]=o[a],s.push(i));if(_&&(i.parameters=_,s.push(i)),s.length)for(const o of s)for(const s in o)if(Array.isArray(w[s])){if(\"parameters\"===s)for(const i of o[s]){w[s].some((s=>!(!fu(s)&&!fu(i))&&(s===i||[\"name\",\"$ref\",\"$$ref\"].some((o=>\"string\"==typeof s[o]&&\"string\"==typeof i[o]&&s[o]===i[o])))))||w[s].push(i)}}else w[s]=o[s]}}}return o.$$normalized=!0,s}const mu={name:\"generic\",match:()=>!0,normalize(s){const{spec:o}=normalize_normalize({spec:s});return o},resolve:async s=>resolveGenericStrategy(s)},gu=mu;const isOpenAPI30=s=>{try{const{openapi:o}=s;return\"string\"==typeof o&&/^3\\.0\\.(?:[1-9]\\d*|0)$/.test(o)}catch{return!1}},isOpenAPI31=s=>{try{const{openapi:o}=s;return\"string\"==typeof o&&/^3\\.1\\.(?:[1-9]\\d*|0)$/.test(o)}catch{return!1}},isOpenAPI3=s=>isOpenAPI30(s)||isOpenAPI31(s),yu={name:\"openapi-2\",match:s=>(s=>{try{const{swagger:o}=s;return\"2.0\"===o}catch{return!1}})(s),normalize(s){const{spec:o}=normalize_normalize({spec:s});return o},resolve:async s=>async function resolveOpenAPI2Strategy(s){return resolveGenericStrategy(s)}(s)},vu=yu;const bu={name:\"openapi-3-0\",match:s=>isOpenAPI30(s),normalize(s){const{spec:o}=normalize_normalize({spec:s});return o},resolve:async s=>async function resolveOpenAPI30Strategy(s){return resolveGenericStrategy(s)}(s)},_u=bu;var Su=__webpack_require__(34035);function _reduced(s){return s&&s[\"@@transducer/reduced\"]?s:{\"@@transducer/value\":s,\"@@transducer/reduced\":!0}}var Eu=function(){function XAll(s,o){this.xf=o,this.f=s,this.all=!0}return XAll.prototype[\"@@transducer/init\"]=_xfBase_init,XAll.prototype[\"@@transducer/result\"]=function(s){return this.all&&(s=this.xf[\"@@transducer/step\"](s,!0)),this.xf[\"@@transducer/result\"](s)},XAll.prototype[\"@@transducer/step\"]=function(s,o){return this.f(o)||(this.all=!1,s=_reduced(this.xf[\"@@transducer/step\"](s,!1))),s},XAll}();function _xall(s){return function(o){return new Eu(s,o)}}var wu=_curry2(_dispatchable([\"all\"],_xall,(function all(s,o){for(var i=0;i<o.length;){if(!s(o[i]))return!1;i+=1}return!0})));const xu=wu;class Annotation extends Su.Om{constructor(s,o,i){super(s,o,i),this.element=\"annotation\"}get code(){return this.attributes.get(\"code\")}set code(s){this.attributes.set(\"code\",s)}}const ku=Annotation;class Comment extends Su.Om{constructor(s,o,i){super(s,o,i),this.element=\"comment\"}}const Ou=Comment;class ParseResult extends Su.wE{constructor(s,o,i){super(s,o,i),this.element=\"parseResult\"}get api(){return this.children.filter((s=>s.classes.contains(\"api\"))).first}get results(){return this.children.filter((s=>s.classes.contains(\"result\")))}get result(){return this.results.first}get annotations(){return this.children.filter((s=>\"annotation\"===s.element))}get warnings(){return this.children.filter((s=>\"annotation\"===s.element&&s.classes.contains(\"warning\")))}get errors(){return this.children.filter((s=>\"annotation\"===s.element&&s.classes.contains(\"error\")))}get isEmpty(){return this.children.reject((s=>\"annotation\"===s.element)).isEmpty}replaceResult(s){const{result:o}=this;if(bc(o))return!1;const i=this.content.findIndex((s=>s===o));return-1!==i&&(this.content[i]=s,!0)}}const Au=ParseResult;class SourceMap extends Su.wE{constructor(s,o,i){super(s,o,i),this.element=\"sourceMap\"}get positionStart(){return this.children.filter((s=>s.classes.contains(\"position\"))).get(0)}get positionEnd(){return this.children.filter((s=>s.classes.contains(\"position\"))).get(1)}set position(s){if(void 0===s)return;const o=new Su.wE([s.start.row,s.start.column,s.start.char]),i=new Su.wE([s.end.row,s.end.column,s.end.char]);o.classes.push(\"position\"),i.classes.push(\"position\"),this.push(o).push(i)}}const Cu=SourceMap,hasMethod=(s,o)=>\"object\"==typeof o&&null!==o&&s in o&&\"function\"==typeof o[s],hasBasicElementProps=s=>\"object\"==typeof s&&null!=s&&\"_storedElement\"in s&&\"string\"==typeof s._storedElement&&\"_content\"in s,primitiveEq=(s,o)=>\"object\"==typeof o&&null!==o&&\"primitive\"in o&&(\"function\"==typeof o.primitive&&o.primitive()===s),hasClass=(s,o)=>\"object\"==typeof o&&null!==o&&\"classes\"in o&&(Array.isArray(o.classes)||o.classes instanceof Su.wE)&&o.classes.includes(s),isElementType=(s,o)=>\"object\"==typeof o&&null!==o&&\"element\"in o&&o.element===s,helpers=s=>s({hasMethod,hasBasicElementProps,primitiveEq,isElementType,hasClass}),ju=helpers((({hasBasicElementProps:s,primitiveEq:o})=>i=>i instanceof Su.Hg||s(i)&&o(void 0,i))),Pu=helpers((({hasBasicElementProps:s,primitiveEq:o})=>i=>i instanceof Su.Om||s(i)&&o(\"string\",i))),Iu=helpers((({hasBasicElementProps:s,primitiveEq:o})=>i=>i instanceof Su.kT||s(i)&&o(\"number\",i))),Tu=helpers((({hasBasicElementProps:s,primitiveEq:o})=>i=>i instanceof Su.Os||s(i)&&o(\"null\",i))),Nu=helpers((({hasBasicElementProps:s,primitiveEq:o})=>i=>i instanceof Su.bd||s(i)&&o(\"boolean\",i))),Mu=helpers((({hasBasicElementProps:s,primitiveEq:o,hasMethod:i})=>a=>a instanceof Su.Sh||s(a)&&o(\"object\",a)&&i(\"keys\",a)&&i(\"values\",a)&&i(\"items\",a))),Ru=helpers((({hasBasicElementProps:s,primitiveEq:o,hasMethod:i})=>a=>a instanceof Su.wE&&!(a instanceof Su.Sh)||s(a)&&o(\"array\",a)&&i(\"push\",a)&&i(\"unshift\",a)&&i(\"map\",a)&&i(\"reduce\",a))),Du=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Su.Pr||s(a)&&o(\"member\",a)&&i(void 0,a))),Lu=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Su.Ft||s(a)&&o(\"link\",a)&&i(void 0,a))),Fu=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Su.sI||s(a)&&o(\"ref\",a)&&i(void 0,a))),Bu=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof ku||s(a)&&o(\"annotation\",a)&&i(\"array\",a))),$u=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Ou||s(a)&&o(\"comment\",a)&&i(\"string\",a))),qu=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Au||s(a)&&o(\"parseResult\",a)&&i(\"array\",a))),Uu=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Cu||s(a)&&o(\"sourceMap\",a)&&i(\"array\",a))),isPrimitiveElement=s=>isElementType(\"object\",s)||isElementType(\"array\",s)||isElementType(\"boolean\",s)||isElementType(\"number\",s)||isElementType(\"string\",s)||isElementType(\"null\",s)||isElementType(\"member\",s),hasElementSourceMap=s=>Uu(s.meta.get(\"sourceMap\")),includesSymbols=(s,o)=>{if(0===s.length)return!0;const i=o.attributes.get(\"symbols\");return!!Ru(i)&&xu(sc(i.toValue()),s)},includesClasses=(s,o)=>0===s.length||xu(sc(o.classes.toValue()),s);const es_T=function(){return!0};const es_F=function(){return!1},getVisitFn=(s,o,i)=>{const a=s[o];if(null!=a){if(!i&&\"function\"==typeof a)return a;const s=i?a.leave:a.enter;if(\"function\"==typeof s)return s}else{const a=i?s.leave:s.enter;if(null!=a){if(\"function\"==typeof a)return a;const s=a[o];if(\"function\"==typeof s)return s}}return null},Vu={},getNodeType=s=>null==s?void 0:s.type,isNode=s=>\"string\"==typeof getNodeType(s),cloneNode=s=>Object.create(Object.getPrototypeOf(s),Object.getOwnPropertyDescriptors(s)),mergeAll=(s,{visitFnGetter:o=getVisitFn,nodeTypeGetter:i=getNodeType,breakSymbol:a=Vu,deleteNodeSymbol:u=null,skipVisitingNodeSymbol:_=!1,exposeEdits:w=!1}={})=>{const x=Symbol(\"skip\"),C=new Array(s.length).fill(x);return{enter(j,L,B,$,U,V){let z=j,Y=!1;const Z={...V,replaceWith(s,o){V.replaceWith(s,o),z=s}};for(let j=0;j<s.length;j+=1)if(C[j]===x){const x=o(s[j],i(z),!1);if(\"function\"==typeof x){const o=x.call(s[j],z,L,B,$,U,Z);if(\"function\"==typeof(null==o?void 0:o.then))throw new Go(\"Async visitor not supported in sync mode\",{visitor:s[j],visitFn:x});if(o===_)C[j]=z;else if(o===a)C[j]=a;else{if(o===u)return o;if(void 0!==o){if(!w)return o;z=o,Y=!0}}}}return Y?z:void 0},leave(u,w,j,L,B,$){let U=u;const V={...$,replaceWith(s,o){$.replaceWith(s,o),U=s}};for(let u=0;u<s.length;u+=1)if(C[u]===x){const x=o(s[u],i(U),!0);if(\"function\"==typeof x){const o=x.call(s[u],U,w,j,L,B,V);if(\"function\"==typeof(null==o?void 0:o.then))throw new Go(\"Async visitor not supported in sync mode\",{visitor:s[u],visitFn:x});if(o===a)C[u]=a;else if(void 0!==o&&o!==_)return o}}else C[u]===U&&(C[u]=x)}}};mergeAll[Symbol.for(\"nodejs.util.promisify.custom\")]=(s,{visitFnGetter:o=getVisitFn,nodeTypeGetter:i=getNodeType,breakSymbol:a=Vu,deleteNodeSymbol:u=null,skipVisitingNodeSymbol:_=!1,exposeEdits:w=!1}={})=>{const x=Symbol(\"skip\"),C=new Array(s.length).fill(x);return{async enter(j,L,B,$,U,V){let z=j,Y=!1;const Z={...V,replaceWith(s,o){V.replaceWith(s,o),z=s}};for(let j=0;j<s.length;j+=1)if(C[j]===x){const x=o(s[j],i(z),!1);if(\"function\"==typeof x){const o=await x.call(s[j],z,L,B,$,U,Z);if(o===_)C[j]=z;else if(o===a)C[j]=a;else{if(o===u)return o;if(void 0!==o){if(!w)return o;z=o,Y=!0}}}}return Y?z:void 0},async leave(u,w,j,L,B,$){let U=u;const V={...$,replaceWith(s,o){$.replaceWith(s,o),U=s}};for(let u=0;u<s.length;u+=1)if(C[u]===x){const x=o(s[u],i(U),!0);if(\"function\"==typeof x){const o=await x.call(s[u],U,w,j,L,B,V);if(o===a)C[u]=a;else if(void 0!==o&&o!==_)return o}}else C[u]===U&&(C[u]=x)}}};const visit=(s,o,{keyMap:i=null,state:a={},breakSymbol:u=Vu,deleteNodeSymbol:_=null,skipVisitingNodeSymbol:w=!1,visitFnGetter:x=getVisitFn,nodeTypeGetter:C=getNodeType,nodePredicate:j=isNode,nodeCloneFn:L=cloneNode,detectCycles:B=!0,detectCyclesCallback:$=null}={})=>{const U=i||{};let V,z,Y=Array.isArray(s),Z=[s],ee=-1,ie=[],ae=s;const ce=[],le=[];do{ee+=1;const s=ee===Z.length;let i;const fe=s&&0!==ie.length;if(s){if(i=0===le.length?void 0:ce.pop(),ae=z,z=le.pop(),fe)if(Y){ae=ae.slice();let s=0;for(const[o,i]of ie){const a=o-s;i===_?(ae.splice(a,1),s+=1):ae[a]=i}}else{ae=L(ae);for(const[s,o]of ie)ae[s]=o}ee=V.index,Z=V.keys,ie=V.edits,Y=V.inArray,V=V.prev}else if(z!==_&&void 0!==z){if(i=Y?ee:Z[ee],ae=z[i],ae===_||void 0===ae)continue;ce.push(i)}let ye;if(!Array.isArray(ae)){var pe;if(!j(ae))throw new Go(`Invalid AST Node:  ${String(ae)}`,{node:ae});if(B&&le.includes(ae)){\"function\"==typeof $&&$(ae,i,z,ce,le),ce.pop();continue}const _=x(o,C(ae),s);if(_){for(const[s,i]of Object.entries(a))o[s]=i;const u={replaceWith(o,a){\"function\"==typeof a?a(o,ae,i,z,ce,le):z&&(z[i]=o),s||(ae=o)}};ye=_.call(o,ae,i,z,ce,le,u)}if(\"function\"==typeof(null===(pe=ye)||void 0===pe?void 0:pe.then))throw new Go(\"Async visitor not supported in sync mode\",{visitor:o,visitFn:_});if(ye===u)break;if(ye===w){if(!s){ce.pop();continue}}else if(void 0!==ye&&(ie.push([i,ye]),!s)){if(!j(ye)){ce.pop();continue}ae=ye}}var de;if(void 0===ye&&fe&&ie.push([i,ae]),!s)V={inArray:Y,index:ee,keys:Z,edits:ie,prev:V},Y=Array.isArray(ae),Z=Y?ae:null!==(de=U[C(ae)])&&void 0!==de?de:[],ee=-1,ie=[],z!==_&&void 0!==z&&le.push(z),z=ae}while(void 0!==V);return 0!==ie.length?ie[ie.length-1][1]:s};visit[Symbol.for(\"nodejs.util.promisify.custom\")]=async(s,o,{keyMap:i=null,state:a={},breakSymbol:u=Vu,deleteNodeSymbol:_=null,skipVisitingNodeSymbol:w=!1,visitFnGetter:x=getVisitFn,nodeTypeGetter:C=getNodeType,nodePredicate:j=isNode,nodeCloneFn:L=cloneNode,detectCycles:B=!0,detectCyclesCallback:$=null}={})=>{const U=i||{};let V,z,Y=Array.isArray(s),Z=[s],ee=-1,ie=[],ae=s;const ce=[],le=[];do{ee+=1;const s=ee===Z.length;let i;const de=s&&0!==ie.length;if(s){if(i=0===le.length?void 0:ce.pop(),ae=z,z=le.pop(),de)if(Y){ae=ae.slice();let s=0;for(const[o,i]of ie){const a=o-s;i===_?(ae.splice(a,1),s+=1):ae[a]=i}}else{ae=L(ae);for(const[s,o]of ie)ae[s]=o}ee=V.index,Z=V.keys,ie=V.edits,Y=V.inArray,V=V.prev}else if(z!==_&&void 0!==z){if(i=Y?ee:Z[ee],ae=z[i],ae===_||void 0===ae)continue;ce.push(i)}let fe;if(!Array.isArray(ae)){if(!j(ae))throw new Go(`Invalid AST Node: ${String(ae)}`,{node:ae});if(B&&le.includes(ae)){\"function\"==typeof $&&$(ae,i,z,ce,le),ce.pop();continue}const _=x(o,C(ae),s);if(_){for(const[s,i]of Object.entries(a))o[s]=i;const u={replaceWith(o,a){\"function\"==typeof a?a(o,ae,i,z,ce,le):z&&(z[i]=o),s||(ae=o)}};fe=await _.call(o,ae,i,z,ce,le,u)}if(fe===u)break;if(fe===w){if(!s){ce.pop();continue}}else if(void 0!==fe&&(ie.push([i,fe]),!s)){if(!j(fe)){ce.pop();continue}ae=fe}}var pe;if(void 0===fe&&de&&ie.push([i,ae]),!s)V={inArray:Y,index:ee,keys:Z,edits:ie,prev:V},Y=Array.isArray(ae),Z=Y?ae:null!==(pe=U[C(ae)])&&void 0!==pe?pe:[],ee=-1,ie=[],z!==_&&void 0!==z&&le.push(z),z=ae}while(void 0!==V);return 0!==ie.length?ie[ie.length-1][1]:s};const zu=class CloneError extends Go{value;constructor(s,o){super(s,o),void 0!==o&&(this.value=o.value)}};const Wu=class DeepCloneError extends zu{};const Ju=class ShallowCloneError extends zu{},cloneDeep=(s,o={})=>{const{visited:i=new WeakMap}=o,a={...o,visited:i};if(i.has(s))return i.get(s);if(s instanceof Su.KeyValuePair){const{key:o,value:u}=s,_=ju(o)?cloneDeep(o,a):o,w=ju(u)?cloneDeep(u,a):u,x=new Su.KeyValuePair(_,w);return i.set(s,x),x}if(s instanceof Su.ot){const mapper=s=>cloneDeep(s,a),o=[...s].map(mapper),u=new Su.ot(o);return i.set(s,u),u}if(s instanceof Su.G6){const mapper=s=>cloneDeep(s,a),o=[...s].map(mapper),u=new Su.G6(o);return i.set(s,u),u}if(ju(s)){const o=cloneShallow(s);if(i.set(s,o),s.content)if(ju(s.content))o.content=cloneDeep(s.content,a);else if(s.content instanceof Su.KeyValuePair)o.content=cloneDeep(s.content,a);else if(Array.isArray(s.content)){const mapper=s=>cloneDeep(s,a);o.content=s.content.map(mapper)}else o.content=s.content;else o.content=s.content;return o}throw new Wu(\"Value provided to cloneDeep function couldn't be cloned\",{value:s})};cloneDeep.safe=s=>{try{return cloneDeep(s)}catch{return s}};const cloneShallowKeyValuePair=s=>{const{key:o,value:i}=s;return new Su.KeyValuePair(o,i)},cloneShallowElement=s=>{const o=new s.constructor;if(o.element=s.element,s.meta.length>0&&(o._meta=cloneDeep(s.meta)),s.attributes.length>0&&(o._attributes=cloneDeep(s.attributes)),ju(s.content)){const i=s.content;o.content=cloneShallowElement(i)}else Array.isArray(s.content)?o.content=[...s.content]:s.content instanceof Su.KeyValuePair?o.content=cloneShallowKeyValuePair(s.content):o.content=s.content;return o},cloneShallow=s=>{if(s instanceof Su.KeyValuePair)return cloneShallowKeyValuePair(s);if(s instanceof Su.ot)return(s=>{const o=[...s];return new Su.ot(o)})(s);if(s instanceof Su.G6)return(s=>{const o=[...s];return new Su.G6(o)})(s);if(ju(s))return cloneShallowElement(s);throw new Ju(\"Value provided to cloneShallow function couldn't be cloned\",{value:s})};cloneShallow.safe=s=>{try{return cloneShallow(s)}catch{return s}};const visitor_getNodeType=s=>Mu(s)?\"ObjectElement\":Ru(s)?\"ArrayElement\":Du(s)?\"MemberElement\":Pu(s)?\"StringElement\":Nu(s)?\"BooleanElement\":Iu(s)?\"NumberElement\":Tu(s)?\"NullElement\":Lu(s)?\"LinkElement\":Fu(s)?\"RefElement\":void 0,visitor_cloneNode=s=>ju(s)?cloneShallow(s):cloneNode(s),Hu=pipe(visitor_getNodeType,Jc),Ku={ObjectElement:[\"content\"],ArrayElement:[\"content\"],MemberElement:[\"key\",\"value\"],StringElement:[],BooleanElement:[],NumberElement:[],NullElement:[],RefElement:[],LinkElement:[],Annotation:[],Comment:[],ParseResultElement:[\"content\"],SourceMap:[\"content\"]};class PredicateVisitor{result;predicate;returnOnTrue;returnOnFalse;constructor({predicate:s=es_F,returnOnTrue:o,returnOnFalse:i}={}){this.result=[],this.predicate=s,this.returnOnTrue=o,this.returnOnFalse=i}enter(s){return this.predicate(s)?(this.result.push(s),this.returnOnTrue):this.returnOnFalse}}const visitor_visit=(s,o,{keyMap:i=Ku,...a}={})=>visit(s,o,{keyMap:i,nodeTypeGetter:visitor_getNodeType,nodePredicate:Hu,nodeCloneFn:visitor_cloneNode,...a});visitor_visit[Symbol.for(\"nodejs.util.promisify.custom\")]=async(s,o,{keyMap:i=Ku,...a}={})=>visit[Symbol.for(\"nodejs.util.promisify.custom\")](s,o,{keyMap:i,nodeTypeGetter:visitor_getNodeType,nodePredicate:Hu,nodeCloneFn:visitor_cloneNode,...a});const nodeTypeGetter=s=>\"string\"==typeof(null==s?void 0:s.type)?s.type:visitor_getNodeType(s),Gu={EphemeralObject:[\"content\"],EphemeralArray:[\"content\"],...Ku},value_visitor_visit=(s,o,{keyMap:i=Gu,...a}={})=>visitor_visit(s,o,{keyMap:i,nodeTypeGetter,nodePredicate:es_T,detectCycles:!1,deleteNodeSymbol:Symbol.for(\"delete-node\"),skipVisitingNodeSymbol:Symbol.for(\"skip-visiting-node\"),...a});value_visitor_visit[Symbol.for(\"nodejs.util.promisify.custom\")]=async(s,{keyMap:o=Gu,...i}={})=>visitor_visit[Symbol.for(\"nodejs.util.promisify.custom\")](s,visitor,{keyMap:o,nodeTypeGetter,nodePredicate:es_T,detectCycles:!1,deleteNodeSymbol:Symbol.for(\"delete-node\"),skipVisitingNodeSymbol:Symbol.for(\"skip-visiting-node\"),...i});const Yu=class EphemeralArray{type=\"EphemeralArray\";content=[];reference=void 0;constructor(s){this.content=s,this.reference=[]}toReference(){return this.reference}toArray(){return this.reference.push(...this.content),this.reference}};const Xu=class EphemeralObject{type=\"EphemeralObject\";content=[];reference=void 0;constructor(s){this.content=s,this.reference={}}toReference(){return this.reference}toObject(){return Object.assign(this.reference,Object.fromEntries(this.content))}};class Visitor{ObjectElement={enter:s=>{if(this.references.has(s))return this.references.get(s).toReference();const o=new Xu(s.content);return this.references.set(s,o),o}};EphemeralObject={leave:s=>s.toObject()};MemberElement={enter:s=>[s.key,s.value]};ArrayElement={enter:s=>{if(this.references.has(s))return this.references.get(s).toReference();const o=new Yu(s.content);return this.references.set(s,o),o}};EphemeralArray={leave:s=>s.toArray()};references=new WeakMap;BooleanElement(s){return s.toValue()}NumberElement(s){return s.toValue()}StringElement(s){return s.toValue()}NullElement(){return null}RefElement(s,...o){var i;const a=o[3];return\"EphemeralObject\"===(null===(i=a[a.length-1])||void 0===i?void 0:i.type)?Symbol.for(\"delete-node\"):String(s.toValue())}LinkElement(s){return Pu(s.href)?s.href.toValue():\"\"}}const serializers_value=s=>ju(s)?Pu(s)||Iu(s)||Nu(s)||Tu(s)?s.toValue():value_visitor_visit(s,new Visitor):s;const Qu=_curry3((function mergeWithKey(s,o,i){var a,u={};for(a in i=i||{},o=o||{})_has(a,o)&&(u[a]=_has(a,i)?s(a,o[a],i[a]):o[a]);for(a in i)_has(a,i)&&!_has(a,u)&&(u[a]=i[a]);return u}));const Zu=_curry3((function mergeDeepWithKey(s,o,i){return Qu((function(o,i,a){return _isObject(i)&&_isObject(a)?mergeDeepWithKey(s,i,a):s(o,i,a)}),o,i)}));const ep=_curry2((function mergeDeepRight(s,o){return Zu((function(s,o,i){return i}),s,o)}));const tp=_curry2(_path);const rp=ja(0,-1);const np=_curry2((function apply(s,o){return s.apply(this,o)}));const sp=dc(Mc);var op=_curry1((function empty(s){return null!=s&&\"function\"==typeof s[\"fantasy-land/empty\"]?s[\"fantasy-land/empty\"]():null!=s&&null!=s.constructor&&\"function\"==typeof s.constructor[\"fantasy-land/empty\"]?s.constructor[\"fantasy-land/empty\"]():null!=s&&\"function\"==typeof s.empty?s.empty():null!=s&&null!=s.constructor&&\"function\"==typeof s.constructor.empty?s.constructor.empty():ca(s)?[]:_isString(s)?\"\":_isObject(s)?{}:Ei(s)?function(){return arguments}():function _isTypedArray(s){var o=Object.prototype.toString.call(s);return\"[object Uint8ClampedArray]\"===o||\"[object Int8Array]\"===o||\"[object Uint8Array]\"===o||\"[object Int16Array]\"===o||\"[object Uint16Array]\"===o||\"[object Int32Array]\"===o||\"[object Uint32Array]\"===o||\"[object Float32Array]\"===o||\"[object Float64Array]\"===o||\"[object BigInt64Array]\"===o||\"[object BigUint64Array]\"===o}(s)?s.constructor.from(\"\"):void 0}));const ip=op;const cp=_curry1((function isEmpty(s){return null!=s&&na(s,ip(s))}));const lp=$a(1,Mc(Array.isArray)?Array.isArray:pipe(ra,Pc(\"Array\")));const up=ou(lp,cp);var pp=$a(3,(function(s,o,i){var a=tp(s,i),u=tp(rp(s),i);if(!sp(a)&&!up(s)){var _=Ea(a,u);return np(_,o)}}));const hp=pp;class Namespace extends Su.g${constructor(){super(),this.register(\"annotation\",ku),this.register(\"comment\",Ou),this.register(\"parseResult\",Au),this.register(\"sourceMap\",Cu)}}const dp=new Namespace,createNamespace=s=>{const o=new Namespace;return fu(s)&&o.use(s),o},fp=dp,toolbox=()=>({predicates:{...ie},namespace:fp}),mp={toolboxCreator:toolbox,visitorOptions:{nodeTypeGetter:visitor_getNodeType,exposeEdits:!0}},dispatchPluginsSync=(s,o,i={})=>{if(0===o.length)return s;const a=ep(mp,i),{toolboxCreator:u,visitorOptions:_}=a,w=u(),x=o.map((s=>s(w))),C=mergeAll(x.map(La({},\"visitor\")),{..._});x.forEach(hp([\"pre\"],[]));const j=visitor_visit(s,C,_);return x.forEach(hp([\"post\"],[])),j};dispatchPluginsSync[Symbol.for(\"nodejs.util.promisify.custom\")]=async(s,o,i={})=>{if(0===o.length)return s;const a=ep(mp,i),{toolboxCreator:u,visitorOptions:_}=a,w=u(),x=o.map((s=>s(w))),C=mergeAll[Symbol.for(\"nodejs.util.promisify.custom\")],j=visitor_visit[Symbol.for(\"nodejs.util.promisify.custom\")],L=C(x.map(La({},\"visitor\")),{..._});await Promise.allSettled(x.map(hp([\"pre\"],[])));const B=await j(s,L,_);return await Promise.allSettled(x.map(hp([\"post\"],[]))),B};const refract=(s,{Type:o,plugins:i=[]})=>{const a=new o(s);return ju(s)&&(s.meta.length>0&&(a.meta=cloneDeep(s.meta)),s.attributes.length>0&&(a.attributes=cloneDeep(s.attributes))),dispatchPluginsSync(a,i,{toolboxCreator:toolbox,visitorOptions:{nodeTypeGetter:visitor_getNodeType}})},createRefractor=s=>(o,i={})=>refract(o,{...i,Type:s});Su.Sh.refract=createRefractor(Su.Sh),Su.wE.refract=createRefractor(Su.wE),Su.Om.refract=createRefractor(Su.Om),Su.bd.refract=createRefractor(Su.bd),Su.Os.refract=createRefractor(Su.Os),Su.kT.refract=createRefractor(Su.kT),Su.Ft.refract=createRefractor(Su.Ft),Su.sI.refract=createRefractor(Su.sI),ku.refract=createRefractor(ku),Ou.refract=createRefractor(Ou),Au.refract=createRefractor(Au),Cu.refract=createRefractor(Cu);const computeEdges=(s,o=new WeakMap)=>(Du(s)?(o.set(s.key,s),computeEdges(s.key,o),o.set(s.value,s),computeEdges(s.value,o)):s.children.forEach((i=>{o.set(i,s),computeEdges(i,o)})),o);const gp=class Transcluder_Transcluder{element;edges;constructor({element:s}){this.element=s}transclude(s,o){var i;if(s===this.element)return o;if(s===o)return this.element;this.edges=null!==(i=this.edges)&&void 0!==i?i:computeEdges(this.element);const a=this.edges.get(s);return bc(a)?void 0:(Mu(a)?((s,o,i)=>{const a=i.get(s);Mu(a)&&(a.content=a.map(((u,_,w)=>w===s?(i.delete(s),i.set(o,a),o):w)))})(s,o,this.edges):Ru(a)?((s,o,i)=>{const a=i.get(s);Ru(a)&&(a.content=a.map((u=>u===s?(i.delete(s),i.set(o,a),o):u)))})(s,o,this.edges):Du(a)&&((s,o,i)=>{const a=i.get(s);Du(a)&&(a.key===s&&(a.key=o,i.delete(s),i.set(o,a)),a.value===s&&(a.value=o,i.delete(s),i.set(o,a)))})(s,o,this.edges),this.element)}},fromURIReference=s=>{const o=s.indexOf(\"#\");return(s=>{try{const o=s.startsWith(\"#\")?s.slice(1):s;return decodeURIComponent(o)}catch{return s}})(-1===o?\"#\":s.substring(o))},yp=function fnparser(){const s=Ep,o=Sp,i=this,a=\"parser.js: Parser(): \";i.ast=void 0,i.stats=void 0,i.trace=void 0,i.callbacks=[];let u,_,w,x,C,j,L,B=0,$=0,U=0,V=0,z=0,Y=new function systemData(){this.state=s.ACTIVE,this.phraseLength=0,this.refresh=()=>{this.state=s.ACTIVE,this.phraseLength=0}};i.parse=(Z,ee,ie,ae)=>{const ce=`${a}parse(): `;B=0,$=0,U=0,V=0,z=0,u=void 0,_=void 0,w=void 0,x=void 0,Y.refresh(),C=void 0,j=void 0,L=void 0,x=o.stringToChars(ie),u=Z.rules,_=Z.udts;const le=ee.toLowerCase();let pe;for(const s in u)if(u.hasOwnProperty(s)&&le===u[s].lower){pe=u[s].index;break}if(void 0===pe)throw new Error(`${ce}start rule name '${startRule}' not recognized`);(()=>{const s=`${a}initializeCallbacks(): `;let o,w;for(C=[],j=[],o=0;o<u.length;o+=1)C[o]=void 0;for(o=0;o<_.length;o+=1)j[o]=void 0;const x=[];for(o=0;o<u.length;o+=1)x.push(u[o].lower);for(o=0;o<_.length;o+=1)x.push(_[o].lower);for(const a in i.callbacks)if(i.callbacks.hasOwnProperty(a)){if(o=x.indexOf(a.toLowerCase()),o<0)throw new Error(`${s}syntax callback '${a}' not a rule or udt name`);if(w=i.callbacks[a]?i.callbacks[a]:void 0,\"function\"!=typeof w&&void 0!==w)throw new Error(`${s}syntax callback[${a}] must be function reference or falsy)`);o<u.length?C[o]=w:j[o-u.length]=w}})(),i.trace&&i.trace.init(u,_,x),i.stats&&i.stats.init(u,_),i.ast&&i.ast.init(u,_,x),L=ae,w=[{type:s.RNM,index:pe}],opExecute(0,0),w=void 0;let de=!1;switch(Y.state){case s.ACTIVE:throw new Error(`${ce}final state should never be 'ACTIVE'`);case s.NOMATCH:de=!1;break;case s.EMPTY:case s.MATCH:de=Y.phraseLength===x.length;break;default:throw new Error(\"unrecognized state\")}return{success:de,state:Y.state,stateName:s.idName(Y.state),length:x.length,matched:Y.phraseLength,maxMatched:z,maxTreeDepth:U,nodeHits:V}};const validateRnmCallbackResult=(o,i,u,_)=>{if(i.phraseLength>u){let s=`${a}opRNM(${o.name}): callback function error: `;throw s+=`sysData.phraseLength: ${i.phraseLength}`,s+=` must be <= remaining chars: ${u}`,new Error(s)}switch(i.state){case s.ACTIVE:if(!_)throw new Error(`${a}opRNM(${o.name}): callback function return error. ACTIVE state not allowed.`);break;case s.EMPTY:i.phraseLength=0;break;case s.MATCH:0===i.phraseLength&&(i.state=s.EMPTY);break;case s.NOMATCH:i.phraseLength=0;break;default:throw new Error(`${a}opRNM(${o.name}): callback function return error. Unrecognized return state: ${i.state}`)}},opUDT=(o,C)=>{let $,U,V;const z=w[o],Z=_[z.index];Y.UdtIndex=Z.index,B||(V=i.ast&&i.ast.udtDefined(z.index),V&&(U=u.length+z.index,$=i.ast.getLength(),i.ast.down(U,Z.name)));const ee=x.length-C;j[z.index](Y,x,C,L),((o,i,u)=>{if(i.phraseLength>u){let s=`${a}opUDT(${o.name}): callback function error: `;throw s+=`sysData.phraseLength: ${i.phraseLength}`,s+=` must be <= remaining chars: ${u}`,new Error(s)}switch(i.state){case s.ACTIVE:throw new Error(`${a}opUDT(${o.name}) ACTIVE state return not allowed.`);case s.EMPTY:if(!o.empty)throw new Error(`${a}opUDT(${o.name}) may not return EMPTY.`);i.phraseLength=0;break;case s.MATCH:if(0===i.phraseLength){if(!o.empty)throw new Error(`${a}opUDT(${o.name}) may not return EMPTY.`);i.state=s.EMPTY}break;case s.NOMATCH:i.phraseLength=0;break;default:throw new Error(`${a}opUDT(${o.name}): callback function return error. Unrecognized return state: ${i.state}`)}})(Z,Y,ee),B||V&&(Y.state===s.NOMATCH?i.ast.setLength($):i.ast.up(U,Z.name,C,Y.phraseLength))},opExecute=(o,_)=>{const j=`${a}opExecute(): `,Z=w[o];switch(V+=1,$>U&&(U=$),$+=1,Y.refresh(),i.trace&&i.trace.down(Z,_),Z.type){case s.ALT:((o,i)=>{const a=w[o];for(let o=0;o<a.children.length&&(opExecute(a.children[o],i),Y.state===s.NOMATCH);o+=1);})(o,_);break;case s.CAT:((o,a)=>{let u,_,x,C;const j=w[o];i.ast&&(_=i.ast.getLength()),u=!0,x=a,C=0;for(let o=0;o<j.children.length;o+=1){if(opExecute(j.children[o],x),Y.state===s.NOMATCH){u=!1;break}x+=Y.phraseLength,C+=Y.phraseLength}u?(Y.state=0===C?s.EMPTY:s.MATCH,Y.phraseLength=C):(Y.state=s.NOMATCH,Y.phraseLength=0,i.ast&&i.ast.setLength(_))})(o,_);break;case s.REP:((o,a)=>{let u,_,C,j;const L=w[o];if(0===L.max)return Y.state=s.EMPTY,void(Y.phraseLength=0);for(_=a,C=0,j=0,i.ast&&(u=i.ast.getLength());!(_>=x.length)&&(opExecute(o+1,_),Y.state!==s.NOMATCH)&&Y.state!==s.EMPTY&&(j+=1,C+=Y.phraseLength,_+=Y.phraseLength,j!==L.max););Y.state===s.EMPTY||j>=L.min?(Y.state=0===C?s.EMPTY:s.MATCH,Y.phraseLength=C):(Y.state=s.NOMATCH,Y.phraseLength=0,i.ast&&i.ast.setLength(u))})(o,_);break;case s.RNM:((o,a)=>{let _,j,$;const U=w[o],V=u[U.index],z=C[V.index];if(B||(j=i.ast&&i.ast.ruleDefined(U.index),j&&(_=i.ast.getLength(),i.ast.down(U.index,u[U.index].name))),z){const o=x.length-a;z(Y,x,a,L),validateRnmCallbackResult(V,Y,o,!0),Y.state===s.ACTIVE&&($=w,w=V.opcodes,opExecute(0,a),w=$,z(Y,x,a,L),validateRnmCallbackResult(V,Y,o,!1))}else $=w,w=V.opcodes,opExecute(0,a,Y),w=$;B||j&&(Y.state===s.NOMATCH?i.ast.setLength(_):i.ast.up(U.index,V.name,a,Y.phraseLength))})(o,_);break;case s.TRG:((o,i)=>{const a=w[o];Y.state=s.NOMATCH,i<x.length&&a.min<=x[i]&&x[i]<=a.max&&(Y.state=s.MATCH,Y.phraseLength=1)})(o,_);break;case s.TBS:((o,i)=>{const a=w[o],u=a.string.length;if(Y.state=s.NOMATCH,i+u<=x.length){for(let s=0;s<u;s+=1)if(x[i+s]!==a.string[s])return;Y.state=s.MATCH,Y.phraseLength=u}})(o,_);break;case s.TLS:((o,i)=>{let a;const u=w[o];Y.state=s.NOMATCH;const _=u.string.length;if(0!==_){if(i+_<=x.length){for(let s=0;s<_;s+=1)if(a=x[i+s],a>=65&&a<=90&&(a+=32),a!==u.string[s])return;Y.state=s.MATCH,Y.phraseLength=_}}else Y.state=s.EMPTY})(o,_);break;case s.UDT:opUDT(o,_);break;case s.AND:((o,i)=>{switch(B+=1,opExecute(o+1,i),B-=1,Y.phraseLength=0,Y.state){case s.EMPTY:case s.MATCH:Y.state=s.EMPTY;break;case s.NOMATCH:Y.state=s.NOMATCH;break;default:throw new Error(`opAND: invalid state ${Y.state}`)}})(o,_);break;case s.NOT:((o,i)=>{switch(B+=1,opExecute(o+1,i),B-=1,Y.phraseLength=0,Y.state){case s.EMPTY:case s.MATCH:Y.state=s.NOMATCH;break;case s.NOMATCH:Y.state=s.EMPTY;break;default:throw new Error(`opNOT: invalid state ${Y.state}`)}})(o,_);break;default:throw new Error(`${j}unrecognized operator`)}B||_+Y.phraseLength>z&&(z=_+Y.phraseLength),i.stats&&i.stats.collect(Z,Y),i.trace&&i.trace.up(Z,Y.state,_,Y.phraseLength),$-=1}},vp=function fnast(){const s=Ep,o=Sp,i=this;let a,u,_,w=0;const x=[],C=[],j=[];function indent(s){let o=\"\";for(;s-- >0;)o+=\" \";return o}i.callbacks=[],i.init=(s,o,L)=>{let B;C.length=0,j.length=0,w=0,a=s,u=o,_=L;const $=[];for(B=0;B<a.length;B+=1)$.push(a[B].lower);for(B=0;B<u.length;B+=1)$.push(u[B].lower);for(w=a.length+u.length,B=0;B<w;B+=1)x[B]=void 0;for(const s in i.callbacks)if(i.callbacks.hasOwnProperty(s)){const o=s.toLowerCase();if(B=$.indexOf(o),B<0)throw new Error(`parser.js: Ast()): init: node '${s}' not a rule or udt name`);x[B]=i.callbacks[s]}},i.ruleDefined=s=>!!x[s],i.udtDefined=s=>!!x[a.length+s],i.down=(o,i)=>{const a=j.length;return C.push(a),j.push({name:i,thisIndex:a,thatIndex:void 0,state:s.SEM_PRE,callbackIndex:o,phraseIndex:void 0,phraseLength:void 0,stack:C.length}),a},i.up=(o,i,a,u)=>{const _=j.length,w=C.pop();return j.push({name:i,thisIndex:_,thatIndex:w,state:s.SEM_POST,callbackIndex:o,phraseIndex:a,phraseLength:u,stack:C.length}),j[w].thatIndex=_,j[w].phraseIndex=a,j[w].phraseLength=u,_},i.translate=o=>{let i,a;for(let u=0;u<j.length;u+=1)a=j[u],i=x[a.callbackIndex],i&&(a.state===s.SEM_PRE?i(s.SEM_PRE,_,a.phraseIndex,a.phraseLength,o):i&&i(s.SEM_POST,_,a.phraseIndex,a.phraseLength,o))},i.setLength=s=>{j.length=s,C.length=s>0?j[s-1].stack:0},i.getLength=()=>j.length,i.toXml=()=>{let i=\"\",a=0;return i+='<?xml version=\"1.0\" encoding=\"utf-8\"?>\\n',i+=`<root nodes=\"${j.length/2}\" characters=\"${_.length}\">\\n`,i+=\"\\x3c!-- input string --\\x3e\\n\",i+=indent(a+2),i+=o.charsToString(_),i+=\"\\n\",j.forEach((u=>{u.state===s.SEM_PRE?(a+=1,i+=indent(a),i+=`<node name=\"${u.name}\" index=\"${u.phraseIndex}\" length=\"${u.phraseLength}\">\\n`,i+=indent(a+2),i+=o.charsToString(_,u.phraseIndex,u.phraseLength),i+=\"\\n\"):(i+=indent(a),i+=`</node>\\x3c!-- name=\"${u.name}\" --\\x3e\\n`,a-=1)})),i+=\"</root>\\n\",i}},bp=function fntrace(){const s=Ep,o=Sp,i=\"parser.js: Trace(): \";let a,u,_,w=\"\",x=0;const C=this,indent=s=>{let o=\"\",i=0;if(s>=0)for(;s--;)i+=1,5===i?(o+=\"|\",i=0):o+=\".\";return o};C.init=(s,o,i)=>{u=s,_=o,a=i};const opName=a=>{let w;switch(a.type){case s.ALT:w=\"ALT\";break;case s.CAT:w=\"CAT\";break;case s.REP:w=a.max===1/0?`REP(${a.min},inf)`:`REP(${a.min},${a.max})`;break;case s.RNM:w=`RNM(${u[a.index].name})`;break;case s.TRG:w=`TRG(${a.min},${a.max})`;break;case s.TBS:w=a.string.length>6?`TBS(${o.charsToString(a.string,0,3)}...)`:`TBS(${o.charsToString(a.string,0,6)})`;break;case s.TLS:w=a.string.length>6?`TLS(${o.charsToString(a.string,0,3)}...)`:`TLS(${o.charsToString(a.string,0,6)})`;break;case s.UDT:w=`UDT(${_[a.index].name})`;break;case s.AND:w=\"AND\";break;case s.NOT:w=\"NOT\";break;default:throw new Error(`${i}Trace: opName: unrecognized opcode`)}return w};C.down=(s,i)=>{const u=indent(x),_=Math.min(100,a.length-i);let C=o.charsToString(a,i,_);_<a.length-i&&(C+=\"...\"),C=`${u}|-|[${opName(s)}]${C}\\n`,w+=C,x+=1},C.up=(u,_,C,j)=>{const L=`${i}trace.up: `;x-=1;const B=indent(x);let $,U,V;switch(_){case s.EMPTY:V=\"|E|\",U=\"''\";break;case s.MATCH:V=\"|M|\",$=Math.min(100,j),U=$<j?`'${o.charsToString(a,C,$)}...'`:`'${o.charsToString(a,C,$)}'`;break;case s.NOMATCH:V=\"|N|\",U=\"\";break;default:throw new Error(`${L} unrecognized state`)}U=`${B}${V}[${opName(u)}]${U}\\n`,w+=U},C.displayTrace=()=>w},_p=function fnstats(){const s=Ep;let o,i,a;const u=[],_=[],w=[];this.init=(s,a)=>{o=s,i=a,clear()},this.collect=(o,i)=>{incStat(a,i.state,i.phraseLength),incStat(u[o.type],i.state,i.phraseLength),o.type===s.RNM&&incStat(_[o.index],i.state,i.phraseLength),o.type===s.UDT&&incStat(w[o.index],i.state,i.phraseLength)},this.displayStats=()=>{let o=\"\";const i={match:0,empty:0,nomatch:0,total:0},displayRow=(s,o,a,u,_)=>{i.match+=o,i.empty+=a,i.nomatch+=u,i.total+=_;return`${s} | ${normalize(o)} | ${normalize(a)} | ${normalize(u)} | ${normalize(_)} |\\n`};return o+=\"          OPERATOR STATS\\n\",o+=\"      |   MATCH |   EMPTY | NOMATCH |   TOTAL |\\n\",o+=displayRow(\"  ALT\",u[s.ALT].match,u[s.ALT].empty,u[s.ALT].nomatch,u[s.ALT].total),o+=displayRow(\"  CAT\",u[s.CAT].match,u[s.CAT].empty,u[s.CAT].nomatch,u[s.CAT].total),o+=displayRow(\"  REP\",u[s.REP].match,u[s.REP].empty,u[s.REP].nomatch,u[s.REP].total),o+=displayRow(\"  RNM\",u[s.RNM].match,u[s.RNM].empty,u[s.RNM].nomatch,u[s.RNM].total),o+=displayRow(\"  TRG\",u[s.TRG].match,u[s.TRG].empty,u[s.TRG].nomatch,u[s.TRG].total),o+=displayRow(\"  TBS\",u[s.TBS].match,u[s.TBS].empty,u[s.TBS].nomatch,u[s.TBS].total),o+=displayRow(\"  TLS\",u[s.TLS].match,u[s.TLS].empty,u[s.TLS].nomatch,u[s.TLS].total),o+=displayRow(\"  UDT\",u[s.UDT].match,u[s.UDT].empty,u[s.UDT].nomatch,u[s.UDT].total),o+=displayRow(\"  AND\",u[s.AND].match,u[s.AND].empty,u[s.AND].nomatch,u[s.AND].total),o+=displayRow(\"  NOT\",u[s.NOT].match,u[s.NOT].empty,u[s.NOT].nomatch,u[s.NOT].total),o+=displayRow(\"TOTAL\",i.match,i.empty,i.nomatch,i.total),o},this.displayHits=s=>{let o=\"\";const displayRow=(s,o,i,u,_)=>{a.match+=s,a.empty+=o,a.nomatch+=i,a.total+=u;return`| ${normalize(s)} | ${normalize(o)} | ${normalize(i)} | ${normalize(u)} | ${_}\\n`};\"string\"==typeof s&&\"a\"===s.toLowerCase()[0]?(_.sort(sortAlpha),w.sort(sortAlpha),o+=\"    RULES/UDTS ALPHABETICALLY\\n\"):\"string\"==typeof s&&\"i\"===s.toLowerCase()[0]?(_.sort(sortIndex),w.sort(sortIndex),o+=\"    RULES/UDTS BY INDEX\\n\"):(_.sort(sortHits),w.sort(sortHits),o+=\"    RULES/UDTS BY HIT COUNT\\n\"),o+=\"|   MATCH |   EMPTY | NOMATCH |   TOTAL | NAME\\n\";for(let s=0;s<_.length;s+=1){let i=_[s];i.total&&(o+=displayRow(i.match,i.empty,i.nomatch,i.total,i.name))}for(let s=0;s<w.length;s+=1){let i=w[s];i.total&&(o+=displayRow(i.match,i.empty,i.nomatch,i.total,i.name))}return o};const normalize=s=>s<10?`      ${s}`:s<100?`     ${s}`:s<1e3?`    ${s}`:s<1e4?`   ${s}`:s<1e5?`  ${s}`:s<1e6?` ${s}`:`${s}`,sortAlpha=(s,o)=>s.lower<o.lower?-1:s.lower>o.lower?1:0,sortHits=(s,o)=>s.total<o.total?1:s.total>o.total?-1:sortAlpha(s,o),sortIndex=(s,o)=>s.index<o.index?-1:s.index>o.index?1:0,x=function fnempty(){this.empty=0,this.match=0,this.nomatch=0,this.total=0},clear=()=>{u.length=0,a=new x,u[s.ALT]=new x,u[s.CAT]=new x,u[s.REP]=new x,u[s.RNM]=new x,u[s.TRG]=new x,u[s.TBS]=new x,u[s.TLS]=new x,u[s.UDT]=new x,u[s.AND]=new x,u[s.NOT]=new x,_.length=0;for(let s=0;s<o.length;s+=1)_.push({empty:0,match:0,nomatch:0,total:0,name:o[s].name,lower:o[s].lower,index:o[s].index});if(i.length>0){w.length=0;for(let s=0;s<i.length;s+=1)w.push({empty:0,match:0,nomatch:0,total:0,name:i[s].name,lower:i[s].lower,index:i[s].index})}},incStat=(o,i)=>{switch(o.total+=1,i){case s.EMPTY:o.empty+=1;break;case s.MATCH:o.match+=1;break;case s.NOMATCH:o.nomatch+=1;break;default:throw new Error(`parser.js: Stats(): collect(): incStat(): unrecognized state: ${i}`)}}},Sp={stringToChars:s=>[...s].map((s=>s.codePointAt(0))),charsToString:(s,o,i)=>{let a=s;for(;!(void 0===o||o<0);){if(void 0===i){a=s.slice(o);break}if(i<=0)return\"\";a=s.slice(o,o+i);break}return String.fromCodePoint(...a)}},Ep={ALT:1,CAT:2,REP:3,RNM:4,TRG:5,TBS:6,TLS:7,UDT:11,AND:12,NOT:13,ACTIVE:100,MATCH:101,EMPTY:102,NOMATCH:103,SEM_PRE:200,SEM_POST:201,SEM_OK:300,idName:s=>{switch(s){case Ep.ALT:return\"ALT\";case Ep.CAT:return\"CAT\";case Ep.REP:return\"REP\";case Ep.RNM:return\"RNM\";case Ep.TRG:return\"TRG\";case Ep.TBS:return\"TBS\";case Ep.TLS:return\"TLS\";case Ep.UDT:return\"UDT\";case Ep.AND:return\"AND\";case Ep.NOT:return\"NOT\";case Ep.ACTIVE:return\"ACTIVE\";case Ep.EMPTY:return\"EMPTY\";case Ep.MATCH:return\"MATCH\";case Ep.NOMATCH:return\"NOMATCH\";case Ep.SEM_PRE:return\"SEM_PRE\";case Ep.SEM_POST:return\"SEM_POST\";case Ep.SEM_OK:return\"SEM_OK\";default:return\"UNRECOGNIZED STATE\"}}};function grammar(){this.grammarObject=\"grammarObject\",this.rules=[],this.rules[0]={name:\"json-pointer\",lower:\"json-pointer\",index:0,isBkr:!1},this.rules[1]={name:\"reference-token\",lower:\"reference-token\",index:1,isBkr:!1},this.rules[2]={name:\"unescaped\",lower:\"unescaped\",index:2,isBkr:!1},this.rules[3]={name:\"escaped\",lower:\"escaped\",index:3,isBkr:!1},this.rules[4]={name:\"array-location\",lower:\"array-location\",index:4,isBkr:!1},this.rules[5]={name:\"array-index\",lower:\"array-index\",index:5,isBkr:!1},this.rules[6]={name:\"array-dash\",lower:\"array-dash\",index:6,isBkr:!1},this.rules[7]={name:\"slash\",lower:\"slash\",index:7,isBkr:!1},this.udts=[],this.rules[0].opcodes=[],this.rules[0].opcodes[0]={type:3,min:0,max:1/0},this.rules[0].opcodes[1]={type:2,children:[2,3]},this.rules[0].opcodes[2]={type:4,index:7},this.rules[0].opcodes[3]={type:4,index:1},this.rules[1].opcodes=[],this.rules[1].opcodes[0]={type:3,min:0,max:1/0},this.rules[1].opcodes[1]={type:1,children:[2,3]},this.rules[1].opcodes[2]={type:4,index:2},this.rules[1].opcodes[3]={type:4,index:3},this.rules[2].opcodes=[],this.rules[2].opcodes[0]={type:1,children:[1,2,3]},this.rules[2].opcodes[1]={type:5,min:0,max:46},this.rules[2].opcodes[2]={type:5,min:48,max:125},this.rules[2].opcodes[3]={type:5,min:127,max:1114111},this.rules[3].opcodes=[],this.rules[3].opcodes[0]={type:2,children:[1,2]},this.rules[3].opcodes[1]={type:7,string:[126]},this.rules[3].opcodes[2]={type:1,children:[3,4]},this.rules[3].opcodes[3]={type:7,string:[48]},this.rules[3].opcodes[4]={type:7,string:[49]},this.rules[4].opcodes=[],this.rules[4].opcodes[0]={type:1,children:[1,2]},this.rules[4].opcodes[1]={type:4,index:5},this.rules[4].opcodes[2]={type:4,index:6},this.rules[5].opcodes=[],this.rules[5].opcodes[0]={type:1,children:[1,2]},this.rules[5].opcodes[1]={type:6,string:[48]},this.rules[5].opcodes[2]={type:2,children:[3,4]},this.rules[5].opcodes[3]={type:5,min:49,max:57},this.rules[5].opcodes[4]={type:3,min:0,max:1/0},this.rules[5].opcodes[5]={type:5,min:48,max:57},this.rules[6].opcodes=[],this.rules[6].opcodes[0]={type:7,string:[45]},this.rules[7].opcodes=[],this.rules[7].opcodes[0]={type:7,string:[47]},this.toString=function toString(){let s=\"\";return s+=\"; JavaScript Object Notation (JSON) Pointer ABNF syntax\\n\",s+=\"; https://datatracker.ietf.org/doc/html/rfc6901\\n\",s+=\"json-pointer    = *( slash reference-token ) ; MODIFICATION: surrogate text rule used\\n\",s+=\"reference-token = *( unescaped / escaped )\\n\",s+=\"unescaped       = %x00-2E / %x30-7D / %x7F-10FFFF\\n\",s+=\"                ; %x2F ('/') and %x7E ('~') are excluded from 'unescaped'\\n\",s+='escaped         = \"~\" ( \"0\" / \"1\" )\\n',s+=\"                ; representing '~' and '/', respectively\\n\",s+=\"\\n\",s+=\"; https://datatracker.ietf.org/doc/html/rfc6901#section-4\\n\",s+=\"array-location  = array-index / array-dash\\n\",s+=\"array-index     = %x30 / ( %x31-39 *(%x30-39) )\\n\",s+='                ; \"0\", or digits without a leading \"0\"\\n',s+='array-dash      = \"-\"\\n',s+=\"\\n\",s+=\"; Surrogate named rules\\n\",s+='slash           = \"/\"\\n','; JavaScript Object Notation (JSON) Pointer ABNF syntax\\n; https://datatracker.ietf.org/doc/html/rfc6901\\njson-pointer    = *( slash reference-token ) ; MODIFICATION: surrogate text rule used\\nreference-token = *( unescaped / escaped )\\nunescaped       = %x00-2E / %x30-7D / %x7F-10FFFF\\n                ; %x2F (\\'/\\') and %x7E (\\'~\\') are excluded from \\'unescaped\\'\\nescaped         = \"~\" ( \"0\" / \"1\" )\\n                ; representing \\'~\\' and \\'/\\', respectively\\n\\n; https://datatracker.ietf.org/doc/html/rfc6901#section-4\\narray-location  = array-index / array-dash\\narray-index     = %x30 / ( %x31-39 *(%x30-39) )\\n                ; \"0\", or digits without a leading \"0\"\\narray-dash      = \"-\"\\n\\n; Surrogate named rules\\nslash           = \"/\"\\n'}}class JSONPointerError extends Error{constructor(s,o=void 0){if(super(s,o),this.name=this.constructor.name,\"string\"==typeof s&&(this.message=s),\"function\"==typeof Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error(s).stack,null!=o&&\"object\"==typeof o&&Object.prototype.hasOwnProperty.call(o,\"cause\")&&!(\"cause\"in this)){const{cause:s}=o;this.cause=s,s instanceof Error&&\"stack\"in s&&(this.stack=`${this.stack}\\nCAUSE: ${s.stack}`)}if(null!=o&&\"object\"==typeof o){const{cause:s,...i}=o;Object.assign(this,i)}}}const wp=JSONPointerError;const xp=class JSONPointerParseError extends wp{},callbacks_cst=s=>(o,i,a,u,_)=>{if(\"object\"!=typeof _||null===_||Array.isArray(_))throw new xp(\"parser's user data must be an object\");if(o===Ep.SEM_PRE){const o={type:s,text:Sp.charsToString(i,a,u),start:a,length:u,children:[]};if(_.stack.length>0){_.stack[_.stack.length-1].children.push(o)}else _.root=o;_.stack.push(o)}o===Ep.SEM_POST&&_.stack.pop()};const kp=class CSTTranslator_CSTTranslator extends vp{constructor(){super(),this.callbacks[\"json-pointer\"]=callbacks_cst(\"json-pointer\"),this.callbacks[\"reference-token\"]=callbacks_cst(\"reference-token\"),this.callbacks.slash=callbacks_cst(\"text\")}getTree(){const s={stack:[],root:null};return this.translate(s),delete s.stack,s}},es_unescape=s=>{if(\"string\"!=typeof s)throw new TypeError(\"Reference token must be a string\");return s.replace(/~1/g,\"/\").replace(/~0/g,\"~\")};const Op=class ASTTranslator extends kp{getTree(){const{root:s}=super.getTree();return s.children.filter((({type:s})=>\"reference-token\"===s)).map((({text:s})=>es_unescape(s)))}};const Ap=class Expectations extends Array{toString(){return this.map((s=>`\"${String(s)}\"`)).join(\", \")}};const Cp=class Trace extends bp{inferExpectations(){const s=this.displayTrace().split(\"\\n\"),o=new Set;let i=-1;for(let a=0;a<s.length;a++){const u=s[a];if(u.includes(\"M|\")){const s=u.match(/]'(.*)'$/);s&&s[1]&&(i=a)}if(a>i){const s=u.match(/N\\|\\[TLS\\(([^)]+)\\)]/);s&&o.add(s[1])}}return new Ap(...o)}},jp=new grammar,es_parse=(s,{translator:o=new Op,stats:i=!1,trace:a=!1}={})=>{if(\"string\"!=typeof s)throw new TypeError(\"JSON Pointer must be a string\");try{const u=new yp;o&&(u.ast=o),i&&(u.stats=new _p),a&&(u.trace=new Cp);const _=u.parse(jp,\"json-pointer\",s);return{result:_,tree:_.success&&o?u.ast.getTree():void 0,stats:u.stats,trace:u.trace}}catch(o){throw new xp(\"Unexpected error during JSON Pointer parsing\",{cause:o,jsonPointer:s})}};new grammar,new yp,new grammar,new yp;const Pp=new grammar,Ip=new yp,array_index=s=>{if(\"string\"!=typeof s)return!1;try{return Ip.parse(Pp,\"array-index\",s).success}catch{return!1}},Tp=new grammar,Np=new yp,array_dash=s=>{if(\"string\"!=typeof s)return!1;try{return Np.parse(Tp,\"array-dash\",s).success}catch{return!1}},es_escape=s=>{if(\"string\"!=typeof s&&\"number\"!=typeof s)throw new TypeError(\"Reference token must be a string or number\");return String(s).replace(/~/g,\"~0\").replace(/\\//g,\"~1\")};const Mp=class JSONPointerCompileError extends wp{},es_compile=s=>{if(!Array.isArray(s))throw new TypeError(\"Reference tokens must be a list of strings or numbers\");try{return 0===s.length?\"\":`/${s.map((s=>{if(\"string\"!=typeof s&&\"number\"!=typeof s)throw new TypeError(\"Reference token must be a string or number\");return es_escape(String(s))})).join(\"/\")}`}catch(o){throw new Mp(\"Unexpected error during JSON Pointer compilation\",{cause:o,referenceTokens:s})}};const Rp=class TraceBuilder{#e;#t;#r;constructor(s,o={}){this.#e=s,this.#e.steps=[],this.#e.failed=!1,this.#e.failedAt=-1,this.#e.message=`JSON Pointer \"${o.jsonPointer}\" was successfully evaluated against the provided value`,this.#e.context={...o,realm:o.realm.name},this.#t=[],this.#r=o.realm}step({referenceToken:s,input:o,output:i,success:a=!0,reason:u}){const _=this.#t.length;this.#t.push(s);const w={referenceToken:s,referenceTokenPosition:_,input:o,inputType:this.#r.isObject(o)?\"object\":this.#r.isArray(o)?\"array\":\"unrecognized\",output:i,success:a};u&&(w.reason=u),this.#e.steps.push(w),a||(this.#e.failed=!0,this.#e.failedAt=_,this.#e.message=u)}};const Dp=class EvaluationRealm{name=\"\";isArray(s){throw new wp(\"Realm.isArray(node) must be implemented in a subclass\")}isObject(s){throw new wp(\"Realm.isObject(node) must be implemented in a subclass\")}sizeOf(s){throw new wp(\"Realm.sizeOf(node) must be implemented in a subclass\")}has(s,o){throw new wp(\"Realm.has(node) must be implemented in a subclass\")}evaluate(s,o){throw new wp(\"Realm.evaluate(node) must be implemented in a subclass\")}};const Lp=class JSONPointerEvaluateError extends wp{};const Fp=class JSONPointerIndexError extends Lp{};const Bp=class JSONEvaluationRealm extends Dp{name=\"json\";isArray(s){return Array.isArray(s)}isObject(s){return\"object\"==typeof s&&null!==s&&!this.isArray(s)}sizeOf(s){return this.isArray(s)?s.length:this.isObject(s)?Object.keys(s).length:0}has(s,o){if(this.isArray(s)){const i=Number(o),a=i>>>0;if(i!==a)throw new Fp(`Invalid array index \"${o}\": index must be an unsinged 32-bit integer`,{referenceToken:o,currentValue:s,realm:this.name});return a<this.sizeOf(s)&&Object.prototype.hasOwnProperty.call(s,i)}return!!this.isObject(s)&&Object.prototype.hasOwnProperty.call(s,o)}evaluate(s,o){return this.isArray(s)?s[Number(o)]:s[o]}};const $p=class JSONPointerTypeError extends Lp{};const qp=class JSONPointerKeyError extends Lp{},es_evaluate=(s,o,{strictArrays:i=!0,strictObjects:a=!0,realm:u=new Bp,trace:_=!0}={})=>{const{result:w,tree:x,trace:C}=es_parse(o,{trace:!!_}),j=\"object\"==typeof _&&null!==_?new Rp(_,{jsonPointer:o,referenceTokens:x,strictArrays:i,strictObjects:a,realm:u,value:s}):null;try{let _;if(!w.success){let i=`Invalid JSON Pointer: \"${o}\". Syntax error at position ${w.maxMatched}`;throw i+=C?`, expected ${C.inferExpectations()}`:\"\",new Lp(i,{jsonPointer:o,currentValue:s,realm:u.name})}return x.reduce(((s,w,C)=>{if(u.isArray(s)){if(array_dash(w)){if(i)throw new Fp(`Invalid array index \"-\" at position ${C} in \"${o}\". The \"-\" token always refers to a nonexistent element during evaluation`,{jsonPointer:o,referenceTokens:x,referenceToken:w,referenceTokenPosition:C,currentValue:s,realm:u.name});return _=u.evaluate(s,String(u.sizeOf(s))),null==j||j.step({referenceToken:w,input:s,output:_}),_}if(!array_index(w))throw new Fp(`Invalid array index \"${w}\" at position ${C} in \"${o}\": index MUST be \"0\", or digits without a leading \"0\"`,{jsonPointer:o,referenceTokens:x,referenceToken:w,referenceTokenPosition:C,currentValue:s,realm:u.name});const a=Number(w);if(!Number.isSafeInteger(a))throw new Fp(`Invalid array index \"${w}\" at position ${C} in \"${o}\": index must be a safe integer`,{jsonPointer:o,referenceTokens:x,referenceToken:w,referenceTokenPosition:C,currentValue:s,realm:u.name});if(!u.has(s,w)&&i)throw new Fp(`Invalid array index \"${w}\" at position ${C} in \"${o}\": index not found in array`,{jsonPointer:o,referenceTokens:x,referenceToken:w,referenceTokenPosition:C,currentValue:s,realm:u.name});return _=u.evaluate(s,w),null==j||j.step({referenceToken:w,input:s,output:_}),_}if(u.isObject(s)){if(!u.has(s,w)&&a)throw new qp(`Invalid object key \"${w}\" at position ${C} in \"${o}\": key not found in object`,{jsonPointer:o,referenceTokens:x,referenceToken:w,referenceTokenPosition:C,currentValue:s,realm:u.name});return _=u.evaluate(s,w),null==j||j.step({referenceToken:w,input:s,output:_}),_}throw new $p(`Invalid reference token \"${w}\" at position ${C} in \"${o}\": cannot be applied to a non-object/non-array value`,{jsonPointer:o,referenceTokens:x,referenceToken:w,referenceTokenPosition:C,currentValue:s,realm:u.name})}),s)}catch(s){if(null==j||j.step({referenceToken:s.referenceToken,input:s.currentValue,success:!1,reason:s.message}),s instanceof Lp)throw s;throw new Lp(\"Unexpected error during JSON Pointer evaluation\",{cause:s,jsonPointer:o,referenceTokens:x})}};const Up=class ApiDOMEvaluationRealm extends Dp{name=\"apidom\";isArray(s){return Ru(s)}isObject(s){return Mu(s)}sizeOf(s){return this.isArray(s)||this.isObject(s)?s.length:0}has(s,o){if(this.isArray(s)){const i=Number(o),a=i>>>0;if(i!==a)throw new Fp(`Invalid array index \"${o}\": index must be an unsinged 32-bit integer`,{referenceToken:o,currentValue:s,realm:this.name});return a<this.sizeOf(s)}if(this.isObject(s)){const i=s.keys(),a=new Set(i);if(i.length!==a.size)throw new qp(`Object key \"${o}\" is not unique — JSON Pointer requires unique member names`,{referenceToken:o,currentValue:s,realm:this.name});return s.hasKey(o)}return!1}evaluate(s,o){return this.isArray(s)?s.get(Number(o)):s.get(o)}},apidom_evaluate=(s,o,i={})=>es_evaluate(s,o,{...i,realm:new Up});class Callback extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"callback\"}}const Vp=Callback;class Components extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"components\"}get schemas(){return this.get(\"schemas\")}set schemas(s){this.set(\"schemas\",s)}get responses(){return this.get(\"responses\")}set responses(s){this.set(\"responses\",s)}get parameters(){return this.get(\"parameters\")}set parameters(s){this.set(\"parameters\",s)}get examples(){return this.get(\"examples\")}set examples(s){this.set(\"examples\",s)}get requestBodies(){return this.get(\"requestBodies\")}set requestBodies(s){this.set(\"requestBodies\",s)}get headers(){return this.get(\"headers\")}set headers(s){this.set(\"headers\",s)}get securitySchemes(){return this.get(\"securitySchemes\")}set securitySchemes(s){this.set(\"securitySchemes\",s)}get links(){return this.get(\"links\")}set links(s){this.set(\"links\",s)}get callbacks(){return this.get(\"callbacks\")}set callbacks(s){this.set(\"callbacks\",s)}}const zp=Components;class Contact extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"contact\"}get name(){return this.get(\"name\")}set name(s){this.set(\"name\",s)}get url(){return this.get(\"url\")}set url(s){this.set(\"url\",s)}get email(){return this.get(\"email\")}set email(s){this.set(\"email\",s)}}const Wp=Contact;class Discriminator extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"discriminator\"}get propertyName(){return this.get(\"propertyName\")}set propertyName(s){this.set(\"propertyName\",s)}get mapping(){return this.get(\"mapping\")}set mapping(s){this.set(\"mapping\",s)}}const Jp=Discriminator;class Encoding extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"encoding\"}get contentType(){return this.get(\"contentType\")}set contentType(s){this.set(\"contentType\",s)}get headers(){return this.get(\"headers\")}set headers(s){this.set(\"headers\",s)}get style(){return this.get(\"style\")}set style(s){this.set(\"style\",s)}get explode(){return this.get(\"explode\")}set explode(s){this.set(\"explode\",s)}get allowedReserved(){return this.get(\"allowedReserved\")}set allowedReserved(s){this.set(\"allowedReserved\",s)}}const Hp=Encoding;class Example extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"example\"}get summary(){return this.get(\"summary\")}set summary(s){this.set(\"summary\",s)}get description(){return this.get(\"description\")}set description(s){this.set(\"description\",s)}get value(){return this.get(\"value\")}set value(s){this.set(\"value\",s)}get externalValue(){return this.get(\"externalValue\")}set externalValue(s){this.set(\"externalValue\",s)}}const Kp=Example;class ExternalDocumentation extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"externalDocumentation\"}get description(){return this.get(\"description\")}set description(s){this.set(\"description\",s)}get url(){return this.get(\"url\")}set url(s){this.set(\"url\",s)}}const Gp=ExternalDocumentation;class Header extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"header\"}get required(){return this.hasKey(\"required\")?this.get(\"required\"):new Su.bd(!1)}set required(s){this.set(\"required\",s)}get deprecated(){return this.hasKey(\"deprecated\")?this.get(\"deprecated\"):new Su.bd(!1)}set deprecated(s){this.set(\"deprecated\",s)}get allowEmptyValue(){return this.get(\"allowEmptyValue\")}set allowEmptyValue(s){this.set(\"allowEmptyValue\",s)}get style(){return this.get(\"style\")}set style(s){this.set(\"style\",s)}get explode(){return this.get(\"explode\")}set explode(s){this.set(\"explode\",s)}get allowReserved(){return this.get(\"allowReserved\")}set allowReserved(s){this.set(\"allowReserved\",s)}get schema(){return this.get(\"schema\")}set schema(s){this.set(\"schema\",s)}get example(){return this.get(\"example\")}set example(s){this.set(\"example\",s)}get examples(){return this.get(\"examples\")}set examples(s){this.set(\"examples\",s)}get contentProp(){return this.get(\"content\")}set contentProp(s){this.set(\"content\",s)}}Object.defineProperty(Header.prototype,\"description\",{get(){return this.get(\"description\")},set(s){this.set(\"description\",s)},enumerable:!0});const Yp=Header;class Info extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"info\",this.classes.push(\"info\")}get title(){return this.get(\"title\")}set title(s){this.set(\"title\",s)}get description(){return this.get(\"description\")}set description(s){this.set(\"description\",s)}get termsOfService(){return this.get(\"termsOfService\")}set termsOfService(s){this.set(\"termsOfService\",s)}get contact(){return this.get(\"contact\")}set contact(s){this.set(\"contact\",s)}get license(){return this.get(\"license\")}set license(s){this.set(\"license\",s)}get version(){return this.get(\"version\")}set version(s){this.set(\"version\",s)}}const Xp=Info;class License extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"license\"}get name(){return this.get(\"name\")}set name(s){this.set(\"name\",s)}get url(){return this.get(\"url\")}set url(s){this.set(\"url\",s)}}const Qp=License;class Link extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"link\"}get operationRef(){return this.get(\"operationRef\")}set operationRef(s){this.set(\"operationRef\",s)}get operationId(){return this.get(\"operationId\")}set operationId(s){this.set(\"operationId\",s)}get operation(){var s,o;return Pu(this.operationRef)?null===(s=this.operationRef)||void 0===s?void 0:s.meta.get(\"operation\"):Pu(this.operationId)?null===(o=this.operationId)||void 0===o?void 0:o.meta.get(\"operation\"):void 0}set operation(s){this.set(\"operation\",s)}get parameters(){return this.get(\"parameters\")}set parameters(s){this.set(\"parameters\",s)}get requestBody(){return this.get(\"requestBody\")}set requestBody(s){this.set(\"requestBody\",s)}get description(){return this.get(\"description\")}set description(s){this.set(\"description\",s)}get server(){return this.get(\"server\")}set server(s){this.set(\"server\",s)}}const Zp=Link;class MediaType extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"mediaType\"}get schema(){return this.get(\"schema\")}set schema(s){this.set(\"schema\",s)}get example(){return this.get(\"example\")}set example(s){this.set(\"example\",s)}get examples(){return this.get(\"examples\")}set examples(s){this.set(\"examples\",s)}get encoding(){return this.get(\"encoding\")}set encoding(s){this.set(\"encoding\",s)}}const th=MediaType;class OAuthFlow extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"oAuthFlow\"}get authorizationUrl(){return this.get(\"authorizationUrl\")}set authorizationUrl(s){this.set(\"authorizationUrl\",s)}get tokenUrl(){return this.get(\"tokenUrl\")}set tokenUrl(s){this.set(\"tokenUrl\",s)}get refreshUrl(){return this.get(\"refreshUrl\")}set refreshUrl(s){this.set(\"refreshUrl\",s)}get scopes(){return this.get(\"scopes\")}set scopes(s){this.set(\"scopes\",s)}}const rh=OAuthFlow;class OAuthFlows extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"oAuthFlows\"}get implicit(){return this.get(\"implicit\")}set implicit(s){this.set(\"implicit\",s)}get password(){return this.get(\"password\")}set password(s){this.set(\"password\",s)}get clientCredentials(){return this.get(\"clientCredentials\")}set clientCredentials(s){this.set(\"clientCredentials\",s)}get authorizationCode(){return this.get(\"authorizationCode\")}set authorizationCode(s){this.set(\"authorizationCode\",s)}}const uh=OAuthFlows;class Openapi extends Su.Om{constructor(s,o,i){super(s,o,i),this.element=\"openapi\",this.classes.push(\"spec-version\"),this.classes.push(\"version\")}}const dh=Openapi;class OpenApi3_0 extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"openApi3_0\",this.classes.push(\"api\")}get openapi(){return this.get(\"openapi\")}set openapi(s){this.set(\"openapi\",s)}get info(){return this.get(\"info\")}set info(s){this.set(\"info\",s)}get servers(){return this.get(\"servers\")}set servers(s){this.set(\"servers\",s)}get paths(){return this.get(\"paths\")}set paths(s){this.set(\"paths\",s)}get components(){return this.get(\"components\")}set components(s){this.set(\"components\",s)}get security(){return this.get(\"security\")}set security(s){this.set(\"security\",s)}get tags(){return this.get(\"tags\")}set tags(s){this.set(\"tags\",s)}get externalDocs(){return this.get(\"externalDocs\")}set externalDocs(s){this.set(\"externalDocs\",s)}}const fh=OpenApi3_0;class Operation extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"operation\"}get tags(){return this.get(\"tags\")}set tags(s){this.set(\"tags\",s)}get summary(){return this.get(\"summary\")}set summary(s){this.set(\"summary\",s)}get description(){return this.get(\"description\")}set description(s){this.set(\"description\",s)}set externalDocs(s){this.set(\"externalDocs\",s)}get externalDocs(){return this.get(\"externalDocs\")}get operationId(){return this.get(\"operationId\")}set operationId(s){this.set(\"operationId\",s)}get parameters(){return this.get(\"parameters\")}set parameters(s){this.set(\"parameters\",s)}get requestBody(){return this.get(\"requestBody\")}set requestBody(s){this.set(\"requestBody\",s)}get responses(){return this.get(\"responses\")}set responses(s){this.set(\"responses\",s)}get callbacks(){return this.get(\"callbacks\")}set callbacks(s){this.set(\"callbacks\",s)}get deprecated(){return this.hasKey(\"deprecated\")?this.get(\"deprecated\"):new Su.bd(!1)}set deprecated(s){this.set(\"deprecated\",s)}get security(){return this.get(\"security\")}set security(s){this.set(\"security\",s)}get servers(){return this.get(\"severs\")}set servers(s){this.set(\"servers\",s)}}const vh=Operation;class Parameter extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"parameter\"}get name(){return this.get(\"name\")}set name(s){this.set(\"name\",s)}get in(){return this.get(\"in\")}set in(s){this.set(\"in\",s)}get required(){return this.hasKey(\"required\")?this.get(\"required\"):new Su.bd(!1)}set required(s){this.set(\"required\",s)}get deprecated(){return this.hasKey(\"deprecated\")?this.get(\"deprecated\"):new Su.bd(!1)}set deprecated(s){this.set(\"deprecated\",s)}get allowEmptyValue(){return this.get(\"allowEmptyValue\")}set allowEmptyValue(s){this.set(\"allowEmptyValue\",s)}get style(){return this.get(\"style\")}set style(s){this.set(\"style\",s)}get explode(){return this.get(\"explode\")}set explode(s){this.set(\"explode\",s)}get allowReserved(){return this.get(\"allowReserved\")}set allowReserved(s){this.set(\"allowReserved\",s)}get schema(){return this.get(\"schema\")}set schema(s){this.set(\"schema\",s)}get example(){return this.get(\"example\")}set example(s){this.set(\"example\",s)}get examples(){return this.get(\"examples\")}set examples(s){this.set(\"examples\",s)}get contentProp(){return this.get(\"content\")}set contentProp(s){this.set(\"content\",s)}}Object.defineProperty(Parameter.prototype,\"description\",{get(){return this.get(\"description\")},set(s){this.set(\"description\",s)},enumerable:!0});const _h=Parameter;class PathItem extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"pathItem\"}get $ref(){return this.get(\"$ref\")}set $ref(s){this.set(\"$ref\",s)}get summary(){return this.get(\"summary\")}set summary(s){this.set(\"summary\",s)}get description(){return this.get(\"description\")}set description(s){this.set(\"description\",s)}get GET(){return this.get(\"get\")}set GET(s){this.set(\"GET\",s)}get PUT(){return this.get(\"put\")}set PUT(s){this.set(\"PUT\",s)}get POST(){return this.get(\"post\")}set POST(s){this.set(\"POST\",s)}get DELETE(){return this.get(\"delete\")}set DELETE(s){this.set(\"DELETE\",s)}get OPTIONS(){return this.get(\"options\")}set OPTIONS(s){this.set(\"OPTIONS\",s)}get HEAD(){return this.get(\"head\")}set HEAD(s){this.set(\"HEAD\",s)}get PATCH(){return this.get(\"patch\")}set PATCH(s){this.set(\"PATCH\",s)}get TRACE(){return this.get(\"trace\")}set TRACE(s){this.set(\"TRACE\",s)}get servers(){return this.get(\"servers\")}set servers(s){this.set(\"servers\",s)}get parameters(){return this.get(\"parameters\")}set parameters(s){this.set(\"parameters\",s)}}const wh=PathItem;class Paths extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"paths\"}}const Oh=Paths;class Reference extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"reference\",this.classes.push(\"openapi-reference\")}get $ref(){return this.get(\"$ref\")}set $ref(s){this.set(\"$ref\",s)}}const jh=Reference;class RequestBody extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"requestBody\"}get description(){return this.get(\"description\")}set description(s){this.set(\"description\",s)}get contentProp(){return this.get(\"content\")}set contentProp(s){this.set(\"content\",s)}get required(){return this.hasKey(\"required\")?this.get(\"required\"):new Su.bd(!1)}set required(s){this.set(\"required\",s)}}const Ph=RequestBody;class Response_Response extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"response\"}get description(){return this.get(\"description\")}set description(s){this.set(\"description\",s)}get headers(){return this.get(\"headers\")}set headers(s){this.set(\"headers\",s)}get contentProp(){return this.get(\"content\")}set contentProp(s){this.set(\"content\",s)}get links(){return this.get(\"links\")}set links(s){this.set(\"links\",s)}}const Ih=Response_Response;class Responses extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"responses\"}get default(){return this.get(\"default\")}set default(s){this.set(\"default\",s)}}const Rh=Responses;const Dh=class UnsupportedOperationError extends Ko{};class JSONSchema extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"JSONSchemaDraft4\"}get idProp(){return this.get(\"id\")}set idProp(s){this.set(\"id\",s)}get $schema(){return this.get(\"$schema\")}set $schema(s){this.set(\"$schema\",s)}get multipleOf(){return this.get(\"multipleOf\")}set multipleOf(s){this.set(\"multipleOf\",s)}get maximum(){return this.get(\"maximum\")}set maximum(s){this.set(\"maximum\",s)}get exclusiveMaximum(){return this.get(\"exclusiveMaximum\")}set exclusiveMaximum(s){this.set(\"exclusiveMaximum\",s)}get minimum(){return this.get(\"minimum\")}set minimum(s){this.set(\"minimum\",s)}get exclusiveMinimum(){return this.get(\"exclusiveMinimum\")}set exclusiveMinimum(s){this.set(\"exclusiveMinimum\",s)}get maxLength(){return this.get(\"maxLength\")}set maxLength(s){this.set(\"maxLength\",s)}get minLength(){return this.get(\"minLength\")}set minLength(s){this.set(\"minLength\",s)}get pattern(){return this.get(\"pattern\")}set pattern(s){this.set(\"pattern\",s)}get additionalItems(){return this.get(\"additionalItems\")}set additionalItems(s){this.set(\"additionalItems\",s)}get items(){return this.get(\"items\")}set items(s){this.set(\"items\",s)}get maxItems(){return this.get(\"maxItems\")}set maxItems(s){this.set(\"maxItems\",s)}get minItems(){return this.get(\"minItems\")}set minItems(s){this.set(\"minItems\",s)}get uniqueItems(){return this.get(\"uniqueItems\")}set uniqueItems(s){this.set(\"uniqueItems\",s)}get maxProperties(){return this.get(\"maxProperties\")}set maxProperties(s){this.set(\"maxProperties\",s)}get minProperties(){return this.get(\"minProperties\")}set minProperties(s){this.set(\"minProperties\",s)}get required(){return this.get(\"required\")}set required(s){this.set(\"required\",s)}get properties(){return this.get(\"properties\")}set properties(s){this.set(\"properties\",s)}get additionalProperties(){return this.get(\"additionalProperties\")}set additionalProperties(s){this.set(\"additionalProperties\",s)}get patternProperties(){return this.get(\"patternProperties\")}set patternProperties(s){this.set(\"patternProperties\",s)}get dependencies(){return this.get(\"dependencies\")}set dependencies(s){this.set(\"dependencies\",s)}get enum(){return this.get(\"enum\")}set enum(s){this.set(\"enum\",s)}get type(){return this.get(\"type\")}set type(s){this.set(\"type\",s)}get allOf(){return this.get(\"allOf\")}set allOf(s){this.set(\"allOf\",s)}get anyOf(){return this.get(\"anyOf\")}set anyOf(s){this.set(\"anyOf\",s)}get oneOf(){return this.get(\"oneOf\")}set oneOf(s){this.set(\"oneOf\",s)}get not(){return this.get(\"not\")}set not(s){this.set(\"not\",s)}get definitions(){return this.get(\"definitions\")}set definitions(s){this.set(\"definitions\",s)}get title(){return this.get(\"title\")}set title(s){this.set(\"title\",s)}get description(){return this.get(\"description\")}set description(s){this.set(\"description\",s)}get default(){return this.get(\"default\")}set default(s){this.set(\"default\",s)}get format(){return this.get(\"format\")}set format(s){this.set(\"format\",s)}get base(){return this.get(\"base\")}set base(s){this.set(\"base\",s)}get links(){return this.get(\"links\")}set links(s){this.set(\"links\",s)}get media(){return this.get(\"media\")}set media(s){this.set(\"media\",s)}get readOnly(){return this.get(\"readOnly\")}set readOnly(s){this.set(\"readOnly\",s)}}const Lh=JSONSchema;class JSONReference extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"JSONReference\",this.classes.push(\"json-reference\")}get $ref(){return this.get(\"$ref\")}set $ref(s){this.set(\"$ref\",s)}}const Fh=JSONReference;class Media extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"media\"}get binaryEncoding(){return this.get(\"binaryEncoding\")}set binaryEncoding(s){this.set(\"binaryEncoding\",s)}get type(){return this.get(\"type\")}set type(s){this.set(\"type\",s)}}const Jh=Media;class LinkDescription extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"linkDescription\"}get href(){return this.get(\"href\")}set href(s){this.set(\"href\",s)}get rel(){return this.get(\"rel\")}set rel(s){this.set(\"rel\",s)}get title(){return this.get(\"title\")}set title(s){this.set(\"title\",s)}get targetSchema(){return this.get(\"targetSchema\")}set targetSchema(s){this.set(\"targetSchema\",s)}get mediaType(){return this.get(\"mediaType\")}set mediaType(s){this.set(\"mediaType\",s)}get method(){return this.get(\"method\")}set method(s){this.set(\"method\",s)}get encType(){return this.get(\"encType\")}set encType(s){this.set(\"encType\",s)}get schema(){return this.get(\"schema\")}set schema(s){this.set(\"schema\",s)}}const Hh=LinkDescription;const Kh=_curry2((function mapObjIndexed(s,o){return _arrayReduce((function(i,a){return i[a]=s(o[a],a,o),i}),{},ea(o))}));const Gh=_curry1((function isNil(s){return null==s}));var Qh=_curry2((function hasPath(s,o){if(0===s.length||Gh(o))return!1;for(var i=o,a=0;a<s.length;){if(Gh(i)||!_has(s[a],i))return!1;i=i[s[a]],a+=1}return!0}));const td=Qh;var sd=_curry2((function has(s,o){return td([s],o)}));const id=sd;const cd=_curry3((function propSatisfies(s,o,i){return s(Da(o,i))}));var ld=function(){function XDropWhile(s,o){this.xf=o,this.f=s}return XDropWhile.prototype[\"@@transducer/init\"]=_xfBase_init,XDropWhile.prototype[\"@@transducer/result\"]=_xfBase_result,XDropWhile.prototype[\"@@transducer/step\"]=function(s,o){if(this.f){if(this.f(o))return s;this.f=null}return this.xf[\"@@transducer/step\"](s,o)},XDropWhile}();function _xdropWhile(s){return function(o){return new ld(s,o)}}const ud=_curry2(_dispatchable([\"dropWhile\"],_xdropWhile,(function dropWhile(s,o){for(var i=0,a=o.length;i<a&&s(o[i]);)i+=1;return ja(i,1/0,o)})));const dd=za((function(s,o){return pipe(Ha(\"\"),ud(sc(s)),rc(\"\"))(o)})),dereference=(s,o)=>{const i=Na(s,o);return Kh((s=>{if(fu(s)&&id(\"$ref\",s)&&cd(Jc,\"$ref\",s)){const o=tp([\"$ref\"],s),a=dd(\"#/\",o);return tp(a.split(\"/\"),i)}return fu(s)?dereference(s,i):s}),s)},emptyElement=s=>{const o=s.meta.length>0?cloneDeep(s.meta):void 0,i=s.attributes.length>0?cloneDeep(s.attributes):void 0;return new s.constructor(void 0,o,i)},cloneUnlessOtherwiseSpecified=(s,o)=>o.clone&&o.isMergeableElement(s)?deepmerge(emptyElement(s),s,o):s,md={clone:!0,isMergeableElement:s=>Mu(s)||Ru(s),arrayElementMerge:(s,o,i)=>s.concat(o)[\"fantasy-land/map\"]((s=>cloneUnlessOtherwiseSpecified(s,i))),objectElementMerge:(s,o,i)=>{const a=Mu(s)?emptyElement(s):emptyElement(o);return Mu(s)&&s.forEach(((s,o,u)=>{const _=cloneShallow(u);_.value=cloneUnlessOtherwiseSpecified(s,i),a.content.push(_)})),o.forEach(((o,u,_)=>{const w=serializers_value(u);let x;if(Mu(s)&&s.hasKey(w)&&i.isMergeableElement(o)){const a=s.get(w);x=cloneShallow(_),x.value=((s,o)=>{if(\"function\"!=typeof o.customMerge)return deepmerge;const i=o.customMerge(s,o);return\"function\"==typeof i?i:deepmerge})(u,i)(a,o,i)}else x=cloneShallow(_),x.value=cloneUnlessOtherwiseSpecified(o,i);a.remove(w),a.content.push(x)})),a},customMerge:void 0,customMetaMerge:void 0,customAttributesMerge:void 0},deepmerge=(s,o,i)=>{var a,u,_;const w={...md,...i};w.isMergeableElement=null!==(a=w.isMergeableElement)&&void 0!==a?a:md.isMergeableElement,w.arrayElementMerge=null!==(u=w.arrayElementMerge)&&void 0!==u?u:md.arrayElementMerge,w.objectElementMerge=null!==(_=w.objectElementMerge)&&void 0!==_?_:md.objectElementMerge;const x=Ru(o);if(!(x===Ru(s)))return cloneUnlessOtherwiseSpecified(o,w);const C=x&&\"function\"==typeof w.arrayElementMerge?w.arrayElementMerge(s,o,w):w.objectElementMerge(s,o,w);return C.meta=(s=>\"function\"!=typeof s.customMetaMerge?s=>cloneDeep(s):s.customMetaMerge)(w)(s.meta,o.meta),C.attributes=(s=>\"function\"!=typeof s.customAttributesMerge?s=>cloneDeep(s):s.customAttributesMerge)(w)(s.attributes,o.attributes),C};deepmerge.all=(s,o)=>{if(!Array.isArray(s))throw new TypeError(\"First argument of deepmerge should be an array.\");return 0===s.length?new Su.Sh:s.reduce(((s,i)=>deepmerge(s,i,o)),emptyElement(s[0]))};const yd=deepmerge;const vd=class Visitor_Visitor{element;constructor(s){Object.assign(this,s)}copyMetaAndAttributes(s,o){(s.meta.length>0||o.meta.length>0)&&(o.meta=yd(o.meta,s.meta),hasElementSourceMap(s)&&o.meta.set(\"sourceMap\",s.meta.get(\"sourceMap\"))),(s.attributes.length>0||s.meta.length>0)&&(o.attributes=yd(o.attributes,s.attributes))}};const _d=class FallbackVisitor extends vd{enter(s){return this.element=cloneDeep(s),Vu}},copyProps=(s,o,i=[])=>{const a=Object.getOwnPropertyDescriptors(o);for(let s of i)delete a[s];Object.defineProperties(s,a)},protoChain=(s,o=[s])=>{const i=Object.getPrototypeOf(s);return null===i?o:protoChain(i,[...o,i])},hardMixProtos=(s,o,i=[])=>{var a;const u=null!==(a=((...s)=>{if(0===s.length)return;let o;const i=s.map((s=>protoChain(s)));for(;i.every((s=>s.length>0));){const s=i.map((s=>s.pop())),a=s[0];if(!s.every((s=>s===a)))break;o=a}return o})(...s))&&void 0!==a?a:Object.prototype,_=Object.create(u),w=protoChain(u);for(let o of s){let s=protoChain(o);for(let o=s.length-1;o>=0;o--){let a=s[o];-1===w.indexOf(a)&&(copyProps(_,a,[\"constructor\",...i]),w.push(a))}}return _.constructor=o,_},unique=s=>s.filter(((o,i)=>s.indexOf(o)==i)),getIngredientWithProp=(s,o)=>{const i=o.map((s=>protoChain(s)));let a=0,u=!0;for(;u;){u=!1;for(let _=o.length-1;_>=0;_--){const o=i[_][a];if(null!=o&&(u=!0,null!=Object.getOwnPropertyDescriptor(o,s)))return i[_][0]}a++}},proxyMix=(s,o=Object.prototype)=>new Proxy({},{getPrototypeOf:()=>o,setPrototypeOf(){throw Error(\"Cannot set prototype of Proxies created by ts-mixer\")},getOwnPropertyDescriptor:(o,i)=>Object.getOwnPropertyDescriptor(getIngredientWithProp(i,s)||{},i),defineProperty(){throw new Error(\"Cannot define new properties on Proxies created by ts-mixer\")},has:(i,a)=>void 0!==getIngredientWithProp(a,s)||void 0!==o[a],get:(i,a)=>(getIngredientWithProp(a,s)||o)[a],set(o,i,a){const u=getIngredientWithProp(i,s);if(void 0===u)throw new Error(\"Cannot set new properties on Proxies created by ts-mixer\");return u[i]=a,!0},deleteProperty(){throw new Error(\"Cannot delete properties on Proxies created by ts-mixer\")},ownKeys:()=>s.map(Object.getOwnPropertyNames).reduce(((s,o)=>o.concat(s.filter((s=>o.indexOf(s)<0)))))}),Sd=null,Ed=\"copy\",wd=\"copy\",xd=\"deep\",kd=new WeakMap,getMixinsForClass=s=>kd.get(s),mergeObjectsOfDecorators=(s,o)=>{var i,a;const u=unique([...Object.getOwnPropertyNames(s),...Object.getOwnPropertyNames(o)]),_={};for(let w of u)_[w]=unique([...null!==(i=null==s?void 0:s[w])&&void 0!==i?i:[],...null!==(a=null==o?void 0:o[w])&&void 0!==a?a:[]]);return _},mergePropertyAndMethodDecorators=(s,o)=>{var i,a,u,_;return{property:mergeObjectsOfDecorators(null!==(i=null==s?void 0:s.property)&&void 0!==i?i:{},null!==(a=null==o?void 0:o.property)&&void 0!==a?a:{}),method:mergeObjectsOfDecorators(null!==(u=null==s?void 0:s.method)&&void 0!==u?u:{},null!==(_=null==o?void 0:o.method)&&void 0!==_?_:{})}},mergeDecorators=(s,o)=>{var i,a,u,_,w,x;return{class:unique([...null!==(i=null==s?void 0:s.class)&&void 0!==i?i:[],...null!==(a=null==o?void 0:o.class)&&void 0!==a?a:[]]),static:mergePropertyAndMethodDecorators(null!==(u=null==s?void 0:s.static)&&void 0!==u?u:{},null!==(_=null==o?void 0:o.static)&&void 0!==_?_:{}),instance:mergePropertyAndMethodDecorators(null!==(w=null==s?void 0:s.instance)&&void 0!==w?w:{},null!==(x=null==o?void 0:o.instance)&&void 0!==x?x:{})}},Od=new Map,deepDecoratorSearch=(...s)=>{const o=((...s)=>{var o;const i=new Set,a=new Set([...s]);for(;a.size>0;)for(let s of a){const u=protoChain(s.prototype).map((s=>s.constructor)),_=[...u,...null!==(o=getMixinsForClass(s))&&void 0!==o?o:[]].filter((s=>!i.has(s)));for(let s of _)a.add(s);i.add(s),a.delete(s)}return[...i]})(...s).map((s=>Od.get(s))).filter((s=>!!s));return 0==o.length?{}:1==o.length?o[0]:o.reduce(((s,o)=>mergeDecorators(s,o)))},getDecoratorsForClass=s=>{let o=Od.get(s);return o||(o={},Od.set(s,o)),o};function Mixin(...s){var o,i,a;const u=s.map((s=>s.prototype)),_=Sd;if(null!==_){const s=u.map((s=>s[_])).filter((s=>\"function\"==typeof s)),combinedInitFunction=function(...o){for(let i of s)i.apply(this,o)},o={[_]:combinedInitFunction};u.push(o)}function MixedClass(...o){for(const i of s)copyProps(this,new i(...o));null!==_&&\"function\"==typeof this[_]&&this[_].apply(this,o)}var w,x;MixedClass.prototype=\"copy\"===wd?hardMixProtos(u,MixedClass):(w=u,x=MixedClass,proxyMix([...w,{constructor:x}])),Object.setPrototypeOf(MixedClass,\"copy\"===Ed?hardMixProtos(s,null,[\"prototype\"]):proxyMix(s,Function.prototype));let C=MixedClass;if(\"none\"!==xd){const u=\"deep\"===xd?deepDecoratorSearch(...s):((...s)=>{const o=s.map((s=>getDecoratorsForClass(s)));return 0===o.length?{}:1===o.length?o[0]:o.reduce(((s,o)=>mergeDecorators(s,o)))})(...s);for(let s of null!==(o=null==u?void 0:u.class)&&void 0!==o?o:[]){const o=s(C);o&&(C=o)}applyPropAndMethodDecorators(null!==(i=null==u?void 0:u.static)&&void 0!==i?i:{},C),applyPropAndMethodDecorators(null!==(a=null==u?void 0:u.instance)&&void 0!==a?a:{},C.prototype)}var j,L;return j=C,L=s,kd.set(j,L),C}const applyPropAndMethodDecorators=(s,o)=>{const i=s.property,a=s.method;if(i)for(let s in i)for(let a of i[s])a(o,s);if(a)for(let s in a)for(let i of a[s])i(o,s,Object.getOwnPropertyDescriptor(o,s))};const Ad=_curry1((function allPass(s){return $a(Aa(Ec,0,Oc(\"length\",s)),(function(){for(var o=0,i=s.length;o<i;){if(!s[o].apply(this,arguments))return!1;o+=1}return!0}))}));const Cd=_curry1((function isNotEmpty(s){return!cp(s)}));const Id=_curry2((function or(s,o){return s||o}));var Td=dc($a(1,ou(au,_curry2((function either(s,o){return _isFunction(s)?function _either(){return s.apply(this,arguments)||o.apply(this,arguments)}:hc(Id)(s,o)}))(cu,Mc))));const Nd=Ad([Jc,Td,Cd]);const Md=_curry2((function pick(s,o){for(var i={},a=0;a<s.length;)s[a]in o&&(i[s[a]]=o[s[a]]),a+=1;return i}));const Rd=class SpecificationVisitor extends vd{specObj;passingOptionsNames=[\"specObj\",\"parent\"];constructor({specObj:s,...o}){super({...o}),this.specObj=s}retrievePassingOptions(){return Md(this.passingOptionsNames,this)}retrieveFixedFields(s){const o=tp([\"visitors\",...s,\"fixedFields\"],this.specObj);return\"object\"==typeof o&&null!==o?Object.keys(o):[]}retrieveVisitor(s){return Qo(Mc,[\"visitors\",...s],this.specObj)?tp([\"visitors\",...s],this.specObj):tp([\"visitors\",...s,\"$visitor\"],this.specObj)}retrieveVisitorInstance(s,o={}){const i=this.retrievePassingOptions();return new(this.retrieveVisitor(s))({...i,...o})}toRefractedElement(s,o,i={}){const a=this.retrieveVisitorInstance(s,i);return a instanceof _d&&(null==a?void 0:a.constructor)===_d?cloneDeep(o):(visitor_visit(o,a,i),a.element)}};const Dd=class FixedFieldsVisitor extends Rd{specPath;ignoredFields;constructor({specPath:s,ignoredFields:o,...i}){super({...i}),this.specPath=s,this.ignoredFields=o||[]}ObjectElement(s){const o=this.specPath(s),i=this.retrieveFixedFields(o);return s.forEach(((s,a,u)=>{if(Pu(a)&&i.includes(serializers_value(a))&&!this.ignoredFields.includes(serializers_value(a))){const i=this.toRefractedElement([...o,\"fixedFields\",serializers_value(a)],s),_=new Su.Pr(cloneDeep(a),i);this.copyMetaAndAttributes(u,_),_.classes.push(\"fixed-field\"),this.element.content.push(_)}else this.ignoredFields.includes(serializers_value(a))||this.element.content.push(cloneDeep(u))})),this.copyMetaAndAttributes(s,this.element),Vu}};const Ld=class ParentSchemaAwareVisitor{parent;constructor({parent:s}){this.parent=s}},Fd=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Lh||s(a)&&o(\"JSONSchemaDraft4\",a)&&i(\"object\",a))),Bd=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Fh||s(a)&&o(\"JSONReference\",a)&&i(\"object\",a))),$d=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Jh||s(a)&&o(\"media\",a)&&i(\"object\",a))),Ud=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Hh||s(a)&&o(\"linkDescription\",a)&&i(\"object\",a)));class JSONSchemaVisitor extends(Mixin(Dd,Ld,_d)){constructor(s){super(s),this.element=new Lh,this.specPath=fc([\"document\",\"objects\",\"JSONSchema\"])}get defaultDialectIdentifier(){return\"http://json-schema.org/draft-04/schema#\"}ObjectElement(s){return this.handleDialectIdentifier(s),this.handleSchemaIdentifier(s),this.parent=this.element,Dd.prototype.ObjectElement.call(this,s)}handleDialectIdentifier(s){if(bc(this.parent)&&!Pu(s.get(\"$schema\")))this.element.setMetaProperty(\"inheritedDialectIdentifier\",this.defaultDialectIdentifier);else if(Fd(this.parent)&&!Pu(s.get(\"$schema\"))){const s=Na(serializers_value(this.parent.meta.get(\"inheritedDialectIdentifier\")),serializers_value(this.parent.$schema));this.element.setMetaProperty(\"inheritedDialectIdentifier\",s)}}handleSchemaIdentifier(s,o=\"id\"){const i=void 0!==this.parent?cloneDeep(this.parent.getMetaProperty(\"ancestorsSchemaIdentifiers\",[])):new Su.wE,a=serializers_value(s.get(o));Nd(a)&&i.push(a),this.element.setMetaProperty(\"ancestorsSchemaIdentifiers\",i)}}const Vd=JSONSchemaVisitor,isJSONReferenceLikeElement=s=>Mu(s)&&s.hasKey(\"$ref\");class ItemsVisitor extends(Mixin(Rd,Ld,_d)){ObjectElement(s){const o=isJSONReferenceLikeElement(s)?[\"document\",\"objects\",\"JSONReference\"]:[\"document\",\"objects\",\"JSONSchema\"];return this.element=this.toRefractedElement(o,s),Vu}ArrayElement(s){return this.element=new Su.wE,this.element.classes.push(\"json-schema-items\"),s.forEach((s=>{const o=isJSONReferenceLikeElement(s)?[\"document\",\"objects\",\"JSONReference\"]:[\"document\",\"objects\",\"JSONSchema\"],i=this.toRefractedElement(o,s);this.element.push(i)})),this.copyMetaAndAttributes(s,this.element),Vu}}const Wd=ItemsVisitor;const Jd=class RequiredVisitor extends _d{ArrayElement(s){const o=this.enter(s);return this.element.classes.push(\"json-schema-required\"),o}};const Hd=class PatternedFieldsVisitor extends Rd{specPath;ignoredFields;fieldPatternPredicate=es_F;constructor({specPath:s,ignoredFields:o,fieldPatternPredicate:i,...a}){super({...a}),this.specPath=s,this.ignoredFields=o||[],\"function\"==typeof i&&(this.fieldPatternPredicate=i)}ObjectElement(s){return s.forEach(((s,o,i)=>{if(!this.ignoredFields.includes(serializers_value(o))&&this.fieldPatternPredicate(serializers_value(o))){const a=this.specPath(s),u=this.toRefractedElement(a,s),_=new Su.Pr(cloneDeep(o),u);this.copyMetaAndAttributes(i,_),_.classes.push(\"patterned-field\"),this.element.content.push(_)}else this.ignoredFields.includes(serializers_value(o))||this.element.content.push(cloneDeep(i))})),this.copyMetaAndAttributes(s,this.element),Vu}};const Kd=class MapVisitor extends Hd{constructor(s){super(s),this.fieldPatternPredicate=Nd}};class PropertiesVisitor extends(Mixin(Kd,Ld,_d)){constructor(s){super(s),this.element=new Su.Sh,this.element.classes.push(\"json-schema-properties\"),this.specPath=s=>isJSONReferenceLikeElement(s)?[\"document\",\"objects\",\"JSONReference\"]:[\"document\",\"objects\",\"JSONSchema\"]}}const Gd=PropertiesVisitor;class PatternPropertiesVisitor extends(Mixin(Kd,Ld,_d)){constructor(s){super(s),this.element=new Su.Sh,this.element.classes.push(\"json-schema-patternProperties\"),this.specPath=s=>isJSONReferenceLikeElement(s)?[\"document\",\"objects\",\"JSONReference\"]:[\"document\",\"objects\",\"JSONSchema\"]}}const Yd=PatternPropertiesVisitor;class DependenciesVisitor extends(Mixin(Kd,Ld,_d)){constructor(s){super(s),this.element=new Su.Sh,this.element.classes.push(\"json-schema-dependencies\"),this.specPath=s=>isJSONReferenceLikeElement(s)?[\"document\",\"objects\",\"JSONReference\"]:[\"document\",\"objects\",\"JSONSchema\"]}}const Xd=DependenciesVisitor;const Qd=class EnumVisitor extends _d{ArrayElement(s){const o=this.enter(s);return this.element.classes.push(\"json-schema-enum\"),o}};const Zd=class TypeVisitor extends _d{StringElement(s){const o=this.enter(s);return this.element.classes.push(\"json-schema-type\"),o}ArrayElement(s){const o=this.enter(s);return this.element.classes.push(\"json-schema-type\"),o}};class AllOfVisitor extends(Mixin(Rd,Ld,_d)){constructor(s){super(s),this.element=new Su.wE,this.element.classes.push(\"json-schema-allOf\")}ArrayElement(s){return s.forEach((s=>{const o=isJSONReferenceLikeElement(s)?[\"document\",\"objects\",\"JSONReference\"]:[\"document\",\"objects\",\"JSONSchema\"],i=this.toRefractedElement(o,s);this.element.push(i)})),this.copyMetaAndAttributes(s,this.element),Vu}}const ef=AllOfVisitor;class AnyOfVisitor extends(Mixin(Rd,Ld,_d)){constructor(s){super(s),this.element=new Su.wE,this.element.classes.push(\"json-schema-anyOf\")}ArrayElement(s){return s.forEach((s=>{const o=isJSONReferenceLikeElement(s)?[\"document\",\"objects\",\"JSONReference\"]:[\"document\",\"objects\",\"JSONSchema\"],i=this.toRefractedElement(o,s);this.element.push(i)})),this.copyMetaAndAttributes(s,this.element),Vu}}const rf=AnyOfVisitor;class OneOfVisitor extends(Mixin(Rd,Ld,_d)){constructor(s){super(s),this.element=new Su.wE,this.element.classes.push(\"json-schema-oneOf\")}ArrayElement(s){return s.forEach((s=>{const o=isJSONReferenceLikeElement(s)?[\"document\",\"objects\",\"JSONReference\"]:[\"document\",\"objects\",\"JSONSchema\"],i=this.toRefractedElement(o,s);this.element.push(i)})),this.copyMetaAndAttributes(s,this.element),Vu}}const of=OneOfVisitor;class DefinitionsVisitor extends(Mixin(Kd,Ld,_d)){constructor(s){super(s),this.element=new Su.Sh,this.element.classes.push(\"json-schema-definitions\"),this.specPath=s=>isJSONReferenceLikeElement(s)?[\"document\",\"objects\",\"JSONReference\"]:[\"document\",\"objects\",\"JSONSchema\"]}}const af=DefinitionsVisitor;class LinksVisitor extends(Mixin(Rd,Ld,_d)){constructor(s){super(s),this.element=new Su.wE,this.element.classes.push(\"json-schema-links\")}ArrayElement(s){return s.forEach((s=>{const o=this.toRefractedElement([\"document\",\"objects\",\"LinkDescription\"],s);this.element.push(o)})),this.copyMetaAndAttributes(s,this.element),Vu}}const cf=LinksVisitor;class JSONReferenceVisitor extends(Mixin(Dd,_d)){constructor(s){super(s),this.element=new Fh,this.specPath=fc([\"document\",\"objects\",\"JSONReference\"])}ObjectElement(s){const o=Dd.prototype.ObjectElement.call(this,s);return Pu(this.element.$ref)&&this.element.classes.push(\"reference-element\"),o}}const lf=JSONReferenceVisitor;const uf=class $RefVisitor extends _d{StringElement(s){const o=this.enter(s);return this.element.classes.push(\"reference-value\"),o}};const hf=_curry3((function ifElse(s,o,i){return $a(Math.max(s.length,o.length,i.length),(function _ifElse(){return s.apply(this,arguments)?o.apply(this,arguments):i.apply(this,arguments)}))}));const df=_curry1((function comparator(s){return function(o,i){return s(o,i)?-1:s(i,o)?1:0}}));var mf=_curry2((function sort(s,o){return Array.prototype.slice.call(o,0).sort(s)}));const gf=mf;var yf=_curry1((function(s){return _nth(0,s)}));const bf=yf;const _f=_curry1(_reduced);const Sf=dc(Gh);const xf=ou(lp,Cd);function _toConsumableArray(s){return function _arrayWithoutHoles(s){if(Array.isArray(s))return _arrayLikeToArray(s)}(s)||function _iterableToArray(s){if(\"undefined\"!=typeof Symbol&&null!=s[Symbol.iterator]||null!=s[\"@@iterator\"])return Array.from(s)}(s)||function _unsupportedIterableToArray(s,o){if(s){if(\"string\"==typeof s)return _arrayLikeToArray(s,o);var i={}.toString.call(s).slice(8,-1);return\"Object\"===i&&s.constructor&&(i=s.constructor.name),\"Map\"===i||\"Set\"===i?Array.from(s):\"Arguments\"===i||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(i)?_arrayLikeToArray(s,o):void 0}}(s)||function _nonIterableSpread(){throw new TypeError(\"Invalid attempt to spread non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\")}()}function _arrayLikeToArray(s,o){(null==o||o>s.length)&&(o=s.length);for(var i=0,a=Array(o);i<o;i++)a[i]=s[i];return a}var kf=pipe(gf(df((function(s,o){return s.length>o.length}))),bf,Da(\"length\")),Of=za((function(s,o,i){var a=i.apply(void 0,_toConsumableArray(s));return Sf(a)?_f(a):o}));const Cf=hf(xf,(function dispatchImpl(s){var o=kf(s);return $a(o,(function(){for(var o=arguments.length,i=new Array(o),a=0;a<o;a++)i[a]=arguments[a];return Aa(Of(i),void 0,s)}))}),gc);const jf=class AlternatingVisitor extends Rd{alternator;constructor({alternator:s,...o}){super({...o}),this.alternator=s}enter(s){const o=this.alternator.map((({predicate:s,specPath:o})=>hf(s,fc(o),gc))),i=Cf(o)(s);return this.element=this.toRefractedElement(i,s),Vu}};const Pf=class SchemaOrReferenceVisitor extends jf{constructor(s){super(s),this.alternator=[{predicate:isJSONReferenceLikeElement,specPath:[\"document\",\"objects\",\"JSONReference\"]},{predicate:es_T,specPath:[\"document\",\"objects\",\"JSONSchema\"]}]}};class MediaVisitor extends(Mixin(Dd,_d)){constructor(s){super(s),this.element=new Jh,this.specPath=fc([\"document\",\"objects\",\"Media\"])}}const Tf=MediaVisitor;class LinkDescriptionVisitor extends(Mixin(Dd,_d)){constructor(s){super(s),this.element=new Hh,this.specPath=fc([\"document\",\"objects\",\"LinkDescription\"])}}const Nf=LinkDescriptionVisitor,Rf={visitors:{value:_d,JSONSchemaOrJSONReferenceVisitor:Pf,document:{objects:{JSONSchema:{$visitor:Vd,fixedFields:{id:{$ref:\"#/visitors/value\"},$schema:{$ref:\"#/visitors/value\"},multipleOf:{$ref:\"#/visitors/value\"},maximum:{$ref:\"#/visitors/value\"},exclusiveMaximum:{$ref:\"#/visitors/value\"},minimum:{$ref:\"#/visitors/value\"},exclusiveMinimum:{$ref:\"#/visitors/value\"},maxLength:{$ref:\"#/visitors/value\"},minLength:{$ref:\"#/visitors/value\"},pattern:{$ref:\"#/visitors/value\"},additionalItems:Pf,items:Wd,maxItems:{$ref:\"#/visitors/value\"},minItems:{$ref:\"#/visitors/value\"},uniqueItems:{$ref:\"#/visitors/value\"},maxProperties:{$ref:\"#/visitors/value\"},minProperties:{$ref:\"#/visitors/value\"},required:Jd,properties:Gd,additionalProperties:Pf,patternProperties:Yd,dependencies:Xd,enum:Qd,type:Zd,allOf:ef,anyOf:rf,oneOf:of,not:Pf,definitions:af,title:{$ref:\"#/visitors/value\"},description:{$ref:\"#/visitors/value\"},default:{$ref:\"#/visitors/value\"},format:{$ref:\"#/visitors/value\"},base:{$ref:\"#/visitors/value\"},links:cf,media:{$ref:\"#/visitors/document/objects/Media\"},readOnly:{$ref:\"#/visitors/value\"}}},JSONReference:{$visitor:lf,fixedFields:{$ref:uf}},Media:{$visitor:Tf,fixedFields:{binaryEncoding:{$ref:\"#/visitors/value\"},type:{$ref:\"#/visitors/value\"}}},LinkDescription:{$visitor:Nf,fixedFields:{href:{$ref:\"#/visitors/value\"},rel:{$ref:\"#/visitors/value\"},title:{$ref:\"#/visitors/value\"},targetSchema:Pf,mediaType:{$ref:\"#/visitors/value\"},method:{$ref:\"#/visitors/value\"},encType:{$ref:\"#/visitors/value\"},schema:Pf}}}}}},traversal_visitor_getNodeType=s=>{if(ju(s))return`${s.element.charAt(0).toUpperCase()+s.element.slice(1)}Element`},Df={JSONSchemaDraft4Element:[\"content\"],JSONReferenceElement:[\"content\"],MediaElement:[\"content\"],LinkDescriptionElement:[\"content\"],...Ku},Ff={namespace:s=>{const{base:o}=s;return o.register(\"jSONSchemaDraft4\",Lh),o.register(\"jSONReference\",Fh),o.register(\"media\",Jh),o.register(\"linkDescription\",Hh),o}},Vf=Ff,refractor_toolbox=()=>{const s=createNamespace(Vf);return{predicates:{...ae,isStringElement:Pu},namespace:s}},refractor_refract=(s,{specPath:o=[\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"$visitor\"],plugins:i=[],specificationObj:a=Rf}={})=>{const u=(0,Su.e)(s),_=dereference(a),w=new(tp(o,_))({specObj:_});return visitor_visit(u,w),dispatchPluginsSync(w.element,i,{toolboxCreator:refractor_toolbox,visitorOptions:{keyMap:Df,nodeTypeGetter:traversal_visitor_getNodeType}})},refractor_createRefractor=s=>(o,i={})=>refractor_refract(o,{specPath:s,...i});Lh.refract=refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"$visitor\"]),Fh.refract=refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"JSONReference\",\"$visitor\"]),Jh.refract=refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Media\",\"$visitor\"]),Hh.refract=refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"$visitor\"]);const Wf=class Schema_Schema extends Lh{constructor(s,o,i){super(s,o,i),this.element=\"schema\",this.classes.push(\"json-schema-draft-4\")}get idProp(){throw new Dh(\"idProp getter in Schema class is not not supported.\")}set idProp(s){throw new Dh(\"idProp setter in Schema class is not not supported.\")}get $schema(){throw new Dh(\"$schema getter in Schema class is not not supported.\")}set $schema(s){throw new Dh(\"$schema setter in Schema class is not not supported.\")}get additionalItems(){return this.get(\"additionalItems\")}set additionalItems(s){this.set(\"additionalItems\",s)}get items(){return this.get(\"items\")}set items(s){this.set(\"items\",s)}get additionalProperties(){return this.get(\"additionalProperties\")}set additionalProperties(s){this.set(\"additionalProperties\",s)}get patternProperties(){throw new Dh(\"patternProperties getter in Schema class is not not supported.\")}set patternProperties(s){throw new Dh(\"patternProperties setter in Schema class is not not supported.\")}get dependencies(){throw new Dh(\"dependencies getter in Schema class is not not supported.\")}set dependencies(s){throw new Dh(\"dependencies setter in Schema class is not not supported.\")}get type(){return this.get(\"type\")}set type(s){this.set(\"type\",s)}get not(){return this.get(\"not\")}set not(s){this.set(\"not\",s)}get definitions(){throw new Dh(\"definitions getter in Schema class is not not supported.\")}set definitions(s){throw new Dh(\"definitions setter in Schema class is not not supported.\")}get base(){throw new Dh(\"base getter in Schema class is not not supported.\")}set base(s){throw new Dh(\"base setter in Schema class is not not supported.\")}get links(){throw new Dh(\"links getter in Schema class is not not supported.\")}set links(s){throw new Dh(\"links setter in Schema class is not not supported.\")}get media(){throw new Dh(\"media getter in Schema class is not not supported.\")}set media(s){throw new Dh(\"media setter in Schema class is not not supported.\")}get nullable(){return this.get(\"nullable\")}set nullable(s){this.set(\"nullable\",s)}get discriminator(){return this.get(\"discriminator\")}set discriminator(s){this.set(\"discriminator\",s)}get writeOnly(){return this.get(\"writeOnly\")}set writeOnly(s){this.set(\"writeOnly\",s)}get xml(){return this.get(\"xml\")}set xml(s){this.set(\"xml\",s)}get externalDocs(){return this.get(\"externalDocs\")}set externalDocs(s){this.set(\"externalDocs\",s)}get example(){return this.get(\"example\")}set example(s){this.set(\"example\",s)}get deprecated(){return this.get(\"deprecated\")}set deprecated(s){this.set(\"deprecated\",s)}};class SecurityRequirement extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"securityRequirement\"}}const Jf=SecurityRequirement;class SecurityScheme extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"securityScheme\"}get type(){return this.get(\"type\")}set type(s){this.set(\"type\",s)}get description(){return this.get(\"description\")}set description(s){this.set(\"description\",s)}get name(){return this.get(\"name\")}set name(s){this.set(\"name\",s)}get in(){return this.get(\"in\")}set in(s){this.set(\"in\",s)}get scheme(){return this.get(\"scheme\")}set scheme(s){this.set(\"scheme\",s)}get bearerFormat(){return this.get(\"bearerFormat\")}set bearerFormat(s){this.set(\"bearerFormat\",s)}get flows(){return this.get(\"flows\")}set flows(s){this.set(\"flows\",s)}get openIdConnectUrl(){return this.get(\"openIdConnectUrl\")}set openIdConnectUrl(s){this.set(\"openIdConnectUrl\",s)}}const Hf=SecurityScheme;class Server extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"server\"}get url(){return this.get(\"url\")}set url(s){this.set(\"url\",s)}get description(){return this.get(\"description\")}set description(s){this.set(\"description\",s)}get variables(){return this.get(\"variables\")}set variables(s){this.set(\"variables\",s)}}const Gf=Server;class ServerVariable extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"serverVariable\"}get enum(){return this.get(\"enum\")}set enum(s){this.set(\"enum\",s)}get default(){return this.get(\"default\")}set default(s){this.set(\"default\",s)}get description(){return this.get(\"description\")}set description(s){this.set(\"description\",s)}}const Xf=ServerVariable;class Tag extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"tag\"}get name(){return this.get(\"name\")}set name(s){this.set(\"name\",s)}get description(){return this.get(\"description\")}set description(s){this.set(\"description\",s)}get externalDocs(){return this.get(\"externalDocs\")}set externalDocs(s){this.set(\"externalDocs\",s)}}const Qf=Tag;class Xml extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"xml\"}get name(){return this.get(\"name\")}set name(s){this.set(\"name\",s)}get namespace(){return this.get(\"namespace\")}set namespace(s){this.set(\"namespace\",s)}get prefix(){return this.get(\"prefix\")}set prefix(s){this.set(\"prefix\",s)}get attribute(){return this.get(\"attribute\")}set attribute(s){this.set(\"attribute\",s)}get wrapped(){return this.get(\"wrapped\")}set wrapped(s){this.set(\"wrapped\",s)}}const em=Xml;const tm=class visitors_Visitor_Visitor{element;constructor(s={}){Object.assign(this,s)}copyMetaAndAttributes(s,o){(s.meta.length>0||o.meta.length>0)&&(o.meta=yd(o.meta,s.meta),hasElementSourceMap(s)&&o.meta.set(\"sourceMap\",s.meta.get(\"sourceMap\"))),(s.attributes.length>0||s.meta.length>0)&&(o.attributes=yd(o.attributes,s.attributes))}};const rm=class FallbackVisitor_FallbackVisitor extends tm{enter(s){return this.element=cloneDeep(s),Vu}};const nm=class SpecificationVisitor_SpecificationVisitor extends tm{specObj;passingOptionsNames=[\"specObj\",\"openApiGenericElement\",\"openApiSemanticElement\"];openApiGenericElement;openApiSemanticElement;constructor({specObj:s,passingOptionsNames:o,openApiGenericElement:i,openApiSemanticElement:a,...u}){super({...u}),this.specObj=s,this.openApiGenericElement=i,this.openApiSemanticElement=a,Array.isArray(o)&&(this.passingOptionsNames=o)}retrievePassingOptions(){return Md(this.passingOptionsNames,this)}retrieveFixedFields(s){const o=tp([\"visitors\",...s,\"fixedFields\"],this.specObj);return\"object\"==typeof o&&null!==o?Object.keys(o):[]}retrieveVisitor(s){return Qo(Mc,[\"visitors\",...s],this.specObj)?tp([\"visitors\",...s],this.specObj):tp([\"visitors\",...s,\"$visitor\"],this.specObj)}retrieveVisitorInstance(s,o={}){const i=this.retrievePassingOptions();return new(this.retrieveVisitor(s))({...i,...o})}toRefractedElement(s,o,i={}){const a=this.retrieveVisitorInstance(s,i);return a instanceof rm&&(null==a?void 0:a.constructor)===rm?cloneDeep(o):(visitor_visit(o,a,i),a.element)}};var sm=function(){function XTake(s,o){this.xf=o,this.n=s,this.i=0}return XTake.prototype[\"@@transducer/init\"]=_xfBase_init,XTake.prototype[\"@@transducer/result\"]=_xfBase_result,XTake.prototype[\"@@transducer/step\"]=function(s,o){this.i+=1;var i=0===this.n?s:this.xf[\"@@transducer/step\"](s,o);return this.n>=0&&this.i>=this.n?_reduced(i):i},XTake}();function _xtake(s){return function(o){return new sm(s,o)}}const om=_curry2(_dispatchable([\"take\"],_xtake,(function take(s,o){return ja(0,s<0?1/0:s,o)})));var im=_curry2((function(s,o){return na(om(s.length,o),s)}));const am=im,isReferenceLikeElement=s=>Mu(s)&&s.hasKey(\"$ref\"),cm=Mu,lm=Mu,isOpenApiExtension=s=>Pu(s.key)&&am(\"x-\",serializers_value(s.key));const um=class FixedFieldsVisitor_FixedFieldsVisitor extends nm{specPath;ignoredFields;canSupportSpecificationExtensions=!0;specificationExtensionPredicate=isOpenApiExtension;constructor({specPath:s,ignoredFields:o,canSupportSpecificationExtensions:i,specificationExtensionPredicate:a,...u}){super({...u}),this.specPath=s,this.ignoredFields=o||[],\"boolean\"==typeof i&&(this.canSupportSpecificationExtensions=i),\"function\"==typeof a&&(this.specificationExtensionPredicate=a)}ObjectElement(s){const o=this.specPath(s),i=this.retrieveFixedFields(o);return s.forEach(((s,a,u)=>{if(Pu(a)&&i.includes(serializers_value(a))&&!this.ignoredFields.includes(serializers_value(a))){const i=this.toRefractedElement([...o,\"fixedFields\",serializers_value(a)],s),_=new Su.Pr(cloneDeep(a),i);this.copyMetaAndAttributes(u,_),_.classes.push(\"fixed-field\"),this.element.content.push(_)}else if(this.canSupportSpecificationExtensions&&this.specificationExtensionPredicate(u)){const s=this.toRefractedElement([\"document\",\"extension\"],u);this.element.content.push(s)}else this.ignoredFields.includes(serializers_value(a))||this.element.content.push(cloneDeep(u))})),this.copyMetaAndAttributes(s,this.element),Vu}};class OpenApi3_0Visitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new fh,this.specPath=fc([\"document\",\"objects\",\"OpenApi\"]),this.canSupportSpecificationExtensions=!0}ObjectElement(s){return um.prototype.ObjectElement.call(this,s)}}const pm=OpenApi3_0Visitor;class OpenapiVisitor extends(Mixin(nm,rm)){StringElement(s){const o=new dh(serializers_value(s));return this.copyMetaAndAttributes(s,o),this.element=o,Vu}}const hm=OpenapiVisitor;const dm=class SpecificationExtensionVisitor extends nm{MemberElement(s){return this.element=cloneDeep(s),this.element.classes.push(\"specification-extension\"),Vu}};class InfoVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Xp,this.specPath=fc([\"document\",\"objects\",\"Info\"]),this.canSupportSpecificationExtensions=!0}}const fm=InfoVisitor;const mm=class VersionVisitor extends rm{StringElement(s){const o=super.enter(s);return this.element.classes.push(\"api-version\"),this.element.classes.push(\"version\"),o}};class ContactVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Wp,this.specPath=fc([\"document\",\"objects\",\"Contact\"]),this.canSupportSpecificationExtensions=!0}}const gm=ContactVisitor;class LicenseVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Qp,this.specPath=fc([\"document\",\"objects\",\"License\"]),this.canSupportSpecificationExtensions=!0}}const ym=LicenseVisitor;class LinkVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Zp,this.specPath=fc([\"document\",\"objects\",\"Link\"]),this.canSupportSpecificationExtensions=!0}ObjectElement(s){const o=um.prototype.ObjectElement.call(this,s);return(Pu(this.element.operationId)||Pu(this.element.operationRef))&&this.element.classes.push(\"reference-element\"),o}}const vm=LinkVisitor;const bm=class OperationRefVisitor extends rm{StringElement(s){const o=super.enter(s);return this.element.classes.push(\"reference-value\"),o}};const _m=class OperationIdVisitor extends rm{StringElement(s){const o=super.enter(s);return this.element.classes.push(\"reference-value\"),o}};const Sm=class PatternedFieldsVisitor_PatternedFieldsVisitor extends nm{specPath;ignoredFields;fieldPatternPredicate=es_F;canSupportSpecificationExtensions=!1;specificationExtensionPredicate=isOpenApiExtension;constructor({specPath:s,ignoredFields:o,fieldPatternPredicate:i,canSupportSpecificationExtensions:a,specificationExtensionPredicate:u,..._}){super({..._}),this.specPath=s,this.ignoredFields=o||[],\"function\"==typeof i&&(this.fieldPatternPredicate=i),\"boolean\"==typeof a&&(this.canSupportSpecificationExtensions=a),\"function\"==typeof u&&(this.specificationExtensionPredicate=u)}ObjectElement(s){return s.forEach(((s,o,i)=>{if(this.canSupportSpecificationExtensions&&this.specificationExtensionPredicate(i)){const s=this.toRefractedElement([\"document\",\"extension\"],i);this.element.content.push(s)}else if(!this.ignoredFields.includes(serializers_value(o))&&this.fieldPatternPredicate(serializers_value(o))){const a=this.specPath(s),u=this.toRefractedElement(a,s),_=new Su.Pr(cloneDeep(o),u);this.copyMetaAndAttributes(i,_),_.classes.push(\"patterned-field\"),this.element.content.push(_)}else this.ignoredFields.includes(serializers_value(o))||this.element.content.push(cloneDeep(i))})),this.copyMetaAndAttributes(s,this.element),Vu}};const Em=class MapVisitor_MapVisitor extends Sm{constructor(s){super(s),this.fieldPatternPredicate=Nd}};class LinkParameters extends Su.Sh{static primaryClass=\"link-parameters\";constructor(s,o,i){super(s,o,i),this.classes.push(LinkParameters.primaryClass)}}const wm=LinkParameters;class ParametersVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new wm,this.specPath=fc([\"value\"])}}const xm=ParametersVisitor;class ServerVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Gf,this.specPath=fc([\"document\",\"objects\",\"Server\"]),this.canSupportSpecificationExtensions=!0}}const km=ServerVisitor;const Om=class UrlVisitor extends rm{StringElement(s){const o=super.enter(s);return this.element.classes.push(\"server-url\"),o}};class Servers extends Su.wE{static primaryClass=\"servers\";constructor(s,o,i){super(s,o,i),this.classes.push(Servers.primaryClass)}}const Am=Servers;class ServersVisitor extends(Mixin(nm,rm)){constructor(s){super(s),this.element=new Am}ArrayElement(s){return s.forEach((s=>{const o=cm(s)?[\"document\",\"objects\",\"Server\"]:[\"value\"],i=this.toRefractedElement(o,s);this.element.push(i)})),this.copyMetaAndAttributes(s,this.element),Vu}}const Cm=ServersVisitor;class ServerVariableVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Xf,this.specPath=fc([\"document\",\"objects\",\"ServerVariable\"]),this.canSupportSpecificationExtensions=!0}}const jm=ServerVariableVisitor;class ServerVariables extends Su.Sh{static primaryClass=\"server-variables\";constructor(s,o,i){super(s,o,i),this.classes.push(ServerVariables.primaryClass)}}const Pm=ServerVariables;class VariablesVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new Pm,this.specPath=fc([\"document\",\"objects\",\"ServerVariable\"])}}const Im=VariablesVisitor;class MediaTypeVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new th,this.specPath=fc([\"document\",\"objects\",\"MediaType\"]),this.canSupportSpecificationExtensions=!0}}const Tm=MediaTypeVisitor;const Nm=class AlternatingVisitor_AlternatingVisitor extends nm{alternator;constructor({alternator:s,...o}){super({...o}),this.alternator=s||[]}enter(s){const o=this.alternator.map((({predicate:s,specPath:o})=>hf(s,fc(o),gc))),i=Cf(o)(s);return this.element=this.toRefractedElement(i,s),Vu}},Mm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Vp||s(a)&&o(\"callback\",a)&&i(\"object\",a))),Rm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof zp||s(a)&&o(\"components\",a)&&i(\"object\",a))),Dm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Wp||s(a)&&o(\"contact\",a)&&i(\"object\",a))),Lm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Kp||s(a)&&o(\"example\",a)&&i(\"object\",a))),Fm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Gp||s(a)&&o(\"externalDocumentation\",a)&&i(\"object\",a))),Bm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Yp||s(a)&&o(\"header\",a)&&i(\"object\",a))),$m=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Xp||s(a)&&o(\"info\",a)&&i(\"object\",a))),qm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Qp||s(a)&&o(\"license\",a)&&i(\"object\",a))),Um=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Zp||s(a)&&o(\"link\",a)&&i(\"object\",a))),Vm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof dh||s(a)&&o(\"openapi\",a)&&i(\"string\",a))),zm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i,hasClass:a})=>u=>u instanceof fh||s(u)&&o(\"openApi3_0\",u)&&i(\"object\",u)&&a(\"api\",u))),Wm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof vh||s(a)&&o(\"operation\",a)&&i(\"object\",a))),Jm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof _h||s(a)&&o(\"parameter\",a)&&i(\"object\",a))),Hm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof wh||s(a)&&o(\"pathItem\",a)&&i(\"object\",a))),Km=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Oh||s(a)&&o(\"paths\",a)&&i(\"object\",a))),Gm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof jh||s(a)&&o(\"reference\",a)&&i(\"object\",a))),Ym=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Ph||s(a)&&o(\"requestBody\",a)&&i(\"object\",a))),Xm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Ih||s(a)&&o(\"response\",a)&&i(\"object\",a))),Qm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Rh||s(a)&&o(\"responses\",a)&&i(\"object\",a))),Zm=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Wf||s(a)&&o(\"schema\",a)&&i(\"object\",a))),isBooleanJsonSchemaElement=s=>Nu(s)&&s.classes.includes(\"boolean-json-schema\"),eg=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Jf||s(a)&&o(\"securityRequirement\",a)&&i(\"object\",a))),rg=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Hf||s(a)&&o(\"securityScheme\",a)&&i(\"object\",a))),ng=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Gf||s(a)&&o(\"server\",a)&&i(\"object\",a))),sg=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Xf||s(a)&&o(\"serverVariable\",a)&&i(\"object\",a))),og=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof th||s(a)&&o(\"mediaType\",a)&&i(\"object\",a))),lg=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i,hasClass:a})=>u=>u instanceof Am||s(u)&&o(\"array\",u)&&i(\"array\",u)&&a(\"servers\",u))),pg=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Jp||s(a)&&o(\"discriminator\",a)&&i(\"object\",a)));class SchemaVisitor extends(Mixin(Nm,rm)){constructor(s){super(s),this.alternator=[{predicate:isReferenceLikeElement,specPath:[\"document\",\"objects\",\"Reference\"]},{predicate:es_T,specPath:[\"document\",\"objects\",\"Schema\"]}]}ObjectElement(s){const o=Nm.prototype.enter.call(this,s);return Gm(this.element)&&this.element.setMetaProperty(\"referenced-element\",\"schema\"),o}}const fg=SchemaVisitor;class ExamplesVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new Su.Sh,this.element.classes.push(\"examples\"),this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"Example\"],this.canSupportSpecificationExtensions=!0}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"example\")})),o}}const mg=ExamplesVisitor;class MediaTypeExamples extends Su.Sh{static primaryClass=\"media-type-examples\";constructor(s,o,i){super(s,o,i),this.classes.push(MediaTypeExamples.primaryClass),this.classes.push(\"examples\")}}const gg=MediaTypeExamples;const yg=class ExamplesVisitor_ExamplesVisitor extends mg{constructor(s){super(s),this.element=new gg}};class MediaTypeEncoding extends Su.Sh{static primaryClass=\"media-type-encoding\";constructor(s,o,i){super(s,o,i),this.classes.push(MediaTypeEncoding.primaryClass)}}const _g=MediaTypeEncoding;class EncodingVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new _g,this.specPath=fc([\"document\",\"objects\",\"Encoding\"])}}const xg=EncodingVisitor;class SecurityRequirementVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new Jf,this.specPath=fc([\"value\"])}}const kg=SecurityRequirementVisitor;class Security extends Su.wE{static primaryClass=\"security\";constructor(s,o,i){super(s,o,i),this.classes.push(Security.primaryClass)}}const qg=Security;class SecurityVisitor extends(Mixin(nm,rm)){constructor(s){super(s),this.element=new qg}ArrayElement(s){return s.forEach((s=>{if(Mu(s)){const o=this.toRefractedElement([\"document\",\"objects\",\"SecurityRequirement\"],s);this.element.push(o)}else this.element.push(cloneDeep(s))})),this.copyMetaAndAttributes(s,this.element),Vu}}const Ug=SecurityVisitor;class ComponentsVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new zp,this.specPath=fc([\"document\",\"objects\",\"Components\"]),this.canSupportSpecificationExtensions=!0}}const Vg=ComponentsVisitor;class TagVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Qf,this.specPath=fc([\"document\",\"objects\",\"Tag\"]),this.canSupportSpecificationExtensions=!0}}const zg=TagVisitor;class ReferenceVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new jh,this.specPath=fc([\"document\",\"objects\",\"Reference\"]),this.canSupportSpecificationExtensions=!1}ObjectElement(s){const o=um.prototype.ObjectElement.call(this,s);return Pu(this.element.$ref)&&this.element.classes.push(\"reference-element\"),o}}const Wg=ReferenceVisitor;const Kg=class $RefVisitor_$RefVisitor extends rm{StringElement(s){const o=super.enter(s);return this.element.classes.push(\"reference-value\"),o}};class ParameterVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new _h,this.specPath=fc([\"document\",\"objects\",\"Parameter\"]),this.canSupportSpecificationExtensions=!0}ObjectElement(s){const o=um.prototype.ObjectElement.call(this,s);return Mu(this.element.contentProp)&&this.element.contentProp.filter(og).forEach(((s,o)=>{s.setMetaProperty(\"media-type\",serializers_value(o))})),o}}const Yg=ParameterVisitor;class SchemaVisitor_SchemaVisitor extends(Mixin(Nm,rm)){constructor(s){super(s),this.alternator=[{predicate:isReferenceLikeElement,specPath:[\"document\",\"objects\",\"Reference\"]},{predicate:es_T,specPath:[\"document\",\"objects\",\"Schema\"]}]}ObjectElement(s){const o=Nm.prototype.enter.call(this,s);return Gm(this.element)&&this.element.setMetaProperty(\"referenced-element\",\"schema\"),o}}const Xg=SchemaVisitor_SchemaVisitor;class HeaderVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Yp,this.specPath=fc([\"document\",\"objects\",\"Header\"]),this.canSupportSpecificationExtensions=!0}}const Zg=HeaderVisitor;class header_SchemaVisitor_SchemaVisitor extends(Mixin(Nm,rm)){constructor(s){super(s),this.alternator=[{predicate:isReferenceLikeElement,specPath:[\"document\",\"objects\",\"Reference\"]},{predicate:es_T,specPath:[\"document\",\"objects\",\"Schema\"]}]}ObjectElement(s){const o=Nm.prototype.enter.call(this,s);return Gm(this.element)&&this.element.setMetaProperty(\"referenced-element\",\"schema\"),o}}const ey=header_SchemaVisitor_SchemaVisitor;class HeaderExamples extends Su.Sh{static primaryClass=\"header-examples\";constructor(s,o,i){super(s,o,i),this.classes.push(HeaderExamples.primaryClass),this.classes.push(\"examples\")}}const ty=HeaderExamples;const ry=class header_ExamplesVisitor_ExamplesVisitor extends mg{constructor(s){super(s),this.element=new ty}};class ContentVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new Su.Sh,this.element.classes.push(\"content\"),this.specPath=fc([\"document\",\"objects\",\"MediaType\"])}}const ny=ContentVisitor;class HeaderContent extends Su.Sh{static primaryClass=\"header-content\";constructor(s,o,i){super(s,o,i),this.classes.push(HeaderContent.primaryClass),this.classes.push(\"content\")}}const sy=HeaderContent;const oy=class ContentVisitor_ContentVisitor extends ny{constructor(s){super(s),this.element=new sy}};class schema_SchemaVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Wf,this.specPath=fc([\"document\",\"objects\",\"Schema\"]),this.canSupportSpecificationExtensions=!0}}const iy=schema_SchemaVisitor,ay=Rf.visitors.document.objects.JSONSchema.fixedFields.allOf;const cy=class AllOfVisitor_AllOfVisitor extends ay{ArrayElement(s){const o=ay.prototype.ArrayElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"schema\")})),o}},ly=Rf.visitors.document.objects.JSONSchema.fixedFields.anyOf;const uy=class AnyOfVisitor_AnyOfVisitor extends ly{ArrayElement(s){const o=ly.prototype.ArrayElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"schema\")})),o}},py=Rf.visitors.document.objects.JSONSchema.fixedFields.oneOf;const hy=class OneOfVisitor_OneOfVisitor extends py{ArrayElement(s){const o=py.prototype.ArrayElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"schema\")})),o}},dy=Rf.visitors.document.objects.JSONSchema.fixedFields.items;const fy=class ItemsVisitor_ItemsVisitor extends dy{ObjectElement(s){const o=dy.prototype.ObjectElement.call(this,s);return Gm(this.element)&&this.element.setMetaProperty(\"referenced-element\",\"schema\"),o}ArrayElement(s){return this.enter(s)}},my=Rf.visitors.document.objects.JSONSchema.fixedFields.properties;const gy=class PropertiesVisitor_PropertiesVisitor extends my{ObjectElement(s){const o=my.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"schema\")})),o}},yy=Rf.visitors.document.objects.JSONSchema.fixedFields.type;const vy=class TypeVisitor_TypeVisitor extends yy{ArrayElement(s){return this.enter(s)}},by=Rf.visitors.JSONSchemaOrJSONReferenceVisitor;const _y=class SchemaOrReferenceVisitor_SchemaOrReferenceVisitor extends by{ObjectElement(s){const o=by.prototype.enter.call(this,s);return Gm(this.element)&&this.element.setMetaProperty(\"referenced-element\",\"schema\"),o}};class DiscriminatorVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Jp,this.specPath=fc([\"document\",\"objects\",\"Discriminator\"]),this.canSupportSpecificationExtensions=!1}}const Sy=DiscriminatorVisitor;class DiscriminatorMapping extends Su.Sh{static primaryClass=\"discriminator-mapping\";constructor(s,o,i){super(s,o,i),this.classes.push(DiscriminatorMapping.primaryClass)}}const Ey=DiscriminatorMapping;class MappingVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new Ey,this.specPath=fc([\"value\"])}}const wy=MappingVisitor;class XmlVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new em,this.specPath=fc([\"document\",\"objects\",\"XML\"]),this.canSupportSpecificationExtensions=!0}}const xy=XmlVisitor;class ParameterExamples extends Su.Sh{static primaryClass=\"parameter-examples\";constructor(s,o,i){super(s,o,i),this.classes.push(ParameterExamples.primaryClass),this.classes.push(\"examples\")}}const ky=ParameterExamples;const Oy=class parameter_ExamplesVisitor_ExamplesVisitor extends mg{constructor(s){super(s),this.element=new ky}};class ParameterContent extends Su.Sh{static primaryClass=\"parameter-content\";constructor(s,o,i){super(s,o,i),this.classes.push(ParameterContent.primaryClass),this.classes.push(\"content\")}}const Ay=ParameterContent;const Cy=class parameter_ContentVisitor_ContentVisitor extends ny{constructor(s){super(s),this.element=new Ay}};class ComponentsSchemas extends Su.Sh{static primaryClass=\"components-schemas\";constructor(s,o,i){super(s,o,i),this.classes.push(ComponentsSchemas.primaryClass)}}const jy=ComponentsSchemas;class SchemasVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new jy,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"Schema\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"schema\")})),o}}const Py=SchemasVisitor;class ComponentsResponses extends Su.Sh{static primaryClass=\"components-responses\";constructor(s,o,i){super(s,o,i),this.classes.push(ComponentsResponses.primaryClass)}}const Iy=ComponentsResponses;class ResponsesVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new Iy,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"Response\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"response\")})),this.element.filter(Xm).forEach(((s,o)=>{s.setMetaProperty(\"http-status-code\",serializers_value(o))})),o}}const Ty=ResponsesVisitor;class ComponentsParameters extends Su.Sh{static primaryClass=\"components-parameters\";constructor(s,o,i){super(s,o,i),this.classes.push(ComponentsParameters.primaryClass),this.classes.push(\"parameters\")}}const Ny=ComponentsParameters;class ParametersVisitor_ParametersVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new Ny,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"Parameter\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"parameter\")})),o}}const My=ParametersVisitor_ParametersVisitor;class ComponentsExamples extends Su.Sh{static primaryClass=\"components-examples\";constructor(s,o,i){super(s,o,i),this.classes.push(ComponentsExamples.primaryClass),this.classes.push(\"examples\")}}const Ry=ComponentsExamples;class components_ExamplesVisitor_ExamplesVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new Ry,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"Example\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"example\")})),o}}const Dy=components_ExamplesVisitor_ExamplesVisitor;class ComponentsRequestBodies extends Su.Sh{static primaryClass=\"components-request-bodies\";constructor(s,o,i){super(s,o,i),this.classes.push(ComponentsRequestBodies.primaryClass)}}const Ly=ComponentsRequestBodies;class RequestBodiesVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new Ly,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"RequestBody\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"requestBody\")})),o}}const Fy=RequestBodiesVisitor;class ComponentsHeaders extends Su.Sh{static primaryClass=\"components-headers\";constructor(s,o,i){super(s,o,i),this.classes.push(ComponentsHeaders.primaryClass)}}const By=ComponentsHeaders;class HeadersVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new By,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"Header\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"header\")})),this.element.filter(Bm).forEach(((s,o)=>{s.setMetaProperty(\"header-name\",serializers_value(o))})),o}}const $y=HeadersVisitor;class ComponentsSecuritySchemes extends Su.Sh{static primaryClass=\"components-security-schemes\";constructor(s,o,i){super(s,o,i),this.classes.push(ComponentsSecuritySchemes.primaryClass)}}const qy=ComponentsSecuritySchemes;class SecuritySchemesVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new qy,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"SecurityScheme\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"securityScheme\")})),o}}const Uy=SecuritySchemesVisitor;class ComponentsLinks extends Su.Sh{static primaryClass=\"components-links\";constructor(s,o,i){super(s,o,i),this.classes.push(ComponentsLinks.primaryClass)}}const Vy=ComponentsLinks;class LinksVisitor_LinksVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new Vy,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"Link\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"link\")})),o}}const zy=LinksVisitor_LinksVisitor;class ComponentsCallbacks extends Su.Sh{static primaryClass=\"components-callbacks\";constructor(s,o,i){super(s,o,i),this.classes.push(ComponentsCallbacks.primaryClass)}}const Wy=ComponentsCallbacks;class CallbacksVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new Wy,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"Callback\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"callback\")})),o}}const Jy=CallbacksVisitor;class ExampleVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Kp,this.specPath=fc([\"document\",\"objects\",\"Example\"]),this.canSupportSpecificationExtensions=!0}ObjectElement(s){const o=um.prototype.ObjectElement.call(this,s);return Pu(this.element.externalValue)&&this.element.classes.push(\"reference-element\"),o}}const Hy=ExampleVisitor;const Ky=class ExternalValueVisitor extends rm{StringElement(s){const o=super.enter(s);return this.element.classes.push(\"reference-value\"),o}};class ExternalDocumentationVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Gp,this.specPath=fc([\"document\",\"objects\",\"ExternalDocumentation\"]),this.canSupportSpecificationExtensions=!0}}const Gy=ExternalDocumentationVisitor;class encoding_EncodingVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Hp,this.specPath=fc([\"document\",\"objects\",\"Encoding\"]),this.canSupportSpecificationExtensions=!0}ObjectElement(s){const o=um.prototype.ObjectElement.call(this,s);return Mu(this.element.headers)&&this.element.headers.filter(Bm).forEach(((s,o)=>{s.setMetaProperty(\"header-name\",serializers_value(o))})),o}}const Yy=encoding_EncodingVisitor;class EncodingHeaders extends Su.Sh{static primaryClass=\"encoding-headers\";constructor(s,o,i){super(s,o,i),this.classes.push(EncodingHeaders.primaryClass)}}const Xy=EncodingHeaders;class HeadersVisitor_HeadersVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new Xy,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"Header\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"header\")})),this.element.forEach(((s,o)=>{if(!Bm(s))return;const i=serializers_value(o);s.setMetaProperty(\"headerName\",i)})),o}}const Qy=HeadersVisitor_HeadersVisitor;class PathsVisitor extends(Mixin(Sm,rm)){constructor(s){super(s),this.element=new Oh,this.specPath=fc([\"document\",\"objects\",\"PathItem\"]),this.canSupportSpecificationExtensions=!0,this.fieldPatternPredicate=es_T}ObjectElement(s){const o=Sm.prototype.ObjectElement.call(this,s);return this.element.filter(Hm).forEach(((s,o)=>{o.classes.push(\"openapi-path-template\"),o.classes.push(\"path-template\"),s.setMetaProperty(\"path\",cloneDeep(o))})),o}}const Zy=PathsVisitor;class RequestBodyVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Ph,this.specPath=fc([\"document\",\"objects\",\"RequestBody\"])}ObjectElement(s){const o=um.prototype.ObjectElement.call(this,s);return Mu(this.element.contentProp)&&this.element.contentProp.filter(og).forEach(((s,o)=>{s.setMetaProperty(\"media-type\",serializers_value(o))})),o}}const ev=RequestBodyVisitor;class RequestBodyContent extends Su.Sh{static primaryClass=\"request-body-content\";constructor(s,o,i){super(s,o,i),this.classes.push(RequestBodyContent.primaryClass),this.classes.push(\"content\")}}const tv=RequestBodyContent;const rv=class request_body_ContentVisitor_ContentVisitor extends ny{constructor(s){super(s),this.element=new tv}};class CallbackVisitor extends(Mixin(Sm,rm)){constructor(s){super(s),this.element=new Vp,this.specPath=fc([\"document\",\"objects\",\"PathItem\"]),this.canSupportSpecificationExtensions=!0,this.fieldPatternPredicate=s=>/{(?<expression>[^}]{1,2083})}/.test(String(s))}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Hm).forEach(((s,o)=>{s.setMetaProperty(\"runtime-expression\",serializers_value(o))})),o}}const nv=CallbackVisitor;class ResponseVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Ih,this.specPath=fc([\"document\",\"objects\",\"Response\"])}ObjectElement(s){const o=um.prototype.ObjectElement.call(this,s);return Mu(this.element.contentProp)&&this.element.contentProp.filter(og).forEach(((s,o)=>{s.setMetaProperty(\"media-type\",serializers_value(o))})),Mu(this.element.headers)&&this.element.headers.filter(Bm).forEach(((s,o)=>{s.setMetaProperty(\"header-name\",serializers_value(o))})),o}}const sv=ResponseVisitor;class ResponseHeaders extends Su.Sh{static primaryClass=\"response-headers\";constructor(s,o,i){super(s,o,i),this.classes.push(ResponseHeaders.primaryClass)}}const ov=ResponseHeaders;class response_HeadersVisitor_HeadersVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new ov,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"Header\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"header\")})),this.element.forEach(((s,o)=>{if(!Bm(s))return;const i=serializers_value(o);s.setMetaProperty(\"header-name\",i)})),o}}const iv=response_HeadersVisitor_HeadersVisitor;class ResponseContent extends Su.Sh{static primaryClass=\"response-content\";constructor(s,o,i){super(s,o,i),this.classes.push(ResponseContent.primaryClass),this.classes.push(\"content\")}}const av=ResponseContent;const cv=class response_ContentVisitor_ContentVisitor extends ny{constructor(s){super(s),this.element=new av}};class ResponseLinks extends Su.Sh{static primaryClass=\"response-links\";constructor(s,o,i){super(s,o,i),this.classes.push(ResponseLinks.primaryClass)}}const lv=ResponseLinks;class response_LinksVisitor_LinksVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new lv,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"Link\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"link\")})),o}}const uv=response_LinksVisitor_LinksVisitor;function _isNumber(s){return\"[object Number]\"===Object.prototype.toString.call(s)}var pv=_curry2((function range(s,o){if(!_isNumber(s)||!_isNumber(o))throw new TypeError(\"Both arguments to range must be numbers\");for(var i=Array(s<o?o-s:0),a=s<0?o+Math.abs(s):o-s,u=0;u<a;)i[u]=u+s,u+=1;return i}));const hv=pv;function hasOrAdd(s,o,i){var a,u=typeof s;switch(u){case\"string\":case\"number\":return 0===s&&1/s==-1/0?!!i._items[\"-0\"]||(o&&(i._items[\"-0\"]=!0),!1):null!==i._nativeSet?o?(a=i._nativeSet.size,i._nativeSet.add(s),i._nativeSet.size===a):i._nativeSet.has(s):u in i._items?s in i._items[u]||(o&&(i._items[u][s]=!0),!1):(o&&(i._items[u]={},i._items[u][s]=!0),!1);case\"boolean\":if(u in i._items){var _=s?1:0;return!!i._items[u][_]||(o&&(i._items[u][_]=!0),!1)}return o&&(i._items[u]=s?[!1,!0]:[!0,!1]),!1;case\"function\":return null!==i._nativeSet?o?(a=i._nativeSet.size,i._nativeSet.add(s),i._nativeSet.size===a):i._nativeSet.has(s):u in i._items?!!_includes(s,i._items[u])||(o&&i._items[u].push(s),!1):(o&&(i._items[u]=[s]),!1);case\"undefined\":return!!i._items[u]||(o&&(i._items[u]=!0),!1);case\"object\":if(null===s)return!!i._items.null||(o&&(i._items.null=!0),!1);default:return(u=Object.prototype.toString.call(s))in i._items?!!_includes(s,i._items[u])||(o&&i._items[u].push(s),!1):(o&&(i._items[u]=[s]),!1)}}const dv=function(){function _Set(){this._nativeSet=\"function\"==typeof Set?new Set:null,this._items={}}return _Set.prototype.add=function(s){return!hasOrAdd(s,!0,this)},_Set.prototype.has=function(s){return hasOrAdd(s,!1,this)},_Set}();var fv=_curry2((function difference(s,o){for(var i=[],a=0,u=s.length,_=o.length,w=new dv,x=0;x<_;x+=1)w.add(o[x]);for(;a<u;)w.add(s[a])&&(i[i.length]=s[a]),a+=1;return i}));const mv=fv;class MixedFieldsVisitor extends(Mixin(um,Sm)){specPathFixedFields;specPathPatternedFields;constructor({specPathFixedFields:s,specPathPatternedFields:o,...i}){super({...i}),this.specPathFixedFields=s,this.specPathPatternedFields=o}ObjectElement(s){const{specPath:o,ignoredFields:i}=this;try{this.specPath=this.specPathFixedFields;const o=this.retrieveFixedFields(this.specPath(s));this.ignoredFields=[...i,...mv(s.keys(),o)],um.prototype.ObjectElement.call(this,s),this.specPath=this.specPathPatternedFields,this.ignoredFields=o,Sm.prototype.ObjectElement.call(this,s)}catch(s){throw this.specPath=o,s}return Vu}}const gv=MixedFieldsVisitor;class responses_ResponsesVisitor extends(Mixin(gv,rm)){constructor(s){super(s),this.element=new Rh,this.specPathFixedFields=fc([\"document\",\"objects\",\"Responses\"]),this.canSupportSpecificationExtensions=!0,this.specPathPatternedFields=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"Response\"],this.fieldPatternPredicate=s=>new RegExp(`^(1XX|2XX|3XX|4XX|5XX|${hv(100,600).join(\"|\")})$`).test(String(s))}ObjectElement(s){const o=gv.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"response\")})),this.element.filter(Xm).forEach(((s,o)=>{const i=cloneDeep(o);this.fieldPatternPredicate(serializers_value(i))&&s.setMetaProperty(\"http-status-code\",i)})),o}}const yv=responses_ResponsesVisitor;class DefaultVisitor extends(Mixin(Nm,rm)){constructor(s){super(s),this.alternator=[{predicate:isReferenceLikeElement,specPath:[\"document\",\"objects\",\"Reference\"]},{predicate:es_T,specPath:[\"document\",\"objects\",\"Response\"]}]}ObjectElement(s){const o=Nm.prototype.enter.call(this,s);return Gm(this.element)?this.element.setMetaProperty(\"referenced-element\",\"response\"):Xm(this.element)&&this.element.setMetaProperty(\"http-status-code\",\"default\"),o}}const vv=DefaultVisitor;class OperationVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new vh,this.specPath=fc([\"document\",\"objects\",\"Operation\"])}}const bv=OperationVisitor;class OperationTags extends Su.wE{static primaryClass=\"operation-tags\";constructor(s,o,i){super(s,o,i),this.classes.push(OperationTags.primaryClass)}}const _v=OperationTags;const Sv=class TagsVisitor extends rm{constructor(s){super(s),this.element=new _v}ArrayElement(s){return this.element=this.element.concat(cloneDeep(s)),Vu}};class OperationParameters extends Su.wE{static primaryClass=\"operation-parameters\";constructor(s,o,i){super(s,o,i),this.classes.push(OperationParameters.primaryClass),this.classes.push(\"parameters\")}}const Ev=OperationParameters;class open_api_3_0_ParametersVisitor_ParametersVisitor extends(Mixin(nm,rm)){constructor(s){super(s),this.element=new Su.wE,this.element.classes.push(\"parameters\")}ArrayElement(s){return s.forEach((s=>{const o=isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"Parameter\"],i=this.toRefractedElement(o,s);Gm(i)&&i.setMetaProperty(\"referenced-element\",\"parameter\"),this.element.push(i)})),this.copyMetaAndAttributes(s,this.element),Vu}}const wv=open_api_3_0_ParametersVisitor_ParametersVisitor;const xv=class operation_ParametersVisitor_ParametersVisitor extends wv{constructor(s){super(s),this.element=new Ev}};const kv=class RequestBodyVisitor_RequestBodyVisitor extends Nm{constructor(s){super(s),this.alternator=[{predicate:isReferenceLikeElement,specPath:[\"document\",\"objects\",\"Reference\"]},{predicate:es_T,specPath:[\"document\",\"objects\",\"RequestBody\"]}]}ObjectElement(s){const o=Nm.prototype.enter.call(this,s);return Gm(this.element)&&this.element.setMetaProperty(\"referenced-element\",\"requestBody\"),o}};class OperationCallbacks extends Su.Sh{static primaryClass=\"operation-callbacks\";constructor(s,o,i){super(s,o,i),this.classes.push(OperationCallbacks.primaryClass)}}const Ov=OperationCallbacks;class CallbacksVisitor_CallbacksVisitor extends(Mixin(Em,rm)){specPath;constructor(s){super(s),this.element=new Ov,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"Callback\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(Gm).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"callback\")})),o}}const Av=CallbacksVisitor_CallbacksVisitor;class OperationSecurity extends Su.wE{static primaryClass=\"operation-security\";constructor(s,o,i){super(s,o,i),this.classes.push(OperationSecurity.primaryClass),this.classes.push(\"security\")}}const Cv=OperationSecurity;class SecurityVisitor_SecurityVisitor extends(Mixin(nm,rm)){constructor(s){super(s),this.element=new Cv}ArrayElement(s){return s.forEach((s=>{const o=Mu(s)?[\"document\",\"objects\",\"SecurityRequirement\"]:[\"value\"],i=this.toRefractedElement(o,s);this.element.push(i)})),this.copyMetaAndAttributes(s,this.element),Vu}}const jv=SecurityVisitor_SecurityVisitor;class OperationServers extends Su.wE{static primaryClass=\"operation-servers\";constructor(s,o,i){super(s,o,i),this.classes.push(OperationServers.primaryClass),this.classes.push(\"servers\")}}const Pv=OperationServers;const Iv=class ServersVisitor_ServersVisitor extends Cm{constructor(s){super(s),this.element=new Pv}};class PathItemVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new wh,this.specPath=fc([\"document\",\"objects\",\"PathItem\"])}ObjectElement(s){const o=um.prototype.ObjectElement.call(this,s);return this.element.filter(Wm).forEach(((s,o)=>{const i=cloneDeep(o);i.content=serializers_value(i).toUpperCase(),s.setMetaProperty(\"http-method\",i)})),Pu(this.element.$ref)&&this.element.classes.push(\"reference-element\"),o}}const Tv=PathItemVisitor;const Nv=class path_item_$RefVisitor_$RefVisitor extends rm{StringElement(s){const o=super.enter(s);return this.element.classes.push(\"reference-value\"),o}};class PathItemServers extends Su.wE{static primaryClass=\"path-item-servers\";constructor(s,o,i){super(s,o,i),this.classes.push(PathItemServers.primaryClass),this.classes.push(\"servers\")}}const Mv=PathItemServers;const Rv=class path_item_ServersVisitor_ServersVisitor extends Cm{constructor(s){super(s),this.element=new Mv}};class PathItemParameters extends Su.wE{static primaryClass=\"path-item-parameters\";constructor(s,o,i){super(s,o,i),this.classes.push(PathItemParameters.primaryClass),this.classes.push(\"parameters\")}}const Dv=PathItemParameters;const Lv=class path_item_ParametersVisitor_ParametersVisitor extends wv{constructor(s){super(s),this.element=new Dv}};class SecuritySchemeVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Hf,this.specPath=fc([\"document\",\"objects\",\"SecurityScheme\"]),this.canSupportSpecificationExtensions=!0}}const Fv=SecuritySchemeVisitor;class OAuthFlowsVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new uh,this.specPath=fc([\"document\",\"objects\",\"OAuthFlows\"]),this.canSupportSpecificationExtensions=!0}}const Bv=OAuthFlowsVisitor;class OAuthFlowVisitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new rh,this.specPath=fc([\"document\",\"objects\",\"OAuthFlow\"]),this.canSupportSpecificationExtensions=!0}}const $v=OAuthFlowVisitor;class OAuthFlowScopes extends Su.Sh{static primaryClass=\"oauth-flow-scopes\";constructor(s,o,i){super(s,o,i),this.classes.push(OAuthFlowScopes.primaryClass)}}const qv=OAuthFlowScopes;class ScopesVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new qv,this.specPath=fc([\"value\"])}}const Uv=ScopesVisitor;class Tags extends Su.wE{static primaryClass=\"tags\";constructor(s,o,i){super(s,o,i),this.classes.push(Tags.primaryClass)}}const Vv=Tags;class TagsVisitor_TagsVisitor extends(Mixin(nm,rm)){constructor(s){super(s),this.element=new Vv}ArrayElement(s){return s.forEach((s=>{const o=lm(s)?[\"document\",\"objects\",\"Tag\"]:[\"value\"],i=this.toRefractedElement(o,s);this.element.push(i)})),this.copyMetaAndAttributes(s,this.element),Vu}}const zv=TagsVisitor_TagsVisitor,{fixedFields:Wv}=Rf.visitors.document.objects.JSONSchema,Jv={visitors:{value:rm,document:{objects:{OpenApi:{$visitor:pm,fixedFields:{openapi:hm,info:{$ref:\"#/visitors/document/objects/Info\"},servers:Cm,paths:{$ref:\"#/visitors/document/objects/Paths\"},components:{$ref:\"#/visitors/document/objects/Components\"},security:Ug,tags:zv,externalDocs:{$ref:\"#/visitors/document/objects/ExternalDocumentation\"}}},Info:{$visitor:fm,fixedFields:{title:{$ref:\"#/visitors/value\"},description:{$ref:\"#/visitors/value\"},termsOfService:{$ref:\"#/visitors/value\"},contact:{$ref:\"#/visitors/document/objects/Contact\"},license:{$ref:\"#/visitors/document/objects/License\"},version:mm}},Contact:{$visitor:gm,fixedFields:{name:{$ref:\"#/visitors/value\"},url:{$ref:\"#/visitors/value\"},email:{$ref:\"#/visitors/value\"}}},License:{$visitor:ym,fixedFields:{name:{$ref:\"#/visitors/value\"},url:{$ref:\"#/visitors/value\"}}},Server:{$visitor:km,fixedFields:{url:Om,description:{$ref:\"#/visitors/value\"},variables:Im}},ServerVariable:{$visitor:jm,fixedFields:{enum:{$ref:\"#/visitors/value\"},default:{$ref:\"#/visitors/value\"},description:{$ref:\"#/visitors/value\"}}},Components:{$visitor:Vg,fixedFields:{schemas:Py,responses:Ty,parameters:My,examples:Dy,requestBodies:Fy,headers:$y,securitySchemes:Uy,links:zy,callbacks:Jy}},Paths:{$visitor:Zy},PathItem:{$visitor:Tv,fixedFields:{$ref:Nv,summary:{$ref:\"#/visitors/value\"},description:{$ref:\"#/visitors/value\"},get:{$ref:\"#/visitors/document/objects/Operation\"},put:{$ref:\"#/visitors/document/objects/Operation\"},post:{$ref:\"#/visitors/document/objects/Operation\"},delete:{$ref:\"#/visitors/document/objects/Operation\"},options:{$ref:\"#/visitors/document/objects/Operation\"},head:{$ref:\"#/visitors/document/objects/Operation\"},patch:{$ref:\"#/visitors/document/objects/Operation\"},trace:{$ref:\"#/visitors/document/objects/Operation\"},servers:Rv,parameters:Lv}},Operation:{$visitor:bv,fixedFields:{tags:Sv,summary:{$ref:\"#/visitors/value\"},description:{$ref:\"#/visitors/value\"},externalDocs:{$ref:\"#/visitors/document/objects/ExternalDocumentation\"},operationId:{$ref:\"#/visitors/value\"},parameters:xv,requestBody:kv,responses:{$ref:\"#/visitors/document/objects/Responses\"},callbacks:Av,deprecated:{$ref:\"#/visitors/value\"},security:jv,servers:Iv}},ExternalDocumentation:{$visitor:Gy,fixedFields:{description:{$ref:\"#/visitors/value\"},url:{$ref:\"#/visitors/value\"}}},Parameter:{$visitor:Yg,fixedFields:{name:{$ref:\"#/visitors/value\"},in:{$ref:\"#/visitors/value\"},description:{$ref:\"#/visitors/value\"},required:{$ref:\"#/visitors/value\"},deprecated:{$ref:\"#/visitors/value\"},allowEmptyValue:{$ref:\"#/visitors/value\"},style:{$ref:\"#/visitors/value\"},explode:{$ref:\"#/visitors/value\"},allowReserved:{$ref:\"#/visitors/value\"},schema:Xg,example:{$ref:\"#/visitors/value\"},examples:Oy,content:Cy}},RequestBody:{$visitor:ev,fixedFields:{description:{$ref:\"#/visitors/value\"},content:rv,required:{$ref:\"#/visitors/value\"}}},MediaType:{$visitor:Tm,fixedFields:{schema:fg,example:{$ref:\"#/visitors/value\"},examples:yg,encoding:xg}},Encoding:{$visitor:Yy,fixedFields:{contentType:{$ref:\"#/visitors/value\"},headers:Qy,style:{$ref:\"#/visitors/value\"},explode:{$ref:\"#/visitors/value\"},allowReserved:{$ref:\"#/visitors/value\"}}},Responses:{$visitor:yv,fixedFields:{default:vv}},Response:{$visitor:sv,fixedFields:{description:{$ref:\"#/visitors/value\"},headers:iv,content:cv,links:uv}},Callback:{$visitor:nv},Example:{$visitor:Hy,fixedFields:{summary:{$ref:\"#/visitors/value\"},description:{$ref:\"#/visitors/value\"},value:{$ref:\"#/visitors/value\"},externalValue:Ky}},Link:{$visitor:vm,fixedFields:{operationRef:bm,operationId:_m,parameters:xm,requestBody:{$ref:\"#/visitors/value\"},description:{$ref:\"#/visitors/value\"},server:{$ref:\"#/visitors/document/objects/Server\"}}},Header:{$visitor:Zg,fixedFields:{description:{$ref:\"#/visitors/value\"},required:{$ref:\"#/visitors/value\"},deprecated:{$ref:\"#/visitors/value\"},allowEmptyValue:{$ref:\"#/visitors/value\"},style:{$ref:\"#/visitors/value\"},explode:{$ref:\"#/visitors/value\"},allowReserved:{$ref:\"#/visitors/value\"},schema:ey,example:{$ref:\"#/visitors/value\"},examples:ry,content:oy}},Tag:{$visitor:zg,fixedFields:{name:{$ref:\"#/visitors/value\"},description:{$ref:\"#/visitors/value\"},externalDocs:{$ref:\"#/visitors/document/objects/ExternalDocumentation\"}}},Reference:{$visitor:Wg,fixedFields:{$ref:Kg}},JSONSchema:{$ref:\"#/visitors/document/objects/Schema\"},JSONReference:{$ref:\"#/visitors/document/objects/Reference\"},Schema:{$visitor:iy,fixedFields:{title:Wv.title,multipleOf:Wv.multipleOf,maximum:Wv.maximum,exclusiveMaximum:Wv.exclusiveMaximum,minimum:Wv.minimum,exclusiveMinimum:Wv.exclusiveMinimum,maxLength:Wv.maxLength,minLength:Wv.minLength,pattern:Wv.pattern,maxItems:Wv.maxItems,minItems:Wv.minItems,uniqueItems:Wv.uniqueItems,maxProperties:Wv.maxProperties,minProperties:Wv.minProperties,required:Wv.required,enum:Wv.enum,type:vy,allOf:cy,anyOf:uy,oneOf:hy,not:_y,items:fy,properties:gy,additionalProperties:_y,description:Wv.description,format:Wv.format,default:Wv.default,nullable:{$ref:\"#/visitors/value\"},discriminator:{$ref:\"#/visitors/document/objects/Discriminator\"},writeOnly:{$ref:\"#/visitors/value\"},xml:{$ref:\"#/visitors/document/objects/XML\"},externalDocs:{$ref:\"#/visitors/document/objects/ExternalDocumentation\"},example:{$ref:\"#/visitors/value\"},deprecated:{$ref:\"#/visitors/value\"}}},Discriminator:{$visitor:Sy,fixedFields:{propertyName:{$ref:\"#/visitors/value\"},mapping:wy}},XML:{$visitor:xy,fixedFields:{name:{$ref:\"#/visitors/value\"},namespace:{$ref:\"#/visitors/value\"},prefix:{$ref:\"#/visitors/value\"},attribute:{$ref:\"#/visitors/value\"},wrapped:{$ref:\"#/visitors/value\"}}},SecurityScheme:{$visitor:Fv,fixedFields:{type:{$ref:\"#/visitors/value\"},description:{$ref:\"#/visitors/value\"},name:{$ref:\"#/visitors/value\"},in:{$ref:\"#/visitors/value\"},scheme:{$ref:\"#/visitors/value\"},bearerFormat:{$ref:\"#/visitors/value\"},flows:{$ref:\"#/visitors/document/objects/OAuthFlows\"},openIdConnectUrl:{$ref:\"#/visitors/value\"}}},OAuthFlows:{$visitor:Bv,fixedFields:{implicit:{$ref:\"#/visitors/document/objects/OAuthFlow\"},password:{$ref:\"#/visitors/document/objects/OAuthFlow\"},clientCredentials:{$ref:\"#/visitors/document/objects/OAuthFlow\"},authorizationCode:{$ref:\"#/visitors/document/objects/OAuthFlow\"}}},OAuthFlow:{$visitor:$v,fixedFields:{authorizationUrl:{$ref:\"#/visitors/value\"},tokenUrl:{$ref:\"#/visitors/value\"},refreshUrl:{$ref:\"#/visitors/value\"},scopes:Uv}},SecurityRequirement:{$visitor:kg}},extension:{$visitor:dm}}}},src_traversal_visitor_getNodeType=s=>{if(ju(s))return`${s.element.charAt(0).toUpperCase()+s.element.slice(1)}Element`},Hv={CallbackElement:[\"content\"],ComponentsElement:[\"content\"],ContactElement:[\"content\"],DiscriminatorElement:[\"content\"],Encoding:[\"content\"],Example:[\"content\"],ExternalDocumentationElement:[\"content\"],HeaderElement:[\"content\"],InfoElement:[\"content\"],LicenseElement:[\"content\"],MediaTypeElement:[\"content\"],OAuthFlowElement:[\"content\"],OAuthFlowsElement:[\"content\"],OpenApi3_0Element:[\"content\"],OperationElement:[\"content\"],ParameterElement:[\"content\"],PathItemElement:[\"content\"],PathsElement:[\"content\"],ReferenceElement:[\"content\"],RequestBodyElement:[\"content\"],ResponseElement:[\"content\"],ResponsesElement:[\"content\"],SchemaElement:[\"content\"],SecurityRequirementElement:[\"content\"],SecuritySchemeElement:[\"content\"],ServerElement:[\"content\"],ServerVariableElement:[\"content\"],TagElement:[\"content\"],...Ku},Kv={namespace:s=>{const{base:o}=s;return o.register(\"callback\",Vp),o.register(\"components\",zp),o.register(\"contact\",Wp),o.register(\"discriminator\",Jp),o.register(\"encoding\",Hp),o.register(\"example\",Kp),o.register(\"externalDocumentation\",Gp),o.register(\"header\",Yp),o.register(\"info\",Xp),o.register(\"license\",Qp),o.register(\"link\",Zp),o.register(\"mediaType\",th),o.register(\"oAuthFlow\",rh),o.register(\"oAuthFlows\",uh),o.register(\"openapi\",dh),o.register(\"openApi3_0\",fh),o.register(\"operation\",vh),o.register(\"parameter\",_h),o.register(\"pathItem\",wh),o.register(\"paths\",Oh),o.register(\"reference\",jh),o.register(\"requestBody\",Ph),o.register(\"response\",Ih),o.register(\"responses\",Rh),o.register(\"schema\",Wf),o.register(\"securityRequirement\",Jf),o.register(\"securityScheme\",Hf),o.register(\"server\",Gf),o.register(\"serverVariable\",Xf),o.register(\"tag\",Qf),o.register(\"xml\",em),o}},Gv=Kv,src_refractor_toolbox=()=>{const s=createNamespace(Gv);return{predicates:{...ce,isElement:ju,isStringElement:Pu,isArrayElement:Ru,isObjectElement:Mu,isMemberElement:Du,includesClasses,hasElementSourceMap},namespace:s}},src_refractor_refract=(s,{specPath:o=[\"visitors\",\"document\",\"objects\",\"OpenApi\",\"$visitor\"],plugins:i=[]}={})=>{const a=(0,Su.e)(s),u=dereference(Jv),_=new(tp(o,u))({specObj:u});return visitor_visit(a,_),dispatchPluginsSync(_.element,i,{toolboxCreator:src_refractor_toolbox,visitorOptions:{keyMap:Hv,nodeTypeGetter:src_traversal_visitor_getNodeType}})},src_refractor_createRefractor=s=>(o,i={})=>src_refractor_refract(o,{specPath:s,...i});Vp.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Callback\",\"$visitor\"]),zp.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Components\",\"$visitor\"]),Wp.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Contact\",\"$visitor\"]),Kp.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Example\",\"$visitor\"]),Jp.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Discriminator\",\"$visitor\"]),Hp.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Encoding\",\"$visitor\"]),Gp.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"ExternalDocumentation\",\"$visitor\"]),Yp.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Header\",\"$visitor\"]),Xp.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Info\",\"$visitor\"]),Qp.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"License\",\"$visitor\"]),Zp.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Link\",\"$visitor\"]),th.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"MediaType\",\"$visitor\"]),rh.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"OAuthFlow\",\"$visitor\"]),uh.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"OAuthFlows\",\"$visitor\"]),dh.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"OpenApi\",\"fixedFields\",\"openapi\"]),fh.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"OpenApi\",\"$visitor\"]),vh.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Operation\",\"$visitor\"]),_h.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Parameter\",\"$visitor\"]),wh.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"PathItem\",\"$visitor\"]),Oh.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Paths\",\"$visitor\"]),jh.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Reference\",\"$visitor\"]),Ph.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"RequestBody\",\"$visitor\"]),Ih.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Response\",\"$visitor\"]),Rh.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Responses\",\"$visitor\"]),Wf.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Schema\",\"$visitor\"]),Jf.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"SecurityRequirement\",\"$visitor\"]),Hf.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"SecurityScheme\",\"$visitor\"]),Gf.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Server\",\"$visitor\"]),Xf.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"ServerVariable\",\"$visitor\"]),Qf.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Tag\",\"$visitor\"]),em.refract=src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"XML\",\"$visitor\"]);const Yv=class Callback_Callback extends Vp{};const Xv=class Components_Components extends zp{get pathItems(){return this.get(\"pathItems\")}set pathItems(s){this.set(\"pathItems\",s)}};const Qv=class Contact_Contact extends Wp{};const Zv=class Discriminator_Discriminator extends Jp{};const eb=class Encoding_Encoding extends Hp{};const tb=class Example_Example extends Kp{};const nb=class ExternalDocumentation_ExternalDocumentation extends Gp{};const pb=class Header_Header extends Yp{get schema(){return this.get(\"schema\")}set schema(s){this.set(\"schema\",s)}};const mb=class Info_Info extends Xp{get license(){return this.get(\"license\")}set license(s){this.set(\"license\",s)}get summary(){return this.get(\"summary\")}set summary(s){this.set(\"summary\",s)}};class JsonSchemaDialect extends Su.Om{static default=new JsonSchemaDialect(\"https://spec.openapis.org/oas/3.1/dialect/base\");constructor(s,o,i){super(s,o,i),this.element=\"jsonSchemaDialect\"}}const yb=JsonSchemaDialect;const _b=class License_License extends Qp{get identifier(){return this.get(\"identifier\")}set identifier(s){this.set(\"identifier\",s)}};const Sb=class Link_Link extends Zp{};const wb=class MediaType_MediaType extends th{get schema(){return this.get(\"schema\")}set schema(s){this.set(\"schema\",s)}};const Ob=class OAuthFlow_OAuthFlow extends rh{};const Ab=class OAuthFlows_OAuthFlows extends uh{};const Pb=class Openapi_Openapi extends dh{};class OpenApi3_1 extends Su.Sh{constructor(s,o,i){super(s,o,i),this.element=\"openApi3_1\",this.classes.push(\"api\")}get openapi(){return this.get(\"openapi\")}set openapi(s){this.set(\"openapi\",s)}get info(){return this.get(\"info\")}set info(s){this.set(\"info\",s)}get jsonSchemaDialect(){return this.get(\"jsonSchemaDialect\")}set jsonSchemaDialect(s){this.set(\"jsonSchemaDialect\",s)}get servers(){return this.get(\"servers\")}set servers(s){this.set(\"servers\",s)}get paths(){return this.get(\"paths\")}set paths(s){this.set(\"paths\",s)}get components(){return this.get(\"components\")}set components(s){this.set(\"components\",s)}get security(){return this.get(\"security\")}set security(s){this.set(\"security\",s)}get tags(){return this.get(\"tags\")}set tags(s){this.set(\"tags\",s)}get externalDocs(){return this.get(\"externalDocs\")}set externalDocs(s){this.set(\"externalDocs\",s)}get webhooks(){return this.get(\"webhooks\")}set webhooks(s){this.set(\"webhooks\",s)}}const Ib=OpenApi3_1;const Mb=class Operation_Operation extends vh{get requestBody(){return this.get(\"requestBody\")}set requestBody(s){this.set(\"requestBody\",s)}};const Rb=class Parameter_Parameter extends _h{get schema(){return this.get(\"schema\")}set schema(s){this.set(\"schema\",s)}};const Lb=class PathItem_PathItem extends wh{get GET(){return this.get(\"get\")}set GET(s){this.set(\"GET\",s)}get PUT(){return this.get(\"put\")}set PUT(s){this.set(\"PUT\",s)}get POST(){return this.get(\"post\")}set POST(s){this.set(\"POST\",s)}get DELETE(){return this.get(\"delete\")}set DELETE(s){this.set(\"DELETE\",s)}get OPTIONS(){return this.get(\"options\")}set OPTIONS(s){this.set(\"OPTIONS\",s)}get HEAD(){return this.get(\"head\")}set HEAD(s){this.set(\"HEAD\",s)}get PATCH(){return this.get(\"patch\")}set PATCH(s){this.set(\"PATCH\",s)}get TRACE(){return this.get(\"trace\")}set TRACE(s){this.set(\"TRACE\",s)}};const qb=class Paths_Paths extends Oh{};class Reference_Reference extends jh{}Object.defineProperty(Reference_Reference.prototype,\"description\",{get(){return this.get(\"description\")},set(s){this.set(\"description\",s)},enumerable:!0}),Object.defineProperty(Reference_Reference.prototype,\"summary\",{get(){return this.get(\"summary\")},set(s){this.set(\"summary\",s)},enumerable:!0});const zb=Reference_Reference;const Qb=class RequestBody_RequestBody extends Ph{};const e_=class elements_Response_Response extends Ih{};const t_=class Responses_Responses extends Rh{};const r_=class JSONSchema_JSONSchema extends Lh{constructor(s,o,i){super(s,o,i),this.element=\"JSONSchemaDraft6\"}get idProp(){throw new Dh(\"id keyword from Core vocabulary has been renamed to $id.\")}set idProp(s){throw new Dh(\"id keyword from Core vocabulary has been renamed to $id.\")}get $id(){return this.get(\"$id\")}set $id(s){this.set(\"$id\",s)}get exclusiveMaximum(){return this.get(\"exclusiveMaximum\")}set exclusiveMaximum(s){this.set(\"exclusiveMaximum\",s)}get exclusiveMinimum(){return this.get(\"exclusiveMinimum\")}set exclusiveMinimum(s){this.set(\"exclusiveMinimum\",s)}get containsProp(){return this.get(\"contains\")}set containsProp(s){this.set(\"contains\",s)}get items(){return this.get(\"items\")}set items(s){this.set(\"items\",s)}get propertyNames(){return this.get(\"propertyNames\")}set propertyNames(s){this.set(\"propertyNames\",s)}get const(){return this.get(\"const\")}set const(s){this.set(\"const\",s)}get not(){return this.get(\"not\")}set not(s){this.set(\"not\",s)}get examples(){return this.get(\"examples\")}set examples(s){this.set(\"examples\",s)}};const n_=class LinkDescription_LinkDescription extends Hh{get hrefSchema(){return this.get(\"hrefSchema\")}set hrefSchema(s){this.set(\"hrefSchema\",s)}get targetSchema(){return this.get(\"targetSchema\")}set targetSchema(s){this.set(\"targetSchema\",s)}get schema(){throw new Dh(\"schema keyword from Hyper-Schema vocabulary has been renamed to submissionSchema.\")}set schema(s){throw new Dh(\"schema keyword from Hyper-Schema vocabulary has been renamed to submissionSchema.\")}get submissionSchema(){return this.get(\"submissionSchema\")}set submissionSchema(s){this.set(\"submissionSchema\",s)}get method(){throw new Dh(\"method keyword from Hyper-Schema vocabulary has been removed.\")}set method(s){throw new Dh(\"method keyword from Hyper-Schema vocabulary has been removed.\")}get encType(){throw new Dh(\"encType keyword from Hyper-Schema vocabulary has been renamed to submissionEncType.\")}set encType(s){throw new Dh(\"encType keyword from Hyper-Schema vocabulary has been renamed to submissionEncType.\")}get submissionEncType(){return this.get(\"submissionEncType\")}set submissionEncType(s){this.set(\"submissionEncType\",s)}};var s_=_curry3((function assocPath(s,o,i){if(0===s.length)return o;var a=s[0];if(s.length>1){var u=!Gh(i)&&_has(a,i)&&\"object\"==typeof i[a]?i[a]:Xo(s[1])?[]:{};o=assocPath(Array.prototype.slice.call(s,1),o,u)}return function _assoc(s,o,i){if(Xo(s)&&ca(i)){var a=[].concat(i);return a[s]=o,a}var u={};for(var _ in i)u[_]=i[_];return u[s]=o,u}(a,o,i)}));const o_=s_;var i_=_curry3((function remove(s,o,i){var a=Array.prototype.slice.call(i,0);return a.splice(s,o),a}));const a_=i_;var c_=_curry3((function assoc(s,o,i){return o_([s],o,i)}));const l_=c_;var u_=_curry2((function dissocPath(s,o){if(null==o)return o;switch(s.length){case 0:return o;case 1:return function _dissoc(s,o){if(null==o)return o;if(Xo(s)&&ca(o))return a_(s,1,o);var i={};for(var a in o)i[a]=o[a];return delete i[s],i}(s[0],o);default:var i=s[0],a=Array.prototype.slice.call(s,1);return null==o[i]?function _shallowCloneObject(s,o){if(Xo(s)&&ca(o))return[].concat(o);var i={};for(var a in o)i[a]=o[a];return i}(i,o):l_(i,dissocPath(a,o[i]),o)}}));const p_=u_;const h_=class json_schema_JSONSchemaVisitor extends Vd{constructor(s){super(s),this.element=new r_}get defaultDialectIdentifier(){return\"http://json-schema.org/draft-06/schema#\"}BooleanElement(s){const o=this.enter(s);return this.element.classes.push(\"boolean-json-schema\"),o}handleSchemaIdentifier(s,o=\"$id\"){return super.handleSchemaIdentifier(s,o)}};const d_=class json_schema_ItemsVisitor_ItemsVisitor extends Wd{BooleanElement(s){return this.element=this.toRefractedElement([\"document\",\"objects\",\"JSONSchema\"],s),Vu}};const f_=class json_schema_ExamplesVisitor_ExamplesVisitor extends _d{ArrayElement(s){const o=this.enter(s);return this.element.classes.push(\"json-schema-examples\"),o}};const m_=class link_description_LinkDescriptionVisitor extends Nf{constructor(s){super(s),this.element=new n_}},g_=pipe(o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"$visitor\"],h_),p_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"id\"]),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"$id\"],Rf.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"contains\"],Rf.visitors.JSONSchemaOrJSONReferenceVisitor),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"items\"],d_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"propertyNames\"],Rf.visitors.JSONSchemaOrJSONReferenceVisitor),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"const\"],Rf.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"examples\"],f_),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"$visitor\"],m_),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"hrefSchema\"],Rf.visitors.JSONSchemaOrJSONReferenceVisitor),p_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"schema\"]),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"submissionSchema\"],Rf.visitors.JSONSchemaOrJSONReferenceVisitor),p_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"method\"]),p_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"encType\"]),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"submissionEncType\"],Rf.visitors.value))(Rf),y_={JSONSchemaDraft6Element:[\"content\"],JSONReferenceElement:[\"content\"],MediaElement:[\"content\"],LinkDescriptionElement:[\"content\"],...Ku},v_=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof r_||s(a)&&o(\"JSONSchemaDraft6\",a)&&i(\"object\",a))),b_=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof n_||s(a)&&o(\"linkDescription\",a)&&i(\"object\",a))),S_={namespace:s=>{const{base:o}=s;return o.register(\"jSONSchemaDraft6\",r_),o.register(\"jSONReference\",Fh),o.register(\"media\",Jh),o.register(\"linkDescription\",n_),o}},E_=S_,apidom_ns_json_schema_draft_6_src_refractor_toolbox=()=>{const s=createNamespace(E_);return{predicates:{...le,isStringElement:Pu},namespace:s}},apidom_ns_json_schema_draft_6_src_refractor_refract=(s,{specPath:o=[\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"$visitor\"],plugins:i=[],specificationObj:a=g_}={})=>{const u=(0,Su.e)(s),_=dereference(a),w=new(tp(o,_))({specObj:_});return visitor_visit(u,w),dispatchPluginsSync(w.element,i,{toolboxCreator:apidom_ns_json_schema_draft_6_src_refractor_toolbox,visitorOptions:{keyMap:y_,nodeTypeGetter:traversal_visitor_getNodeType}})},apidom_ns_json_schema_draft_6_src_refractor_createRefractor=s=>(o,i={})=>apidom_ns_json_schema_draft_6_src_refractor_refract(o,{specPath:s,...i});r_.refract=apidom_ns_json_schema_draft_6_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"$visitor\"]),n_.refract=apidom_ns_json_schema_draft_6_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"$visitor\"]);const w_=class elements_JSONSchema_JSONSchema extends r_{constructor(s,o,i){super(s,o,i),this.element=\"JSONSchemaDraft7\"}get $comment(){return this.get(\"$comment\")}set $comment(s){this.set(\"$comment\",s)}get items(){return this.get(\"items\")}set items(s){this.set(\"items\",s)}get if(){return this.get(\"if\")}set if(s){this.set(\"if\",s)}get then(){return this.get(\"then\")}set then(s){this.set(\"then\",s)}get else(){return this.get(\"else\")}set else(s){this.set(\"else\",s)}get not(){return this.get(\"not\")}set not(s){this.set(\"not\",s)}get contentEncoding(){return this.get(\"contentEncoding\")}set contentEncoding(s){this.set(\"contentEncoding\",s)}get contentMediaType(){return this.get(\"contentMediaType\")}set contentMediaType(s){this.set(\"contentMediaType\",s)}get media(){throw new Dh('media keyword from Hyper-Schema vocabulary has been moved to validation vocabulary as \"contentMediaType\" / \"contentEncoding\"')}set media(s){throw new Dh('media keyword from Hyper-Schema vocabulary has been moved to validation vocabulary as \"contentMediaType\" / \"contentEncoding\"')}get writeOnly(){return this.get(\"writeOnly\")}set writeOnly(s){this.set(\"writeOnly\",s)}};const x_=class elements_LinkDescription_LinkDescription extends n_{get anchor(){return this.get(\"anchor\")}set anchor(s){this.set(\"anchor\",s)}get anchorPointer(){return this.get(\"anchorPointer\")}set anchorPointer(s){this.set(\"anchorPointer\",s)}get templatePointers(){return this.get(\"templatePointers\")}set templatePointers(s){this.set(\"templatePointers\",s)}get templateRequired(){return this.get(\"templateRequired\")}set templateRequired(s){this.set(\"templateRequired\",s)}get targetSchema(){return this.get(\"targetSchema\")}set targetSchema(s){this.set(\"targetSchema\",s)}get mediaType(){throw new Dh(\"mediaType keyword from Hyper-Schema vocabulary has been renamed to targetMediaType.\")}set mediaType(s){throw new Dh(\"mediaType keyword from Hyper-Schema vocabulary has been renamed to targetMediaType.\")}get targetMediaType(){return this.get(\"targetMediaType\")}set targetMediaType(s){this.set(\"targetMediaType\",s)}get targetHints(){return this.get(\"targetHints\")}set targetHints(s){this.set(\"targetHints\",s)}get description(){return this.get(\"description\")}set description(s){this.set(\"description\",s)}get $comment(){return this.get(\"$comment\")}set $comment(s){this.set(\"$comment\",s)}get hrefSchema(){return this.get(\"hrefSchema\")}set hrefSchema(s){this.set(\"hrefSchema\",s)}get headerSchema(){return this.get(\"headerSchema\")}set headerSchema(s){this.set(\"headerSchema\",s)}get submissionSchema(){return this.get(\"submissionSchema\")}set submissionSchema(s){this.set(\"submissionSchema\",s)}get submissionEncType(){throw new Dh(\"submissionEncType keyword from Hyper-Schema vocabulary has been renamed to submissionMediaType.\")}set submissionEncType(s){throw new Dh(\"submissionEncType keyword from Hyper-Schema vocabulary has been renamed to submissionMediaType.\")}get submissionMediaType(){return this.get(\"submissionMediaType\")}set submissionMediaType(s){this.set(\"submissionMediaType\",s)}};const k_=class visitors_json_schema_JSONSchemaVisitor extends h_{constructor(s){super(s),this.element=new w_}get defaultDialectIdentifier(){return\"http://json-schema.org/draft-07/schema#\"}};const O_=class json_schema_link_description_LinkDescriptionVisitor extends m_{constructor(s){super(s),this.element=new x_}},A_=pipe(o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"$visitor\"],k_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"$comment\"],g_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"if\"],g_.visitors.JSONSchemaOrJSONReferenceVisitor),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"then\"],g_.visitors.JSONSchemaOrJSONReferenceVisitor),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"else\"],g_.visitors.JSONSchemaOrJSONReferenceVisitor),p_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"media\"]),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"contentEncoding\"],g_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"contentMediaType\"],g_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"writeOnly\"],g_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"$visitor\"],O_),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"anchor\"],g_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"anchorPointer\"],g_.visitors.value),p_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"mediaType\"]),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"targetMediaType\"],g_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"targetHints\"],g_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"description\"],g_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"$comment\"],g_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"headerSchema\"],g_.visitors.JSONSchemaOrJSONReferenceVisitor),p_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"submissionEncType\"]),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"submissionMediaType\"],g_.visitors.value))(g_),C_={JSONSchemaDraft7Element:[\"content\"],JSONReferenceElement:[\"content\"],LinkDescriptionElement:[\"content\"],...Ku},j_=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof w_||s(a)&&o(\"JSONSchemaDraft7\",a)&&i(\"object\",a))),P_=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof x_||s(a)&&o(\"linkDescription\",a)&&i(\"object\",a))),I_={namespace:s=>{const{base:o}=s;return o.register(\"jSONSchemaDraft7\",w_),o.register(\"jSONReference\",Fh),o.register(\"linkDescription\",x_),o}},T_=I_,apidom_ns_json_schema_draft_7_src_refractor_toolbox=()=>{const s=createNamespace(T_);return{predicates:{...pe,isStringElement:Pu},namespace:s}},apidom_ns_json_schema_draft_7_src_refractor_refract=(s,{specPath:o=[\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"$visitor\"],plugins:i=[],specificationObj:a=A_}={})=>{const u=(0,Su.e)(s),_=dereference(a),w=new(tp(o,_))({specObj:_});return visitor_visit(u,w),dispatchPluginsSync(w.element,i,{toolboxCreator:apidom_ns_json_schema_draft_7_src_refractor_toolbox,visitorOptions:{keyMap:C_,nodeTypeGetter:traversal_visitor_getNodeType}})},apidom_ns_json_schema_draft_7_src_refractor_createRefractor=s=>(o,i={})=>apidom_ns_json_schema_draft_7_src_refractor_refract(o,{specPath:s,...i});w_.refract=apidom_ns_json_schema_draft_7_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"$visitor\"]),x_.refract=apidom_ns_json_schema_draft_7_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"$visitor\"]);const N_=class src_elements_JSONSchema_JSONSchema extends w_{constructor(s,o,i){super(s,o,i),this.element=\"JSONSchema201909\"}get $vocabulary(){return this.get(\"$vocabulary\")}set $vocabulary(s){this.set(\"$vocabulary\",s)}get $anchor(){return this.get(\"$anchor\")}set $anchor(s){this.set(\"$anchor\",s)}get $recursiveAnchor(){return this.get(\"$recursiveAnchor\")}set $recursiveAnchor(s){this.set(\"$recursiveAnchor\",s)}get $recursiveRef(){return this.get(\"$recursiveRef\")}set $recursiveRef(s){this.set(\"$recursiveRef\",s)}get $ref(){return this.get(\"$ref\")}set $ref(s){this.set(\"$ref\",s)}get $defs(){return this.get(\"$defs\")}set $defs(s){this.set(\"$defs\",s)}get definitions(){throw new Dh(\"definitions keyword from Validation vocabulary has been renamed to $defs.\")}set definitions(s){throw new Dh(\"definitions keyword from Validation vocabulary has been renamed to $defs.\")}get not(){return this.get(\"not\")}set not(s){this.set(\"not\",s)}get if(){return this.get(\"if\")}set if(s){this.set(\"if\",s)}get then(){return this.get(\"then\")}set then(s){this.set(\"then\",s)}get else(){return this.get(\"else\")}set else(s){this.set(\"else\",s)}get dependentSchemas(){return this.get(\"dependentSchemas\")}set dependentSchemas(s){this.set(\"dependentSchemas\",s)}get dependencies(){throw new Dh(\"dependencies keyword from Validation vocabulary has been renamed to dependentSchemas.\")}set dependencies(s){throw new Dh(\"dependencies keyword from Validation vocabulary has been renamed to dependentSchemas.\")}get items(){return this.get(\"items\")}set items(s){this.set(\"items\",s)}get containsProp(){return this.get(\"contains\")}set containsProp(s){this.set(\"contains\",s)}get additionalProperties(){return this.get(\"additionalProperties\")}set additionalProperties(s){this.set(\"additionalProperties\",s)}get additionalItems(){return this.get(\"additionalItems\")}set additionalItems(s){this.set(\"additionalItems\",s)}get propertyNames(){return this.get(\"propertyNames\")}set propertyNames(s){this.set(\"propertyNames\",s)}get unevaluatedItems(){return this.get(\"unevaluatedItems\")}set unevaluatedItems(s){this.set(\"unevaluatedItems\",s)}get unevaluatedProperties(){return this.get(\"unevaluatedProperties\")}set unevaluatedProperties(s){this.set(\"unevaluatedProperties\",s)}get maxContains(){return this.get(\"maxContains\")}set maxContains(s){this.set(\"maxContains\",s)}get minContains(){return this.get(\"minContains\")}set minContains(s){this.set(\"minContains\",s)}get dependentRequired(){return this.get(\"dependentRequired\")}set dependentRequired(s){this.set(\"dependentRequired\",s)}get deprecated(){return this.get(\"deprecated\")}set deprecated(s){this.set(\"deprecated\",s)}get contentSchema(){return this.get(\"contentSchema\")}set contentSchema(s){this.set(\"contentSchema\",s)}};const M_=class src_elements_LinkDescription_LinkDescription extends x_{get targetSchema(){return this.get(\"targetSchema\")}set targetSchema(s){this.set(\"targetSchema\",s)}get hrefSchema(){return this.get(\"hrefSchema\")}set hrefSchema(s){this.set(\"hrefSchema\",s)}get headerSchema(){return this.get(\"headerSchema\")}set headerSchema(s){this.set(\"headerSchema\",s)}get submissionSchema(){return this.get(\"submissionSchema\")}set submissionSchema(s){this.set(\"submissionSchema\",s)}};const R_=class refractor_visitors_json_schema_JSONSchemaVisitor extends k_{constructor(s){super(s),this.element=new N_}get defaultDialectIdentifier(){return\"https://json-schema.org/draft/2019-09/schema\"}ObjectElement(s){this.handleDialectIdentifier(s),this.handleSchemaIdentifier(s),this.parent=this.element;const o=Dd.prototype.ObjectElement.call(this,s);return Pu(this.element.$ref)&&(this.element.classes.push(\"reference-element\"),this.element.setMetaProperty(\"referenced-element\",\"schema\")),o}};const D_=class $vocabularyVisitor extends _d{ObjectElement(s){const o=super.enter(s);return this.element.classes.push(\"json-schema-$vocabulary\"),o}};const L_=class $refVisitor extends _d{StringElement(s){const o=super.enter(s);return this.element.classes.push(\"reference-value\"),o}};class $defsVisitor extends(Mixin(Kd,Ld,_d)){constructor(s){super(s),this.element=new Su.Sh,this.element.classes.push(\"json-schema-$defs\"),this.specPath=fc([\"document\",\"objects\",\"JSONSchema\"])}}const F_=$defsVisitor;class json_schema_AllOfVisitor_AllOfVisitor extends(Mixin(Rd,Ld,_d)){constructor(s){super(s),this.element=new Su.wE,this.element.classes.push(\"json-schema-allOf\")}ArrayElement(s){return s.forEach((s=>{const o=this.toRefractedElement([\"document\",\"objects\",\"JSONSchema\"],s);this.element.push(o)})),this.copyMetaAndAttributes(s,this.element),Vu}}const B_=json_schema_AllOfVisitor_AllOfVisitor;class json_schema_AnyOfVisitor_AnyOfVisitor extends(Mixin(Rd,Ld,_d)){constructor(s){super(s),this.element=new Su.wE,this.element.classes.push(\"json-schema-anyOf\")}ArrayElement(s){return s.forEach((s=>{const o=this.toRefractedElement([\"document\",\"objects\",\"JSONSchema\"],s);this.element.push(o)})),this.copyMetaAndAttributes(s,this.element),Vu}}const $_=json_schema_AnyOfVisitor_AnyOfVisitor;class json_schema_OneOfVisitor_OneOfVisitor extends(Mixin(Rd,Ld,_d)){constructor(s){super(s),this.element=new Su.wE,this.element.classes.push(\"json-schema-oneOf\")}ArrayElement(s){return s.forEach((s=>{const o=this.toRefractedElement([\"document\",\"objects\",\"JSONSchema\"],s);this.element.push(o)})),this.copyMetaAndAttributes(s,this.element),Vu}}const q_=json_schema_OneOfVisitor_OneOfVisitor;class DependentSchemasVisitor extends(Mixin(Kd,Ld,_d)){constructor(s){super(s),this.element=new Su.Sh,this.element.classes.push(\"json-schema-dependentSchemas\"),this.specPath=fc([\"document\",\"objects\",\"JSONSchema\"])}}const U_=DependentSchemasVisitor;class visitors_json_schema_ItemsVisitor_ItemsVisitor extends(Mixin(Rd,Ld,_d)){ObjectElement(s){return this.element=this.toRefractedElement([\"document\",\"objects\",\"JSONSchema\"],s),Vu}ArrayElement(s){return this.element=new Su.wE,this.element.classes.push(\"json-schema-items\"),s.forEach((s=>{const o=this.toRefractedElement([\"document\",\"objects\",\"JSONSchema\"],s);this.element.push(o)})),this.copyMetaAndAttributes(s,this.element),Vu}BooleanElement(s){return this.element=this.toRefractedElement([\"document\",\"objects\",\"JSONSchema\"],s),Vu}}const V_=visitors_json_schema_ItemsVisitor_ItemsVisitor;class json_schema_PropertiesVisitor_PropertiesVisitor extends(Mixin(Kd,Ld,_d)){constructor(s){super(s),this.element=new Su.Sh,this.element.classes.push(\"json-schema-properties\"),this.specPath=fc([\"document\",\"objects\",\"JSONSchema\"])}}const z_=json_schema_PropertiesVisitor_PropertiesVisitor;class PatternPropertiesVisitor_PatternPropertiesVisitor extends(Mixin(Kd,Ld,_d)){constructor(s){super(s),this.element=new Su.Sh,this.element.classes.push(\"json-schema-patternProperties\"),this.specPath=fc([\"document\",\"objects\",\"JSONSchema\"])}}const W_=PatternPropertiesVisitor_PatternPropertiesVisitor;const J_=class DependentRequiredVisitor extends _d{ObjectElement(s){const o=super.enter(s);return this.element.classes.push(\"json-schema-dependentRequired\"),o}};const H_=class visitors_json_schema_link_description_LinkDescriptionVisitor extends O_{constructor(s){super(s),this.element=new M_}},K_=pipe(o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"$visitor\"],R_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"$vocabulary\"],D_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"$anchor\"],A_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"$recursiveAnchor\"],A_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"$recursiveRef\"],A_.visitors.value),p_([\"visitors\",\"document\",\"objects\",\"JSONReference\",\"$visitor\"]),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"$ref\"],L_),p_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"definitions\"]),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"$defs\"],F_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"allOf\"],B_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"anyOf\"],$_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"oneOf\"],q_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"not\"],R_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"if\"],R_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"then\"],R_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"else\"],R_),p_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"dependencies\"]),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"dependentSchemas\"],U_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"items\"],V_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"contains\"],R_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"properties\"],z_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"patternProperties\"],W_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"additionalProperties\"],R_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"additionalItems\"],R_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"propertyNames\"],R_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"unevaluatedItems\"],R_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"unevaluatedProperties\"],R_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"maxContains\"],A_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"minContains\"],A_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"dependentRequired\"],J_),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"deprecated\"],A_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"contentSchema\"],R_),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"$visitor\"],H_),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"targetSchema\"],R_),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"hrefSchema\"],R_),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"headerSchema\"],R_),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"submissionSchema\"],R_))(A_),G_={JSONSchema201909Element:[\"content\"],LinkDescriptionElement:[\"content\"],...Ku},Y_=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof N_||s(a)&&o(\"JSONSchema201909\",a)&&i(\"object\",a))),X_=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof M_||s(a)&&o(\"linkDescription\",a)&&i(\"object\",a))),Q_={namespace:s=>{const{base:o}=s;return o.register(\"jSONSchema201909\",N_),o.register(\"linkDescription\",M_),o}},Z_=Q_,apidom_ns_json_schema_2019_09_src_refractor_toolbox=()=>{const s=createNamespace(Z_);return{predicates:{...de,isStringElement:Pu},namespace:s}},apidom_ns_json_schema_2019_09_src_refractor_refract=(s,{specPath:o=[\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"$visitor\"],plugins:i=[],specificationObj:a=K_}={})=>{const u=(0,Su.e)(s),_=dereference(a),w=new(tp(o,_))({specObj:_});return visitor_visit(u,w),dispatchPluginsSync(w.element,i,{toolboxCreator:apidom_ns_json_schema_2019_09_src_refractor_toolbox,visitorOptions:{keyMap:G_,nodeTypeGetter:traversal_visitor_getNodeType}})},apidom_ns_json_schema_2019_09_src_refractor_createRefractor=s=>(o,i={})=>apidom_ns_json_schema_2019_09_src_refractor_refract(o,{specPath:s,...i});N_.refract=apidom_ns_json_schema_2019_09_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"$visitor\"]),M_.refract=apidom_ns_json_schema_2019_09_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"$visitor\"]);const eS=class apidom_ns_json_schema_2020_12_src_elements_JSONSchema_JSONSchema extends N_{constructor(s,o,i){super(s,o,i),this.element=\"JSONSchema202012\"}get $dynamicAnchor(){return this.get(\"$dynamicAnchor\")}set $dynamicAnchor(s){this.set(\"$dynamicAnchor\",s)}get $recursiveAnchor(){throw new Dh(\"$recursiveAnchor keyword from Core vocabulary has been renamed to $dynamicAnchor.\")}set $recursiveAnchor(s){throw new Dh(\"$recursiveAnchor keyword from Core vocabulary has been renamed to $dynamicAnchor.\")}get $dynamicRef(){return this.get(\"$dynamicRef\")}set $dynamicRef(s){this.set(\"$dynamicRef\",s)}get $recursiveRef(){throw new Dh(\"$recursiveRef keyword from Core vocabulary has been renamed to $dynamicRef.\")}set $recursiveRef(s){throw new Dh(\"$recursiveRef keyword from Core vocabulary has been renamed to $dynamicRef.\")}get prefixItems(){return this.get(\"prefixItems\")}set prefixItems(s){this.set(\"prefixItems\",s)}};const tS=class apidom_ns_json_schema_2020_12_src_elements_LinkDescription_LinkDescription extends M_{get targetSchema(){return this.get(\"targetSchema\")}set targetSchema(s){this.set(\"targetSchema\",s)}get hrefSchema(){return this.get(\"hrefSchema\")}set hrefSchema(s){this.set(\"hrefSchema\",s)}get headerSchema(){return this.get(\"headerSchema\")}set headerSchema(s){this.set(\"headerSchema\",s)}get submissionSchema(){return this.get(\"submissionSchema\")}set submissionSchema(s){this.set(\"submissionSchema\",s)}};const rS=class src_refractor_visitors_json_schema_JSONSchemaVisitor extends R_{constructor(s){super(s),this.element=new eS}get defaultDialectIdentifier(){return\"https://json-schema.org/draft/2020-12/schema\"}};class PrefixItemsVisitor extends(Mixin(Rd,Ld,_d)){constructor(s){super(s),this.element=new Su.wE,this.element.classes.push(\"json-schema-prefixItems\")}ArrayElement(s){return s.forEach((s=>{const o=this.toRefractedElement([\"document\",\"objects\",\"JSONSchema\"],s);this.element.push(o)})),this.copyMetaAndAttributes(s,this.element),Vu}}const nS=PrefixItemsVisitor;const sS=class refractor_visitors_json_schema_link_description_LinkDescriptionVisitor extends H_{constructor(s){super(s),this.element=new tS}},oS=pipe(o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"$visitor\"],rS),p_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"$recursiveAnchor\"]),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"$dynamicAnchor\"],K_.visitors.value),p_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"$recursiveRef\"]),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"$dynamicRef\"],K_.visitors.value),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"not\"],rS),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"if\"],rS),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"then\"],rS),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"else\"],rS),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"prefixItems\"],nS),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"items\"],rS),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"contains\"],rS),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"additionalProperties\"],rS),p_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"additionalItems\"]),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"propertyNames\"],rS),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"unevaluatedItems\"],rS),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"unevaluatedProperties\"],rS),o_([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"fixedFields\",\"contentSchema\"],rS),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"$visitor\"],sS),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"targetSchema\"],rS),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"hrefSchema\"],rS),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"headerSchema\"],rS),o_([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"fixedFields\",\"submissionSchema\"],rS))(K_),iS={JSONSchema202012Element:[\"content\"],LinkDescriptionElement:[\"content\"],...Ku},aS=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof eS||s(a)&&o(\"JSONSchema202012\",a)&&i(\"object\",a))),cS=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof tS||s(a)&&o(\"linkDescription\",a)&&i(\"object\",a))),lS={namespace:s=>{const{base:o}=s;return o.register(\"jSONSchema202012\",eS),o.register(\"linkDescription\",tS),o}},uS=lS,apidom_ns_json_schema_2020_12_src_refractor_toolbox=()=>{const s=createNamespace(uS);return{predicates:{...fe,isStringElement:Pu},namespace:s}},apidom_ns_json_schema_2020_12_src_refractor_refract=(s,{specPath:o=[\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"$visitor\"],plugins:i=[],specificationObj:a=oS}={})=>{const u=(0,Su.e)(s),_=dereference(a),w=new(tp(o,_))({specObj:_});return visitor_visit(u,w),dispatchPluginsSync(w.element,i,{toolboxCreator:apidom_ns_json_schema_2020_12_src_refractor_toolbox,visitorOptions:{keyMap:iS,nodeTypeGetter:traversal_visitor_getNodeType}})},apidom_ns_json_schema_2020_12_src_refractor_createRefractor=s=>(o,i={})=>apidom_ns_json_schema_2020_12_src_refractor_refract(o,{specPath:s,...i});eS.refract=apidom_ns_json_schema_2020_12_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"JSONSchema\",\"$visitor\"]),tS.refract=apidom_ns_json_schema_2020_12_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"LinkDescription\",\"$visitor\"]);const pS=class elements_Schema_Schema extends eS{constructor(s,o,i){super(s,o,i),this.element=\"schema\"}get discriminator(){return this.get(\"discriminator\")}set discriminator(s){this.set(\"discriminator\",s)}get xml(){return this.get(\"xml\")}set xml(s){this.set(\"xml\",s)}get externalDocs(){return this.get(\"externalDocs\")}set externalDocs(s){this.set(\"externalDocs\",s)}get example(){return this.get(\"example\")}set example(s){this.set(\"example\",s)}};const hS=class SecurityRequirement_SecurityRequirement extends Jf{};const dS=class SecurityScheme_SecurityScheme extends Hf{};const fS=class Server_Server extends Gf{};const mS=class ServerVariable_ServerVariable extends Xf{};const gS=class Tag_Tag extends Qf{};const yS=class Xml_Xml extends em{};class OpenApi3_1Visitor extends(Mixin(um,rm)){constructor(s){super(s),this.element=new Ib,this.specPath=fc([\"document\",\"objects\",\"OpenApi\"]),this.canSupportSpecificationExtensions=!0,this.openApiSemanticElement=this.element}ObjectElement(s){return this.openApiGenericElement=s,um.prototype.ObjectElement.call(this,s)}}const vS=OpenApi3_1Visitor,bS=Jv.visitors.document.objects.Info.$visitor;const _S=class info_InfoVisitor extends bS{constructor(s){super(s),this.element=new mb}},SS=Jv.visitors.document.objects.Contact.$visitor;const ES=class contact_ContactVisitor extends SS{constructor(s){super(s),this.element=new Qv}},wS=Jv.visitors.document.objects.License.$visitor;const xS=class license_LicenseVisitor extends wS{constructor(s){super(s),this.element=new _b}},kS=Jv.visitors.document.objects.Link.$visitor;const OS=class link_LinkVisitor extends kS{constructor(s){super(s),this.element=new Sb}};class JsonSchemaDialectVisitor extends(Mixin(nm,rm)){StringElement(s){const o=new yb(serializers_value(s));return this.copyMetaAndAttributes(s,o),this.element=o,Vu}}const AS=JsonSchemaDialectVisitor,CS=Jv.visitors.document.objects.Server.$visitor;const jS=class server_ServerVisitor extends CS{constructor(s){super(s),this.element=new fS}},PS=Jv.visitors.document.objects.ServerVariable.$visitor;const IS=class server_variable_ServerVariableVisitor extends PS{constructor(s){super(s),this.element=new mS}},TS=Jv.visitors.document.objects.MediaType.$visitor;const NS=class media_type_MediaTypeVisitor extends TS{constructor(s){super(s),this.element=new wb}},MS=Jv.visitors.document.objects.SecurityRequirement.$visitor;const RS=class security_requirement_SecurityRequirementVisitor extends MS{constructor(s){super(s),this.element=new hS}},DS=Jv.visitors.document.objects.Components.$visitor;const LS=class components_ComponentsVisitor extends DS{constructor(s){super(s),this.element=new Xv}},FS=Jv.visitors.document.objects.Tag.$visitor;const BS=class tag_TagVisitor extends FS{constructor(s){super(s),this.element=new gS}},$S=Jv.visitors.document.objects.Reference.$visitor;const qS=class reference_ReferenceVisitor extends $S{constructor(s){super(s),this.element=new zb}},US=Jv.visitors.document.objects.Parameter.$visitor;const VS=class parameter_ParameterVisitor extends US{constructor(s){super(s),this.element=new Rb}},zS=Jv.visitors.document.objects.Header.$visitor;const WS=class header_HeaderVisitor extends zS{constructor(s){super(s),this.element=new pb}},JS=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Yv||s(a)&&o(\"callback\",a)&&i(\"object\",a))),HS=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Xv||s(a)&&o(\"components\",a)&&i(\"object\",a))),KS=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Qv||s(a)&&o(\"contact\",a)&&i(\"object\",a))),GS=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof tb||s(a)&&o(\"example\",a)&&i(\"object\",a))),YS=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof nb||s(a)&&o(\"externalDocumentation\",a)&&i(\"object\",a))),XS=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof pb||s(a)&&o(\"header\",a)&&i(\"object\",a))),QS=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof mb||s(a)&&o(\"info\",a)&&i(\"object\",a))),ZS=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof yb||s(a)&&o(\"jsonSchemaDialect\",a)&&i(\"string\",a))),eE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof _b||s(a)&&o(\"license\",a)&&i(\"object\",a))),tE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Sb||s(a)&&o(\"link\",a)&&i(\"object\",a))),rE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Pb||s(a)&&o(\"openapi\",a)&&i(\"string\",a))),nE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i,hasClass:a})=>u=>u instanceof Ib||s(u)&&o(\"openApi3_1\",u)&&i(\"object\",u)&&a(\"api\",u))),sE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Mb||s(a)&&o(\"operation\",a)&&i(\"object\",a))),oE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Rb||s(a)&&o(\"parameter\",a)&&i(\"object\",a))),iE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Lb||s(a)&&o(\"pathItem\",a)&&i(\"object\",a))),isPathItemElementExternal=s=>{if(!iE(s))return!1;if(!Pu(s.$ref))return!1;const o=serializers_value(s.$ref);return\"string\"==typeof o&&o.length>0&&!o.startsWith(\"#\")},aE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof qb||s(a)&&o(\"paths\",a)&&i(\"object\",a))),cE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof zb||s(a)&&o(\"reference\",a)&&i(\"object\",a))),isReferenceElementExternal=s=>{if(!cE(s))return!1;if(!Pu(s.$ref))return!1;const o=serializers_value(s.$ref);return\"string\"==typeof o&&o.length>0&&!o.startsWith(\"#\")},lE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof Qb||s(a)&&o(\"requestBody\",a)&&i(\"object\",a))),uE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof e_||s(a)&&o(\"response\",a)&&i(\"object\",a))),pE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof t_||s(a)&&o(\"responses\",a)&&i(\"object\",a))),hE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof pS||s(a)&&o(\"schema\",a)&&i(\"object\",a))),predicates_isBooleanJsonSchemaElement=s=>Nu(s)&&s.classes.includes(\"boolean-json-schema\"),dE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof hS||s(a)&&o(\"securityRequirement\",a)&&i(\"object\",a))),fE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof dS||s(a)&&o(\"securityScheme\",a)&&i(\"object\",a))),mE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof fS||s(a)&&o(\"server\",a)&&i(\"object\",a))),gE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof mS||s(a)&&o(\"serverVariable\",a)&&i(\"object\",a))),yE=helpers((({hasBasicElementProps:s,isElementType:o,primitiveEq:i})=>a=>a instanceof wb||s(a)&&o(\"mediaType\",a)&&i(\"object\",a)));class open_api_3_1_schema_SchemaVisitor extends(Mixin(um,Ld,rm)){constructor(s){super(s),this.element=new pS,this.specPath=fc([\"document\",\"objects\",\"Schema\"]),this.canSupportSpecificationExtensions=!0,this.jsonSchemaDefaultDialect=yb.default,this.passingOptionsNames.push(\"parent\")}ObjectElement(s){this.handleDialectIdentifier(s),this.handleSchemaIdentifier(s),this.parent=this.element;const o=um.prototype.ObjectElement.call(this,s);return Pu(this.element.$ref)&&(this.element.classes.push(\"reference-element\"),this.element.setMetaProperty(\"referenced-element\",\"schema\")),o}BooleanElement(s){return rS.prototype.BooleanElement.call(this,s)}get defaultDialectIdentifier(){let s;return s=void 0!==this.openApiSemanticElement&&ZS(this.openApiSemanticElement.jsonSchemaDialect)?serializers_value(this.openApiSemanticElement.jsonSchemaDialect):void 0!==this.openApiGenericElement&&Pu(this.openApiGenericElement.get(\"jsonSchemaDialect\"))?serializers_value(this.openApiGenericElement.get(\"jsonSchemaDialect\")):serializers_value(this.jsonSchemaDefaultDialect),s}handleDialectIdentifier(s){return rS.prototype.handleDialectIdentifier.call(this,s)}handleSchemaIdentifier(s){return rS.prototype.handleSchemaIdentifier.call(this,s)}}const vE=open_api_3_1_schema_SchemaVisitor;const bE=class $defsVisitor_$defsVisitor extends F_{constructor(s){super(s),this.passingOptionsNames.push(\"parent\")}};const _E=class schema_AllOfVisitor_AllOfVisitor extends B_{constructor(s){super(s),this.passingOptionsNames.push(\"parent\")}};const SE=class schema_AnyOfVisitor_AnyOfVisitor extends $_{constructor(s){super(s),this.passingOptionsNames.push(\"parent\")}};const EE=class schema_OneOfVisitor_OneOfVisitor extends q_{constructor(s){super(s),this.passingOptionsNames.push(\"parent\")}};const wE=class DependentSchemasVisitor_DependentSchemasVisitor extends U_{constructor(s){super(s),this.passingOptionsNames.push(\"parent\")}};const xE=class PrefixItemsVisitor_PrefixItemsVisitor extends nS{constructor(s){super(s),this.passingOptionsNames.push(\"parent\")}};const kE=class schema_PropertiesVisitor_PropertiesVisitor extends z_{constructor(s){super(s),this.passingOptionsNames.push(\"parent\")}};const OE=class schema_PatternPropertiesVisitor_PatternPropertiesVisitor extends W_{constructor(s){super(s),this.passingOptionsNames.push(\"parent\")}},AE=Jv.visitors.document.objects.Discriminator.$visitor;const CE=class distriminator_DiscriminatorVisitor extends AE{constructor(s){super(s),this.element=new Zv,this.canSupportSpecificationExtensions=!0}},jE=Jv.visitors.document.objects.XML.$visitor;const PE=class xml_XmlVisitor extends jE{constructor(s){super(s),this.element=new yS}};class SchemasVisitor_SchemasVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new jy,this.specPath=fc([\"document\",\"objects\",\"Schema\"])}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(hE).forEach(((s,o)=>{s.setMetaProperty(\"schemaName\",serializers_value(o))})),o}}const IE=SchemasVisitor_SchemasVisitor;class ComponentsPathItems extends Su.Sh{static primaryClass=\"components-path-items\";constructor(s,o,i){super(s,o,i),this.classes.push(ComponentsPathItems.primaryClass)}}const TE=ComponentsPathItems;class PathItemsVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new TE,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"PathItem\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(cE).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"pathItem\")})),o}}const NE=PathItemsVisitor,ME=Jv.visitors.document.objects.Example.$visitor;const RE=class example_ExampleVisitor extends ME{constructor(s){super(s),this.element=new tb}},DE=Jv.visitors.document.objects.ExternalDocumentation.$visitor;const LE=class external_documentation_ExternalDocumentationVisitor extends DE{constructor(s){super(s),this.element=new nb}},FE=Jv.visitors.document.objects.Encoding.$visitor;const BE=class open_api_3_1_encoding_EncodingVisitor extends FE{constructor(s){super(s),this.element=new eb}},$E=Jv.visitors.document.objects.Paths.$visitor;const qE=class paths_PathsVisitor extends $E{constructor(s){super(s),this.element=new qb}},UE=Jv.visitors.document.objects.RequestBody.$visitor;const VE=class request_body_RequestBodyVisitor extends UE{constructor(s){super(s),this.element=new Qb}},zE=Jv.visitors.document.objects.Callback.$visitor;const WE=class callback_CallbackVisitor extends zE{constructor(s){super(s),this.element=new Yv,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"PathItem\"]}ObjectElement(s){const o=zE.prototype.ObjectElement.call(this,s);return this.element.filter(cE).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"pathItem\")})),o}},JE=Jv.visitors.document.objects.Response.$visitor;const HE=class response_ResponseVisitor extends JE{constructor(s){super(s),this.element=new e_}},KE=Jv.visitors.document.objects.Responses.$visitor;const GE=class open_api_3_1_responses_ResponsesVisitor extends KE{constructor(s){super(s),this.element=new t_}},YE=Jv.visitors.document.objects.Operation.$visitor;const XE=class operation_OperationVisitor extends YE{constructor(s){super(s),this.element=new Mb}},QE=Jv.visitors.document.objects.PathItem.$visitor;const ZE=class path_item_PathItemVisitor extends QE{constructor(s){super(s),this.element=new Lb}},ew=Jv.visitors.document.objects.SecurityScheme.$visitor;const tw=class security_scheme_SecuritySchemeVisitor extends ew{constructor(s){super(s),this.element=new dS}},rw=Jv.visitors.document.objects.OAuthFlows.$visitor;const nw=class oauth_flows_OAuthFlowsVisitor extends rw{constructor(s){super(s),this.element=new Ab}},sw=Jv.visitors.document.objects.OAuthFlow.$visitor;const ow=class oauth_flow_OAuthFlowVisitor extends sw{constructor(s){super(s),this.element=new Ob}};class Webhooks extends Su.Sh{static primaryClass=\"webhooks\";constructor(s,o,i){super(s,o,i),this.classes.push(Webhooks.primaryClass)}}const iw=Webhooks;class WebhooksVisitor extends(Mixin(Em,rm)){constructor(s){super(s),this.element=new iw,this.specPath=s=>isReferenceLikeElement(s)?[\"document\",\"objects\",\"Reference\"]:[\"document\",\"objects\",\"PathItem\"]}ObjectElement(s){const o=Em.prototype.ObjectElement.call(this,s);return this.element.filter(cE).forEach((s=>{s.setMetaProperty(\"referenced-element\",\"pathItem\")})),this.element.filter(iE).forEach(((s,o)=>{s.setMetaProperty(\"webhook-name\",serializers_value(o))})),o}}const aw=WebhooksVisitor,{JSONSchema:cw,LinkDescription:lw}=oS.visitors.document.objects,uw={visitors:{value:Jv.visitors.value,document:{objects:{OpenApi:{$visitor:vS,fixedFields:{openapi:Jv.visitors.document.objects.OpenApi.fixedFields.openapi,info:{$ref:\"#/visitors/document/objects/Info\"},jsonSchemaDialect:AS,servers:Jv.visitors.document.objects.OpenApi.fixedFields.servers,paths:{$ref:\"#/visitors/document/objects/Paths\"},webhooks:aw,components:{$ref:\"#/visitors/document/objects/Components\"},security:Jv.visitors.document.objects.OpenApi.fixedFields.security,tags:Jv.visitors.document.objects.OpenApi.fixedFields.tags,externalDocs:{$ref:\"#/visitors/document/objects/ExternalDocumentation\"}}},Info:{$visitor:_S,fixedFields:{title:Jv.visitors.document.objects.Info.fixedFields.title,description:Jv.visitors.document.objects.Info.fixedFields.description,summary:{$ref:\"#/visitors/value\"},termsOfService:Jv.visitors.document.objects.Info.fixedFields.termsOfService,contact:{$ref:\"#/visitors/document/objects/Contact\"},license:{$ref:\"#/visitors/document/objects/License\"},version:Jv.visitors.document.objects.Info.fixedFields.version}},Contact:{$visitor:ES,fixedFields:{name:Jv.visitors.document.objects.Contact.fixedFields.name,url:Jv.visitors.document.objects.Contact.fixedFields.url,email:Jv.visitors.document.objects.Contact.fixedFields.email}},License:{$visitor:xS,fixedFields:{name:Jv.visitors.document.objects.License.fixedFields.name,identifier:{$ref:\"#/visitors/value\"},url:Jv.visitors.document.objects.License.fixedFields.url}},Server:{$visitor:jS,fixedFields:{url:Jv.visitors.document.objects.Server.fixedFields.url,description:Jv.visitors.document.objects.Server.fixedFields.description,variables:Jv.visitors.document.objects.Server.fixedFields.variables}},ServerVariable:{$visitor:IS,fixedFields:{enum:Jv.visitors.document.objects.ServerVariable.fixedFields.enum,default:Jv.visitors.document.objects.ServerVariable.fixedFields.default,description:Jv.visitors.document.objects.ServerVariable.fixedFields.description}},Components:{$visitor:LS,fixedFields:{schemas:IE,responses:Jv.visitors.document.objects.Components.fixedFields.responses,parameters:Jv.visitors.document.objects.Components.fixedFields.parameters,examples:Jv.visitors.document.objects.Components.fixedFields.examples,requestBodies:Jv.visitors.document.objects.Components.fixedFields.requestBodies,headers:Jv.visitors.document.objects.Components.fixedFields.headers,securitySchemes:Jv.visitors.document.objects.Components.fixedFields.securitySchemes,links:Jv.visitors.document.objects.Components.fixedFields.links,callbacks:Jv.visitors.document.objects.Components.fixedFields.callbacks,pathItems:NE}},Paths:{$visitor:qE},PathItem:{$visitor:ZE,fixedFields:{$ref:Jv.visitors.document.objects.PathItem.fixedFields.$ref,summary:Jv.visitors.document.objects.PathItem.fixedFields.summary,description:Jv.visitors.document.objects.PathItem.fixedFields.description,get:{$ref:\"#/visitors/document/objects/Operation\"},put:{$ref:\"#/visitors/document/objects/Operation\"},post:{$ref:\"#/visitors/document/objects/Operation\"},delete:{$ref:\"#/visitors/document/objects/Operation\"},options:{$ref:\"#/visitors/document/objects/Operation\"},head:{$ref:\"#/visitors/document/objects/Operation\"},patch:{$ref:\"#/visitors/document/objects/Operation\"},trace:{$ref:\"#/visitors/document/objects/Operation\"},servers:Jv.visitors.document.objects.PathItem.fixedFields.servers,parameters:Jv.visitors.document.objects.PathItem.fixedFields.parameters}},Operation:{$visitor:XE,fixedFields:{tags:Jv.visitors.document.objects.Operation.fixedFields.tags,summary:Jv.visitors.document.objects.Operation.fixedFields.summary,description:Jv.visitors.document.objects.Operation.fixedFields.description,externalDocs:{$ref:\"#/visitors/document/objects/ExternalDocumentation\"},operationId:Jv.visitors.document.objects.Operation.fixedFields.operationId,parameters:Jv.visitors.document.objects.Operation.fixedFields.parameters,requestBody:Jv.visitors.document.objects.Operation.fixedFields.requestBody,responses:{$ref:\"#/visitors/document/objects/Responses\"},callbacks:Jv.visitors.document.objects.Operation.fixedFields.callbacks,deprecated:Jv.visitors.document.objects.Operation.fixedFields.deprecated,security:Jv.visitors.document.objects.Operation.fixedFields.security,servers:Jv.visitors.document.objects.Operation.fixedFields.servers}},ExternalDocumentation:{$visitor:LE,fixedFields:{description:Jv.visitors.document.objects.ExternalDocumentation.fixedFields.description,url:Jv.visitors.document.objects.ExternalDocumentation.fixedFields.url}},Parameter:{$visitor:VS,fixedFields:{name:Jv.visitors.document.objects.Parameter.fixedFields.name,in:Jv.visitors.document.objects.Parameter.fixedFields.in,description:Jv.visitors.document.objects.Parameter.fixedFields.description,required:Jv.visitors.document.objects.Parameter.fixedFields.required,deprecated:Jv.visitors.document.objects.Parameter.fixedFields.deprecated,allowEmptyValue:Jv.visitors.document.objects.Parameter.fixedFields.allowEmptyValue,style:Jv.visitors.document.objects.Parameter.fixedFields.style,explode:Jv.visitors.document.objects.Parameter.fixedFields.explode,allowReserved:Jv.visitors.document.objects.Parameter.fixedFields.allowReserved,schema:{$ref:\"#/visitors/document/objects/Schema\"},example:Jv.visitors.document.objects.Parameter.fixedFields.example,examples:Jv.visitors.document.objects.Parameter.fixedFields.examples,content:Jv.visitors.document.objects.Parameter.fixedFields.content}},RequestBody:{$visitor:VE,fixedFields:{description:Jv.visitors.document.objects.RequestBody.fixedFields.description,content:Jv.visitors.document.objects.RequestBody.fixedFields.content,required:Jv.visitors.document.objects.RequestBody.fixedFields.required}},MediaType:{$visitor:NS,fixedFields:{schema:{$ref:\"#/visitors/document/objects/Schema\"},example:Jv.visitors.document.objects.MediaType.fixedFields.example,examples:Jv.visitors.document.objects.MediaType.fixedFields.examples,encoding:Jv.visitors.document.objects.MediaType.fixedFields.encoding}},Encoding:{$visitor:BE,fixedFields:{contentType:Jv.visitors.document.objects.Encoding.fixedFields.contentType,headers:Jv.visitors.document.objects.Encoding.fixedFields.headers,style:Jv.visitors.document.objects.Encoding.fixedFields.style,explode:Jv.visitors.document.objects.Encoding.fixedFields.explode,allowReserved:Jv.visitors.document.objects.Encoding.fixedFields.allowReserved}},Responses:{$visitor:GE,fixedFields:{default:Jv.visitors.document.objects.Responses.fixedFields.default}},Response:{$visitor:HE,fixedFields:{description:Jv.visitors.document.objects.Response.fixedFields.description,headers:Jv.visitors.document.objects.Response.fixedFields.headers,content:Jv.visitors.document.objects.Response.fixedFields.content,links:Jv.visitors.document.objects.Response.fixedFields.links}},Callback:{$visitor:WE},Example:{$visitor:RE,fixedFields:{summary:Jv.visitors.document.objects.Example.fixedFields.summary,description:Jv.visitors.document.objects.Example.fixedFields.description,value:Jv.visitors.document.objects.Example.fixedFields.value,externalValue:Jv.visitors.document.objects.Example.fixedFields.externalValue}},Link:{$visitor:OS,fixedFields:{operationRef:Jv.visitors.document.objects.Link.fixedFields.operationRef,operationId:Jv.visitors.document.objects.Link.fixedFields.operationId,parameters:Jv.visitors.document.objects.Link.fixedFields.parameters,requestBody:Jv.visitors.document.objects.Link.fixedFields.requestBody,description:Jv.visitors.document.objects.Link.fixedFields.description,server:{$ref:\"#/visitors/document/objects/Server\"}}},Header:{$visitor:WS,fixedFields:{description:Jv.visitors.document.objects.Header.fixedFields.description,required:Jv.visitors.document.objects.Header.fixedFields.required,deprecated:Jv.visitors.document.objects.Header.fixedFields.deprecated,allowEmptyValue:Jv.visitors.document.objects.Header.fixedFields.allowEmptyValue,style:Jv.visitors.document.objects.Header.fixedFields.style,explode:Jv.visitors.document.objects.Header.fixedFields.explode,allowReserved:Jv.visitors.document.objects.Header.fixedFields.allowReserved,schema:{$ref:\"#/visitors/document/objects/Schema\"},example:Jv.visitors.document.objects.Header.fixedFields.example,examples:Jv.visitors.document.objects.Header.fixedFields.examples,content:Jv.visitors.document.objects.Header.fixedFields.content}},Tag:{$visitor:BS,fixedFields:{name:Jv.visitors.document.objects.Tag.fixedFields.name,description:Jv.visitors.document.objects.Tag.fixedFields.description,externalDocs:{$ref:\"#/visitors/document/objects/ExternalDocumentation\"}}},Reference:{$visitor:qS,fixedFields:{$ref:Jv.visitors.document.objects.Reference.fixedFields.$ref,summary:{$ref:\"#/visitors/value\"},description:{$ref:\"#/visitors/value\"}}},JSONSchema:{$ref:\"#/visitors/document/objects/Schema\"},LinkDescription:{...lw},Schema:{$visitor:vE,fixedFields:{...cw.fixedFields,$defs:bE,allOf:_E,anyOf:SE,oneOf:EE,not:{$ref:\"#/visitors/document/objects/Schema\"},if:{$ref:\"#/visitors/document/objects/Schema\"},then:{$ref:\"#/visitors/document/objects/Schema\"},else:{$ref:\"#/visitors/document/objects/Schema\"},dependentSchemas:wE,prefixItems:xE,items:{$ref:\"#/visitors/document/objects/Schema\"},contains:{$ref:\"#/visitors/document/objects/Schema\"},properties:kE,patternProperties:OE,additionalProperties:{$ref:\"#/visitors/document/objects/Schema\"},propertyNames:{$ref:\"#/visitors/document/objects/Schema\"},unevaluatedItems:{$ref:\"#/visitors/document/objects/Schema\"},unevaluatedProperties:{$ref:\"#/visitors/document/objects/Schema\"},contentSchema:{$ref:\"#/visitors/document/objects/Schema\"},discriminator:{$ref:\"#/visitors/document/objects/Discriminator\"},xml:{$ref:\"#/visitors/document/objects/XML\"},externalDocs:{$ref:\"#/visitors/document/objects/ExternalDocumentation\"},example:{$ref:\"#/visitors/value\"}}},Discriminator:{$visitor:CE,fixedFields:{propertyName:Jv.visitors.document.objects.Discriminator.fixedFields.propertyName,mapping:Jv.visitors.document.objects.Discriminator.fixedFields.mapping}},XML:{$visitor:PE,fixedFields:{name:Jv.visitors.document.objects.XML.fixedFields.name,namespace:Jv.visitors.document.objects.XML.fixedFields.namespace,prefix:Jv.visitors.document.objects.XML.fixedFields.prefix,attribute:Jv.visitors.document.objects.XML.fixedFields.attribute,wrapped:Jv.visitors.document.objects.XML.fixedFields.wrapped}},SecurityScheme:{$visitor:tw,fixedFields:{type:Jv.visitors.document.objects.SecurityScheme.fixedFields.type,description:Jv.visitors.document.objects.SecurityScheme.fixedFields.description,name:Jv.visitors.document.objects.SecurityScheme.fixedFields.name,in:Jv.visitors.document.objects.SecurityScheme.fixedFields.in,scheme:Jv.visitors.document.objects.SecurityScheme.fixedFields.scheme,bearerFormat:Jv.visitors.document.objects.SecurityScheme.fixedFields.bearerFormat,flows:{$ref:\"#/visitors/document/objects/OAuthFlows\"},openIdConnectUrl:Jv.visitors.document.objects.SecurityScheme.fixedFields.openIdConnectUrl}},OAuthFlows:{$visitor:nw,fixedFields:{implicit:{$ref:\"#/visitors/document/objects/OAuthFlow\"},password:{$ref:\"#/visitors/document/objects/OAuthFlow\"},clientCredentials:{$ref:\"#/visitors/document/objects/OAuthFlow\"},authorizationCode:{$ref:\"#/visitors/document/objects/OAuthFlow\"}}},OAuthFlow:{$visitor:ow,fixedFields:{authorizationUrl:Jv.visitors.document.objects.OAuthFlow.fixedFields.authorizationUrl,tokenUrl:Jv.visitors.document.objects.OAuthFlow.fixedFields.tokenUrl,refreshUrl:Jv.visitors.document.objects.OAuthFlow.fixedFields.refreshUrl,scopes:Jv.visitors.document.objects.OAuthFlow.fixedFields.scopes}},SecurityRequirement:{$visitor:RS}},extension:{$visitor:Jv.visitors.document.extension.$visitor}}}},apidom_ns_openapi_3_1_src_traversal_visitor_getNodeType=s=>{if(ju(s))return`${s.element.charAt(0).toUpperCase()+s.element.slice(1)}Element`},pw={CallbackElement:[\"content\"],ComponentsElement:[\"content\"],ContactElement:[\"content\"],DiscriminatorElement:[\"content\"],Encoding:[\"content\"],Example:[\"content\"],ExternalDocumentationElement:[\"content\"],HeaderElement:[\"content\"],InfoElement:[\"content\"],LicenseElement:[\"content\"],MediaTypeElement:[\"content\"],OAuthFlowElement:[\"content\"],OAuthFlowsElement:[\"content\"],OpenApi3_1Element:[\"content\"],OperationElement:[\"content\"],ParameterElement:[\"content\"],PathItemElement:[\"content\"],PathsElement:[\"content\"],ReferenceElement:[\"content\"],RequestBodyElement:[\"content\"],ResponseElement:[\"content\"],ResponsesElement:[\"content\"],SchemaElement:[\"content\"],SecurityRequirementElement:[\"content\"],SecuritySchemeElement:[\"content\"],ServerElement:[\"content\"],ServerVariableElement:[\"content\"],TagElement:[\"content\"],...Ku},hw={namespace:s=>{const{base:o}=s;return o.register(\"callback\",Yv),o.register(\"components\",Xv),o.register(\"contact\",Qv),o.register(\"discriminator\",Zv),o.register(\"encoding\",eb),o.register(\"example\",tb),o.register(\"externalDocumentation\",nb),o.register(\"header\",pb),o.register(\"info\",mb),o.register(\"jsonSchemaDialect\",yb),o.register(\"license\",_b),o.register(\"link\",Sb),o.register(\"mediaType\",wb),o.register(\"oAuthFlow\",Ob),o.register(\"oAuthFlows\",Ab),o.register(\"openapi\",Pb),o.register(\"openApi3_1\",Ib),o.register(\"operation\",Mb),o.register(\"parameter\",Rb),o.register(\"pathItem\",Lb),o.register(\"paths\",qb),o.register(\"reference\",zb),o.register(\"requestBody\",Qb),o.register(\"response\",e_),o.register(\"responses\",t_),o.register(\"schema\",pS),o.register(\"securityRequirement\",hS),o.register(\"securityScheme\",dS),o.register(\"server\",fS),o.register(\"serverVariable\",mS),o.register(\"tag\",gS),o.register(\"xml\",yS),o}},dw=hw,ancestorLineageToJSONPointer=s=>{const o=s.reduce(((o,i,a)=>{if(Du(i)){const s=String(serializers_value(i.key));o.push(s)}else if(Ru(s[a-2])){const u=String(s[a-2].content.indexOf(i));o.push(u)}return o}),[]);return es_compile(o)},apidom_ns_openapi_3_1_src_refractor_toolbox=()=>{const s=createNamespace(dw);return{predicates:{...ye,isElement:ju,isStringElement:Pu,isArrayElement:Ru,isObjectElement:Mu,isMemberElement:Du,isServersElement:lg,includesClasses,hasElementSourceMap},ancestorLineageToJSONPointer,namespace:s}},apidom_ns_openapi_3_1_src_refractor_refract=(s,{specPath:o=[\"visitors\",\"document\",\"objects\",\"OpenApi\",\"$visitor\"],plugins:i=[]}={})=>{const a=(0,Su.e)(s),u=dereference(uw),_=new(tp(o,u))({specObj:u});return visitor_visit(a,_),dispatchPluginsSync(_.element,i,{toolboxCreator:apidom_ns_openapi_3_1_src_refractor_toolbox,visitorOptions:{keyMap:pw,nodeTypeGetter:apidom_ns_openapi_3_1_src_traversal_visitor_getNodeType}})},apidom_ns_openapi_3_1_src_refractor_createRefractor=s=>(o,i={})=>apidom_ns_openapi_3_1_src_refractor_refract(o,{specPath:s,...i});Yv.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Callback\",\"$visitor\"]),Xv.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Components\",\"$visitor\"]),Qv.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Contact\",\"$visitor\"]),tb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Example\",\"$visitor\"]),Zv.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Discriminator\",\"$visitor\"]),eb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Encoding\",\"$visitor\"]),nb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"ExternalDocumentation\",\"$visitor\"]),pb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Header\",\"$visitor\"]),mb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Info\",\"$visitor\"]),yb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"OpenApi\",\"fixedFields\",\"jsonSchemaDialect\"]),_b.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"License\",\"$visitor\"]),Sb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Link\",\"$visitor\"]),wb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"MediaType\",\"$visitor\"]),Ob.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"OAuthFlow\",\"$visitor\"]),Ab.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"OAuthFlows\",\"$visitor\"]),Pb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"OpenApi\",\"fixedFields\",\"openapi\"]),Ib.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"OpenApi\",\"$visitor\"]),Mb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Operation\",\"$visitor\"]),Rb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Parameter\",\"$visitor\"]),Lb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"PathItem\",\"$visitor\"]),qb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Paths\",\"$visitor\"]),zb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Reference\",\"$visitor\"]),Qb.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"RequestBody\",\"$visitor\"]),e_.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Response\",\"$visitor\"]),t_.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Responses\",\"$visitor\"]),pS.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Schema\",\"$visitor\"]),hS.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"SecurityRequirement\",\"$visitor\"]),dS.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"SecurityScheme\",\"$visitor\"]),fS.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Server\",\"$visitor\"]),mS.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"ServerVariable\",\"$visitor\"]),gS.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"Tag\",\"$visitor\"]),yS.refract=apidom_ns_openapi_3_1_src_refractor_createRefractor([\"visitors\",\"document\",\"objects\",\"XML\",\"$visitor\"]);const fw=class NotImplementedError extends Dh{};const mw=class MediaTypes extends Array{unknownMediaType=\"application/octet-stream\";filterByFormat(){throw new fw(\"filterByFormat method in MediaTypes class is not yet implemented.\")}findBy(){throw new fw(\"findBy method in MediaTypes class is not yet implemented.\")}latest(){throw new fw(\"latest method in MediaTypes class is not yet implemented.\")}};class OpenAPIMediaTypes extends mw{filterByFormat(s=\"generic\"){const o=\"generic\"===s?\"openapi;version\":s;return this.filter((s=>s.includes(o)))}findBy(s=\"3.1.0\",o=\"generic\"){const i=\"generic\"===o?`vnd.oai.openapi;version=${s}`:`vnd.oai.openapi+${o};version=${s}`;return this.find((s=>s.includes(i)))||this.unknownMediaType}latest(s=\"generic\"){return Ba(this.filterByFormat(s))}}const gw=new OpenAPIMediaTypes(\"application/vnd.oai.openapi;version=3.1.0\",\"application/vnd.oai.openapi+json;version=3.1.0\",\"application/vnd.oai.openapi+yaml;version=3.1.0\");const yw=class src_Reference_Reference{uri;depth;value;refSet;errors;constructor({uri:s,depth:o=0,refSet:i,value:a}){this.uri=s,this.value=a,this.depth=o,this.refSet=i,this.errors=[]}};const vw=class ReferenceSet{rootRef;refs;circular;constructor({refs:s=[],circular:o=!1}={}){this.refs=[],this.circular=o,s.forEach(this.add.bind(this))}get size(){return this.refs.length}add(s){return this.has(s)||(this.refs.push(s),this.rootRef=void 0===this.rootRef?s:this.rootRef,s.refSet=this),this}merge(s){for(const o of s.values())this.add(o);return this}has(s){const o=Jc(s)?s:s.uri;return _c(this.find((s=>s.uri===o)))}find(s){return this.refs.find(s)}*values(){yield*this.refs}clean(){this.refs.forEach((s=>{s.refSet=void 0})),this.rootRef=void 0,this.refs.length=0}};function _identity(s){return s}const bw=_curry1(_identity),_w={parse:{mediaType:\"text/plain\",parsers:[],parserOpts:{}},resolve:{baseURI:\"\",resolvers:[],resolverOpts:{},strategies:[],strategyOpts:{},internal:!0,external:!0,maxDepth:1/0},dereference:{strategies:[],strategyOpts:{},refSet:null,maxDepth:1/0,circular:\"ignore\",circularReplacer:bw,immutable:!0},bundle:{strategies:[],refSet:null,maxDepth:1/0}};const Sw=_curry2((function lens(s,o){return function(i){return function(a){return cc((function(s){return o(s,a)}),i(s(a)))}}}));var Identity=function(s){return{value:s,map:function(o){return Identity(o(s))}}},Ew=_curry3((function over(s,o,i){return s((function(s){return Identity(o(s))}))(i).value}));const ww=Ew;const xw=na(\"\"),kw=Sw(tp([\"resolve\",\"baseURI\"]),o_([\"resolve\",\"baseURI\"])),baseURIDefault=s=>xw(s)?url_cwd():s,util_merge=(s,o)=>{const i=ep(s,o);return ww(kw,baseURIDefault,i)};const Ow=class File_File{uri;mediaType;data;parseResult;constructor({uri:s,mediaType:o=\"text/plain\",data:i,parseResult:a}){this.uri=s,this.mediaType=o,this.data=i,this.parseResult=a}get extension(){return Jc(this.uri)?(s=>{const o=s.lastIndexOf(\".\");return o>=0?s.substring(o).toLowerCase():\"\"})(this.uri):\"\"}toString(){if(\"string\"==typeof this.data)return this.data;if(this.data instanceof ArrayBuffer||[\"ArrayBuffer\"].includes(ra(this.data))||ArrayBuffer.isView(this.data)){return new TextDecoder(\"utf-8\").decode(this.data)}return String(this.data)}};const Aw=class PluginError extends Ko{plugin;constructor(s,o){super(s,{cause:o.cause}),this.plugin=o.plugin}},plugins_filter=async(s,o,i)=>{const a=await Promise.all(i.map(hp([s],o)));return i.filter(((s,o)=>a[o]))},run=async(s,o,i)=>{let a;for(const u of i)try{const i=await u[s].call(u,...o);return{plugin:u,result:i}}catch(s){a=new Aw(\"Error while running plugin\",{cause:s,plugin:u})}return Promise.reject(a)};const Cw=class DereferenceError extends Ko{};const jw=class UnmatchedDereferenceStrategyError extends Cw{},dereferenceApiDOM=async(s,o)=>{let i=s,a=!1;if(!qu(s)){const o=cloneShallow(s);o.classes.push(\"result\"),i=new Au([o]),a=!0}const u=new Ow({uri:o.resolve.baseURI,parseResult:i,mediaType:o.parse.mediaType}),_=await plugins_filter(\"canDereference\",[u,o],o.dereference.strategies);if(cp(_))throw new jw(u.uri);try{const{result:s}=await run(\"dereference\",[u,o],_);return a?s.get(0):s}catch(s){throw new Cw(`Error while dereferencing file \"${u.uri}\"`,{cause:s})}};const Pw=class ParseError extends Ko{};const Iw=class ParserError extends Pw{};const Tw=class Parser_Parser{name;allowEmpty;sourceMap;fileExtensions;mediaTypes;constructor({name:s,allowEmpty:o=!0,sourceMap:i=!1,fileExtensions:a=[],mediaTypes:u=[]}){this.name=s,this.allowEmpty=o,this.sourceMap=i,this.fileExtensions=a,this.mediaTypes=u}};const Nw=class BinaryParser extends Tw{constructor(s){super({...null!=s?s:{},name:\"binary\"})}canParse(s){return 0===this.fileExtensions.length||this.fileExtensions.includes(s.extension)}parse(s){try{const o=unescape(encodeURIComponent(s.toString())),i=btoa(o),a=new Au;if(0!==i.length){const s=new Su.Om(i);s.classes.push(\"result\"),a.push(s)}return a}catch(o){throw new Iw(`Error parsing \"${s.uri}\"`,{cause:o})}}};const Mw=class ResolveStrategy{name;constructor({name:s}){this.name=s}};const Rw=class OpenAPI3_1ResolveStrategy extends Mw{constructor(s){super({...null!=s?s:{},name:\"openapi-3-1\"})}canResolve(s,o){const i=o.dereference.strategies.find((s=>\"openapi-3-1\"===s.name));return void 0!==i&&i.canDereference(s,o)}async resolve(s,o){const i=o.dereference.strategies.find((s=>\"openapi-3-1\"===s.name));if(void 0===i)throw new jw('\"openapi-3-1\" dereference strategy is not available.');const a=new vw,u=util_merge(o,{resolve:{internal:!1},dereference:{refSet:a}});return await i.dereference(s,u),a}};const Dw=class Resolver{name;constructor({name:s}){this.name=s}};const Lw=class HTTPResolver extends Dw{timeout;redirects;withCredentials;constructor(s){const{name:o=\"http-resolver\",timeout:i=5e3,redirects:a=5,withCredentials:u=!1}=null!=s?s:{};super({name:o}),this.timeout=i,this.redirects=a,this.withCredentials=u}canRead(s){return isHttpUrl(s.uri)}};const Fw=class ResolveError extends Ko{};const Bw=class ResolverError extends Fw{},{AbortController:$w,AbortSignal:qw}=globalThis;void 0===globalThis.AbortController&&(globalThis.AbortController=$w),void 0===globalThis.AbortSignal&&(globalThis.AbortSignal=qw);const Uw=class HTTPResolverSwaggerClient extends Lw{swaggerHTTPClient=http_http;swaggerHTTPClientConfig;constructor({swaggerHTTPClient:s=http_http,swaggerHTTPClientConfig:o={},...i}={}){super({...i,name:\"http-swagger-client\"}),this.swaggerHTTPClient=s,this.swaggerHTTPClientConfig=o}getHttpClient(){return this.swaggerHTTPClient}async read(s){const o=this.getHttpClient(),i=new AbortController,{signal:a}=i,u=setTimeout((()=>{i.abort()}),this.timeout),_=this.getHttpClient().withCredentials||this.withCredentials?\"include\":\"same-origin\",w=0===this.redirects?\"error\":\"follow\",x=this.redirects>0?this.redirects:void 0;try{return(await o({url:s.uri,signal:a,userFetch:async(s,o)=>{let i=await fetch(s,o);try{i.headers.delete(\"Content-Type\")}catch{i=new Response(i.body,{...i,headers:new Headers(i.headers)}),i.headers.delete(\"Content-Type\")}return i},credentials:_,redirect:w,follow:x,...this.swaggerHTTPClientConfig})).text.arrayBuffer()}catch(o){throw new Bw(`Error downloading \"${s.uri}\"`,{cause:o})}finally{clearTimeout(u)}}},transformers_from=(s,o=fp)=>{if(Jc(s))try{return o.fromRefract(JSON.parse(s))}catch{}return fu(s)&&id(\"element\",s)?o.fromRefract(s):o.toElement(s)};const Vw=class JSONParser extends Tw{constructor(s={}){super({name:\"json-swagger-client\",mediaTypes:[\"application/json\"],...s})}async canParse(s){const o=0===this.fileExtensions.length||this.fileExtensions.includes(s.extension),i=this.mediaTypes.includes(s.mediaType);if(!o)return!1;if(i)return!0;if(!i)try{return JSON.parse(s.toString()),!0}catch(s){return!1}return!1}async parse(s){if(this.sourceMap)throw new Iw(\"json-swagger-client parser plugin doesn't support sourceMaps option\");const o=new Au,i=s.toString();if(this.allowEmpty&&\"\"===i.trim())return o;try{const s=transformers_from(JSON.parse(i));return s.classes.push(\"result\"),o.push(s),o}catch(o){throw new Iw(`Error parsing \"${s.uri}\"`,{cause:o})}}};const zw=class YAMLParser extends Tw{constructor(s={}){super({name:\"yaml-1-2-swagger-client\",mediaTypes:[\"text/yaml\",\"application/yaml\"],...s})}async canParse(s){const o=0===this.fileExtensions.length||this.fileExtensions.includes(s.extension),i=this.mediaTypes.includes(s.mediaType);if(!o)return!1;if(i)return!0;if(!i)try{return fn.load(s.toString(),{schema:rn}),!0}catch(s){return!1}return!1}async parse(s){if(this.sourceMap)throw new Iw(\"yaml-1-2-swagger-client parser plugin doesn't support sourceMaps option\");const o=new Au,i=s.toString();try{const s=fn.load(i,{schema:rn});if(this.allowEmpty&&void 0===s)return o;const a=transformers_from(s);return a.classes.push(\"result\"),o.push(a),o}catch(o){throw new Iw(`Error parsing \"${s.uri}\"`,{cause:o})}}};const Ww=class OpenAPIJSON3_1Parser extends Tw{detectionRegExp=/\"openapi\"\\s*:\\s*\"(?<version_json>3\\.1\\.(?:[1-9]\\d*|0))\"/;constructor(s={}){super({name:\"openapi-json-3-1-swagger-client\",mediaTypes:new OpenAPIMediaTypes(...gw.filterByFormat(\"generic\"),...gw.filterByFormat(\"json\")),...s})}async canParse(s){const o=0===this.fileExtensions.length||this.fileExtensions.includes(s.extension),i=this.mediaTypes.includes(s.mediaType);if(!o)return!1;if(i)return!0;if(!i)try{const o=s.toString();return JSON.parse(o),this.detectionRegExp.test(o)}catch(s){return!1}return!1}async parse(s){if(this.sourceMap)throw new Iw(\"openapi-json-3-1-swagger-client parser plugin doesn't support sourceMaps option\");const o=new Au,i=s.toString();if(this.allowEmpty&&\"\"===i.trim())return o;try{const s=JSON.parse(i),a=Ib.refract(s,this.refractorOpts);return a.classes.push(\"result\"),o.push(a),o}catch(o){throw new Iw(`Error parsing \"${s.uri}\"`,{cause:o})}}};const Jw=class OpenAPIYAML31Parser extends Tw{detectionRegExp=/(?<YAML>^([\"']?)openapi\\2\\s*:\\s*([\"']?)(?<version_yaml>3\\.1\\.(?:[1-9]\\d*|0))\\3(?:\\s+|$))|(?<JSON>\"openapi\"\\s*:\\s*\"(?<version_json>3\\.1\\.(?:[1-9]\\d*|0))\")/m;constructor(s={}){super({name:\"openapi-yaml-3-1-swagger-client\",mediaTypes:new OpenAPIMediaTypes(...gw.filterByFormat(\"generic\"),...gw.filterByFormat(\"yaml\")),...s})}async canParse(s){const o=0===this.fileExtensions.length||this.fileExtensions.includes(s.extension),i=this.mediaTypes.includes(s.mediaType);if(!o)return!1;if(i)return!0;if(!i)try{const o=s.toString();return fn.load(o),this.detectionRegExp.test(o)}catch(s){return!1}return!1}async parse(s){if(this.sourceMap)throw new Iw(\"openapi-yaml-3-1-swagger-client parser plugin doesn't support sourceMaps option\");const o=new Au,i=s.toString();try{const s=fn.load(i,{schema:rn});if(this.allowEmpty&&void 0===s)return o;const a=Ib.refract(s,this.refractorOpts);return a.classes.push(\"result\"),o.push(a),o}catch(o){throw new Iw(`Error parsing \"${s.uri}\"`,{cause:o})}}};const Hw=_curry3((function propEq(s,o,i){return na(s,Da(o,i))}));const Kw=class DereferenceStrategy{name;constructor({name:s}){this.name=s}};const Gw=_curry2((function none(s,o){return xu(_complement(s),o)}));var Yw=__webpack_require__(8068);const Xw=class ElementIdentityError extends Go{value;constructor(s,o){super(s,o),void 0!==o&&(this.value=o.value)}};class IdentityManager{uuid;identityMap;constructor({length:s=6}={}){this.uuid=new Yw({length:s}),this.identityMap=new WeakMap}identify(s){if(!ju(s))throw new Xw(\"Cannot not identify the element. `element` is neither structurally compatible nor a subclass of an Element class.\",{value:s});if(s.meta.hasKey(\"id\")&&Pu(s.meta.get(\"id\"))&&!s.meta.get(\"id\").equals(\"\"))return s.id;if(this.identityMap.has(s))return this.identityMap.get(s);const o=new Su.Om(this.generateId());return this.identityMap.set(s,o),o}forget(s){return!!this.identityMap.has(s)&&(this.identityMap.delete(s),!0)}generateId(){return this.uuid.randomUUID()}}new IdentityManager;const Qw=_curry3((function pathOr(s,o,i){return Na(s,_path(o,i))})),traversal_find=(s,o)=>{const i=new PredicateVisitor({predicate:s,returnOnTrue:Vu});return visitor_visit(o,i),Qw(void 0,[0],i.result)};const Zw=class JsonSchema$anchorError extends Ko{};const ex=class EvaluationJsonSchema$anchorError extends Zw{};const tx=class InvalidJsonSchema$anchorError extends Zw{constructor(s){super(`Invalid JSON Schema $anchor \"${s}\".`)}},isAnchor=s=>/^[A-Za-z_][A-Za-z_0-9.-]*$/.test(s),uriToAnchor=s=>{const o=getHash(s);return dd(\"#\",o)},$anchor_evaluate=(s,o)=>{const i=(s=>{if(!isAnchor(s))throw new tx(s);return s})(s),a=traversal_find((s=>hE(s)&&serializers_value(s.$anchor)===i),o);if(bc(a))throw new ex(`Evaluation failed on token: \"${i}\"`);return a},traversal_filter=(s,o)=>{const i=new PredicateVisitor({predicate:s});return visitor_visit(o,i),new Su.G6(i.result)};const rx=class JsonSchemaUriError extends Ko{};const nx=class EvaluationJsonSchemaUriError extends rx{},resolveSchema$refField=(s,o)=>{if(void 0===o.$ref)return;const i=getHash(serializers_value(o.$ref)),a=serializers_value(o.meta.get(\"ancestorsSchemaIdentifiers\")),u=Aa(((s,o)=>resolve(s,sanitize(stripHash(o)))),s,[...a,serializers_value(o.$ref)]);return`${u}${\"#\"===i?\"\":i}`},refractToSchemaElement=s=>{if(refractToSchemaElement.cache.has(s))return refractToSchemaElement.cache.get(s);const o=pS.refract(s);return refractToSchemaElement.cache.set(s,o),o};refractToSchemaElement.cache=new WeakMap;const maybeRefractToSchemaElement=s=>isPrimitiveElement(s)?refractToSchemaElement(s):s,uri_evaluate=(s,o)=>{const{cache:i}=uri_evaluate,a=stripHash(s),isSchemaElementWith$id=s=>hE(s)&&void 0!==s.$id;if(!i.has(o)){const s=traversal_filter(isSchemaElementWith$id,o);i.set(o,Array.from(s))}const u=i.get(o).find((s=>{const o=((s,o)=>{if(void 0===o.$id)return;const i=serializers_value(o.meta.get(\"ancestorsSchemaIdentifiers\"));return Aa(((s,o)=>resolve(s,sanitize(stripHash(o)))),s,i)})(a,s);return o===a}));if(bc(u))throw new nx(`Evaluation failed on URI: \"${s}\"`);return isAnchor(uriToAnchor(s))?$anchor_evaluate(uriToAnchor(s),u):apidom_evaluate(u,fromURIReference(s))};uri_evaluate.cache=new WeakMap;const sx=class MaximumDereferenceDepthError extends Cw{};const ox=class MaximumResolveDepthError extends Fw{};const ix=class UnmatchedResolverError extends Bw{},apidom_reference_src_parse=async(s,o)=>{const i=new Ow({uri:sanitize(stripHash(s)),mediaType:o.parse.mediaType}),a=await(async(s,o)=>{const i=o.resolve.resolvers.map((s=>{const i=Object.create(s);return Object.assign(i,o.resolve.resolverOpts)})),a=await plugins_filter(\"canRead\",[s,o],i);if(cp(a))throw new ix(s.uri);try{const{result:o}=await run(\"read\",[s],a);return o}catch(o){throw new Fw(`Error while reading file \"${s.uri}\"`,{cause:o})}})(i,o);return(async(s,o)=>{const i=o.parse.parsers.map((s=>{const i=Object.create(s);return Object.assign(i,o.parse.parserOpts)})),a=await plugins_filter(\"canParse\",[s,o],i);if(cp(a))throw new ix(s.uri);try{const{plugin:i,result:u}=await run(\"parse\",[s,o],a);return!i.allowEmpty&&u.isEmpty?Promise.reject(new Pw(`Error while parsing file \"${s.uri}\". File is empty.`)):u}catch(o){throw new Pw(`Error while parsing file \"${s.uri}\"`,{cause:o})}})(new Ow({...i,data:a}),o)};class AncestorLineage extends Array{includesCycle(s){return this.filter((o=>o.has(s))).length>1}includes(s,o){return s instanceof Set?super.includes(s,o):this.some((o=>o.has(s)))}findItem(s){for(const o of this)for(const i of o)if(ju(i)&&s(i))return i}}const ax=visitor_visit[Symbol.for(\"nodejs.util.promisify.custom\")],cx=new IdentityManager,mutationReplacer=(s,o,i,a)=>{Du(a)?a.value=s:Array.isArray(a)&&(a[i]=s)};class OpenAPI3_1DereferenceVisitor{indirections;namespace;reference;options;ancestors;refractCache;allOfDiscriminatorMapping;constructor({reference:s,namespace:o,options:i,indirections:a=[],ancestors:u=new AncestorLineage,refractCache:_=new Map,allOfDiscriminatorMapping:w=new Map}){this.indirections=a,this.namespace=o,this.reference=s,this.options=i,this.ancestors=new AncestorLineage(...u),this.refractCache=_,this.allOfDiscriminatorMapping=w}toBaseURI(s){return resolve(this.reference.uri,sanitize(stripHash(s)))}async toReference(s){if(this.reference.depth>=this.options.resolve.maxDepth)throw new ox(`Maximum resolution depth of ${this.options.resolve.maxDepth} has been exceeded by file \"${this.reference.uri}\"`);const o=this.toBaseURI(s),{refSet:i}=this.reference;if(i.has(o))return i.find(Hw(o,\"uri\"));const a=await apidom_reference_src_parse(unsanitize(o),{...this.options,parse:{...this.options.parse,mediaType:\"text/plain\"}}),u=new yw({uri:o,value:cloneDeep(a),depth:this.reference.depth+1});if(i.add(u),this.options.dereference.immutable){const s=new yw({uri:`immutable://${o}`,value:a,depth:this.reference.depth+1});i.add(s)}return u}toAncestorLineage(s){const o=new Set(s.filter(ju));return[new AncestorLineage(...this.ancestors,o),o]}OpenApi3_1Element={leave:(s,o,i,a,u,_)=>{var w;if(null===(w=this.options.dereference.strategyOpts[\"openapi-3-1\"])||void 0===w||!w.dereferenceDiscriminatorMapping)return;const x=cloneShallow(s);return x.setMetaProperty(\"allOfDiscriminatorMapping\",Object.fromEntries(this.allOfDiscriminatorMapping)),_.replaceWith(x,mutationReplacer),i?void 0:x}};async ReferenceElement(s,o,i,a,u,_){if(this.indirections.includes(s))return!1;const[w,x]=this.toAncestorLineage([...u,i]),C=this.toBaseURI(serializers_value(s.$ref)),j=stripHash(this.reference.uri)===C,L=!j;if(!this.options.resolve.internal&&j)return!1;if(!this.options.resolve.external&&L)return!1;const B=await this.toReference(serializers_value(s.$ref)),$=resolve(C,serializers_value(s.$ref));this.indirections.push(s);const U=fromURIReference($);let V=apidom_evaluate(B.value.result,U);if(V.id=cx.identify(V),isPrimitiveElement(V)){const o=serializers_value(s.meta.get(\"referenced-element\")),i=`${o}-${serializers_value(cx.identify(V))}`;if(this.refractCache.has(i))V=this.refractCache.get(i);else if(isReferenceLikeElement(V))V=zb.refract(V),V.setMetaProperty(\"referenced-element\",o),this.refractCache.set(i,V);else{V=this.namespace.getElementClass(o).refract(V),this.refractCache.set(i,V)}}if(s===V)throw new Ko(\"Recursive Reference Object detected\");if(this.indirections.length>this.options.dereference.maxDepth)throw new sx(`Maximum dereference depth of \"${this.options.dereference.maxDepth}\" has been exceeded in file \"${this.reference.uri}\"`);if(w.includes(V)){if(B.refSet.circular=!0,\"error\"===this.options.dereference.circular)throw new Ko(\"Circular reference detected\");if(\"replace\"===this.options.dereference.circular){var z,Y;const o=new Su.sI(V.id,{type:\"reference\",uri:B.uri,$ref:serializers_value(s.$ref)}),a=(null!==(z=null===(Y=this.options.dereference.strategyOpts[\"openapi-3-1\"])||void 0===Y?void 0:Y.circularReplacer)&&void 0!==z?z:this.options.dereference.circularReplacer)(o);return _.replaceWith(a,mutationReplacer),!i&&a}}const Z=stripHash(B.refSet.rootRef.uri)!==B.uri,ee=[\"error\",\"replace\"].includes(this.options.dereference.circular);if((L||Z||cE(V)||ee)&&!w.includesCycle(V)){x.add(s);const o=new OpenAPI3_1DereferenceVisitor({reference:B,namespace:this.namespace,indirections:[...this.indirections],options:this.options,refractCache:this.refractCache,ancestors:w,allOfDiscriminatorMapping:this.allOfDiscriminatorMapping});V=await ax(V,o,{keyMap:pw,nodeTypeGetter:apidom_ns_openapi_3_1_src_traversal_visitor_getNodeType}),x.delete(s)}this.indirections.pop();const ie=cloneShallow(V);return ie.setMetaProperty(\"id\",cx.generateId()),ie.setMetaProperty(\"ref-fields\",{$ref:serializers_value(s.$ref),description:serializers_value(s.description),summary:serializers_value(s.summary)}),ie.setMetaProperty(\"ref-origin\",B.uri),ie.setMetaProperty(\"ref-referencing-element-id\",cloneDeep(cx.identify(s))),Mu(V)&&Mu(ie)&&(s.hasKey(\"description\")&&\"description\"in V&&(ie.remove(\"description\"),ie.set(\"description\",s.get(\"description\"))),s.hasKey(\"summary\")&&\"summary\"in V&&(ie.remove(\"summary\"),ie.set(\"summary\",s.get(\"summary\")))),_.replaceWith(ie,mutationReplacer),!i&&ie}async PathItemElement(s,o,i,a,u,_){if(!Pu(s.$ref))return;if(this.indirections.includes(s))return!1;const[w,x]=this.toAncestorLineage([...u,i]),C=this.toBaseURI(serializers_value(s.$ref)),j=stripHash(this.reference.uri)===C,L=!j;if(!this.options.resolve.internal&&j)return;if(!this.options.resolve.external&&L)return;const B=await this.toReference(serializers_value(s.$ref)),$=resolve(C,serializers_value(s.$ref));this.indirections.push(s);const U=fromURIReference($);let V=apidom_evaluate(B.value.result,U);if(V.id=cx.identify(V),isPrimitiveElement(V)){const s=`path-item-${serializers_value(cx.identify(V))}`;this.refractCache.has(s)?V=this.refractCache.get(s):(V=Lb.refract(V),this.refractCache.set(s,V))}if(s===V)throw new Ko(\"Recursive Path Item Object reference detected\");if(this.indirections.length>this.options.dereference.maxDepth)throw new sx(`Maximum dereference depth of \"${this.options.dereference.maxDepth}\" has been exceeded in file \"${this.reference.uri}\"`);if(w.includes(V)){if(B.refSet.circular=!0,\"error\"===this.options.dereference.circular)throw new Ko(\"Circular reference detected\");if(\"replace\"===this.options.dereference.circular){var z,Y;const o=new Su.sI(V.id,{type:\"path-item\",uri:B.uri,$ref:serializers_value(s.$ref)}),a=(null!==(z=null===(Y=this.options.dereference.strategyOpts[\"openapi-3-1\"])||void 0===Y?void 0:Y.circularReplacer)&&void 0!==z?z:this.options.dereference.circularReplacer)(o);return _.replaceWith(a,mutationReplacer),!i&&a}}const Z=stripHash(B.refSet.rootRef.uri)!==B.uri,ee=[\"error\",\"replace\"].includes(this.options.dereference.circular);if((L||Z||iE(V)&&Pu(V.$ref)||ee)&&!w.includesCycle(V)){x.add(s);const o=new OpenAPI3_1DereferenceVisitor({reference:B,namespace:this.namespace,indirections:[...this.indirections],options:this.options,refractCache:this.refractCache,ancestors:w,allOfDiscriminatorMapping:this.allOfDiscriminatorMapping});V=await ax(V,o,{keyMap:pw,nodeTypeGetter:apidom_ns_openapi_3_1_src_traversal_visitor_getNodeType}),x.delete(s)}if(this.indirections.pop(),iE(V)){const o=new Lb([...V.content],cloneDeep(V.meta),cloneDeep(V.attributes));o.setMetaProperty(\"id\",cx.generateId()),s.forEach(((s,i,a)=>{o.remove(serializers_value(i)),o.content.push(a)})),o.remove(\"$ref\"),o.setMetaProperty(\"ref-fields\",{$ref:serializers_value(s.$ref)}),o.setMetaProperty(\"ref-origin\",B.uri),o.setMetaProperty(\"ref-referencing-element-id\",cloneDeep(cx.identify(s))),V=o}return _.replaceWith(V,mutationReplacer),i?void 0:V}async LinkElement(s,o,i,a,u,_){if(!Pu(s.operationRef)&&!Pu(s.operationId))return;if(Pu(s.operationRef)&&Pu(s.operationId))throw new Ko(\"LinkElement operationRef and operationId fields are mutually exclusive.\");let w;if(Pu(s.operationRef)){var x;const o=fromURIReference(serializers_value(s.operationRef)),a=this.toBaseURI(serializers_value(s.operationRef)),u=stripHash(this.reference.uri)===a,C=!u;if(!this.options.resolve.internal&&u)return;if(!this.options.resolve.external&&C)return;const j=await this.toReference(serializers_value(s.operationRef));if(w=apidom_evaluate(j.value.result,o),isPrimitiveElement(w)){const s=`operation-${serializers_value(cx.identify(w))}`;this.refractCache.has(s)?w=this.refractCache.get(s):(w=Mb.refract(w),this.refractCache.set(s,w))}w=cloneShallow(w),w.setMetaProperty(\"ref-origin\",j.uri);const L=cloneShallow(s);return null===(x=L.operationRef)||void 0===x||x.meta.set(\"operation\",w),_.replaceWith(L,mutationReplacer),i?void 0:L}if(Pu(s.operationId)){var C;const o=serializers_value(s.operationId),a=await this.toReference(unsanitize(this.reference.uri));if(w=traversal_find((s=>sE(s)&&ju(s.operationId)&&s.operationId.equals(o)),a.value.result),bc(w))throw new Ko(`OperationElement(operationId=${o}) not found.`);const u=cloneShallow(s);return null===(C=u.operationId)||void 0===C||C.meta.set(\"operation\",w),_.replaceWith(u,mutationReplacer),i?void 0:u}}async ExampleElement(s,o,i,a,u,_){if(!Pu(s.externalValue))return;if(s.hasKey(\"value\")&&Pu(s.externalValue))throw new Ko(\"ExampleElement value and externalValue fields are mutually exclusive.\");const w=this.toBaseURI(serializers_value(s.externalValue)),x=stripHash(this.reference.uri)===w,C=!x;if(!this.options.resolve.internal&&x)return;if(!this.options.resolve.external&&C)return;const j=await this.toReference(serializers_value(s.externalValue)),L=cloneShallow(j.value.result);L.setMetaProperty(\"ref-origin\",j.uri);const B=cloneShallow(s);return B.value=L,_.replaceWith(B,mutationReplacer),i?void 0:B}async MemberElement(s,o,i,a,u,_){var w;const x=u[u.length-1];if(!Mu(x)||!x.classes.contains(\"discriminator-mapping\"))return;if(null===(w=this.options.dereference.strategyOpts[\"openapi-3-1\"])||void 0===w||!w.dereferenceDiscriminatorMapping)return!1;if(!Pu(s.key)||!Pu(s.value))return!1;if(this.indirections.includes(s))return!1;this.indirections.push(s);const[C,j]=this.toAncestorLineage([...u,i]),L=[...j].findLast(hE),B=cloneDeep(L.getMetaProperty(\"ancestorsSchemaIdentifiers\")),$=serializers_value(s.value),U=/^[a-zA-Z0-9\\\\.\\\\-_]+$/.test($)?`#/components/schemas/${$}`:$,V=new pS({$ref:U});V.setMetaProperty(\"ancestorsSchemaIdentifiers\",B),j.add(V);const z=new OpenAPI3_1DereferenceVisitor({reference:this.reference,namespace:this.namespace,indirections:[...this.indirections],options:this.options,refractCache:this.refractCache,ancestors:C,allOfDiscriminatorMapping:this.allOfDiscriminatorMapping}),Y=await ax(V,z,{keyMap:pw,nodeTypeGetter:apidom_ns_openapi_3_1_src_traversal_visitor_getNodeType});j.delete(V),this.indirections.pop();const Z=cloneShallow(s);return Z.value.setMetaProperty(\"ref-schema\",Y),_.replaceWith(Z,mutationReplacer),i?void 0:Z}async SchemaElement(s,o,i,a,u,_){if(!Pu(s.$ref))return;if(this.indirections.includes(s))return!1;const[w,x]=this.toAncestorLineage([...u,i]);let C=await this.toReference(unsanitize(this.reference.uri)),{uri:j}=C;const L=resolveSchema$refField(j,s),B=stripHash(L),$=new Ow({uri:B}),U=Gw((s=>s.canRead($)),this.options.resolve.resolvers),V=!U;let z,Y=stripHash(this.reference.uri)===L,Z=!Y;this.indirections.push(s);try{if(U||V){j=this.toBaseURI(L);const s=L,o=maybeRefractToSchemaElement(C.value.result);if(z=uri_evaluate(s,o),z=maybeRefractToSchemaElement(z),z.id=cx.identify(z),!this.options.resolve.internal&&Y)return;if(!this.options.resolve.external&&Z)return}else{if(j=this.toBaseURI(L),Y=stripHash(this.reference.uri)===j,Z=!Y,!this.options.resolve.internal&&Y)return;if(!this.options.resolve.external&&Z)return;C=await this.toReference(unsanitize(L));const s=fromURIReference(L),o=maybeRefractToSchemaElement(C.value.result);z=apidom_evaluate(o,s),z=maybeRefractToSchemaElement(z),z.id=cx.identify(z)}}catch(s){if(!(V&&s instanceof nx))throw s;if(isAnchor(uriToAnchor(L))){if(Y=stripHash(this.reference.uri)===j,Z=!Y,!this.options.resolve.internal&&Y)return;if(!this.options.resolve.external&&Z)return;C=await this.toReference(unsanitize(L));const s=uriToAnchor(L),o=maybeRefractToSchemaElement(C.value.result);z=$anchor_evaluate(s,o),z=maybeRefractToSchemaElement(z),z.id=cx.identify(z)}else{if(j=this.toBaseURI(L),Y=stripHash(this.reference.uri)===j,Z=!Y,!this.options.resolve.internal&&Y)return;if(!this.options.resolve.external&&Z)return;C=await this.toReference(unsanitize(L));const s=fromURIReference(L),o=maybeRefractToSchemaElement(C.value.result);z=apidom_evaluate(o,s),z=maybeRefractToSchemaElement(z),z.id=cx.identify(z)}}if(s===z)throw new Ko(\"Recursive Schema Object reference detected\");if(this.indirections.length>this.options.dereference.maxDepth)throw new sx(`Maximum dereference depth of \"${this.options.dereference.maxDepth}\" has been exceeded in file \"${this.reference.uri}\"`);if(w.includes(z)){if(C.refSet.circular=!0,\"error\"===this.options.dereference.circular)throw new Ko(\"Circular reference detected\");if(\"replace\"===this.options.dereference.circular){var ee,ie;const o=new Su.sI(z.id,{type:\"json-schema\",uri:C.uri,$ref:serializers_value(s.$ref)}),a=(null!==(ee=null===(ie=this.options.dereference.strategyOpts[\"openapi-3-1\"])||void 0===ie?void 0:ie.circularReplacer)&&void 0!==ee?ee:this.options.dereference.circularReplacer)(o);return _.replaceWith(a,mutationReplacer),!i&&a}}const ae=stripHash(C.refSet.rootRef.uri)!==C.uri,ce=[\"error\",\"replace\"].includes(this.options.dereference.circular);if((Z||ae||hE(z)&&Pu(z.$ref)||ce)&&!w.includesCycle(z)){x.add(s);const o=new OpenAPI3_1DereferenceVisitor({reference:C,namespace:this.namespace,indirections:[...this.indirections],options:this.options,refractCache:this.refractCache,ancestors:w,allOfDiscriminatorMapping:this.allOfDiscriminatorMapping});z=await ax(z,o,{keyMap:pw,nodeTypeGetter:apidom_ns_openapi_3_1_src_traversal_visitor_getNodeType}),x.delete(s)}if(this.indirections.pop(),predicates_isBooleanJsonSchemaElement(z)){const o=cloneDeep(z);return o.setMetaProperty(\"id\",cx.generateId()),o.setMetaProperty(\"ref-fields\",{$ref:serializers_value(s.$ref),$refBaseURI:L}),o.setMetaProperty(\"ref-origin\",C.uri),o.setMetaProperty(\"ref-referencing-element-id\",cloneDeep(cx.identify(s))),_.replaceWith(o,mutationReplacer),!i&&o}if(hE(z)){var le;const o=new pS([...z.content],cloneDeep(z.meta),cloneDeep(z.attributes));if(o.setMetaProperty(\"id\",cx.generateId()),s.forEach(((s,i,a)=>{o.remove(serializers_value(i)),o.content.push(a)})),o.remove(\"$ref\"),o.setMetaProperty(\"ref-fields\",{$ref:serializers_value(s.$ref),$refBaseURI:L}),o.setMetaProperty(\"ref-origin\",C.uri),o.setMetaProperty(\"ref-referencing-element-id\",cloneDeep(cx.identify(s))),null!==(le=this.options.dereference.strategyOpts[\"openapi-3-1\"])&&void 0!==le&&le.dereferenceDiscriminatorMapping){var pe;const s=u[u.length-1],i=[...x].findLast(hE),a=null==i?void 0:i.getMetaProperty(\"schemaName\"),_=serializers_value(o.getMetaProperty(\"schemaName\"));if(_&&a&&null!=s&&null!==(pe=s.classes)&&void 0!==pe&&pe.contains(\"json-schema-allOf\")){var de;const s=null!==(de=this.allOfDiscriminatorMapping.get(_))&&void 0!==de?de:[];s.push(i),this.allOfDiscriminatorMapping.set(_,s)}}z=o}return _.replaceWith(z,mutationReplacer),i?void 0:z}}const lx=OpenAPI3_1DereferenceVisitor,ux=visitor_visit[Symbol.for(\"nodejs.util.promisify.custom\")];const px=class OpenAPI3_1DereferenceStrategy extends Kw{constructor(s){super({...null!=s?s:{},name:\"openapi-3-1\"})}canDereference(s){var o;return\"text/plain\"!==s.mediaType?gw.includes(s.mediaType):nE(null===(o=s.parseResult)||void 0===o?void 0:o.result)}async dereference(s,o){var i;const a=createNamespace(dw),u=null!==(i=o.dereference.refSet)&&void 0!==i?i:new vw,_=new vw;let w,x=u;u.has(s.uri)?w=u.find(Hw(s.uri,\"uri\")):(w=new yw({uri:s.uri,value:s.parseResult}),u.add(w)),o.dereference.immutable&&(u.refs.map((s=>new yw({...s,value:cloneDeep(s.value)}))).forEach((s=>_.add(s))),w=_.find((o=>o.uri===s.uri)),x=_);const C=new lx({reference:w,namespace:a,options:o}),j=await ux(x.rootRef.value,C,{keyMap:pw,nodeTypeGetter:apidom_ns_openapi_3_1_src_traversal_visitor_getNodeType});return o.dereference.immutable&&_.refs.filter((s=>s.uri.startsWith(\"immutable://\"))).map((s=>new yw({...s,uri:s.uri.replace(/^immutable:\\/\\//,\"\")}))).forEach((s=>u.add(s))),null===o.dereference.refSet&&u.clean(),_.clean(),j}},to_path=s=>{const o=(s=>s.slice(2))(s);return o.reduce(((s,i,a)=>{if(Du(i)){const o=String(serializers_value(i.key));s.push(o)}else if(Ru(o[a-2])){const u=o[a-2].content.indexOf(i);s.push(u)}return s}),[])};const hx=class ModelPropertyMacroVisitor{modelPropertyMacro;options;SchemaElement={leave:(s,o,i,a,u)=>{void 0!==s.properties&&Mu(s.properties)&&s.properties.forEach((o=>{if(Mu(o))try{const s=this.modelPropertyMacro(serializers_value(o));o.set(\"default\",s)}catch(o){var a,_;const w=new Error(o,{cause:o});w.fullPath=[...to_path([...u,i,s]),\"properties\"],null===(a=this.options.dereference.dereferenceOpts)||void 0===a||null===(a=a.errors)||void 0===a||null===(_=a.push)||void 0===_||_.call(a,w)}}))}};constructor({modelPropertyMacro:s,options:o}){this.modelPropertyMacro=s,this.options=o}};var dx=function(){function XUniqWith(s,o){this.xf=o,this.pred=s,this.items=[]}return XUniqWith.prototype[\"@@transducer/init\"]=_xfBase_init,XUniqWith.prototype[\"@@transducer/result\"]=_xfBase_result,XUniqWith.prototype[\"@@transducer/step\"]=function(s,o){return _includesWith(this.pred,o,this.items)?s:(this.items.push(o),this.xf[\"@@transducer/step\"](s,o))},XUniqWith}();function _xuniqWith(s){return function(o){return new dx(s,o)}}var fx=_curry2(_dispatchable([],_xuniqWith,(function(s,o){for(var i,a=0,u=o.length,_=[];a<u;)_includesWith(s,i=o[a],_)||(_[_.length]=i),a+=1;return _})));const mx=fx;const gx=class all_of_AllOfVisitor{options;SchemaElement={leave(s,o,i,a,u){if(void 0===s.allOf)return;if(!Ru(s.allOf)){var _,w;const o=new TypeError(\"allOf must be an array\");return o.fullPath=[...to_path([...u,i,s]),\"allOf\"],void(null===(_=this.options.dereference.dereferenceOpts)||void 0===_||null===(_=_.errors)||void 0===_||null===(w=_.push)||void 0===w||w.call(_,o))}if(s.allOf.isEmpty)return void s.remove(\"allOf\");if(!s.allOf.content.every(hE)){var x,C;const o=new TypeError(\"Elements in allOf must be objects\");return o.fullPath=[...to_path([...u,i,s]),\"allOf\"],void(null===(x=this.options.dereference.dereferenceOpts)||void 0===x||null===(x=x.errors)||void 0===x||null===(C=x.push)||void 0===C||C.call(x,o))}for(;s.hasKey(\"allOf\");){const{allOf:o}=s;s.remove(\"allOf\");const i=yd.all([...o.content,s],{customMerge:s=>\"enum\"===serializers_value(s)?(s,o)=>{if(includesClasses([\"json-schema-enum\"],s)&&includesClasses([\"json-schema-enum\"],o)){const areElementsEqual=(s,o)=>!(Ru(s)||Ru(o)||Mu(s)||Mu(o))&&s.equals(serializers_value(o)),i=cloneShallow(s);return i.content=mx(areElementsEqual)([...s.content,...o.content]),i}return yd(s,o)}:yd});if(s.hasKey(\"$$ref\")||i.remove(\"$$ref\"),s.hasKey(\"example\")){const o=i.getMember(\"example\");o&&(o.value=s.get(\"example\"))}if(s.hasKey(\"examples\")){const o=i.getMember(\"examples\");o&&(o.value=s.get(\"examples\"))}s.content=i.content}}};constructor({options:s}){this.options=s}};const yx=class ParameterMacroVisitor{parameterMacro;options;#n;OperationElement={enter:s=>{this.#n=s},leave:()=>{this.#n=void 0}};ParameterElement={leave:(s,o,i,a,u)=>{const _=this.#n?serializers_value(this.#n):null,w=serializers_value(s);try{const o=this.parameterMacro(_,w);s.set(\"default\",o)}catch(s){var x,C;const o=new Error(s,{cause:s});o.fullPath=to_path([...u,i]),null===(x=this.options.dereference.dereferenceOpts)||void 0===x||null===(x=x.errors)||void 0===x||null===(C=x.push)||void 0===C||C.call(x,o)}}};constructor({parameterMacro:s,options:o}){this.parameterMacro=s,this.options=o}},get_root_cause=s=>{if(null==s.cause)return s;let{cause:o}=s;for(;null!=o.cause;)o=o.cause;return o};const vx=class SchemaRefError extends Go{},{wrapError:bx}=Xl,_x=visitor_visit[Symbol.for(\"nodejs.util.promisify.custom\")],Sx=new IdentityManager,dereference_mutationReplacer=(s,o,i,a)=>{Du(a)?a.value=s:Array.isArray(a)&&(a[i]=s)};class OpenAPI3_1SwaggerClientDereferenceVisitor extends lx{useCircularStructures;allowMetaPatches;basePath;constructor({allowMetaPatches:s=!0,useCircularStructures:o=!1,basePath:i=null,...a}){super(a),this.allowMetaPatches=s,this.useCircularStructures=o,this.basePath=i}async ReferenceElement(s,o,i,a,u,_){try{if(this.indirections.includes(s))return!1;const[o,a]=this.toAncestorLineage([...u,i]),j=this.toBaseURI(serializers_value(s.$ref)),L=stripHash(this.reference.uri)===j,B=!L;if(!this.options.resolve.internal&&L)return!1;if(!this.options.resolve.external&&B)return!1;const $=await this.toReference(serializers_value(s.$ref)),U=resolve(j,serializers_value(s.$ref));this.indirections.push(s);const V=fromURIReference(U);let z=apidom_evaluate($.value.result,V);if(z.id=Sx.identify(z),isPrimitiveElement(z)){const o=serializers_value(s.meta.get(\"referenced-element\")),i=`${o}-${serializers_value(Sx.identify(z))}`;if(this.refractCache.has(i))z=this.refractCache.get(i);else if(isReferenceLikeElement(z))z=zb.refract(z),z.setMetaProperty(\"referenced-element\",o),this.refractCache.set(i,z);else{z=this.namespace.getElementClass(o).refract(z),this.refractCache.set(i,z)}}if(s===z)throw new Ko(\"Recursive Reference Object detected\");if(this.indirections.length>this.options.dereference.maxDepth)throw new sx(`Maximum dereference depth of \"${this.options.dereference.maxDepth}\" has been exceeded in file \"${this.reference.uri}\"`);if(o.includes(z)){if($.refSet.circular=!0,\"error\"===this.options.dereference.circular)throw new Ko(\"Circular reference detected\");if(\"replace\"===this.options.dereference.circular){var w,x;const o=new Su.sI(z.id,{type:\"reference\",uri:$.uri,$ref:serializers_value(s.$ref),baseURI:U,referencingElement:s}),a=(null!==(w=null===(x=this.options.dereference.strategyOpts[\"openapi-3-1\"])||void 0===x?void 0:x.circularReplacer)&&void 0!==w?w:this.options.dereference.circularReplacer)(o);return _.replaceWith(o,dereference_mutationReplacer),!i&&a}}const Y=stripHash($.refSet.rootRef.uri)!==$.uri,Z=[\"error\",\"replace\"].includes(this.options.dereference.circular);if((B||Y||cE(z)||Z)&&!o.includesCycle(z)){var C;a.add(s);const _=new OpenAPI3_1SwaggerClientDereferenceVisitor({reference:$,namespace:this.namespace,indirections:[...this.indirections],options:this.options,refractCache:this.refractCache,ancestors:o,allowMetaPatches:this.allowMetaPatches,useCircularStructures:this.useCircularStructures,basePath:null!==(C=this.basePath)&&void 0!==C?C:[...to_path([...u,i,s]),\"$ref\"]});z=await _x(z,_,{keyMap:pw,nodeTypeGetter:apidom_ns_openapi_3_1_src_traversal_visitor_getNodeType}),a.delete(s)}this.indirections.pop();const ee=cloneShallow(z);if(ee.setMetaProperty(\"ref-fields\",{$ref:serializers_value(s.$ref),description:serializers_value(s.description),summary:serializers_value(s.summary)}),ee.setMetaProperty(\"ref-origin\",$.uri),ee.setMetaProperty(\"ref-referencing-element-id\",cloneDeep(Sx.identify(s))),Mu(z)&&(s.hasKey(\"description\")&&\"description\"in z&&(ee.remove(\"description\"),ee.set(\"description\",s.get(\"description\"))),s.hasKey(\"summary\")&&\"summary\"in z&&(ee.remove(\"summary\"),ee.set(\"summary\",s.get(\"summary\")))),this.allowMetaPatches&&Mu(ee)&&!ee.hasKey(\"$$ref\")){const s=resolve(j,U);ee.set(\"$$ref\",s)}return _.replaceWith(ee,dereference_mutationReplacer),!i&&ee}catch(o){var j,L,B;const a=get_root_cause(o),_=bx(a,{baseDoc:this.reference.uri,$ref:serializers_value(s.$ref),pointer:fromURIReference(serializers_value(s.$ref)),fullPath:null!==(j=this.basePath)&&void 0!==j?j:[...to_path([...u,i,s]),\"$ref\"]});return void(null===(L=this.options.dereference.dereferenceOpts)||void 0===L||null===(L=L.errors)||void 0===L||null===(B=L.push)||void 0===B||B.call(L,_))}}async PathItemElement(s,o,i,a,u,_){try{if(!Pu(s.$ref))return;if(this.indirections.includes(s))return!1;if(includesClasses([\"cycle\"],s.$ref))return!1;const[o,a]=this.toAncestorLineage([...u,i]),j=this.toBaseURI(serializers_value(s.$ref)),L=stripHash(this.reference.uri)===j,B=!L;if(!this.options.resolve.internal&&L)return;if(!this.options.resolve.external&&B)return;const $=await this.toReference(serializers_value(s.$ref)),U=resolve(j,serializers_value(s.$ref));this.indirections.push(s);const V=fromURIReference(U);let z=apidom_evaluate($.value.result,V);if(z.id=Sx.identify(z),isPrimitiveElement(z)){const s=`path-item-${serializers_value(Sx.identify(z))}`;this.refractCache.has(s)?z=this.refractCache.get(s):(z=Lb.refract(z),this.refractCache.set(s,z))}if(s===z)throw new Ko(\"Recursive Path Item Object reference detected\");if(this.indirections.length>this.options.dereference.maxDepth)throw new sx(`Maximum dereference depth of \"${this.options.dereference.maxDepth}\" has been exceeded in file \"${this.reference.uri}\"`);if(o.includes(z)){if($.refSet.circular=!0,\"error\"===this.options.dereference.circular)throw new Ko(\"Circular reference detected\");if(\"replace\"===this.options.dereference.circular){var w,x;const o=new Su.sI(z.id,{type:\"path-item\",uri:$.uri,$ref:serializers_value(s.$ref),baseURI:U,referencingElement:s}),a=(null!==(w=null===(x=this.options.dereference.strategyOpts[\"openapi-3-1\"])||void 0===x?void 0:x.circularReplacer)&&void 0!==w?w:this.options.dereference.circularReplacer)(o);return _.replaceWith(o,dereference_mutationReplacer),!i&&a}}const Y=stripHash($.refSet.rootRef.uri)!==$.uri,Z=[\"error\",\"replace\"].includes(this.options.dereference.circular);if((B||Y||iE(z)&&Pu(z.$ref)||Z)&&!o.includesCycle(z)){var C;a.add(s);const _=new OpenAPI3_1SwaggerClientDereferenceVisitor({reference:$,namespace:this.namespace,indirections:[...this.indirections],options:this.options,ancestors:o,allowMetaPatches:this.allowMetaPatches,useCircularStructures:this.useCircularStructures,basePath:null!==(C=this.basePath)&&void 0!==C?C:[...to_path([...u,i,s]),\"$ref\"]});z=await _x(z,_,{keyMap:pw,nodeTypeGetter:apidom_ns_openapi_3_1_src_traversal_visitor_getNodeType}),a.delete(s)}if(this.indirections.pop(),iE(z)){const o=new Lb([...z.content],cloneDeep(z.meta),cloneDeep(z.attributes));if(s.forEach(((s,i,a)=>{o.remove(serializers_value(i)),o.content.push(a)})),o.remove(\"$ref\"),o.setMetaProperty(\"ref-fields\",{$ref:serializers_value(s.$ref)}),o.setMetaProperty(\"ref-origin\",$.uri),o.setMetaProperty(\"ref-referencing-element-id\",cloneDeep(Sx.identify(s))),this.allowMetaPatches&&void 0===o.get(\"$$ref\")){const s=resolve(j,U);o.set(\"$$ref\",s)}z=o}return _.replaceWith(z,dereference_mutationReplacer),i?void 0:z}catch(o){var j,L,B;const a=get_root_cause(o),_=bx(a,{baseDoc:this.reference.uri,$ref:serializers_value(s.$ref),pointer:fromURIReference(serializers_value(s.$ref)),fullPath:null!==(j=this.basePath)&&void 0!==j?j:[...to_path([...u,i,s]),\"$ref\"]});return void(null===(L=this.options.dereference.dereferenceOpts)||void 0===L||null===(L=L.errors)||void 0===L||null===(B=L.push)||void 0===B||B.call(L,_))}}async SchemaElement(s,o,i,a,u,_){try{if(!Pu(s.$ref))return;if(this.indirections.includes(s))return!1;const[o,a]=this.toAncestorLineage([...u,i]);let j=await this.toReference(unsanitize(this.reference.uri)),{uri:L}=j;const B=resolveSchema$refField(L,s),$=stripHash(B),U=new Ow({uri:$}),V=!this.options.resolve.resolvers.some((s=>s.canRead(U))),z=!V;let Y,Z=stripHash(this.reference.uri)===B,ee=!Z;this.indirections.push(s);try{if(V||z){L=this.toBaseURI(B);const s=B,o=maybeRefractToSchemaElement(j.value.result);if(Y=uri_evaluate(s,o),Y=maybeRefractToSchemaElement(Y),Y.id=Sx.identify(Y),!this.options.resolve.internal&&Z)return;if(!this.options.resolve.external&&ee)return}else{if(L=this.toBaseURI(B),Z=stripHash(this.reference.uri)===L,ee=!Z,!this.options.resolve.internal&&Z)return;if(!this.options.resolve.external&&ee)return;j=await this.toReference(unsanitize(B));const s=fromURIReference(B),o=maybeRefractToSchemaElement(j.value.result);Y=apidom_evaluate(o,s),Y=maybeRefractToSchemaElement(Y),Y.id=Sx.identify(Y)}}catch(s){if(!(z&&s instanceof nx))throw s;if(isAnchor(uriToAnchor(B))){if(Z=stripHash(this.reference.uri)===L,ee=!Z,!this.options.resolve.internal&&Z)return;if(!this.options.resolve.external&&ee)return;j=await this.toReference(unsanitize(B));const s=uriToAnchor(B),o=maybeRefractToSchemaElement(j.value.result);Y=$anchor_evaluate(s,o),Y=maybeRefractToSchemaElement(Y),Y.id=Sx.identify(Y)}else{if(L=this.toBaseURI(serializers_value(B)),Z=stripHash(this.reference.uri)===L,ee=!Z,!this.options.resolve.internal&&Z)return;if(!this.options.resolve.external&&ee)return;j=await this.toReference(unsanitize(B));const s=fromURIReference(B),o=maybeRefractToSchemaElement(j.value.result);Y=apidom_evaluate(o,s),Y=maybeRefractToSchemaElement(Y),Y.id=Sx.identify(Y)}}if(s===Y)throw new Ko(\"Recursive Schema Object reference detected\");if(this.indirections.length>this.options.dereference.maxDepth)throw new sx(`Maximum dereference depth of \"${this.options.dereference.maxDepth}\" has been exceeded in file \"${this.reference.uri}\"`);if(o.includes(Y)){if(j.refSet.circular=!0,\"error\"===this.options.dereference.circular)throw new Ko(\"Circular reference detected\");if(\"replace\"===this.options.dereference.circular){var w,x;const o=new Su.sI(Y.id,{type:\"json-schema\",uri:j.uri,$ref:serializers_value(s.$ref),baseURI:resolve(L,B),referencingElement:s}),a=(null!==(w=null===(x=this.options.dereference.strategyOpts[\"openapi-3-1\"])||void 0===x?void 0:x.circularReplacer)&&void 0!==w?w:this.options.dereference.circularReplacer)(o);return _.replaceWith(a,dereference_mutationReplacer),!i&&a}}const ie=stripHash(j.refSet.rootRef.uri)!==j.uri,ae=[\"error\",\"replace\"].includes(this.options.dereference.circular);if((ee||ie||hE(Y)&&Pu(Y.$ref)||ae)&&!o.includesCycle(Y)){var C;a.add(s);const _=new OpenAPI3_1SwaggerClientDereferenceVisitor({reference:j,namespace:this.namespace,indirections:[...this.indirections],options:this.options,useCircularStructures:this.useCircularStructures,allowMetaPatches:this.allowMetaPatches,ancestors:o,basePath:null!==(C=this.basePath)&&void 0!==C?C:[...to_path([...u,i,s]),\"$ref\"]});Y=await _x(Y,_,{keyMap:pw,nodeTypeGetter:apidom_ns_openapi_3_1_src_traversal_visitor_getNodeType}),a.delete(s)}if(this.indirections.pop(),predicates_isBooleanJsonSchemaElement(Y)){const o=cloneDeep(Y);return o.setMetaProperty(\"ref-fields\",{$ref:serializers_value(s.$ref)}),o.setMetaProperty(\"ref-origin\",j.uri),o.setMetaProperty(\"ref-referencing-element-id\",cloneDeep(Sx.identify(s))),_.replaceWith(o,dereference_mutationReplacer),!i&&o}if(hE(Y)){const o=new pS([...Y.content],cloneDeep(Y.meta),cloneDeep(Y.attributes));if(s.forEach(((s,i,a)=>{o.remove(serializers_value(i)),o.content.push(a)})),o.remove(\"$ref\"),o.setMetaProperty(\"ref-fields\",{$ref:serializers_value(s.$ref)}),o.setMetaProperty(\"ref-origin\",j.uri),o.setMetaProperty(\"ref-referencing-element-id\",cloneDeep(Sx.identify(s))),this.allowMetaPatches&&void 0===o.get(\"$$ref\")){const s=resolve(L,B);o.set(\"$$ref\",s)}Y=o}return _.replaceWith(Y,dereference_mutationReplacer),i?void 0:Y}catch(o){var j,L,B;const a=get_root_cause(o),_=new vx(`Could not resolve reference: ${a.message}`,{baseDoc:this.reference.uri,$ref:serializers_value(s.$ref),fullPath:null!==(j=this.basePath)&&void 0!==j?j:[...to_path([...u,i,s]),\"$ref\"],cause:a});return void(null===(L=this.options.dereference.dereferenceOpts)||void 0===L||null===(L=L.errors)||void 0===L||null===(B=L.push)||void 0===B||B.call(L,_))}}async LinkElement(){}async ExampleElement(s,o,i,a,u,_){try{return await super.ExampleElement(s,o,i,a,u,_)}catch(o){var w,x,C;const a=get_root_cause(o),_=bx(a,{baseDoc:this.reference.uri,externalValue:serializers_value(s.externalValue),fullPath:null!==(w=this.basePath)&&void 0!==w?w:[...to_path([...u,i,s]),\"externalValue\"]});return void(null===(x=this.options.dereference.dereferenceOpts)||void 0===x||null===(x=x.errors)||void 0===x||null===(C=x.push)||void 0===C||C.call(x,_))}}}const Ex=OpenAPI3_1SwaggerClientDereferenceVisitor,wx=mergeAll[Symbol.for(\"nodejs.util.promisify.custom\")];const xx=class RootVisitor{constructor({parameterMacro:s,modelPropertyMacro:o,mode:i,options:a,...u}){const _=[];_.push(new Ex({...u,options:a})),\"function\"==typeof o&&_.push(new hx({modelPropertyMacro:o,options:a})),\"strict\"!==i&&_.push(new gx({options:a})),\"function\"==typeof s&&_.push(new yx({parameterMacro:s,options:a}));const w=wx(_,{nodeTypeGetter:apidom_ns_openapi_3_1_src_traversal_visitor_getNodeType});Object.assign(this,w)}},kx=visitor_visit[Symbol.for(\"nodejs.util.promisify.custom\")];const Ox=class OpenAPI3_1SwaggerClientDereferenceStrategy extends px{allowMetaPatches;parameterMacro;modelPropertyMacro;mode;ancestors;constructor({allowMetaPatches:s=!1,parameterMacro:o=null,modelPropertyMacro:i=null,mode:a=\"non-strict\",ancestors:u=[],..._}={}){super({..._}),this.name=\"openapi-3-1-swagger-client\",this.allowMetaPatches=s,this.parameterMacro=o,this.modelPropertyMacro=i,this.mode=a,this.ancestors=[...u]}async dereference(s,o){var i;const a=createNamespace(dw),u=null!==(i=o.dereference.refSet)&&void 0!==i?i:new vw,_=new vw;let w,x=u;u.has(s.uri)?w=u.find((o=>o.uri===s.uri)):(w=new yw({uri:s.uri,value:s.parseResult}),u.add(w)),o.dereference.immutable&&(u.refs.map((s=>new yw({...s,value:cloneDeep(s.value)}))).forEach((s=>_.add(s))),w=_.find((o=>o.uri===s.uri)),x=_);const C=new xx({reference:w,namespace:a,options:o,allowMetaPatches:this.allowMetaPatches,ancestors:this.ancestors,modelPropertyMacro:this.modelPropertyMacro,mode:this.mode,parameterMacro:this.parameterMacro}),j=await kx(x.rootRef.value,C,{keyMap:pw,nodeTypeGetter:apidom_ns_openapi_3_1_src_traversal_visitor_getNodeType});return o.dereference.immutable&&_.refs.filter((s=>s.uri.startsWith(\"immutable://\"))).map((s=>new yw({...s,uri:s.uri.replace(/^immutable:\\/\\//,\"\")}))).forEach((s=>u.add(s))),null===o.dereference.refSet&&u.clean(),_.clean(),j}},circularReplacer=s=>{const o=serializers_value(s.meta.get(\"baseURI\")),i=s.meta.get(\"referencingElement\");return new Su.Sh({$ref:o},cloneDeep(i.meta),cloneDeep(i.attributes))},resolveOpenAPI31Strategy=async s=>{const{spec:o,timeout:i,redirects:a,requestInterceptor:u,responseInterceptor:_,pathDiscriminator:w=[],allowMetaPatches:x=!1,useCircularStructures:C=!1,skipNormalization:j=!1,parameterMacro:L=null,modelPropertyMacro:B=null,mode:$=\"non-strict\",strategies:U}=s;try{const{cache:V}=resolveOpenAPI31Strategy,z=U.find((s=>s.match(o))),Y=isHttpUrl(url_cwd())?url_cwd():Ll,Z=options_retrievalURI(s),ee=resolve(Y,Z);let ie;V.has(o)?ie=V.get(o):(ie=Ib.refract(o),ie.classes.push(\"result\"),V.set(o,ie));const ae=new Au([ie]),ce=es_compile(w),le=\"\"===ce?\"\":`#${ce}`,pe=apidom_evaluate(ie,ce),de=new yw({uri:ee,value:ae}),fe=new vw({refs:[de]});\"\"!==ce&&(fe.rootRef=void 0);const ye=[new Set([pe])],be=[],_e=await(async(s,o={})=>{const i=util_merge(_w,o);return dereferenceApiDOM(s,i)})(pe,{resolve:{baseURI:`${ee}${le}`,resolvers:[new Uw({timeout:i||1e4,redirects:a||10})],resolverOpts:{swaggerHTTPClientConfig:{requestInterceptor:u,responseInterceptor:_}},strategies:[new Rw]},parse:{mediaType:gw.latest(),parsers:[new Ww({allowEmpty:!1,sourceMap:!1}),new Jw({allowEmpty:!1,sourceMap:!1}),new Vw({allowEmpty:!1,sourceMap:!1}),new zw({allowEmpty:!1,sourceMap:!1}),new Nw({allowEmpty:!1,sourceMap:!1})]},dereference:{maxDepth:100,strategies:[new Ox({allowMetaPatches:x,useCircularStructures:C,parameterMacro:L,modelPropertyMacro:B,mode:$,ancestors:ye})],refSet:fe,dereferenceOpts:{errors:be},immutable:!1,circular:C?\"ignore\":\"replace\",circularReplacer:C?_w.dereference.circularReplacer:circularReplacer}}),Se=((s,o,i)=>new gp({element:i}).transclude(s,o))(pe,_e,ie),we=j?Se:z.normalize(Se);return{spec:serializers_value(we),errors:be}}catch(s){if(s instanceof Lp)return{spec:o,errors:[]};throw s}};resolveOpenAPI31Strategy.cache=new WeakMap;const Ax=resolveOpenAPI31Strategy;function _clone(s,o,i){if(i||(i=new Cx),function _isPrimitive(s){var o=typeof s;return null==s||\"object\"!=o&&\"function\"!=o}(s))return s;var a=function copy(a){var u=i.get(s);if(u)return u;for(var _ in i.set(s,a),s)Object.prototype.hasOwnProperty.call(s,_)&&(a[_]=o?_clone(s[_],!0,i):s[_]);return a};switch(ra(s)){case\"Object\":return a(Object.create(Object.getPrototypeOf(s)));case\"Array\":return a(Array(s.length));case\"Date\":return new Date(s.valueOf());case\"RegExp\":return _cloneRegExp(s);case\"Int8Array\":case\"Uint8Array\":case\"Uint8ClampedArray\":case\"Int16Array\":case\"Uint16Array\":case\"Int32Array\":case\"Uint32Array\":case\"Float32Array\":case\"Float64Array\":case\"BigInt64Array\":case\"BigUint64Array\":return s.slice();default:return s}}var Cx=function(){function _ObjectMap(){this.map={},this.length=0}return _ObjectMap.prototype.set=function(s,o){var i=this.hash(s),a=this.map[i];a||(this.map[i]=a=[]),a.push([s,o]),this.length+=1},_ObjectMap.prototype.hash=function(s){var o=[];for(var i in s)o.push(Object.prototype.toString.call(s[i]));return o.join()},_ObjectMap.prototype.get=function(s){if(this.length<=180)for(var o in this.map)for(var i=this.map[o],a=0;a<i.length;a+=1){if((_=i[a])[0]===s)return _[1]}else{var u=this.hash(s);if(i=this.map[u])for(a=0;a<i.length;a+=1){var _;if((_=i[a])[0]===s)return _[1]}}},_ObjectMap}(),jx=function(){function XReduceBy(s,o,i,a){this.valueFn=s,this.valueAcc=o,this.keyFn=i,this.xf=a,this.inputs={}}return XReduceBy.prototype[\"@@transducer/init\"]=_xfBase_init,XReduceBy.prototype[\"@@transducer/result\"]=function(s){var o;for(o in this.inputs)if(_has(o,this.inputs)&&(s=this.xf[\"@@transducer/step\"](s,this.inputs[o]))[\"@@transducer/reduced\"]){s=s[\"@@transducer/value\"];break}return this.inputs=null,this.xf[\"@@transducer/result\"](s)},XReduceBy.prototype[\"@@transducer/step\"]=function(s,o){var i=this.keyFn(o);return this.inputs[i]=this.inputs[i]||[i,_clone(this.valueAcc,!1)],this.inputs[i][1]=this.valueFn(this.inputs[i][1],o),s},XReduceBy}();function _xreduceBy(s,o,i){return function(a){return new jx(s,o,i,a)}}var Px=_curryN(4,[],_dispatchable([],_xreduceBy,(function reduceBy(s,o,i,a){var u=_xwrap((function(a,u){var _=i(u),w=s(_has(_,a)?a[_]:_clone(o,!1),u);return w&&w[\"@@transducer/reduced\"]?_reduced(a):(a[_]=w,a)}));return wa(u,{},a)})));const Ix=_curry2(_checkForMethod(\"groupBy\",Px((function(s,o){return s.push(o),s}),[])));const Tx=class NormalizeStorage{internalStore;constructor(s,o,i){this.storageElement=s,this.storageField=o,this.storageSubField=i}get store(){if(!this.internalStore){let s=this.storageElement.get(this.storageField);Mu(s)||(s=new Su.Sh,this.storageElement.set(this.storageField,s));let o=s.get(this.storageSubField);Ru(o)||(o=new Su.wE,s.set(this.storageSubField,o)),this.internalStore=o}return this.internalStore}append(s){this.includes(s)||this.store.push(s)}includes(s){return this.store.includes(s)}},removeSpaces=s=>s.replace(/\\s/g,\"\"),normalize_operation_ids_replaceSpecialCharsWithUnderscore=s=>s.replace(/\\W/gi,\"_\"),normalizeOperationId=(s,o,i)=>{const a=removeSpaces(s);return a.length>0?normalize_operation_ids_replaceSpecialCharsWithUnderscore(a):((s,o)=>`${normalize_operation_ids_replaceSpecialCharsWithUnderscore(removeSpaces(o.toLowerCase()))}${normalize_operation_ids_replaceSpecialCharsWithUnderscore(removeSpaces(s))}`)(o,i)},normalize_operation_ids=({storageField:s=\"x-normalized\",operationIdNormalizer:o=normalizeOperationId}={})=>i=>{const{predicates:a,ancestorLineageToJSONPointer:u,namespace:_}=i,w=[],x=[],C=[];let j;return{visitor:{OpenApi3_1Element:{enter(o){j=new Tx(o,s,\"operation-ids\")},leave(){const s=Ix((s=>serializers_value(s.operationId)),x);Object.entries(s).forEach((([s,o])=>{Array.isArray(o)&&(o.length<=1||o.forEach(((o,i)=>{const a=`${s}${i+1}`;o.operationId=new _.elements.String(a)})))})),C.forEach((s=>{if(void 0===s.operationId)return;const o=String(serializers_value(s.operationId)),i=x.find((s=>serializers_value(s.meta.get(\"originalOperationId\"))===o));void 0!==i&&(s.operationId=cloneDeep.safe(i.operationId),s.meta.set(\"originalOperationId\",o),s.set(\"__originalOperationId\",o))})),x.length=0,C.length=0,j=void 0}},PathItemElement:{enter(s){const o=Na(\"path\",serializers_value(s.meta.get(\"path\")));w.push(o)},leave(){w.pop()}},OperationElement:{enter(s,i,a,C,L){if(void 0===s.operationId)return;const B=u([...L,a,s]);if(j.includes(B))return;const $=String(serializers_value(s.operationId)),U=Ba(w),V=Na(\"method\",serializers_value(s.meta.get(\"http-method\"))),z=o($,U,V);$!==z&&(s.operationId=new _.elements.String(z),s.set(\"__originalOperationId\",$),s.meta.set(\"originalOperationId\",$),x.push(s),j.append(B))}},LinkElement:{leave(s){a.isLinkElement(s)&&void 0!==s.operationId&&C.push(s)}}}}},normalize_parameters=({storageField:s=\"x-normalized\"}={})=>o=>{const{predicates:i,ancestorLineageToJSONPointer:a}=o,parameterEquals=(s,o)=>!!i.isParameterElement(s)&&(!!i.isParameterElement(o)&&(!!i.isStringElement(s.name)&&(!!i.isStringElement(s.in)&&(!!i.isStringElement(o.name)&&(!!i.isStringElement(o.in)&&(serializers_value(s.name)===serializers_value(o.name)&&serializers_value(s.in)===serializers_value(o.in))))))),u=[];let _;return{visitor:{OpenApi3_1Element:{enter(o){_=new Tx(o,s,\"parameters\")},leave(){_=void 0}},PathItemElement:{enter(s,o,a,_,w){if(w.some(i.isComponentsElement))return;const{parameters:x}=s;i.isArrayElement(x)?u.push([...x.content]):u.push([])},leave(){u.pop()}},OperationElement:{leave(s,o,i,w,x){const C=Ba(u);if(!Array.isArray(C)||0===C.length)return;const j=a([...x,i,s]);if(_.includes(j))return;const L=Qw([],[\"parameters\",\"content\"],s),B=mx(parameterEquals,[...L,...C]);s.parameters=new Ev(B),_.append(j)}}}}},normalize_security_requirements=({storageField:s=\"x-normalized\"}={})=>o=>{const{predicates:i,ancestorLineageToJSONPointer:a}=o;let u,_;return{visitor:{OpenApi3_1Element:{enter(o){_=new Tx(o,s,\"security-requirements\"),i.isArrayElement(o.security)&&(u=o.security)},leave(){_=void 0,u=void 0}},OperationElement:{leave(s,o,w,x,C){if(C.some(i.isComponentsElement))return;const j=a([...C,w,s]);if(_.includes(j))return;var L;void 0===s.security&&void 0!==u&&(s.security=new Cv(null===(L=u)||void 0===L?void 0:L.content),_.append(j))}}}}},normalize_parameter_examples=({storageField:s=\"x-normalized\"}={})=>o=>{const{predicates:i,ancestorLineageToJSONPointer:a}=o;let u;return{visitor:{OpenApi3_1Element:{enter(o){u=new Tx(o,s,\"parameter-examples\")},leave(){u=void 0}},ParameterElement:{leave(s,o,_,w,x){var C,j;if(x.some(i.isComponentsElement))return;if(void 0===s.schema||!i.isSchemaElement(s.schema))return;if(void 0===(null===(C=s.schema)||void 0===C?void 0:C.example)&&void 0===(null===(j=s.schema)||void 0===j?void 0:j.examples))return;const L=a([...x,_,s]);if(!u.includes(L)){if(void 0!==s.examples&&i.isObjectElement(s.examples)){const o=s.examples.map((s=>cloneDeep.safe(s.value)));return void 0!==s.schema.examples&&(s.schema.set(\"examples\",o),u.append(L)),void(void 0!==s.schema.example&&(s.schema.set(\"example\",o[0]),u.append(L)))}void 0!==s.example&&(void 0!==s.schema.examples&&(s.schema.set(\"examples\",[cloneDeep(s.example)]),u.append(L)),void 0!==s.schema.example&&(s.schema.set(\"example\",cloneDeep(s.example)),u.append(L)))}}}}}},normalize_header_examples=({storageField:s=\"x-normalized\"}={})=>o=>{const{predicates:i,ancestorLineageToJSONPointer:a}=o;let u;return{visitor:{OpenApi3_1Element:{enter(o){u=new Tx(o,s,\"header-examples\")},leave(){u=void 0}},HeaderElement:{leave(s,o,_,w,x){var C,j;if(x.some(i.isComponentsElement))return;if(void 0===s.schema||!i.isSchemaElement(s.schema))return;if(void 0===(null===(C=s.schema)||void 0===C?void 0:C.example)&&void 0===(null===(j=s.schema)||void 0===j?void 0:j.examples))return;const L=a([...x,_,s]);if(!u.includes(L)){if(void 0!==s.examples&&i.isObjectElement(s.examples)){const o=s.examples.map((s=>cloneDeep.safe(s.value)));return void 0!==s.schema.examples&&(s.schema.set(\"examples\",o),u.append(L)),void(void 0!==s.schema.example&&(s.schema.set(\"example\",o[0]),u.append(L)))}void 0!==s.example&&(void 0!==s.schema.examples&&(s.schema.set(\"examples\",[cloneDeep(s.example)]),u.append(L)),void 0!==s.schema.example&&(s.schema.set(\"example\",cloneDeep(s.example)),u.append(L)))}}}}}},openapi_3_1_apidom_normalize=s=>{if(!Mu(s))return s;const o=[normalize_operation_ids({operationIdNormalizer:(s,o,i)=>opId({operationId:s},o,i,{v2OperationIdCompatibilityMode:!1})}),normalize_parameters(),normalize_security_requirements(),normalize_parameter_examples(),normalize_header_examples()];return dispatchPluginsSync(s,o,{toolboxCreator:apidom_ns_openapi_3_1_src_refractor_toolbox,visitorOptions:{keyMap:pw,nodeTypeGetter:apidom_ns_openapi_3_1_src_traversal_visitor_getNodeType}})},Nx={name:\"openapi-3-1-apidom\",match:s=>isOpenAPI31(s),normalize(s){if(!ju(s)&&fu(s)&&!s.$$normalized){const i=(o=openapi_3_1_apidom_normalize,s=>{const i=Ib.refract(s);i.classes.push(\"result\");const a=o(i),u=serializers_value(a);return Ax.cache.set(u,a),serializers_value(a)})(s);return i.$$normalized=!0,i}var o;return ju(s)?openapi_3_1_apidom_normalize(s):s},resolve:async s=>Ax(s)},Mx=Nx,makeResolve=s=>async o=>(async s=>{const{spec:o,requestInterceptor:i,responseInterceptor:a}=s,u=options_retrievalURI(s),_=options_httpClient(s),w=o||await makeFetchJSON(_,{requestInterceptor:i,responseInterceptor:a})(u),x={...s,spec:w};return s.strategies.find((s=>s.match(w))).resolve(x)})({...s,...o}),Rx=makeResolve({strategies:[_u,vu,gu]});const server_url_template=(s,o,i,a,u)=>{if(s===Ep.SEM_PRE){if(!1===Array.isArray(u))throw new Error(\"parser's user data must be an array\");u.push([\"server-url-template\",Sp.charsToString(o,i,a)])}return Ep.SEM_OK},callbacks_server_variable=(s,o,i,a,u)=>{if(s===Ep.SEM_PRE){if(!1===Array.isArray(u))throw new Error(\"parser's user data must be an array\");u.push([\"server-variable\",Sp.charsToString(o,i,a)])}return Ep.SEM_OK},server_variable_name=(s,o,i,a,u)=>{if(s===Ep.SEM_PRE){if(!1===Array.isArray(u))throw new Error(\"parser's user data must be an array\");u.push([\"server-variable-name\",Sp.charsToString(o,i,a)])}return Ep.SEM_OK},callbacks_literals=(s,o,i,a,u)=>{if(s===Ep.SEM_PRE){if(!1===Array.isArray(u))throw new Error(\"parser's user data must be an array\");u.push([\"literals\",Sp.charsToString(o,i,a)])}return Ep.SEM_OK},Dx=new function server_url_templating_grammar(){this.grammarObject=\"grammarObject\",this.rules=[],this.rules[0]={name:\"server-url-template\",lower:\"server-url-template\",index:0,isBkr:!1},this.rules[1]={name:\"server-variable\",lower:\"server-variable\",index:1,isBkr:!1},this.rules[2]={name:\"server-variable-name\",lower:\"server-variable-name\",index:2,isBkr:!1},this.rules[3]={name:\"literals\",lower:\"literals\",index:3,isBkr:!1},this.rules[4]={name:\"DIGIT\",lower:\"digit\",index:4,isBkr:!1},this.rules[5]={name:\"HEXDIG\",lower:\"hexdig\",index:5,isBkr:!1},this.rules[6]={name:\"pct-encoded\",lower:\"pct-encoded\",index:6,isBkr:!1},this.rules[7]={name:\"ucschar\",lower:\"ucschar\",index:7,isBkr:!1},this.rules[8]={name:\"iprivate\",lower:\"iprivate\",index:8,isBkr:!1},this.udts=[],this.rules[0].opcodes=[],this.rules[0].opcodes[0]={type:3,min:1,max:1/0},this.rules[0].opcodes[1]={type:1,children:[2,3]},this.rules[0].opcodes[2]={type:4,index:3},this.rules[0].opcodes[3]={type:4,index:1},this.rules[1].opcodes=[],this.rules[1].opcodes[0]={type:2,children:[1,2,3]},this.rules[1].opcodes[1]={type:7,string:[123]},this.rules[1].opcodes[2]={type:4,index:2},this.rules[1].opcodes[3]={type:7,string:[125]},this.rules[2].opcodes=[],this.rules[2].opcodes[0]={type:3,min:1,max:1/0},this.rules[2].opcodes[1]={type:1,children:[2,3,4]},this.rules[2].opcodes[2]={type:5,min:0,max:122},this.rules[2].opcodes[3]={type:6,string:[124]},this.rules[2].opcodes[4]={type:5,min:126,max:1114111},this.rules[3].opcodes=[],this.rules[3].opcodes[0]={type:3,min:1,max:1/0},this.rules[3].opcodes[1]={type:1,children:[2,3,4,5,6,7,8,9,10,11,12,13]},this.rules[3].opcodes[2]={type:6,string:[33]},this.rules[3].opcodes[3]={type:5,min:35,max:36},this.rules[3].opcodes[4]={type:5,min:38,max:59},this.rules[3].opcodes[5]={type:6,string:[61]},this.rules[3].opcodes[6]={type:5,min:63,max:91},this.rules[3].opcodes[7]={type:6,string:[93]},this.rules[3].opcodes[8]={type:6,string:[95]},this.rules[3].opcodes[9]={type:5,min:97,max:122},this.rules[3].opcodes[10]={type:6,string:[126]},this.rules[3].opcodes[11]={type:4,index:7},this.rules[3].opcodes[12]={type:4,index:8},this.rules[3].opcodes[13]={type:4,index:6},this.rules[4].opcodes=[],this.rules[4].opcodes[0]={type:5,min:48,max:57},this.rules[5].opcodes=[],this.rules[5].opcodes[0]={type:1,children:[1,2,3,4,5,6,7]},this.rules[5].opcodes[1]={type:4,index:4},this.rules[5].opcodes[2]={type:7,string:[97]},this.rules[5].opcodes[3]={type:7,string:[98]},this.rules[5].opcodes[4]={type:7,string:[99]},this.rules[5].opcodes[5]={type:7,string:[100]},this.rules[5].opcodes[6]={type:7,string:[101]},this.rules[5].opcodes[7]={type:7,string:[102]},this.rules[6].opcodes=[],this.rules[6].opcodes[0]={type:2,children:[1,2,3]},this.rules[6].opcodes[1]={type:7,string:[37]},this.rules[6].opcodes[2]={type:4,index:5},this.rules[6].opcodes[3]={type:4,index:5},this.rules[7].opcodes=[],this.rules[7].opcodes[0]={type:1,children:[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17]},this.rules[7].opcodes[1]={type:5,min:160,max:55295},this.rules[7].opcodes[2]={type:5,min:63744,max:64975},this.rules[7].opcodes[3]={type:5,min:65008,max:65519},this.rules[7].opcodes[4]={type:5,min:65536,max:131069},this.rules[7].opcodes[5]={type:5,min:131072,max:196605},this.rules[7].opcodes[6]={type:5,min:196608,max:262141},this.rules[7].opcodes[7]={type:5,min:262144,max:327677},this.rules[7].opcodes[8]={type:5,min:327680,max:393213},this.rules[7].opcodes[9]={type:5,min:393216,max:458749},this.rules[7].opcodes[10]={type:5,min:458752,max:524285},this.rules[7].opcodes[11]={type:5,min:524288,max:589821},this.rules[7].opcodes[12]={type:5,min:589824,max:655357},this.rules[7].opcodes[13]={type:5,min:655360,max:720893},this.rules[7].opcodes[14]={type:5,min:720896,max:786429},this.rules[7].opcodes[15]={type:5,min:786432,max:851965},this.rules[7].opcodes[16]={type:5,min:851968,max:917501},this.rules[7].opcodes[17]={type:5,min:921600,max:983037},this.rules[8].opcodes=[],this.rules[8].opcodes[0]={type:1,children:[1,2,3]},this.rules[8].opcodes[1]={type:5,min:57344,max:63743},this.rules[8].opcodes[2]={type:5,min:983040,max:1048573},this.rules[8].opcodes[3]={type:5,min:1048576,max:1114109},this.toString=function toString(){let s=\"\";return s+=\"; OpenAPI Server URL templating ABNF syntax\\n\",s+=\"server-url-template    = 1*( literals / server-variable ) ; variant of https://www.rfc-editor.org/rfc/rfc6570#section-2\\n\",s+='server-variable        = \"{\" server-variable-name \"}\"\\n',s+=\"server-variable-name   = 1*( %x00-7A / %x7C / %x7E-10FFFF ) ; every UTF8 character except { and } (from OpenAPI)\\n\",s+=\"\\n\",s+=\"; https://www.rfc-editor.org/rfc/rfc6570#section-2.1\\n\",s+=\"; https://www.rfc-editor.org/errata/eid6937\\n\",s+=\"literals               = 1*( %x21 / %x23-24 / %x26-3B / %x3D / %x3F-5B\\n\",s+=\"                       / %x5D / %x5F / %x61-7A / %x7E / ucschar / iprivate\\n\",s+=\"                       / pct-encoded)\\n\",s+=\"                            ; any Unicode character except: CTL, SP,\\n\",s+='                            ;  DQUOTE, \"%\" (aside from pct-encoded),\\n',s+='                            ;  \"<\", \">\", \"\\\\\", \"^\", \"`\", \"{\", \"|\", \"}\"\\n',s+=\"\\n\",s+=\"; https://www.rfc-editor.org/rfc/rfc6570#section-1.5\\n\",s+=\"DIGIT          =  %x30-39             ; 0-9\\n\",s+='HEXDIG         =  DIGIT / \"A\" / \"B\" / \"C\" / \"D\" / \"E\" / \"F\" ; case-insensitive\\n',s+=\"\\n\",s+='pct-encoded    =  \"%\" HEXDIG HEXDIG\\n',s+=\"\\n\",s+=\"ucschar        =  %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF\\n\",s+=\"               /  %x10000-1FFFD / %x20000-2FFFD / %x30000-3FFFD\\n\",s+=\"               /  %x40000-4FFFD / %x50000-5FFFD / %x60000-6FFFD\\n\",s+=\"               /  %x70000-7FFFD / %x80000-8FFFD / %x90000-9FFFD\\n\",s+=\"               /  %xA0000-AFFFD / %xB0000-BFFFD / %xC0000-CFFFD\\n\",s+=\"               /  %xD0000-DFFFD / %xE1000-EFFFD\\n\",s+=\"\\n\",s+=\"iprivate       =  %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD\\n\",'; OpenAPI Server URL templating ABNF syntax\\nserver-url-template    = 1*( literals / server-variable ) ; variant of https://www.rfc-editor.org/rfc/rfc6570#section-2\\nserver-variable        = \"{\" server-variable-name \"}\"\\nserver-variable-name   = 1*( %x00-7A / %x7C / %x7E-10FFFF ) ; every UTF8 character except { and } (from OpenAPI)\\n\\n; https://www.rfc-editor.org/rfc/rfc6570#section-2.1\\n; https://www.rfc-editor.org/errata/eid6937\\nliterals               = 1*( %x21 / %x23-24 / %x26-3B / %x3D / %x3F-5B\\n                       / %x5D / %x5F / %x61-7A / %x7E / ucschar / iprivate\\n                       / pct-encoded)\\n                            ; any Unicode character except: CTL, SP,\\n                            ;  DQUOTE, \"%\" (aside from pct-encoded),\\n                            ;  \"<\", \">\", \"\\\\\", \"^\", \"`\", \"{\", \"|\", \"}\"\\n\\n; https://www.rfc-editor.org/rfc/rfc6570#section-1.5\\nDIGIT          =  %x30-39             ; 0-9\\nHEXDIG         =  DIGIT / \"A\" / \"B\" / \"C\" / \"D\" / \"E\" / \"F\" ; case-insensitive\\n\\npct-encoded    =  \"%\" HEXDIG HEXDIG\\n\\nucschar        =  %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF\\n               /  %x10000-1FFFD / %x20000-2FFFD / %x30000-3FFFD\\n               /  %x40000-4FFFD / %x50000-5FFFD / %x60000-6FFFD\\n               /  %x70000-7FFFD / %x80000-8FFFD / %x90000-9FFFD\\n               /  %xA0000-AFFFD / %xB0000-BFFFD / %xC0000-CFFFD\\n               /  %xD0000-DFFFD / %xE1000-EFFFD\\n\\niprivate       =  %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD\\n'}},openapi_server_url_templating_es_parse=s=>{const o=new yp;o.ast=new vp,o.ast.callbacks[\"server-url-template\"]=server_url_template,o.ast.callbacks[\"server-variable\"]=callbacks_server_variable,o.ast.callbacks[\"server-variable-name\"]=server_variable_name,o.ast.callbacks.literals=callbacks_literals;return{result:o.parse(Dx,\"server-url-template\",s),ast:o.ast}},openapi_server_url_templating_es_test=(s,{strict:o=!1}={})=>{try{const i=openapi_server_url_templating_es_parse(s);if(!i.result.success)return!1;const a=[];i.ast.translate(a);const u=a.some((([s])=>\"server-variable\"===s));if(!o&&!u)try{return new URL(s,\"https://vladimirgorej.com\"),!0}catch{return!1}return!o||u}catch{return!1}},encodeServerVariable=s=>(s=>{try{return\"string\"==typeof s&&decodeURIComponent(s)!==s}catch{return!1}})(s)?s:encodeURIComponent(s).replace(/%5B/g,\"[\").replace(/%5D/g,\"]\"),Lx=[\"literals\",\"server-variable-name\"],es_substitute=(s,o,i={})=>{const a={...{encoder:encodeServerVariable},...i},u=openapi_server_url_templating_es_parse(s);if(!u.result.success)return s;const _=[];u.ast.translate(_);const w=_.filter((([s])=>Lx.includes(s))).map((([s,i])=>\"server-variable-name\"===s?Object.hasOwn(o,i)?a.encoder(o[i],i):`{${i}}`:i));return w.join(\"\")};function path_templating_grammar(){this.grammarObject=\"grammarObject\",this.rules=[],this.rules[0]={name:\"path-template\",lower:\"path-template\",index:0,isBkr:!1},this.rules[1]={name:\"path-segment\",lower:\"path-segment\",index:1,isBkr:!1},this.rules[2]={name:\"slash\",lower:\"slash\",index:2,isBkr:!1},this.rules[3]={name:\"path-literal\",lower:\"path-literal\",index:3,isBkr:!1},this.rules[4]={name:\"template-expression\",lower:\"template-expression\",index:4,isBkr:!1},this.rules[5]={name:\"template-expression-param-name\",lower:\"template-expression-param-name\",index:5,isBkr:!1},this.rules[6]={name:\"pchar\",lower:\"pchar\",index:6,isBkr:!1},this.rules[7]={name:\"unreserved\",lower:\"unreserved\",index:7,isBkr:!1},this.rules[8]={name:\"pct-encoded\",lower:\"pct-encoded\",index:8,isBkr:!1},this.rules[9]={name:\"sub-delims\",lower:\"sub-delims\",index:9,isBkr:!1},this.rules[10]={name:\"ALPHA\",lower:\"alpha\",index:10,isBkr:!1},this.rules[11]={name:\"DIGIT\",lower:\"digit\",index:11,isBkr:!1},this.rules[12]={name:\"HEXDIG\",lower:\"hexdig\",index:12,isBkr:!1},this.udts=[],this.rules[0].opcodes=[],this.rules[0].opcodes[0]={type:2,children:[1,2,6]},this.rules[0].opcodes[1]={type:4,index:2},this.rules[0].opcodes[2]={type:3,min:0,max:1/0},this.rules[0].opcodes[3]={type:2,children:[4,5]},this.rules[0].opcodes[4]={type:4,index:1},this.rules[0].opcodes[5]={type:4,index:2},this.rules[0].opcodes[6]={type:3,min:0,max:1},this.rules[0].opcodes[7]={type:4,index:1},this.rules[1].opcodes=[],this.rules[1].opcodes[0]={type:3,min:1,max:1/0},this.rules[1].opcodes[1]={type:1,children:[2,3]},this.rules[1].opcodes[2]={type:4,index:3},this.rules[1].opcodes[3]={type:4,index:4},this.rules[2].opcodes=[],this.rules[2].opcodes[0]={type:7,string:[47]},this.rules[3].opcodes=[],this.rules[3].opcodes[0]={type:3,min:1,max:1/0},this.rules[3].opcodes[1]={type:4,index:6},this.rules[4].opcodes=[],this.rules[4].opcodes[0]={type:2,children:[1,2,3]},this.rules[4].opcodes[1]={type:7,string:[123]},this.rules[4].opcodes[2]={type:4,index:5},this.rules[4].opcodes[3]={type:7,string:[125]},this.rules[5].opcodes=[],this.rules[5].opcodes[0]={type:3,min:1,max:1/0},this.rules[5].opcodes[1]={type:1,children:[2,3,4]},this.rules[5].opcodes[2]={type:5,min:0,max:122},this.rules[5].opcodes[3]={type:6,string:[124]},this.rules[5].opcodes[4]={type:5,min:126,max:1114111},this.rules[6].opcodes=[],this.rules[6].opcodes[0]={type:1,children:[1,2,3,4,5]},this.rules[6].opcodes[1]={type:4,index:7},this.rules[6].opcodes[2]={type:4,index:8},this.rules[6].opcodes[3]={type:4,index:9},this.rules[6].opcodes[4]={type:7,string:[58]},this.rules[6].opcodes[5]={type:7,string:[64]},this.rules[7].opcodes=[],this.rules[7].opcodes[0]={type:1,children:[1,2,3,4,5,6]},this.rules[7].opcodes[1]={type:4,index:10},this.rules[7].opcodes[2]={type:4,index:11},this.rules[7].opcodes[3]={type:7,string:[45]},this.rules[7].opcodes[4]={type:7,string:[46]},this.rules[7].opcodes[5]={type:7,string:[95]},this.rules[7].opcodes[6]={type:7,string:[126]},this.rules[8].opcodes=[],this.rules[8].opcodes[0]={type:2,children:[1,2,3]},this.rules[8].opcodes[1]={type:7,string:[37]},this.rules[8].opcodes[2]={type:4,index:12},this.rules[8].opcodes[3]={type:4,index:12},this.rules[9].opcodes=[],this.rules[9].opcodes[0]={type:1,children:[1,2,3,4,5,6,7,8,9,10,11]},this.rules[9].opcodes[1]={type:7,string:[33]},this.rules[9].opcodes[2]={type:7,string:[36]},this.rules[9].opcodes[3]={type:7,string:[38]},this.rules[9].opcodes[4]={type:7,string:[39]},this.rules[9].opcodes[5]={type:7,string:[40]},this.rules[9].opcodes[6]={type:7,string:[41]},this.rules[9].opcodes[7]={type:7,string:[42]},this.rules[9].opcodes[8]={type:7,string:[43]},this.rules[9].opcodes[9]={type:7,string:[44]},this.rules[9].opcodes[10]={type:7,string:[59]},this.rules[9].opcodes[11]={type:7,string:[61]},this.rules[10].opcodes=[],this.rules[10].opcodes[0]={type:1,children:[1,2]},this.rules[10].opcodes[1]={type:5,min:65,max:90},this.rules[10].opcodes[2]={type:5,min:97,max:122},this.rules[11].opcodes=[],this.rules[11].opcodes[0]={type:5,min:48,max:57},this.rules[12].opcodes=[],this.rules[12].opcodes[0]={type:1,children:[1,2,3,4,5,6,7]},this.rules[12].opcodes[1]={type:4,index:11},this.rules[12].opcodes[2]={type:7,string:[97]},this.rules[12].opcodes[3]={type:7,string:[98]},this.rules[12].opcodes[4]={type:7,string:[99]},this.rules[12].opcodes[5]={type:7,string:[100]},this.rules[12].opcodes[6]={type:7,string:[101]},this.rules[12].opcodes[7]={type:7,string:[102]},this.toString=function toString(){let s=\"\";return s+=\"; OpenAPI Path Templating ABNF syntax\\n\",s+=\"; variant of https://datatracker.ietf.org/doc/html/rfc3986#section-3.3\\n\",s+=\"path-template                  = slash *( path-segment slash ) [ path-segment ]\\n\",s+=\"path-segment                   = 1*( path-literal / template-expression )\\n\",s+='slash                          = \"/\"\\n',s+=\"path-literal                   = 1*pchar\\n\",s+='template-expression            = \"{\" template-expression-param-name \"}\"\\n',s+=\"template-expression-param-name = 1*( %x00-7A / %x7C / %x7E-10FFFF ) ; every UTF8 character except { and } (from OpenAPI)\\n\",s+=\"\\n\",s+=\"; https://datatracker.ietf.org/doc/html/rfc3986#section-3.3\\n\",s+='pchar               = unreserved / pct-encoded / sub-delims / \":\" / \"@\"\\n',s+='unreserved          = ALPHA / DIGIT / \"-\" / \".\" / \"_\" / \"~\"\\n',s+=\"                    ; https://datatracker.ietf.org/doc/html/rfc3986#section-2.3\\n\",s+='pct-encoded         = \"%\" HEXDIG HEXDIG\\n',s+=\"                    ; https://datatracker.ietf.org/doc/html/rfc3986#section-2.1\\n\",s+='sub-delims          = \"!\" / \"$\" / \"&\" / \"\\'\" / \"(\" / \")\"\\n',s+='                    / \"*\" / \"+\" / \",\" / \";\" / \"=\"\\n',s+=\"                    ; https://datatracker.ietf.org/doc/html/rfc3986#section-2.2\\n\",s+=\"\\n\",s+=\"; https://datatracker.ietf.org/doc/html/rfc5234#appendix-B.1\\n\",s+=\"ALPHA               = %x41-5A / %x61-7A   ; A-Z / a-z\\n\",s+=\"DIGIT               = %x30-39            ; 0-9\\n\",s+='HEXDIG              = DIGIT / \"A\" / \"B\" / \"C\" / \"D\" / \"E\" / \"F\"\\n','; OpenAPI Path Templating ABNF syntax\\n; variant of https://datatracker.ietf.org/doc/html/rfc3986#section-3.3\\npath-template                  = slash *( path-segment slash ) [ path-segment ]\\npath-segment                   = 1*( path-literal / template-expression )\\nslash                          = \"/\"\\npath-literal                   = 1*pchar\\ntemplate-expression            = \"{\" template-expression-param-name \"}\"\\ntemplate-expression-param-name = 1*( %x00-7A / %x7C / %x7E-10FFFF ) ; every UTF8 character except { and } (from OpenAPI)\\n\\n; https://datatracker.ietf.org/doc/html/rfc3986#section-3.3\\npchar               = unreserved / pct-encoded / sub-delims / \":\" / \"@\"\\nunreserved          = ALPHA / DIGIT / \"-\" / \".\" / \"_\" / \"~\"\\n                    ; https://datatracker.ietf.org/doc/html/rfc3986#section-2.3\\npct-encoded         = \"%\" HEXDIG HEXDIG\\n                    ; https://datatracker.ietf.org/doc/html/rfc3986#section-2.1\\nsub-delims          = \"!\" / \"$\" / \"&\" / \"\\'\" / \"(\" / \")\"\\n                    / \"*\" / \"+\" / \",\" / \";\" / \"=\"\\n                    ; https://datatracker.ietf.org/doc/html/rfc3986#section-2.2\\n\\n; https://datatracker.ietf.org/doc/html/rfc5234#appendix-B.1\\nALPHA               = %x41-5A / %x61-7A   ; A-Z / a-z\\nDIGIT               = %x30-39            ; 0-9\\nHEXDIG              = DIGIT / \"A\" / \"B\" / \"C\" / \"D\" / \"E\" / \"F\"\\n'}}const callbacks_slash=(s,o,i,a,u)=>(s===Ep.SEM_PRE?u.push([\"slash\",Sp.charsToString(o,i,a)]):Ep.SEM_POST,Ep.SEM_OK),path_template=(s,o,i,a,u)=>{if(s===Ep.SEM_PRE){if(!1===Array.isArray(u))throw new Error(\"parser's user data must be an array\");u.push([\"path-template\",Sp.charsToString(o,i,a)])}return Ep.SEM_OK},path_literal=(s,o,i,a,u)=>(s===Ep.SEM_PRE?u.push([\"path-literal\",Sp.charsToString(o,i,a)]):Ep.SEM_POST,Ep.SEM_OK),template_expression=(s,o,i,a,u)=>(s===Ep.SEM_PRE?u.push([\"template-expression\",Sp.charsToString(o,i,a)]):Ep.SEM_POST,Ep.SEM_OK),template_expression_param_name=(s,o,i,a,u)=>(s===Ep.SEM_PRE?u.push([\"template-expression-param-name\",Sp.charsToString(o,i,a)]):Ep.SEM_POST,Ep.SEM_OK),Fx=new path_templating_grammar,openapi_path_templating_es_parse=s=>{const o=new yp;o.ast=new vp,o.ast.callbacks[\"path-template\"]=path_template,o.ast.callbacks.slash=callbacks_slash,o.ast.callbacks[\"path-literal\"]=path_literal,o.ast.callbacks[\"template-expression\"]=template_expression,o.ast.callbacks[\"template-expression-param-name\"]=template_expression_param_name;return{result:o.parse(Fx,\"path-template\",s),ast:o.ast}},encodePathComponent=s=>(s=>{try{return\"string\"==typeof s&&decodeURIComponent(s)!==s}catch{return!1}})(s)?s:encodeURIComponent(s).replace(/%5B/g,\"[\").replace(/%5D/g,\"]\"),Bx=[\"slash\",\"path-literal\",\"template-expression-param-name\"],es_resolve=(s,o,i={})=>{const a={...{encoder:encodePathComponent},...i},u=openapi_path_templating_es_parse(s);if(!u.result.success)return s;const _=[];u.ast.translate(_);const w=_.filter((([s])=>Bx.includes(s))).map((([s,i])=>\"template-expression-param-name\"===s?Object.prototype.hasOwnProperty.call(o,i)?a.encoder(o[i],i):`{${i}}`:i));return w.join(\"\")},$x=(new path_templating_grammar,new yp,{body:function bodyBuilder({req:s,value:o}){void 0!==o&&(s.body=o)},header:function headerBuilder({req:s,parameter:o,value:i}){s.headers=s.headers||{},void 0!==i&&(s.headers[o.name]=i)},query:function queryBuilder({req:s,value:o,parameter:i}){s.query=s.query||{},!1===o&&\"boolean\"===i.type&&(o=\"false\");0===o&&[\"number\",\"integer\"].indexOf(i.type)>-1&&(o=\"0\");if(o)s.query[i.name]={collectionFormat:i.collectionFormat,value:o};else if(i.allowEmptyValue&&void 0!==o){const o=i.name;s.query[o]=s.query[o]||{},s.query[o].allowEmptyValue=!0}},path:function pathBuilder({req:s,value:o,parameter:i,baseURL:a}){if(void 0!==o){const u=s.url.replace(a,\"\"),_=es_resolve(u,{[i.name]:o});s.url=a+_}},formData:function formDataBuilder({req:s,value:o,parameter:i}){!1===o&&\"boolean\"===i.type&&(o=\"false\");0===o&&[\"number\",\"integer\"].indexOf(i.type)>-1&&(o=\"0\");if(o)s.form=s.form||{},s.form[i.name]={collectionFormat:i.collectionFormat,value:o};else if(i.allowEmptyValue&&void 0!==o){s.form=s.form||{};const o=i.name;s.form[o]=s.form[o]||{},s.form[o].allowEmptyValue=!0}}});function serialize(s,o){return o.includes(\"application/json\")?\"string\"==typeof s?s:(Array.isArray(s)&&(s=s.map((s=>{try{return JSON.parse(s)}catch(o){return s}}))),JSON.stringify(s)):String(s)}function grammar_grammar(){this.grammarObject=\"grammarObject\",this.rules=[],this.rules[0]={name:\"lenient-cookie-string\",lower:\"lenient-cookie-string\",index:0,isBkr:!1},this.rules[1]={name:\"lenient-cookie-entry\",lower:\"lenient-cookie-entry\",index:1,isBkr:!1},this.rules[2]={name:\"lenient-cookie-pair\",lower:\"lenient-cookie-pair\",index:2,isBkr:!1},this.rules[3]={name:\"lenient-cookie-pair-invalid\",lower:\"lenient-cookie-pair-invalid\",index:3,isBkr:!1},this.rules[4]={name:\"lenient-cookie-name\",lower:\"lenient-cookie-name\",index:4,isBkr:!1},this.rules[5]={name:\"lenient-cookie-value\",lower:\"lenient-cookie-value\",index:5,isBkr:!1},this.rules[6]={name:\"lenient-quoted-value\",lower:\"lenient-quoted-value\",index:6,isBkr:!1},this.rules[7]={name:\"lenient-quoted-char\",lower:\"lenient-quoted-char\",index:7,isBkr:!1},this.rules[8]={name:\"lenient-cookie-octet\",lower:\"lenient-cookie-octet\",index:8,isBkr:!1},this.rules[9]={name:\"cookie-string\",lower:\"cookie-string\",index:9,isBkr:!1},this.rules[10]={name:\"cookie-pair\",lower:\"cookie-pair\",index:10,isBkr:!1},this.rules[11]={name:\"cookie-name\",lower:\"cookie-name\",index:11,isBkr:!1},this.rules[12]={name:\"cookie-value\",lower:\"cookie-value\",index:12,isBkr:!1},this.rules[13]={name:\"cookie-octet\",lower:\"cookie-octet\",index:13,isBkr:!1},this.rules[14]={name:\"OWS\",lower:\"ows\",index:14,isBkr:!1},this.rules[15]={name:\"token\",lower:\"token\",index:15,isBkr:!1},this.rules[16]={name:\"tchar\",lower:\"tchar\",index:16,isBkr:!1},this.rules[17]={name:\"CHAR\",lower:\"char\",index:17,isBkr:!1},this.rules[18]={name:\"CTL\",lower:\"ctl\",index:18,isBkr:!1},this.rules[19]={name:\"separators\",lower:\"separators\",index:19,isBkr:!1},this.rules[20]={name:\"SP\",lower:\"sp\",index:20,isBkr:!1},this.rules[21]={name:\"HT\",lower:\"ht\",index:21,isBkr:!1},this.rules[22]={name:\"ALPHA\",lower:\"alpha\",index:22,isBkr:!1},this.rules[23]={name:\"DIGIT\",lower:\"digit\",index:23,isBkr:!1},this.rules[24]={name:\"DQUOTE\",lower:\"dquote\",index:24,isBkr:!1},this.rules[25]={name:\"WSP\",lower:\"wsp\",index:25,isBkr:!1},this.rules[26]={name:\"HTAB\",lower:\"htab\",index:26,isBkr:!1},this.rules[27]={name:\"CRLF\",lower:\"crlf\",index:27,isBkr:!1},this.rules[28]={name:\"CR\",lower:\"cr\",index:28,isBkr:!1},this.rules[29]={name:\"LF\",lower:\"lf\",index:29,isBkr:!1},this.udts=[],this.rules[0].opcodes=[],this.rules[0].opcodes[0]={type:2,children:[1,2]},this.rules[0].opcodes[1]={type:4,index:1},this.rules[0].opcodes[2]={type:3,min:0,max:1/0},this.rules[0].opcodes[3]={type:2,children:[4,5,6]},this.rules[0].opcodes[4]={type:7,string:[59]},this.rules[0].opcodes[5]={type:4,index:14},this.rules[0].opcodes[6]={type:4,index:1},this.rules[1].opcodes=[],this.rules[1].opcodes[0]={type:1,children:[1,2]},this.rules[1].opcodes[1]={type:4,index:2},this.rules[1].opcodes[2]={type:4,index:3},this.rules[2].opcodes=[],this.rules[2].opcodes[0]={type:2,children:[1,2,3,4,5,6,7]},this.rules[2].opcodes[1]={type:4,index:14},this.rules[2].opcodes[2]={type:4,index:4},this.rules[2].opcodes[3]={type:4,index:14},this.rules[2].opcodes[4]={type:7,string:[61]},this.rules[2].opcodes[5]={type:4,index:14},this.rules[2].opcodes[6]={type:4,index:5},this.rules[2].opcodes[7]={type:4,index:14},this.rules[3].opcodes=[],this.rules[3].opcodes[0]={type:2,children:[1,2,4]},this.rules[3].opcodes[1]={type:4,index:14},this.rules[3].opcodes[2]={type:3,min:1,max:1/0},this.rules[3].opcodes[3]={type:4,index:16},this.rules[3].opcodes[4]={type:4,index:14},this.rules[4].opcodes=[],this.rules[4].opcodes[0]={type:3,min:1,max:1/0},this.rules[4].opcodes[1]={type:1,children:[2,3,4]},this.rules[4].opcodes[2]={type:5,min:33,max:58},this.rules[4].opcodes[3]={type:6,string:[60]},this.rules[4].opcodes[4]={type:5,min:62,max:126},this.rules[5].opcodes=[],this.rules[5].opcodes[0]={type:1,children:[1,6]},this.rules[5].opcodes[1]={type:2,children:[2,3]},this.rules[5].opcodes[2]={type:4,index:6},this.rules[5].opcodes[3]={type:3,min:0,max:1},this.rules[5].opcodes[4]={type:3,min:0,max:1/0},this.rules[5].opcodes[5]={type:4,index:8},this.rules[5].opcodes[6]={type:3,min:0,max:1/0},this.rules[5].opcodes[7]={type:4,index:8},this.rules[6].opcodes=[],this.rules[6].opcodes[0]={type:2,children:[1,2,4]},this.rules[6].opcodes[1]={type:4,index:24},this.rules[6].opcodes[2]={type:3,min:0,max:1/0},this.rules[6].opcodes[3]={type:4,index:7},this.rules[6].opcodes[4]={type:4,index:24},this.rules[7].opcodes=[],this.rules[7].opcodes[0]={type:1,children:[1,2]},this.rules[7].opcodes[1]={type:5,min:32,max:33},this.rules[7].opcodes[2]={type:5,min:35,max:126},this.rules[8].opcodes=[],this.rules[8].opcodes[0]={type:1,children:[1,2,3]},this.rules[8].opcodes[1]={type:5,min:33,max:43},this.rules[8].opcodes[2]={type:5,min:45,max:58},this.rules[8].opcodes[3]={type:5,min:60,max:126},this.rules[9].opcodes=[],this.rules[9].opcodes[0]={type:2,children:[1,2]},this.rules[9].opcodes[1]={type:4,index:10},this.rules[9].opcodes[2]={type:3,min:0,max:1/0},this.rules[9].opcodes[3]={type:2,children:[4,5,6]},this.rules[9].opcodes[4]={type:7,string:[59]},this.rules[9].opcodes[5]={type:4,index:20},this.rules[9].opcodes[6]={type:4,index:10},this.rules[10].opcodes=[],this.rules[10].opcodes[0]={type:2,children:[1,2,3]},this.rules[10].opcodes[1]={type:4,index:11},this.rules[10].opcodes[2]={type:7,string:[61]},this.rules[10].opcodes[3]={type:4,index:12},this.rules[11].opcodes=[],this.rules[11].opcodes[0]={type:4,index:15},this.rules[12].opcodes=[],this.rules[12].opcodes[0]={type:1,children:[1,6]},this.rules[12].opcodes[1]={type:2,children:[2,3,5]},this.rules[12].opcodes[2]={type:4,index:24},this.rules[12].opcodes[3]={type:3,min:0,max:1/0},this.rules[12].opcodes[4]={type:4,index:13},this.rules[12].opcodes[5]={type:4,index:24},this.rules[12].opcodes[6]={type:3,min:0,max:1/0},this.rules[12].opcodes[7]={type:4,index:13},this.rules[13].opcodes=[],this.rules[13].opcodes[0]={type:1,children:[1,2,3,4,5]},this.rules[13].opcodes[1]={type:6,string:[33]},this.rules[13].opcodes[2]={type:5,min:35,max:43},this.rules[13].opcodes[3]={type:5,min:45,max:58},this.rules[13].opcodes[4]={type:5,min:60,max:91},this.rules[13].opcodes[5]={type:5,min:93,max:126},this.rules[14].opcodes=[],this.rules[14].opcodes[0]={type:3,min:0,max:1/0},this.rules[14].opcodes[1]={type:2,children:[2,4]},this.rules[14].opcodes[2]={type:3,min:0,max:1},this.rules[14].opcodes[3]={type:4,index:27},this.rules[14].opcodes[4]={type:4,index:25},this.rules[15].opcodes=[],this.rules[15].opcodes[0]={type:3,min:1,max:1/0},this.rules[15].opcodes[1]={type:4,index:16},this.rules[16].opcodes=[],this.rules[16].opcodes[0]={type:1,children:[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17]},this.rules[16].opcodes[1]={type:7,string:[33]},this.rules[16].opcodes[2]={type:7,string:[35]},this.rules[16].opcodes[3]={type:7,string:[36]},this.rules[16].opcodes[4]={type:7,string:[37]},this.rules[16].opcodes[5]={type:7,string:[38]},this.rules[16].opcodes[6]={type:7,string:[39]},this.rules[16].opcodes[7]={type:7,string:[42]},this.rules[16].opcodes[8]={type:7,string:[43]},this.rules[16].opcodes[9]={type:7,string:[45]},this.rules[16].opcodes[10]={type:7,string:[46]},this.rules[16].opcodes[11]={type:7,string:[94]},this.rules[16].opcodes[12]={type:7,string:[95]},this.rules[16].opcodes[13]={type:7,string:[96]},this.rules[16].opcodes[14]={type:7,string:[124]},this.rules[16].opcodes[15]={type:7,string:[126]},this.rules[16].opcodes[16]={type:4,index:23},this.rules[16].opcodes[17]={type:4,index:22},this.rules[17].opcodes=[],this.rules[17].opcodes[0]={type:5,min:1,max:127},this.rules[18].opcodes=[],this.rules[18].opcodes[0]={type:1,children:[1,2]},this.rules[18].opcodes[1]={type:5,min:0,max:31},this.rules[18].opcodes[2]={type:6,string:[127]},this.rules[19].opcodes=[],this.rules[19].opcodes[0]={type:1,children:[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19]},this.rules[19].opcodes[1]={type:7,string:[40]},this.rules[19].opcodes[2]={type:7,string:[41]},this.rules[19].opcodes[3]={type:7,string:[60]},this.rules[19].opcodes[4]={type:7,string:[62]},this.rules[19].opcodes[5]={type:7,string:[64]},this.rules[19].opcodes[6]={type:7,string:[44]},this.rules[19].opcodes[7]={type:7,string:[59]},this.rules[19].opcodes[8]={type:7,string:[58]},this.rules[19].opcodes[9]={type:7,string:[92]},this.rules[19].opcodes[10]={type:6,string:[34]},this.rules[19].opcodes[11]={type:7,string:[47]},this.rules[19].opcodes[12]={type:7,string:[91]},this.rules[19].opcodes[13]={type:7,string:[93]},this.rules[19].opcodes[14]={type:7,string:[63]},this.rules[19].opcodes[15]={type:7,string:[61]},this.rules[19].opcodes[16]={type:7,string:[123]},this.rules[19].opcodes[17]={type:7,string:[125]},this.rules[19].opcodes[18]={type:4,index:20},this.rules[19].opcodes[19]={type:4,index:21},this.rules[20].opcodes=[],this.rules[20].opcodes[0]={type:6,string:[32]},this.rules[21].opcodes=[],this.rules[21].opcodes[0]={type:6,string:[9]},this.rules[22].opcodes=[],this.rules[22].opcodes[0]={type:1,children:[1,2]},this.rules[22].opcodes[1]={type:5,min:65,max:90},this.rules[22].opcodes[2]={type:5,min:97,max:122},this.rules[23].opcodes=[],this.rules[23].opcodes[0]={type:5,min:48,max:57},this.rules[24].opcodes=[],this.rules[24].opcodes[0]={type:6,string:[34]},this.rules[25].opcodes=[],this.rules[25].opcodes[0]={type:1,children:[1,2]},this.rules[25].opcodes[1]={type:4,index:20},this.rules[25].opcodes[2]={type:4,index:26},this.rules[26].opcodes=[],this.rules[26].opcodes[0]={type:6,string:[9]},this.rules[27].opcodes=[],this.rules[27].opcodes[0]={type:2,children:[1,2]},this.rules[27].opcodes[1]={type:4,index:28},this.rules[27].opcodes[2]={type:4,index:29},this.rules[28].opcodes=[],this.rules[28].opcodes[0]={type:6,string:[13]},this.rules[29].opcodes=[],this.rules[29].opcodes[0]={type:6,string:[10]},this.toString=function toString(){let s=\"\";return s+=\"; Lenient version of https://datatracker.ietf.org/doc/html/rfc6265#section-4.2.1\\n\",s+='lenient-cookie-string        = lenient-cookie-entry *( \";\" OWS lenient-cookie-entry )\\n',s+=\"lenient-cookie-entry         = lenient-cookie-pair / lenient-cookie-pair-invalid\\n\",s+='lenient-cookie-pair          = OWS lenient-cookie-name OWS \"=\" OWS lenient-cookie-value OWS\\n',s+='lenient-cookie-pair-invalid  = OWS 1*tchar OWS ; Allow for standalone entries like \"fizz\" to be ignored\\n',s+='lenient-cookie-name          = 1*( %x21-3A / %x3C / %x3E-7E ) ; Allow all printable US-ASCII except \"=\"\\n',s+=\"lenient-cookie-value         = lenient-quoted-value [ *lenient-cookie-octet ] / *lenient-cookie-octet\\n\",s+=\"lenient-quoted-value         = DQUOTE *( lenient-quoted-char ) DQUOTE\\n\",s+=\"lenient-quoted-char          = %x20-21 / %x23-7E ; Allow all printable US-ASCII except DQUOTE\\n\",s+=\"lenient-cookie-octet         = %x21-2B / %x2D-3A / %x3C-7E\\n\",s+=\"                             ; Allow all printable characters except CTLs, semicolon and SP\\n\",s+=\"\\n\",s+=\"; https://datatracker.ietf.org/doc/html/rfc6265#section-4.2.1\\n\",s+='cookie-string     = cookie-pair *( \";\" SP cookie-pair )\\n',s+=\"\\n\",s+=\"; https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1\\n\",s+=\"; https://www.rfc-editor.org/errata/eid5518\\n\",s+='cookie-pair       = cookie-name \"=\" cookie-value\\n',s+=\"cookie-name       = token\\n\",s+=\"cookie-value      = ( DQUOTE *cookie-octet DQUOTE ) / *cookie-octet\\n\",s+=\"                  ; https://www.rfc-editor.org/errata/eid8242\\n\",s+=\"cookie-octet      = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E\\n\",s+=\"                       ; US-ASCII characters excluding CTLs,\\n\",s+=\"                       ; whitespace, DQUOTE, comma, semicolon,\\n\",s+=\"                       ; and backslash\\n\",s+=\"\\n\",s+=\"; https://datatracker.ietf.org/doc/html/rfc6265#section-2.2\\n\",s+='OWS            = *( [ CRLF ] WSP ) ; \"optional\" whitespace\\n',s+=\"\\n\",s+=\"; https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2\\n\",s+=\"token          = 1*(tchar)\\n\",s+='tchar          = \"!\" / \"#\" / \"$\" / \"%\" / \"&\" / \"\\'\" / \"*\"\\n',s+='                 / \"+\" / \"-\" / \".\" / \"^\" / \"_\" / \"`\" / \"|\" / \"~\"\\n',s+=\"                 / DIGIT / ALPHA\\n\",s+=\"                 ; any VCHAR, except delimiters\\n\",s+=\"\\n\",s+=\"; https://datatracker.ietf.org/doc/html/rfc2616#section-2.2\\n\",s+=\"CHAR           = %x01-7F ; any US-ASCII character (octets 0 - 127)\\n\",s+=\"CTL            = %x00-1F / %x7F ; any US-ASCII control character\\n\",s+='separators     = \"(\" / \")\" / \"<\" / \">\" / \"@\" / \",\" / \";\" / \":\" / \"\\\\\" / %x22 / \"/\" / \"[\" / \"]\" / \"?\" / \"=\" / \"{\" / \"}\" / SP / HT\\n',s+=\"SP             = %x20 ; US-ASCII SP, space (32)\\n\",s+=\"HT             = %x09 ; US-ASCII HT, horizontal-tab (9)\\n\",s+=\"\\n\",s+=\"; https://datatracker.ietf.org/doc/html/rfc5234#appendix-B.1\\n\",s+=\"ALPHA          =  %x41-5A / %x61-7A ; A-Z / a-z\\n\",s+=\"DIGIT          =  %x30-39 ; 0-9\\n\",s+='DQUOTE         =  %x22 ; \" (Double Quote)\\n',s+=\"WSP            =  SP / HTAB ; white space\\n\",s+=\"HTAB           =  %x09 ; horizontal tab\\n\",s+=\"CRLF           =  CR LF ; Internet standard newline\\n\",s+=\"CR             =  %x0D ; carriage return\\n\",s+=\"LF             =  %x0A ; linefeed\\n\",'; Lenient version of https://datatracker.ietf.org/doc/html/rfc6265#section-4.2.1\\nlenient-cookie-string        = lenient-cookie-entry *( \";\" OWS lenient-cookie-entry )\\nlenient-cookie-entry         = lenient-cookie-pair / lenient-cookie-pair-invalid\\nlenient-cookie-pair          = OWS lenient-cookie-name OWS \"=\" OWS lenient-cookie-value OWS\\nlenient-cookie-pair-invalid  = OWS 1*tchar OWS ; Allow for standalone entries like \"fizz\" to be ignored\\nlenient-cookie-name          = 1*( %x21-3A / %x3C / %x3E-7E ) ; Allow all printable US-ASCII except \"=\"\\nlenient-cookie-value         = lenient-quoted-value [ *lenient-cookie-octet ] / *lenient-cookie-octet\\nlenient-quoted-value         = DQUOTE *( lenient-quoted-char ) DQUOTE\\nlenient-quoted-char          = %x20-21 / %x23-7E ; Allow all printable US-ASCII except DQUOTE\\nlenient-cookie-octet         = %x21-2B / %x2D-3A / %x3C-7E\\n                             ; Allow all printable characters except CTLs, semicolon and SP\\n\\n; https://datatracker.ietf.org/doc/html/rfc6265#section-4.2.1\\ncookie-string     = cookie-pair *( \";\" SP cookie-pair )\\n\\n; https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1\\n; https://www.rfc-editor.org/errata/eid5518\\ncookie-pair       = cookie-name \"=\" cookie-value\\ncookie-name       = token\\ncookie-value      = ( DQUOTE *cookie-octet DQUOTE ) / *cookie-octet\\n                  ; https://www.rfc-editor.org/errata/eid8242\\ncookie-octet      = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E\\n                       ; US-ASCII characters excluding CTLs,\\n                       ; whitespace, DQUOTE, comma, semicolon,\\n                       ; and backslash\\n\\n; https://datatracker.ietf.org/doc/html/rfc6265#section-2.2\\nOWS            = *( [ CRLF ] WSP ) ; \"optional\" whitespace\\n\\n; https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2\\ntoken          = 1*(tchar)\\ntchar          = \"!\" / \"#\" / \"$\" / \"%\" / \"&\" / \"\\'\" / \"*\"\\n                 / \"+\" / \"-\" / \".\" / \"^\" / \"_\" / \"`\" / \"|\" / \"~\"\\n                 / DIGIT / ALPHA\\n                 ; any VCHAR, except delimiters\\n\\n; https://datatracker.ietf.org/doc/html/rfc2616#section-2.2\\nCHAR           = %x01-7F ; any US-ASCII character (octets 0 - 127)\\nCTL            = %x00-1F / %x7F ; any US-ASCII control character\\nseparators     = \"(\" / \")\" / \"<\" / \">\" / \"@\" / \",\" / \";\" / \":\" / \"\\\\\" / %x22 / \"/\" / \"[\" / \"]\" / \"?\" / \"=\" / \"{\" / \"}\" / SP / HT\\nSP             = %x20 ; US-ASCII SP, space (32)\\nHT             = %x09 ; US-ASCII HT, horizontal-tab (9)\\n\\n; https://datatracker.ietf.org/doc/html/rfc5234#appendix-B.1\\nALPHA          =  %x41-5A / %x61-7A ; A-Z / a-z\\nDIGIT          =  %x30-39 ; 0-9\\nDQUOTE         =  %x22 ; \" (Double Quote)\\nWSP            =  SP / HTAB ; white space\\nHTAB           =  %x09 ; horizontal tab\\nCRLF           =  CR LF ; Internet standard newline\\nCR             =  %x0D ; carriage return\\nLF             =  %x0A ; linefeed\\n'}}new grammar_grammar;const utils_percentEncodeChar=s=>{if(\"string\"!=typeof s||1!==[...s].length)throw new TypeError(\"Input must be a single character string.\");const o=s.codePointAt(0);return o<=127?`%${o.toString(16).toUpperCase().padStart(2,\"0\")}`:encodeURIComponent(s)},utils_isQuoted=s=>s.length>=2&&s.startsWith('\"')&&s.endsWith('\"'),utils_unquote=s=>utils_isQuoted(s)?s.slice(1,-1):s,utils_quote=s=>`\"${s}\"`,utils_identity=s=>s,qx=new yp,Ux=new grammar_grammar,test_cookie_value=(s,{strict:o=!0,quoted:i=null}={})=>{try{const a=o?\"cookie-value\":\"lenient-cookie-value\",u=qx.parse(Ux,a,s);return\"boolean\"==typeof i?u.success&&i===utils_isQuoted(s):u.success}catch{return!1}},base64_browser=s=>{const o=(new TextEncoder).encode(s).reduce(((s,o)=>s+String.fromCharCode(o)),\"\");return btoa(o)},cookie_value_strict_base64=(s,o=base64_browser)=>{const i=String(s);if(test_cookie_value(i))return i;const a=utils_isQuoted(i),u=o(a?utils_unquote(i):i);return a?utils_quote(u):u},base64url_browser=s=>(s=>s.replace(/\\+/g,\"-\").replace(/\\//g,\"_\").replace(/=+$/g,\"\"))(base64_browser(s)),cookie_value_strict_base64url=s=>cookie_value_strict_base64(s,base64url_browser),Vx=new yp,zx=new grammar_grammar,test_cookie_name=(s,{strict:o=!0}={})=>{try{const i=o?\"cookie-name\":\"lenient-cookie-name\";return Vx.parse(zx,i,s).success}catch{return!1}},cookie_name_strict=s=>{if(!test_cookie_name(s))throw new TypeError(`Invalid cookie name: ${s}`)},cookie_value_strict=s=>{if(!test_cookie_value(s))throw new TypeError(`Invalid cookie value: ${s}`)},Wx={encoders:{name:utils_identity,value:cookie_value_strict_base64url},validators:{name:cookie_name_strict,value:cookie_value_strict}},set_cookie_serialize=(s,o,i={})=>{const a={...Wx,...i,encoders:{...Wx.encoders,...i.encoders},validators:{...Wx.validators,...i.validators}},u=a.encoders.name(s),_=a.encoders.value(o);return a.validators.name(u),a.validators.value(_),`${u}=${_}`},cookie_serialize=(s,o={})=>(Array.isArray(s)?s:\"object\"==typeof s&&null!==s?Object.entries(s):[]).map((([s,i])=>set_cookie_serialize(s,i,o))).join(\"; \"),Jx=new yp,Hx=new grammar_grammar,cookie_value_strict_percent=s=>{const o=String(s);if(test_cookie_value(o))return o;const i=utils_isQuoted(o),a=i?utils_unquote(o):o;let u=\"\";for(const s of a)u+=Jx.parse(Hx,\"cookie-octet\",s).success?s:utils_percentEncodeChar(s);return i?utils_quote(u):u},Kx=(new yp,new grammar_grammar,s=>{if(!test_cookie_name(s,{strict:!1}))throw new TypeError(`Invalid cookie name: ${s}`)}),valuePercentEncoder=s=>cookie_value_strict_percent(s).replace(/[=&]/gu,(s=>\"=\"===s?\"%3D\":\"%26\")),helpers_cookie_serialize=(s,o={})=>cookie_serialize(s,ep({encoders:{name:utils_identity,value:valuePercentEncoder},validators:{name:Kx,value:cookie_value_strict}},o));function parameter_builders_path({req:s,value:o,parameter:i,baseURL:a}){const{name:u,style:_,explode:w,content:x}=i;if(void 0===o)return;const C=s.url.replace(a,\"\");let j;if(x){const s=Object.keys(x)[0];j=es_resolve(C,{[u]:o},{encoder:o=>encodeCharacters(serialize(o,s))})}else j=es_resolve(C,{[u]:o},{encoder:s=>stylize({key:i.name,value:s,style:_||\"simple\",explode:null!=w&&w,escape:\"reserved\"})});s.url=a+j}function query({req:s,value:o,parameter:i}){if(s.query=s.query||{},void 0!==o&&i.content){const a=serialize(o,Object.keys(i.content)[0]);if(a)s.query[i.name]=a;else if(i.allowEmptyValue){const o=i.name;s.query[o]=s.query[o]||{},s.query[o].allowEmptyValue=!0}}else if(!1===o&&(o=\"false\"),0===o&&(o=\"0\"),o){const{style:a,explode:u,allowReserved:_}=i;s.query[i.name]={value:o,serializationOption:{style:a,explode:u,allowReserved:_}}}else if(i.allowEmptyValue&&void 0!==o){const o=i.name;s.query[o]=s.query[o]||{},s.query[o].allowEmptyValue=!0}}const Gx=[\"accept\",\"authorization\",\"content-type\"];function parameter_builders_header({req:s,parameter:o,value:i}){if(s.headers=s.headers||{},!(Gx.indexOf(o.name.toLowerCase())>-1))if(void 0!==i&&o.content){const a=Object.keys(o.content)[0];s.headers[o.name]=serialize(i,a)}else void 0===i||Array.isArray(i)&&0===i.length||(s.headers[o.name]=stylize({key:o.name,value:i,style:o.style||\"simple\",explode:void 0!==o.explode&&o.explode,escape:!1}))}function cookie({req:s,parameter:o,value:i}){const{name:a}=o;if(s.headers=s.headers||{},void 0!==i&&o.content){const u=serialize(i,Object.keys(o.content)[0]);s.headers.Cookie=helpers_cookie_serialize({[a]:u})}else if(void 0!==i&&(!Array.isArray(i)||0!==i.length)){var u;const _=stylize({key:o.name,value:i,escape:!1,style:o.style||\"form\",explode:null!==(u=o.explode)&&void 0!==u&&u}),w=Array.isArray(i)&&o.explode?`${a}=${_}`:_;s.headers.Cookie=helpers_cookie_serialize({[a]:w})}}const Yx=\"undefined\"!=typeof globalThis?globalThis:\"undefined\"!=typeof self?self:window,{btoa:Xx}=Yx,Qx=Xx;function buildRequest(s,o){const{operation:i,requestBody:a,securities:u,spec:_,attachContentTypeForEmptyPayload:w}=s;let{requestContentType:x}=s;o=function applySecurities({request:s,securities:o={},operation:i={},spec:a}){var u;const _={...s},{authorized:w={}}=o,x=i.security||a.security||[],C=w&&!!Object.keys(w).length,j=(null==a||null===(u=a.components)||void 0===u?void 0:u.securitySchemes)||{};if(_.headers=_.headers||{},_.query=_.query||{},!Object.keys(o).length||!C||!x||Array.isArray(i.security)&&!i.security.length)return s;return x.forEach((s=>{Object.keys(s).forEach((s=>{const o=w[s],i=j[s];if(!o)return;const a=o.value||o,{type:u}=i;if(o)if(\"apiKey\"===u)\"query\"===i.in&&(_.query[i.name]=a),\"header\"===i.in&&(_.headers[i.name]=a),\"cookie\"===i.in&&(_.cookies[i.name]=a);else if(\"http\"===u){if(/^basic$/i.test(i.scheme)){const s=a.username||\"\",o=a.password||\"\",i=Qx(`${s}:${o}`);_.headers.Authorization=`Basic ${i}`}/^bearer$/i.test(i.scheme)&&(_.headers.Authorization=`Bearer ${a}`)}else if(\"oauth2\"===u||\"openIdConnect\"===u){const s=o.token||{},a=s[i[\"x-tokenName\"]||\"access_token\"];let u=s.token_type;u&&\"bearer\"!==u.toLowerCase()||(u=\"Bearer\"),_.headers.Authorization=`${u} ${a}`}}))})),_}({request:o,securities:u,operation:i,spec:_});const C=i.requestBody||{},j=Object.keys(C.content||{}),L=x&&j.indexOf(x)>-1;if(a||w){if(x&&L)o.headers[\"Content-Type\"]=x;else if(!x){const s=j[0];s&&(o.headers[\"Content-Type\"]=s,x=s)}}else x&&L&&(o.headers[\"Content-Type\"]=x);if(!s.responseContentType&&i.responses){const s=Object.entries(i.responses).filter((([s,o])=>{const i=parseInt(s,10);return i>=200&&i<300&&fu(o.content)})).reduce(((s,[,o])=>s.concat(Object.keys(o.content))),[]);s.length>0&&(o.headers.accept=s.join(\", \"))}if(a)if(x){if(j.indexOf(x)>-1)if(\"application/x-www-form-urlencoded\"===x||\"multipart/form-data\"===x)if(\"object\"==typeof a){var B,$;const s=null!==(B=null===($=C.content[x])||void 0===$?void 0:$.encoding)&&void 0!==B?B:{};o.form={},Object.keys(a).forEach((i=>{let u;try{u=JSON.parse(a[i])}catch{u=a[i]}o.form[i]={value:u,encoding:s[i]||{}}}))}else if(\"string\"==typeof a){var U,V;const s=null!==(U=null===(V=C.content[x])||void 0===V?void 0:V.encoding)&&void 0!==U?U:{};try{o.form={};const i=JSON.parse(a);Object.entries(i).forEach((([i,a])=>{o.form[i]={value:a,encoding:s[i]||{}}}))}catch{o.form=a}}else o.form=a;else o.body=a}else o.body=a;return o}function build_request_buildRequest(s,o){const{spec:i,operation:a,securities:u,requestContentType:_,responseContentType:w,attachContentTypeForEmptyPayload:x}=s;if(o=function build_request_applySecurities({request:s,securities:o={},operation:i={},spec:a}){const u={...s},{authorized:_={},specSecurity:w=[]}=o,x=i.security||w,C=_&&!!Object.keys(_).length,j=a.securityDefinitions;if(u.headers=u.headers||{},u.query=u.query||{},!Object.keys(o).length||!C||!x||Array.isArray(i.security)&&!i.security.length)return s;return x.forEach((s=>{Object.keys(s).forEach((s=>{const o=_[s];if(!o)return;const{token:i}=o,a=o.value||o,w=j[s],{type:x}=w,C=w[\"x-tokenName\"]||\"access_token\",L=i&&i[C];let B=i&&i.token_type;if(o)if(\"apiKey\"===x){const s=\"query\"===w.in?\"query\":\"headers\";u[s]=u[s]||{},u[s][w.name]=a}else if(\"basic\"===x)if(a.header)u.headers.authorization=a.header;else{const s=a.username||\"\",o=a.password||\"\";a.base64=Qx(`${s}:${o}`),u.headers.authorization=`Basic ${a.base64}`}else\"oauth2\"===x&&L&&(B=B&&\"bearer\"!==B.toLowerCase()?B:\"Bearer\",u.headers.authorization=`${B} ${L}`)}))})),u}({request:o,securities:u,operation:a,spec:i}),o.body||o.form||x)_?o.headers[\"Content-Type\"]=_:Array.isArray(a.consumes)?[o.headers[\"Content-Type\"]]=a.consumes:Array.isArray(i.consumes)?[o.headers[\"Content-Type\"]]=i.consumes:a.parameters&&a.parameters.filter((s=>\"file\"===s.type)).length?o.headers[\"Content-Type\"]=\"multipart/form-data\":a.parameters&&a.parameters.filter((s=>\"formData\"===s.in)).length&&(o.headers[\"Content-Type\"]=\"application/x-www-form-urlencoded\");else if(_){const s=a.parameters&&a.parameters.filter((s=>\"body\"===s.in)).length>0,i=a.parameters&&a.parameters.filter((s=>\"formData\"===s.in)).length>0;(s||i)&&(o.headers[\"Content-Type\"]=_)}return!w&&Array.isArray(a.produces)&&a.produces.length>0&&(o.headers.accept=a.produces.join(\", \")),o}function idFromPathMethodLegacy(s,o){return`${o.toLowerCase()}-${s}`}const arrayOrEmpty=s=>Array.isArray(s)?s:[],findObjectOrArraySchema=(s,{recurse:o=!0,depth:i=1}={})=>{if(fu(s)){if(\"object\"===s.type||\"array\"===s.type||Array.isArray(s.type)&&(s.type.includes(\"object\")||s.type.includes(\"array\")))return s;if(!(i>Bl)&&o){const a=Array.isArray(s.oneOf)?s.oneOf.find((s=>findObjectOrArraySchema(s,{recurse:o,depth:i+1}))):void 0;if(a)return a;const u=Array.isArray(s.anyOf)?s.anyOf.find((s=>findObjectOrArraySchema(s,{recurse:o,depth:i+1}))):void 0;if(u)return u}}},parseJsonObjectOrArray=({value:s,silentFail:o=!1})=>{try{const i=JSON.parse(s);if(fu(i)||Array.isArray(i))return i;if(!o)throw new Error(\"Expected JSON serialized object or array\")}catch{if(!o)throw new Error(\"Could not parse parameter value string as JSON Object or JSON Array\")}return s},parseURIReference=s=>{try{return new URL(s)}catch{const o=new URL(s,Ll),i=String(s).startsWith(\"/\")?o.pathname:o.pathname.substring(1);return{hash:o.hash,host:\"\",hostname:\"\",href:\"\",origin:\"\",password:\"\",pathname:i,port:\"\",protocol:\"\",search:o.search,searchParams:o.searchParams}}};class OperationNotFoundError extends Go{}const Zx={buildRequest:execute_buildRequest};function execute_execute({http:s,fetch:o,spec:i,operationId:a,pathName:u,method:_,parameters:w,securities:x,...C}){const j=s||o||http_http;u&&_&&!a&&(a=idFromPathMethodLegacy(u,_));const L=Zx.buildRequest({spec:i,operationId:a,parameters:w,securities:x,http:j,...C});return L.body&&(fu(L.body)||Array.isArray(L.body))&&(L.body=JSON.stringify(L.body)),j(L)}function execute_buildRequest(s){const{spec:o,operationId:i,responseContentType:a,scheme:u,requestInterceptor:_,responseInterceptor:w,contextUrl:x,userFetch:C,server:j,serverVariables:L,http:B,signal:$,serverVariableEncoder:U}=s;let{parameters:V,parameterBuilders:z,baseURL:Y}=s;const Z=isOpenAPI3(o);z||(z=Z?be:$x);let ee={url:\"\",credentials:B&&B.withCredentials?\"include\":\"same-origin\",headers:{},cookies:{}};$&&(ee.signal=$),_&&(ee.requestInterceptor=_),w&&(ee.responseInterceptor=w),C&&(ee.userFetch=C);const ie=function getOperationRaw(s,o){return s&&s.paths?function findOperation(s,o){return function eachOperation(s,o,i){if(!s||\"object\"!=typeof s||!s.paths||\"object\"!=typeof s.paths)return null;const{paths:a}=s;for(const u in a)for(const _ in a[u]){if(\"PARAMETERS\"===_.toUpperCase())continue;const w=a[u][_];if(!w||\"object\"!=typeof w)continue;const x={spec:s,pathName:u,method:_.toUpperCase(),operation:w},C=o(x);if(i&&C)return x}}(s,o,!0)||null}(s,(({pathName:s,method:i,operation:a})=>{if(!a||\"object\"!=typeof a)return!1;const u=a.operationId;return[opId(a,s,i),idFromPathMethodLegacy(s,i),u].some((s=>s&&s===o))})):null}(o,i);if(!ie)throw new OperationNotFoundError(`Operation ${i} not found`);const{operation:ae={},method:ce,pathName:le}=ie;if(Y=null!=Y?Y:function baseUrl(s){const o=isOpenAPI3(s.spec);return o?function oas3BaseUrl({spec:s,pathName:o,method:i,server:a,contextUrl:u,serverVariables:_={},serverVariableEncoder:w}){var x,C;let j,L=[],B=\"\";const $=null==s||null===(x=s.paths)||void 0===x||null===(x=x[o])||void 0===x||null===(x=x[(i||\"\").toLowerCase()])||void 0===x?void 0:x.servers,U=null==s||null===(C=s.paths)||void 0===C||null===(C=C[o])||void 0===C?void 0:C.servers,V=null==s?void 0:s.servers;L=isNonEmptyServerList($)?$:isNonEmptyServerList(U)?U:isNonEmptyServerList(V)?V:[Fl],a&&(j=L.find((s=>s.url===a)),j&&(B=a));B||([j]=L,B=j.url);if(openapi_server_url_templating_es_test(B,{strict:!0})){const s=Object.entries({...j.variables}).reduce(((s,[o,i])=>(s[o]=i.default,s)),{});B=es_substitute(B,{...s,..._},{encoder:\"function\"==typeof w?w:bw})}return function buildOas3UrlWithContext(s=\"\",o=\"\"){const i=parseURIReference(s&&o?resolve(o,s):s),a=parseURIReference(o),u=stripNonAlpha(i.protocol)||stripNonAlpha(a.protocol),_=i.host||a.host,w=i.pathname;let x;x=u&&_?`${u}://${_+w}`:w;return\"/\"===x[x.length-1]?x.slice(0,-1):x}(B,u)}(s):function swagger2BaseUrl({spec:s,scheme:o,contextUrl:i=\"\"}){const a=parseURIReference(i),u=Array.isArray(s.schemes)?s.schemes[0]:null,_=o||u||stripNonAlpha(a.protocol)||\"http\",w=s.host||a.host||\"\",x=s.basePath||\"\";let C;C=_&&w?`${_}://${w+x}`:x;return\"/\"===C[C.length-1]?C.slice(0,-1):C}(s)}({spec:o,scheme:u,contextUrl:x,server:j,serverVariables:L,pathName:le,method:ce,serverVariableEncoder:U}),ee.url+=Y,!i)return delete ee.cookies,ee;ee.url+=le,ee.method=`${ce}`.toUpperCase(),V=V||{};const pe=o.paths[le]||{};a&&(ee.headers.accept=a);const de=(s=>{const o={};s.forEach((s=>{o[s.in]||(o[s.in]={}),o[s.in][s.name]=s}));const i=[];return Object.keys(o).forEach((s=>{Object.keys(o[s]).forEach((a=>{i.push(o[s][a])}))})),i})([].concat(arrayOrEmpty(ae.parameters)).concat(arrayOrEmpty(pe.parameters)));de.forEach((s=>{const i=z[s.in];let a;if(\"body\"===s.in&&s.schema&&s.schema.properties&&(a=V),a=s&&s.name&&V[s.name],void 0===a?a=s&&s.name&&V[`${s.in}.${s.name}`]:((s,o)=>o.filter((o=>o.name===s)))(s.name,de).length>1&&console.warn(`Parameter '${s.name}' is ambiguous because the defined spec has more than one parameter with the name: '${s.name}' and the passed-in parameter values did not define an 'in' value.`),null!==a){if(void 0!==s.default&&void 0===a&&(a=s.default),void 0===a&&s.required&&!s.allowEmptyValue)throw new Error(`Required parameter ${s.name} is not provided`);Z&&\"string\"==typeof a&&(id(\"type\",s.schema)&&\"string\"==typeof s.schema.type&&findObjectOrArraySchema(s.schema,{recurse:!1})?a=parseJsonObjectOrArray({value:a,silentFail:!1}):(id(\"type\",s.schema)&&Array.isArray(s.schema.type)&&findObjectOrArraySchema(s.schema,{recurse:!1})||!id(\"type\",s.schema)&&findObjectOrArraySchema(s.schema,{recurse:!0}))&&(a=parseJsonObjectOrArray({value:a,silentFail:!0}))),i&&i({req:ee,parameter:s,value:a,operation:ae,spec:o,baseURL:Y})}}));const fe={...s,operation:ae};if(ee=Z?buildRequest(fe,ee):build_request_buildRequest(fe,ee),ee.cookies&&Object.keys(ee.cookies).length>0){const s=helpers_cookie_serialize(ee.cookies);Nd(ee.headers.Cookie)?ee.headers.Cookie+=`; ${s}`:ee.headers.Cookie=s}return ee.cookies&&delete ee.cookies,serializeRequest(ee)}const stripNonAlpha=s=>s?s.replace(/\\W/g,\"\"):null;const isNonEmptyServerList=s=>Array.isArray(s)&&s.length>0;const makeResolveSubtree=s=>async(o,i,a={})=>(async(s,o,i={})=>{const{returnEntireTree:a,baseDoc:u,requestInterceptor:_,responseInterceptor:w,parameterMacro:x,modelPropertyMacro:C,useCircularStructures:j,strategies:L}=i,B={spec:s,pathDiscriminator:o,baseDoc:u,requestInterceptor:_,responseInterceptor:w,parameterMacro:x,modelPropertyMacro:C,useCircularStructures:j,strategies:L},$=L.find((o=>o.match(s))).normalize(s),U=await Rx({spec:$,...B,allowMetaPatches:!0,skipNormalization:!isOpenAPI31(s)});return!a&&Array.isArray(o)&&o.length&&(U.spec=o.reduce(((s,o)=>null==s?void 0:s[o]),U.spec)||null),U})(o,i,{...s,...a}),tk=(makeResolveSubtree({strategies:[_u,vu,gu]}),(s,o)=>(...i)=>{s(...i);const a=o.getConfigs().withCredentials;o.fn.fetch.withCredentials=a});function swagger_client({configs:s,getConfigs:o}){return{fn:{fetch:(i=http_http,a=s.preFetch,u=s.postFetch,u=u||(s=>s),a=a||(s=>s),s=>(\"string\"==typeof s&&(s={url:s}),s=serializeRequest(s),s=a(s),u(i(s)))),buildRequest:execute_buildRequest,execute:execute_execute,resolve:makeResolve({strategies:[Mx,_u,vu,gu]}),resolveSubtree:async(s,i,a={})=>{const u=o(),_={modelPropertyMacro:u.modelPropertyMacro,parameterMacro:u.parameterMacro,requestInterceptor:u.requestInterceptor,responseInterceptor:u.responseInterceptor,strategies:[Mx,_u,vu,gu]};return makeResolveSubtree(_)(s,i,a)},serializeRes:serializeResponse,opId},statePlugins:{configs:{wrapActions:{loaded:tk}}}};var i,a,u}function util(){return{fn:{shallowEqualKeys,sanitizeUrl}}}var rk=__webpack_require__(40961),nk=(__webpack_require__(78418),Re.version.startsWith(\"19\")),sk=Symbol.for(nk?\"react.transitional.element\":\"react.element\"),ok=Symbol.for(\"react.portal\"),lk=Symbol.for(\"react.fragment\"),uk=Symbol.for(\"react.strict_mode\"),pk=Symbol.for(\"react.profiler\"),fk=Symbol.for(\"react.consumer\"),mk=Symbol.for(\"react.context\"),yk=Symbol.for(\"react.forward_ref\"),vk=Symbol.for(\"react.suspense\"),_k=Symbol.for(\"react.suspense_list\"),wk=Symbol.for(\"react.memo\"),xk=Symbol.for(\"react.lazy\"),Ak=yk,Bk=wk;function typeOf(s){if(\"object\"==typeof s&&null!==s){const{$$typeof:o}=s;switch(o){case sk:switch(s=s.type){case lk:case pk:case uk:case vk:case _k:return s;default:switch(s=s&&s.$$typeof){case mk:case yk:case xk:case wk:case fk:return s;default:return o}}case ok:return o}}}function pureFinalPropsSelectorFactory(s,o,i,a,{areStatesEqual:u,areOwnPropsEqual:_,areStatePropsEqual:w}){let x,C,j,L,B,$=!1;function handleSubsequentCalls($,U){const V=!_(U,C),z=!u($,x,U,C);return x=$,C=U,V&&z?function handleNewPropsAndNewState(){return j=s(x,C),o.dependsOnOwnProps&&(L=o(a,C)),B=i(j,L,C),B}():V?function handleNewProps(){return s.dependsOnOwnProps&&(j=s(x,C)),o.dependsOnOwnProps&&(L=o(a,C)),B=i(j,L,C),B}():z?function handleNewState(){const o=s(x,C),a=!w(o,j);return j=o,a&&(B=i(j,L,C)),B}():B}return function pureFinalPropsSelector(u,_){return $?handleSubsequentCalls(u,_):function handleFirstCall(u,_){return x=u,C=_,j=s(x,C),L=o(a,C),B=i(j,L,C),$=!0,B}(u,_)}}function wrapMapToPropsConstant(s){return function initConstantSelector(o){const i=s(o);function constantSelector(){return i}return constantSelector.dependsOnOwnProps=!1,constantSelector}}function getDependsOnOwnProps(s){return s.dependsOnOwnProps?Boolean(s.dependsOnOwnProps):1!==s.length}function wrapMapToPropsFunc(s,o){return function initProxySelector(o,{displayName:i}){const a=function mapToPropsProxy(s,o){return a.dependsOnOwnProps?a.mapToProps(s,o):a.mapToProps(s,void 0)};return a.dependsOnOwnProps=!0,a.mapToProps=function detectFactoryAndVerify(o,i){a.mapToProps=s,a.dependsOnOwnProps=getDependsOnOwnProps(s);let u=a(o,i);return\"function\"==typeof u&&(a.mapToProps=u,a.dependsOnOwnProps=getDependsOnOwnProps(u),u=a(o,i)),u},a}}function createInvalidArgFactory(s,o){return(i,a)=>{throw new Error(`Invalid value of type ${typeof s} for ${o} argument when connecting component ${a.wrappedComponentName}.`)}}function defaultMergeProps(s,o,i){return{...i,...s,...o}}function defaultNoopBatch(s){s()}var qk={notify(){},get:()=>[]};function createSubscription(s,o){let i,a=qk,u=0,_=!1;function handleChangeWrapper(){w.onStateChange&&w.onStateChange()}function trySubscribe(){u++,i||(i=o?o.addNestedSub(handleChangeWrapper):s.subscribe(handleChangeWrapper),a=function createListenerCollection(){let s=null,o=null;return{clear(){s=null,o=null},notify(){defaultNoopBatch((()=>{let o=s;for(;o;)o.callback(),o=o.next}))},get(){const o=[];let i=s;for(;i;)o.push(i),i=i.next;return o},subscribe(i){let a=!0;const u=o={callback:i,next:null,prev:o};return u.prev?u.prev.next=u:s=u,function unsubscribe(){a&&null!==s&&(a=!1,u.next?u.next.prev=u.prev:o=u.prev,u.prev?u.prev.next=u.next:s=u.next)}}}}())}function tryUnsubscribe(){u--,i&&0===u&&(i(),i=void 0,a.clear(),a=qk)}const w={addNestedSub:function addNestedSub(s){trySubscribe();const o=a.subscribe(s);let i=!1;return()=>{i||(i=!0,o(),tryUnsubscribe())}},notifyNestedSubs:function notifyNestedSubs(){a.notify()},handleChangeWrapper,isSubscribed:function isSubscribed(){return _},trySubscribe:function trySubscribeSelf(){_||(_=!0,trySubscribe())},tryUnsubscribe:function tryUnsubscribeSelf(){_&&(_=!1,tryUnsubscribe())},getListeners:()=>a};return w}var Vk=(()=>!(\"undefined\"==typeof window||void 0===window.document||void 0===window.document.createElement))(),zk=(()=>\"undefined\"!=typeof navigator&&\"ReactNative\"===navigator.product)(),eO=(()=>Vk||zk?Re.useLayoutEffect:Re.useEffect)();function is(s,o){return s===o?0!==s||0!==o||1/s==1/o:s!=s&&o!=o}function shallowEqual(s,o){if(is(s,o))return!0;if(\"object\"!=typeof s||null===s||\"object\"!=typeof o||null===o)return!1;const i=Object.keys(s),a=Object.keys(o);if(i.length!==a.length)return!1;for(let a=0;a<i.length;a++)if(!Object.prototype.hasOwnProperty.call(o,i[a])||!is(s[i[a]],o[i[a]]))return!1;return!0}var tO={childContextTypes:!0,contextType:!0,contextTypes:!0,defaultProps:!0,displayName:!0,getDefaultProps:!0,getDerivedStateFromError:!0,getDerivedStateFromProps:!0,mixins:!0,propTypes:!0,type:!0},rO={name:!0,length:!0,prototype:!0,caller:!0,callee:!0,arguments:!0,arity:!0},nO={$$typeof:!0,compare:!0,defaultProps:!0,displayName:!0,propTypes:!0,type:!0},sO={[Ak]:{$$typeof:!0,render:!0,defaultProps:!0,displayName:!0,propTypes:!0},[Bk]:nO};function getStatics(s){return function isMemo(s){return typeOf(s)===wk}(s)?nO:sO[s.$$typeof]||tO}var oO=Object.defineProperty,iO=Object.getOwnPropertyNames,aO=Object.getOwnPropertySymbols,cO=Object.getOwnPropertyDescriptor,lO=Object.getPrototypeOf,uO=Object.prototype;function hoistNonReactStatics(s,o){if(\"string\"!=typeof o){if(uO){const i=lO(o);i&&i!==uO&&hoistNonReactStatics(s,i)}let i=iO(o);aO&&(i=i.concat(aO(o)));const a=getStatics(s),u=getStatics(o);for(let _=0;_<i.length;++_){const w=i[_];if(!(rO[w]||u&&u[w]||a&&a[w])){const i=cO(o,w);try{oO(s,w,i)}catch(s){}}}}return s}var pO=Symbol.for(\"react-redux-context\"),hO=\"undefined\"!=typeof globalThis?globalThis:{};function getContext(){if(!Re.createContext)return{};const s=hO[pO]??=new Map;let o=s.get(Re.createContext);return o||(o=Re.createContext(null),s.set(Re.createContext,o)),o}var dO=getContext(),fO=[null,null];function captureWrapperProps(s,o,i,a,u,_){s.current=a,i.current=!1,u.current&&(u.current=null,_())}function strictEqual(s,o){return s===o}var mO=function connect(s,o,i,{pure:a,areStatesEqual:u=strictEqual,areOwnPropsEqual:_=shallowEqual,areStatePropsEqual:w=shallowEqual,areMergedPropsEqual:x=shallowEqual,forwardRef:C=!1,context:j=dO}={}){const L=j,B=function mapStateToPropsFactory(s){return s?\"function\"==typeof s?wrapMapToPropsFunc(s):createInvalidArgFactory(s,\"mapStateToProps\"):wrapMapToPropsConstant((()=>({})))}(s),$=function mapDispatchToPropsFactory(s){return s&&\"object\"==typeof s?wrapMapToPropsConstant((o=>function react_redux_bindActionCreators(s,o){const i={};for(const a in s){const u=s[a];\"function\"==typeof u&&(i[a]=(...s)=>o(u(...s)))}return i}(s,o))):s?\"function\"==typeof s?wrapMapToPropsFunc(s):createInvalidArgFactory(s,\"mapDispatchToProps\"):wrapMapToPropsConstant((s=>({dispatch:s})))}(o),U=function mergePropsFactory(s){return s?\"function\"==typeof s?function wrapMergePropsFunc(s){return function initMergePropsProxy(o,{displayName:i,areMergedPropsEqual:a}){let u,_=!1;return function mergePropsProxy(o,i,w){const x=s(o,i,w);return _?a(x,u)||(u=x):(_=!0,u=x),u}}}(s):createInvalidArgFactory(s,\"mergeProps\"):()=>defaultMergeProps}(i),V=Boolean(s);return s=>{const o=s.displayName||s.name||\"Component\",i=`Connect(${o})`,a={shouldHandleStateChanges:V,displayName:i,wrappedComponentName:o,WrappedComponent:s,initMapStateToProps:B,initMapDispatchToProps:$,initMergeProps:U,areStatesEqual:u,areStatePropsEqual:w,areOwnPropsEqual:_,areMergedPropsEqual:x};function ConnectFunction(o){const[i,u,_]=Re.useMemo((()=>{const{reactReduxForwardedRef:s,...i}=o;return[o.context,s,i]}),[o]),w=Re.useMemo((()=>L),[i,L]),x=Re.useContext(w),C=Boolean(o.store)&&Boolean(o.store.getState)&&Boolean(o.store.dispatch),j=Boolean(x)&&Boolean(x.store);const B=C?o.store:x.store,$=j?x.getServerState:B.getState,U=Re.useMemo((()=>function finalPropsSelectorFactory(s,{initMapStateToProps:o,initMapDispatchToProps:i,initMergeProps:a,...u}){return pureFinalPropsSelectorFactory(o(s,u),i(s,u),a(s,u),s,u)}(B.dispatch,a)),[B]),[z,Y]=Re.useMemo((()=>{if(!V)return fO;const s=createSubscription(B,C?void 0:x.subscription),o=s.notifyNestedSubs.bind(s);return[s,o]}),[B,C,x]),Z=Re.useMemo((()=>C?x:{...x,subscription:z}),[C,x,z]),ee=Re.useRef(void 0),ie=Re.useRef(_),ae=Re.useRef(void 0),ce=Re.useRef(!1),le=Re.useRef(!1),pe=Re.useRef(void 0);eO((()=>(le.current=!0,()=>{le.current=!1})),[]);const de=Re.useMemo((()=>()=>ae.current&&_===ie.current?ae.current:U(B.getState(),_)),[B,_]),fe=Re.useMemo((()=>s=>z?function subscribeUpdates(s,o,i,a,u,_,w,x,C,j,L){if(!s)return()=>{};let B=!1,$=null;const checkForUpdates=()=>{if(B||!x.current)return;const s=o.getState();let i,U;try{i=a(s,u.current)}catch(s){U=s,$=s}U||($=null),i===_.current?w.current||j():(_.current=i,C.current=i,w.current=!0,L())};return i.onStateChange=checkForUpdates,i.trySubscribe(),checkForUpdates(),()=>{if(B=!0,i.tryUnsubscribe(),i.onStateChange=null,$)throw $}}(V,B,z,U,ie,ee,ce,le,ae,Y,s):()=>{}),[z]);let ye;!function useIsomorphicLayoutEffectWithArgs(s,o,i){eO((()=>s(...o)),i)}(captureWrapperProps,[ie,ee,ce,_,ae,Y]);try{ye=Re.useSyncExternalStore(fe,de,$?()=>U($(),_):de)}catch(s){throw pe.current&&(s.message+=`\\nThe error may be correlated with this previous error:\\n${pe.current.stack}\\n\\n`),s}eO((()=>{pe.current=void 0,ae.current=void 0,ee.current=ye}));const be=Re.useMemo((()=>Re.createElement(s,{...ye,ref:u})),[u,s,ye]);return Re.useMemo((()=>V?Re.createElement(w.Provider,{value:Z},be):be),[w,be,Z])}const j=Re.memo(ConnectFunction);if(j.WrappedComponent=s,j.displayName=ConnectFunction.displayName=i,C){const o=Re.forwardRef((function forwardConnectRef(s,o){return Re.createElement(j,{...s,reactReduxForwardedRef:o})}));return o.displayName=i,o.WrappedComponent=s,hoistNonReactStatics(o,s)}return hoistNonReactStatics(j,s)}};var gO=function Provider(s){const{children:o,context:i,serverState:a,store:u}=s,_=Re.useMemo((()=>{const s=createSubscription(u);return{store:u,subscription:s,getServerState:a?()=>a:void 0}}),[u,a]),w=Re.useMemo((()=>u.getState()),[u]);eO((()=>{const{subscription:s}=_;return s.onStateChange=s.notifyNestedSubs,s.trySubscribe(),w!==u.getState()&&s.notifyNestedSubs(),()=>{s.tryUnsubscribe(),s.onStateChange=void 0}}),[_,w]);const x=i||dO;return Re.createElement(x.Provider,{value:_},o)};var yO=__webpack_require__(83488),vO=__webpack_require__.n(yO);const withSystem=s=>o=>{const{fn:i}=s();class WithSystem extends Re.Component{render(){return Re.createElement(o,Mn()({},s(),this.props,this.context))}}return WithSystem.displayName=`WithSystem(${i.getDisplayName(o)})`,WithSystem},withRoot=(s,o)=>i=>{const{fn:a}=s();class WithRoot extends Re.Component{render(){return Re.createElement(gO,{store:o},Re.createElement(i,Mn()({},this.props,this.context)))}}return WithRoot.displayName=`WithRoot(${a.getDisplayName(i)})`,WithRoot},withConnect=(s,o,i)=>compose(i?withRoot(s,i):vO(),mO(((i,a)=>{const u={...a,...s()},_=o.prototype?.mapStateToProps||(s=>({state:s}));return _(i,u)})),withSystem(s))(o),handleProps=(s,o,i,a)=>{for(const u in o){const _=o[u];\"function\"==typeof _&&_(i[u],a[u],s())}},withMappedContainer=(s,o,i)=>(o,a)=>{const{fn:u}=s(),_=i(o,\"root\");class WithMappedContainer extends Re.Component{constructor(o,i){super(o,i),handleProps(s,a,o,{})}UNSAFE_componentWillReceiveProps(o){handleProps(s,a,o,this.props)}render(){const s=Gt()(this.props,a?Object.keys(a):[]);return Re.createElement(_,s)}}return WithMappedContainer.displayName=`WithMappedContainer(${u.getDisplayName(_)})`,WithMappedContainer},render=(s,o,i,a)=>u=>{const _=i(s,o,a)(\"App\",\"root\"),{createRoot:w}=rk;w(u).render(Re.createElement(_,null))},getComponent=(s,o,i)=>(a,u,_={})=>{if(\"string\"!=typeof a)throw new TypeError(\"Need a string, to fetch a component. Was given a \"+typeof a);const w=i(a);return w?u?\"root\"===u?withConnect(s,w,o()):withConnect(s,w):w:(_.failSilently||s().log.warn(\"Could not find component:\",a),null)},getDisplayName=s=>s.displayName||s.name||\"Component\",view=({getComponents:s,getStore:o,getSystem:i})=>{const a=(u=getComponent(i,o,s),Pt(u,((...s)=>JSON.stringify(s))));var u;const _=(s=>utils_memoizeN(s,((...s)=>s)))(withMappedContainer(i,0,a));return{rootInjects:{getComponent:a,makeMappedContainer:_,render:render(i,o,getComponent,s)},fn:{getDisplayName}}},view_legacy=({React:s,getSystem:o,getStore:i,getComponents:a})=>{const u={},_=parseInt(s?.version,10);return _>=16&&_<18&&(u.render=((s,o,i,a)=>u=>{const _=i(s,o,a)(\"App\",\"root\");rk.render(Re.createElement(_,null),u)})(o,i,getComponent,a)),{rootInjects:u}};function downloadUrlPlugin(s){let{fn:o}=s;const i={download:s=>({errActions:i,specSelectors:a,specActions:u,getConfigs:_})=>{let{fetch:w}=o;const x=_();function next(o){if(o instanceof Error||o.status>=400)return u.updateLoadingStatus(\"failed\"),i.newThrownErr(Object.assign(new Error((o.message||o.statusText)+\" \"+s),{source:\"fetch\"})),void(!o.status&&o instanceof Error&&function checkPossibleFailReasons(){try{let o;if(\"URL\"in lt?o=new URL(s):(o=document.createElement(\"a\"),o.href=s),\"https:\"!==o.protocol&&\"https:\"===lt.location.protocol){const s=Object.assign(new Error(`Possible mixed-content issue? The page was loaded over https:// but a ${o.protocol}// URL was specified. Check that you are not attempting to load mixed content.`),{source:\"fetch\"});return void i.newThrownErr(s)}if(o.origin!==lt.location.origin){const s=Object.assign(new Error(`Possible cross-origin (CORS) issue? The URL origin (${o.origin}) does not match the page (${lt.location.origin}). Check the server returns the correct 'Access-Control-Allow-*' headers.`),{source:\"fetch\"});i.newThrownErr(s)}}catch(s){return}}());u.updateLoadingStatus(\"success\"),u.updateSpec(o.text),a.url()!==s&&u.updateUrl(s)}s=s||a.url(),u.updateLoadingStatus(\"loading\"),i.clear({source:\"fetch\"}),w({url:s,loadSpec:!0,requestInterceptor:x.requestInterceptor||(s=>s),responseInterceptor:x.responseInterceptor||(s=>s),credentials:\"same-origin\",headers:{Accept:\"application/json,*/*\"}}).then(next,next)},updateLoadingStatus:s=>{let o=[null,\"loading\",\"failed\",\"success\",\"failedConfig\"];return-1===o.indexOf(s)&&console.error(`Error: ${s} is not one of ${JSON.stringify(o)}`),{type:\"spec_update_loading_status\",payload:s}}};let a={loadingStatus:Ut((s=>s||(0,ze.Map)()),(s=>s.get(\"loadingStatus\")||null))};return{statePlugins:{spec:{actions:i,reducers:{spec_update_loading_status:(s,o)=>\"string\"==typeof o.payload?s.set(\"loadingStatus\",o.payload):s},selectors:a}}}}function arrayLikeToArray_arrayLikeToArray(s,o){(null==o||o>s.length)&&(o=s.length);for(var i=0,a=Array(o);i<o;i++)a[i]=s[i];return a}function toConsumableArray_toConsumableArray(s){return function arrayWithoutHoles_arrayWithoutHoles(s){if(Array.isArray(s))return arrayLikeToArray_arrayLikeToArray(s)}(s)||function iterableToArray_iterableToArray(s){if(\"undefined\"!=typeof Symbol&&null!=s[Symbol.iterator]||null!=s[\"@@iterator\"])return Array.from(s)}(s)||function unsupportedIterableToArray_unsupportedIterableToArray(s,o){if(s){if(\"string\"==typeof s)return arrayLikeToArray_arrayLikeToArray(s,o);var i={}.toString.call(s).slice(8,-1);return\"Object\"===i&&s.constructor&&(i=s.constructor.name),\"Map\"===i||\"Set\"===i?Array.from(s):\"Arguments\"===i||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(i)?arrayLikeToArray_arrayLikeToArray(s,o):void 0}}(s)||function nonIterableSpread_nonIterableSpread(){throw new TypeError(\"Invalid attempt to spread non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\")}()}function typeof_typeof(s){return typeof_typeof=\"function\"==typeof Symbol&&\"symbol\"==typeof Symbol.iterator?function(s){return typeof s}:function(s){return s&&\"function\"==typeof Symbol&&s.constructor===Symbol&&s!==Symbol.prototype?\"symbol\":typeof s},typeof_typeof(s)}function toPropertyKey(s){var o=function toPrimitive(s,o){if(\"object\"!=typeof_typeof(s)||!s)return s;var i=s[Symbol.toPrimitive];if(void 0!==i){var a=i.call(s,o||\"default\");if(\"object\"!=typeof_typeof(a))return a;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(\"string\"===o?String:Number)(s)}(s,\"string\");return\"symbol\"==typeof_typeof(o)?o:o+\"\"}function defineProperty_defineProperty(s,o,i){return(o=toPropertyKey(o))in s?Object.defineProperty(s,o,{value:i,enumerable:!0,configurable:!0,writable:!0}):s[o]=i,s}function extends_extends(){return extends_extends=Object.assign?Object.assign.bind():function(s){for(var o=1;o<arguments.length;o++){var i=arguments[o];for(var a in i)({}).hasOwnProperty.call(i,a)&&(s[a]=i[a])}return s},extends_extends.apply(null,arguments)}function create_element_ownKeys(s,o){var i=Object.keys(s);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(s);o&&(a=a.filter((function(o){return Object.getOwnPropertyDescriptor(s,o).enumerable}))),i.push.apply(i,a)}return i}function _objectSpread(s){for(var o=1;o<arguments.length;o++){var i=null!=arguments[o]?arguments[o]:{};o%2?create_element_ownKeys(Object(i),!0).forEach((function(o){defineProperty_defineProperty(s,o,i[o])})):Object.getOwnPropertyDescriptors?Object.defineProperties(s,Object.getOwnPropertyDescriptors(i)):create_element_ownKeys(Object(i)).forEach((function(o){Object.defineProperty(s,o,Object.getOwnPropertyDescriptor(i,o))}))}return s}var bO={};function createStyleObject(s){var o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2?arguments[2]:void 0;return function getClassNameCombinations(s){if(0===s.length||1===s.length)return s;var o=s.join(\".\");return bO[o]||(bO[o]=function powerSetPermutations(s){var o=s.length;return 0===o||1===o?s:2===o?[s[0],s[1],\"\".concat(s[0],\".\").concat(s[1]),\"\".concat(s[1],\".\").concat(s[0])]:3===o?[s[0],s[1],s[2],\"\".concat(s[0],\".\").concat(s[1]),\"\".concat(s[0],\".\").concat(s[2]),\"\".concat(s[1],\".\").concat(s[0]),\"\".concat(s[1],\".\").concat(s[2]),\"\".concat(s[2],\".\").concat(s[0]),\"\".concat(s[2],\".\").concat(s[1]),\"\".concat(s[0],\".\").concat(s[1],\".\").concat(s[2]),\"\".concat(s[0],\".\").concat(s[2],\".\").concat(s[1]),\"\".concat(s[1],\".\").concat(s[0],\".\").concat(s[2]),\"\".concat(s[1],\".\").concat(s[2],\".\").concat(s[0]),\"\".concat(s[2],\".\").concat(s[0],\".\").concat(s[1]),\"\".concat(s[2],\".\").concat(s[1],\".\").concat(s[0])]:o>=4?[s[0],s[1],s[2],s[3],\"\".concat(s[0],\".\").concat(s[1]),\"\".concat(s[0],\".\").concat(s[2]),\"\".concat(s[0],\".\").concat(s[3]),\"\".concat(s[1],\".\").concat(s[0]),\"\".concat(s[1],\".\").concat(s[2]),\"\".concat(s[1],\".\").concat(s[3]),\"\".concat(s[2],\".\").concat(s[0]),\"\".concat(s[2],\".\").concat(s[1]),\"\".concat(s[2],\".\").concat(s[3]),\"\".concat(s[3],\".\").concat(s[0]),\"\".concat(s[3],\".\").concat(s[1]),\"\".concat(s[3],\".\").concat(s[2]),\"\".concat(s[0],\".\").concat(s[1],\".\").concat(s[2]),\"\".concat(s[0],\".\").concat(s[1],\".\").concat(s[3]),\"\".concat(s[0],\".\").concat(s[2],\".\").concat(s[1]),\"\".concat(s[0],\".\").concat(s[2],\".\").concat(s[3]),\"\".concat(s[0],\".\").concat(s[3],\".\").concat(s[1]),\"\".concat(s[0],\".\").concat(s[3],\".\").concat(s[2]),\"\".concat(s[1],\".\").concat(s[0],\".\").concat(s[2]),\"\".concat(s[1],\".\").concat(s[0],\".\").concat(s[3]),\"\".concat(s[1],\".\").concat(s[2],\".\").concat(s[0]),\"\".concat(s[1],\".\").concat(s[2],\".\").concat(s[3]),\"\".concat(s[1],\".\").concat(s[3],\".\").concat(s[0]),\"\".concat(s[1],\".\").concat(s[3],\".\").concat(s[2]),\"\".concat(s[2],\".\").concat(s[0],\".\").concat(s[1]),\"\".concat(s[2],\".\").concat(s[0],\".\").concat(s[3]),\"\".concat(s[2],\".\").concat(s[1],\".\").concat(s[0]),\"\".concat(s[2],\".\").concat(s[1],\".\").concat(s[3]),\"\".concat(s[2],\".\").concat(s[3],\".\").concat(s[0]),\"\".concat(s[2],\".\").concat(s[3],\".\").concat(s[1]),\"\".concat(s[3],\".\").concat(s[0],\".\").concat(s[1]),\"\".concat(s[3],\".\").concat(s[0],\".\").concat(s[2]),\"\".concat(s[3],\".\").concat(s[1],\".\").concat(s[0]),\"\".concat(s[3],\".\").concat(s[1],\".\").concat(s[2]),\"\".concat(s[3],\".\").concat(s[2],\".\").concat(s[0]),\"\".concat(s[3],\".\").concat(s[2],\".\").concat(s[1]),\"\".concat(s[0],\".\").concat(s[1],\".\").concat(s[2],\".\").concat(s[3]),\"\".concat(s[0],\".\").concat(s[1],\".\").concat(s[3],\".\").concat(s[2]),\"\".concat(s[0],\".\").concat(s[2],\".\").concat(s[1],\".\").concat(s[3]),\"\".concat(s[0],\".\").concat(s[2],\".\").concat(s[3],\".\").concat(s[1]),\"\".concat(s[0],\".\").concat(s[3],\".\").concat(s[1],\".\").concat(s[2]),\"\".concat(s[0],\".\").concat(s[3],\".\").concat(s[2],\".\").concat(s[1]),\"\".concat(s[1],\".\").concat(s[0],\".\").concat(s[2],\".\").concat(s[3]),\"\".concat(s[1],\".\").concat(s[0],\".\").concat(s[3],\".\").concat(s[2]),\"\".concat(s[1],\".\").concat(s[2],\".\").concat(s[0],\".\").concat(s[3]),\"\".concat(s[1],\".\").concat(s[2],\".\").concat(s[3],\".\").concat(s[0]),\"\".concat(s[1],\".\").concat(s[3],\".\").concat(s[0],\".\").concat(s[2]),\"\".concat(s[1],\".\").concat(s[3],\".\").concat(s[2],\".\").concat(s[0]),\"\".concat(s[2],\".\").concat(s[0],\".\").concat(s[1],\".\").concat(s[3]),\"\".concat(s[2],\".\").concat(s[0],\".\").concat(s[3],\".\").concat(s[1]),\"\".concat(s[2],\".\").concat(s[1],\".\").concat(s[0],\".\").concat(s[3]),\"\".concat(s[2],\".\").concat(s[1],\".\").concat(s[3],\".\").concat(s[0]),\"\".concat(s[2],\".\").concat(s[3],\".\").concat(s[0],\".\").concat(s[1]),\"\".concat(s[2],\".\").concat(s[3],\".\").concat(s[1],\".\").concat(s[0]),\"\".concat(s[3],\".\").concat(s[0],\".\").concat(s[1],\".\").concat(s[2]),\"\".concat(s[3],\".\").concat(s[0],\".\").concat(s[2],\".\").concat(s[1]),\"\".concat(s[3],\".\").concat(s[1],\".\").concat(s[0],\".\").concat(s[2]),\"\".concat(s[3],\".\").concat(s[1],\".\").concat(s[2],\".\").concat(s[0]),\"\".concat(s[3],\".\").concat(s[2],\".\").concat(s[0],\".\").concat(s[1]),\"\".concat(s[3],\".\").concat(s[2],\".\").concat(s[1],\".\").concat(s[0])]:void 0}(s)),bO[o]}(s.filter((function(s){return\"token\"!==s}))).reduce((function(s,o){return _objectSpread(_objectSpread({},s),i[o])}),o)}function createClassNameString(s){return s.join(\" \")}function createElement(s){var o=s.node,i=s.stylesheet,a=s.style,u=void 0===a?{}:a,_=s.useInlineStyles,w=s.key,x=o.properties,C=o.type,j=o.tagName,L=o.value;if(\"text\"===C)return L;if(j){var B,$=function createChildren(s,o){var i=0;return function(a){return i+=1,a.map((function(a,u){return createElement({node:a,stylesheet:s,useInlineStyles:o,key:\"code-segment-\".concat(i,\"-\").concat(u)})}))}}(i,_);if(_){var U=Object.keys(i).reduce((function(s,o){return o.split(\".\").forEach((function(o){s.includes(o)||s.push(o)})),s}),[]),V=x.className&&x.className.includes(\"token\")?[\"token\"]:[],z=x.className&&V.concat(x.className.filter((function(s){return!U.includes(s)})));B=_objectSpread(_objectSpread({},x),{},{className:createClassNameString(z)||void 0,style:createStyleObject(x.className,Object.assign({},x.style,u),i)})}else B=_objectSpread(_objectSpread({},x),{},{className:createClassNameString(x.className)});var Y=$(o.children);return Re.createElement(j,extends_extends({key:w},B),Y)}}var _O=[\"language\",\"children\",\"style\",\"customStyle\",\"codeTagProps\",\"useInlineStyles\",\"showLineNumbers\",\"showInlineLineNumbers\",\"startingLineNumber\",\"lineNumberContainerStyle\",\"lineNumberStyle\",\"wrapLines\",\"wrapLongLines\",\"lineProps\",\"renderer\",\"PreTag\",\"CodeTag\",\"code\",\"astGenerator\"];function highlight_ownKeys(s,o){var i=Object.keys(s);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(s);o&&(a=a.filter((function(o){return Object.getOwnPropertyDescriptor(s,o).enumerable}))),i.push.apply(i,a)}return i}function highlight_objectSpread(s){for(var o=1;o<arguments.length;o++){var i=null!=arguments[o]?arguments[o]:{};o%2?highlight_ownKeys(Object(i),!0).forEach((function(o){defineProperty_defineProperty(s,o,i[o])})):Object.getOwnPropertyDescriptors?Object.defineProperties(s,Object.getOwnPropertyDescriptors(i)):highlight_ownKeys(Object(i)).forEach((function(o){Object.defineProperty(s,o,Object.getOwnPropertyDescriptor(i,o))}))}return s}var SO=/\\n/g;function AllLineNumbers(s){var o=s.codeString,i=s.codeStyle,a=s.containerStyle,u=void 0===a?{float:\"left\",paddingRight:\"10px\"}:a,_=s.numberStyle,w=void 0===_?{}:_,x=s.startingLineNumber;return Re.createElement(\"code\",{style:Object.assign({},i,u)},function getAllLineNumbers(s){var o=s.lines,i=s.startingLineNumber,a=s.style;return o.map((function(s,o){var u=o+i;return Re.createElement(\"span\",{key:\"line-\".concat(o),className:\"react-syntax-highlighter-line-number\",style:\"function\"==typeof a?a(u):a},\"\".concat(u,\"\\n\"))}))}({lines:o.replace(/\\n$/,\"\").split(\"\\n\"),style:w,startingLineNumber:x}))}function getInlineLineNumber(s,o){return{type:\"element\",tagName:\"span\",properties:{key:\"line-number--\".concat(s),className:[\"comment\",\"linenumber\",\"react-syntax-highlighter-line-number\"],style:o},children:[{type:\"text\",value:s}]}}function assembleLineNumberStyles(s,o,i){var a,u={display:\"inline-block\",minWidth:(a=i,\"\".concat(a.toString().length,\".25em\")),paddingRight:\"1em\",textAlign:\"right\",userSelect:\"none\"},_=\"function\"==typeof s?s(o):s;return highlight_objectSpread(highlight_objectSpread({},u),_)}function createLineElement(s){var o=s.children,i=s.lineNumber,a=s.lineNumberStyle,u=s.largestLineNumber,_=s.showInlineLineNumbers,w=s.lineProps,x=void 0===w?{}:w,C=s.className,j=void 0===C?[]:C,L=s.showLineNumbers,B=s.wrapLongLines,$=s.wrapLines,U=void 0!==$&&$?highlight_objectSpread({},\"function\"==typeof x?x(i):x):{};if(U.className=U.className?[].concat(toConsumableArray_toConsumableArray(U.className.trim().split(/\\s+/)),toConsumableArray_toConsumableArray(j)):j,i&&_){var V=assembleLineNumberStyles(a,i,u);o.unshift(getInlineLineNumber(i,V))}return B&L&&(U.style=highlight_objectSpread({display:\"flex\"},U.style)),{type:\"element\",tagName:\"span\",properties:U,children:o}}function flattenCodeTree(s){for(var o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[],i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[],a=0;a<s.length;a++){var u=s[a];if(\"text\"===u.type)i.push(createLineElement({children:[u],className:toConsumableArray_toConsumableArray(new Set(o))}));else if(u.children){var _=o.concat(u.properties.className);flattenCodeTree(u.children,_).forEach((function(s){return i.push(s)}))}}return i}function processLines(s,o,i,a,u,_,w,x,C){var j,L=flattenCodeTree(s.value),B=[],$=-1,U=0;function createLine(s,_){var j=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[];return o||j.length>0?function createWrappedLine(s,_){return createLineElement({children:s,lineNumber:_,lineNumberStyle:x,largestLineNumber:w,showInlineLineNumbers:u,lineProps:i,className:arguments.length>2&&void 0!==arguments[2]?arguments[2]:[],showLineNumbers:a,wrapLongLines:C,wrapLines:o})}(s,_,j):function createUnwrappedLine(s,o){if(a&&o&&u){var i=assembleLineNumberStyles(x,o,w);s.unshift(getInlineLineNumber(o,i))}return s}(s,_)}for(var V=function _loop(){var s=L[U],o=s.children[0].value,i=function getNewLines(s){return s.match(SO)}(o);if(i){var u=o.split(\"\\n\");u.forEach((function(o,i){var w=a&&B.length+_,x={type:\"text\",value:\"\".concat(o,\"\\n\")};if(0===i){var C=createLine(L.slice($+1,U).concat(createLineElement({children:[x],className:s.properties.className})),w);B.push(C)}else if(i===u.length-1){var j=L[U+1]&&L[U+1].children&&L[U+1].children[0],V={type:\"text\",value:\"\".concat(o)};if(j){var z=createLineElement({children:[V],className:s.properties.className});L.splice(U+1,0,z)}else{var Y=createLine([V],w,s.properties.className);B.push(Y)}}else{var Z=createLine([x],w,s.properties.className);B.push(Z)}})),$=U}U++};U<L.length;)V();if($!==L.length-1){var z=L.slice($+1,L.length);if(z&&z.length){var Y=createLine(z,a&&B.length+_);B.push(Y)}}return o?B:(j=[]).concat.apply(j,B)}function defaultRenderer(s){var o=s.rows,i=s.stylesheet,a=s.useInlineStyles;return o.map((function(s,o){return createElement({node:s,stylesheet:i,useInlineStyles:a,key:\"code-segement\".concat(o)})}))}function isHighlightJs(s){return s&&void 0!==s.highlightAuto}var EO=__webpack_require__(43768),wO=function highlight(s,o){return function SyntaxHighlighter(i){var a=i.language,u=i.children,_=i.style,w=void 0===_?o:_,x=i.customStyle,C=void 0===x?{}:x,j=i.codeTagProps,L=void 0===j?{className:a?\"language-\".concat(a):void 0,style:highlight_objectSpread(highlight_objectSpread({},w['code[class*=\"language-\"]']),w['code[class*=\"language-'.concat(a,'\"]')])}:j,B=i.useInlineStyles,$=void 0===B||B,U=i.showLineNumbers,V=void 0!==U&&U,z=i.showInlineLineNumbers,Y=void 0===z||z,Z=i.startingLineNumber,ee=void 0===Z?1:Z,ie=i.lineNumberContainerStyle,ae=i.lineNumberStyle,ce=void 0===ae?{}:ae,le=i.wrapLines,pe=i.wrapLongLines,de=void 0!==pe&&pe,fe=i.lineProps,ye=void 0===fe?{}:fe,be=i.renderer,_e=i.PreTag,Se=void 0===_e?\"pre\":_e,we=i.CodeTag,xe=void 0===we?\"code\":we,Pe=i.code,Te=void 0===Pe?(Array.isArray(u)?u[0]:u)||\"\":Pe,$e=i.astGenerator,qe=function _objectWithoutProperties(s,o){if(null==s)return{};var i,a,u=function _objectWithoutPropertiesLoose(s,o){if(null==s)return{};var i={};for(var a in s)if({}.hasOwnProperty.call(s,a)){if(-1!==o.indexOf(a))continue;i[a]=s[a]}return i}(s,o);if(Object.getOwnPropertySymbols){var _=Object.getOwnPropertySymbols(s);for(a=0;a<_.length;a++)i=_[a],-1===o.indexOf(i)&&{}.propertyIsEnumerable.call(s,i)&&(u[i]=s[i])}return u}(i,_O);$e=$e||s;var ze=V?Re.createElement(AllLineNumbers,{containerStyle:ie,codeStyle:L.style||{},numberStyle:ce,startingLineNumber:ee,codeString:Te}):null,We=w.hljs||w['pre[class*=\"language-\"]']||{backgroundColor:\"#fff\"},He=isHighlightJs($e)?\"hljs\":\"prismjs\",Ye=$?Object.assign({},qe,{style:Object.assign({},We,C)}):Object.assign({},qe,{className:qe.className?\"\".concat(He,\" \").concat(qe.className):He,style:Object.assign({},C)});if(L.style=highlight_objectSpread(de?{whiteSpace:\"pre-wrap\"}:{whiteSpace:\"pre\"},L.style),!$e)return Re.createElement(Se,Ye,ze,Re.createElement(xe,L,Te));(void 0===le&&be||de)&&(le=!0),be=be||defaultRenderer;var Xe=[{type:\"text\",value:Te}],Qe=function getCodeTree(s){var o=s.astGenerator,i=s.language,a=s.code,u=s.defaultCodeValue;if(isHighlightJs(o)){var _=function(s,o){return-1!==s.listLanguages().indexOf(o)}(o,i);return\"text\"===i?{value:u,language:\"text\"}:_?o.highlight(i,a):o.highlightAuto(a)}try{return i&&\"text\"!==i?{value:o.highlight(a,i)}:{value:u}}catch(s){return{value:u}}}({astGenerator:$e,language:a,code:Te,defaultCodeValue:Xe});null===Qe.language&&(Qe.value=Xe);var et=Qe.value.length;1===et&&\"text\"===Qe.value[0].type&&(et=Qe.value[0].value.split(\"\\n\").length);var tt=processLines(Qe,le,ye,V,Y,ee,et+ee,ce,de);return Re.createElement(Se,Ye,Re.createElement(xe,L,!Y&&ze,be({rows:tt,stylesheet:w,useInlineStyles:$})))}}(EO,{});wO.registerLanguage=EO.registerLanguage;const xO=wO;var kO=__webpack_require__(95089);const OO=__webpack_require__.n(kO)();var AO=__webpack_require__(65772);const CO=__webpack_require__.n(AO)();var jO=__webpack_require__(17285);const PO=__webpack_require__.n(jO)();var IO=__webpack_require__(35344);const TO=__webpack_require__.n(IO)();var NO=__webpack_require__(17533);const MO=__webpack_require__.n(NO)();var RO=__webpack_require__(73402);const DO=__webpack_require__.n(RO)();var LO=__webpack_require__(26571);const FO=__webpack_require__.n(LO)(),after_load=()=>{xO.registerLanguage(\"json\",CO),xO.registerLanguage(\"js\",OO),xO.registerLanguage(\"xml\",PO),xO.registerLanguage(\"yaml\",MO),xO.registerLanguage(\"http\",DO),xO.registerLanguage(\"bash\",TO),xO.registerLanguage(\"powershell\",FO),xO.registerLanguage(\"javascript\",OO)},BO={hljs:{display:\"block\",overflowX:\"auto\",padding:\"0.5em\",background:\"#333\",color:\"white\"},\"hljs-name\":{fontWeight:\"bold\"},\"hljs-strong\":{fontWeight:\"bold\"},\"hljs-code\":{fontStyle:\"italic\",color:\"#888\"},\"hljs-emphasis\":{fontStyle:\"italic\"},\"hljs-tag\":{color:\"#62c8f3\"},\"hljs-variable\":{color:\"#ade5fc\"},\"hljs-template-variable\":{color:\"#ade5fc\"},\"hljs-selector-id\":{color:\"#ade5fc\"},\"hljs-selector-class\":{color:\"#ade5fc\"},\"hljs-string\":{color:\"#a2fca2\"},\"hljs-bullet\":{color:\"#d36363\"},\"hljs-type\":{color:\"#ffa\"},\"hljs-title\":{color:\"#ffa\"},\"hljs-section\":{color:\"#ffa\"},\"hljs-attribute\":{color:\"#ffa\"},\"hljs-quote\":{color:\"#ffa\"},\"hljs-built_in\":{color:\"#ffa\"},\"hljs-builtin-name\":{color:\"#ffa\"},\"hljs-number\":{color:\"#d36363\"},\"hljs-symbol\":{color:\"#d36363\"},\"hljs-keyword\":{color:\"#fcc28c\"},\"hljs-selector-tag\":{color:\"#fcc28c\"},\"hljs-literal\":{color:\"#fcc28c\"},\"hljs-comment\":{color:\"#888\"},\"hljs-deletion\":{color:\"#333\",backgroundColor:\"#fc9b9b\"},\"hljs-regexp\":{color:\"#c6b4f0\"},\"hljs-link\":{color:\"#c6b4f0\"},\"hljs-meta\":{color:\"#fc9b9b\"},\"hljs-addition\":{backgroundColor:\"#a2fca2\",color:\"#333\"}},$O={agate:BO,arta:{hljs:{display:\"block\",overflowX:\"auto\",padding:\"0.5em\",background:\"#222\",color:\"#aaa\"},\"hljs-subst\":{color:\"#aaa\"},\"hljs-section\":{color:\"#fff\",fontWeight:\"bold\"},\"hljs-comment\":{color:\"#444\"},\"hljs-quote\":{color:\"#444\"},\"hljs-meta\":{color:\"#444\"},\"hljs-string\":{color:\"#ffcc33\"},\"hljs-symbol\":{color:\"#ffcc33\"},\"hljs-bullet\":{color:\"#ffcc33\"},\"hljs-regexp\":{color:\"#ffcc33\"},\"hljs-number\":{color:\"#00cc66\"},\"hljs-addition\":{color:\"#00cc66\"},\"hljs-built_in\":{color:\"#32aaee\"},\"hljs-builtin-name\":{color:\"#32aaee\"},\"hljs-literal\":{color:\"#32aaee\"},\"hljs-type\":{color:\"#32aaee\"},\"hljs-template-variable\":{color:\"#32aaee\"},\"hljs-attribute\":{color:\"#32aaee\"},\"hljs-link\":{color:\"#32aaee\"},\"hljs-keyword\":{color:\"#6644aa\"},\"hljs-selector-tag\":{color:\"#6644aa\"},\"hljs-name\":{color:\"#6644aa\"},\"hljs-selector-id\":{color:\"#6644aa\"},\"hljs-selector-class\":{color:\"#6644aa\"},\"hljs-title\":{color:\"#bb1166\"},\"hljs-variable\":{color:\"#bb1166\"},\"hljs-deletion\":{color:\"#bb1166\"},\"hljs-template-tag\":{color:\"#bb1166\"},\"hljs-doctag\":{fontWeight:\"bold\"},\"hljs-strong\":{fontWeight:\"bold\"},\"hljs-emphasis\":{fontStyle:\"italic\"}},monokai:{hljs:{display:\"block\",overflowX:\"auto\",padding:\"0.5em\",background:\"#272822\",color:\"#ddd\"},\"hljs-tag\":{color:\"#f92672\"},\"hljs-keyword\":{color:\"#f92672\",fontWeight:\"bold\"},\"hljs-selector-tag\":{color:\"#f92672\",fontWeight:\"bold\"},\"hljs-literal\":{color:\"#f92672\",fontWeight:\"bold\"},\"hljs-strong\":{color:\"#f92672\"},\"hljs-name\":{color:\"#f92672\"},\"hljs-code\":{color:\"#66d9ef\"},\"hljs-class .hljs-title\":{color:\"white\"},\"hljs-attribute\":{color:\"#bf79db\"},\"hljs-symbol\":{color:\"#bf79db\"},\"hljs-regexp\":{color:\"#bf79db\"},\"hljs-link\":{color:\"#bf79db\"},\"hljs-string\":{color:\"#a6e22e\"},\"hljs-bullet\":{color:\"#a6e22e\"},\"hljs-subst\":{color:\"#a6e22e\"},\"hljs-title\":{color:\"#a6e22e\",fontWeight:\"bold\"},\"hljs-section\":{color:\"#a6e22e\",fontWeight:\"bold\"},\"hljs-emphasis\":{color:\"#a6e22e\"},\"hljs-type\":{color:\"#a6e22e\",fontWeight:\"bold\"},\"hljs-built_in\":{color:\"#a6e22e\"},\"hljs-builtin-name\":{color:\"#a6e22e\"},\"hljs-selector-attr\":{color:\"#a6e22e\"},\"hljs-selector-pseudo\":{color:\"#a6e22e\"},\"hljs-addition\":{color:\"#a6e22e\"},\"hljs-variable\":{color:\"#a6e22e\"},\"hljs-template-tag\":{color:\"#a6e22e\"},\"hljs-template-variable\":{color:\"#a6e22e\"},\"hljs-comment\":{color:\"#75715e\"},\"hljs-quote\":{color:\"#75715e\"},\"hljs-deletion\":{color:\"#75715e\"},\"hljs-meta\":{color:\"#75715e\"},\"hljs-doctag\":{fontWeight:\"bold\"},\"hljs-selector-id\":{fontWeight:\"bold\"}},nord:{hljs:{display:\"block\",overflowX:\"auto\",padding:\"0.5em\",background:\"#2E3440\",color:\"#D8DEE9\"},\"hljs-subst\":{color:\"#D8DEE9\"},\"hljs-selector-tag\":{color:\"#81A1C1\"},\"hljs-selector-id\":{color:\"#8FBCBB\",fontWeight:\"bold\"},\"hljs-selector-class\":{color:\"#8FBCBB\"},\"hljs-selector-attr\":{color:\"#8FBCBB\"},\"hljs-selector-pseudo\":{color:\"#88C0D0\"},\"hljs-addition\":{backgroundColor:\"rgba(163, 190, 140, 0.5)\"},\"hljs-deletion\":{backgroundColor:\"rgba(191, 97, 106, 0.5)\"},\"hljs-built_in\":{color:\"#8FBCBB\"},\"hljs-type\":{color:\"#8FBCBB\"},\"hljs-class\":{color:\"#8FBCBB\"},\"hljs-function\":{color:\"#88C0D0\"},\"hljs-function > .hljs-title\":{color:\"#88C0D0\"},\"hljs-keyword\":{color:\"#81A1C1\"},\"hljs-literal\":{color:\"#81A1C1\"},\"hljs-symbol\":{color:\"#81A1C1\"},\"hljs-number\":{color:\"#B48EAD\"},\"hljs-regexp\":{color:\"#EBCB8B\"},\"hljs-string\":{color:\"#A3BE8C\"},\"hljs-title\":{color:\"#8FBCBB\"},\"hljs-params\":{color:\"#D8DEE9\"},\"hljs-bullet\":{color:\"#81A1C1\"},\"hljs-code\":{color:\"#8FBCBB\"},\"hljs-emphasis\":{fontStyle:\"italic\"},\"hljs-formula\":{color:\"#8FBCBB\"},\"hljs-strong\":{fontWeight:\"bold\"},\"hljs-link:hover\":{textDecoration:\"underline\"},\"hljs-quote\":{color:\"#4C566A\"},\"hljs-comment\":{color:\"#4C566A\"},\"hljs-doctag\":{color:\"#8FBCBB\"},\"hljs-meta\":{color:\"#5E81AC\"},\"hljs-meta-keyword\":{color:\"#5E81AC\"},\"hljs-meta-string\":{color:\"#A3BE8C\"},\"hljs-attr\":{color:\"#8FBCBB\"},\"hljs-attribute\":{color:\"#D8DEE9\"},\"hljs-builtin-name\":{color:\"#81A1C1\"},\"hljs-name\":{color:\"#81A1C1\"},\"hljs-section\":{color:\"#88C0D0\"},\"hljs-tag\":{color:\"#81A1C1\"},\"hljs-variable\":{color:\"#D8DEE9\"},\"hljs-template-variable\":{color:\"#D8DEE9\"},\"hljs-template-tag\":{color:\"#5E81AC\"},\"abnf .hljs-attribute\":{color:\"#88C0D0\"},\"abnf .hljs-symbol\":{color:\"#EBCB8B\"},\"apache .hljs-attribute\":{color:\"#88C0D0\"},\"apache .hljs-section\":{color:\"#81A1C1\"},\"arduino .hljs-built_in\":{color:\"#88C0D0\"},\"aspectj .hljs-meta\":{color:\"#D08770\"},\"aspectj > .hljs-title\":{color:\"#88C0D0\"},\"bnf .hljs-attribute\":{color:\"#8FBCBB\"},\"clojure .hljs-name\":{color:\"#88C0D0\"},\"clojure .hljs-symbol\":{color:\"#EBCB8B\"},\"coq .hljs-built_in\":{color:\"#88C0D0\"},\"cpp .hljs-meta-string\":{color:\"#8FBCBB\"},\"css .hljs-built_in\":{color:\"#88C0D0\"},\"css .hljs-keyword\":{color:\"#D08770\"},\"diff .hljs-meta\":{color:\"#8FBCBB\"},\"ebnf .hljs-attribute\":{color:\"#8FBCBB\"},\"glsl .hljs-built_in\":{color:\"#88C0D0\"},\"groovy .hljs-meta:not(:first-child)\":{color:\"#D08770\"},\"haxe .hljs-meta\":{color:\"#D08770\"},\"java .hljs-meta\":{color:\"#D08770\"},\"ldif .hljs-attribute\":{color:\"#8FBCBB\"},\"lisp .hljs-name\":{color:\"#88C0D0\"},\"lua .hljs-built_in\":{color:\"#88C0D0\"},\"moonscript .hljs-built_in\":{color:\"#88C0D0\"},\"nginx .hljs-attribute\":{color:\"#88C0D0\"},\"nginx .hljs-section\":{color:\"#5E81AC\"},\"pf .hljs-built_in\":{color:\"#88C0D0\"},\"processing .hljs-built_in\":{color:\"#88C0D0\"},\"scss .hljs-keyword\":{color:\"#81A1C1\"},\"stylus .hljs-keyword\":{color:\"#81A1C1\"},\"swift .hljs-meta\":{color:\"#D08770\"},\"vim .hljs-built_in\":{color:\"#88C0D0\",fontStyle:\"italic\"},\"yaml .hljs-meta\":{color:\"#D08770\"}},obsidian:{hljs:{display:\"block\",overflowX:\"auto\",padding:\"0.5em\",background:\"#282b2e\",color:\"#e0e2e4\"},\"hljs-keyword\":{color:\"#93c763\",fontWeight:\"bold\"},\"hljs-selector-tag\":{color:\"#93c763\",fontWeight:\"bold\"},\"hljs-literal\":{color:\"#93c763\",fontWeight:\"bold\"},\"hljs-selector-id\":{color:\"#93c763\"},\"hljs-number\":{color:\"#ffcd22\"},\"hljs-attribute\":{color:\"#668bb0\"},\"hljs-code\":{color:\"white\"},\"hljs-class .hljs-title\":{color:\"white\"},\"hljs-section\":{color:\"white\",fontWeight:\"bold\"},\"hljs-regexp\":{color:\"#d39745\"},\"hljs-link\":{color:\"#d39745\"},\"hljs-meta\":{color:\"#557182\"},\"hljs-tag\":{color:\"#8cbbad\"},\"hljs-name\":{color:\"#8cbbad\",fontWeight:\"bold\"},\"hljs-bullet\":{color:\"#8cbbad\"},\"hljs-subst\":{color:\"#8cbbad\"},\"hljs-emphasis\":{color:\"#8cbbad\"},\"hljs-type\":{color:\"#8cbbad\",fontWeight:\"bold\"},\"hljs-built_in\":{color:\"#8cbbad\"},\"hljs-selector-attr\":{color:\"#8cbbad\"},\"hljs-selector-pseudo\":{color:\"#8cbbad\"},\"hljs-addition\":{color:\"#8cbbad\"},\"hljs-variable\":{color:\"#8cbbad\"},\"hljs-template-tag\":{color:\"#8cbbad\"},\"hljs-template-variable\":{color:\"#8cbbad\"},\"hljs-string\":{color:\"#ec7600\"},\"hljs-symbol\":{color:\"#ec7600\"},\"hljs-comment\":{color:\"#818e96\"},\"hljs-quote\":{color:\"#818e96\"},\"hljs-deletion\":{color:\"#818e96\"},\"hljs-selector-class\":{color:\"#A082BD\"},\"hljs-doctag\":{fontWeight:\"bold\"},\"hljs-title\":{fontWeight:\"bold\"},\"hljs-strong\":{fontWeight:\"bold\"}},\"tomorrow-night\":{\"hljs-comment\":{color:\"#969896\"},\"hljs-quote\":{color:\"#969896\"},\"hljs-variable\":{color:\"#cc6666\"},\"hljs-template-variable\":{color:\"#cc6666\"},\"hljs-tag\":{color:\"#cc6666\"},\"hljs-name\":{color:\"#cc6666\"},\"hljs-selector-id\":{color:\"#cc6666\"},\"hljs-selector-class\":{color:\"#cc6666\"},\"hljs-regexp\":{color:\"#cc6666\"},\"hljs-deletion\":{color:\"#cc6666\"},\"hljs-number\":{color:\"#de935f\"},\"hljs-built_in\":{color:\"#de935f\"},\"hljs-builtin-name\":{color:\"#de935f\"},\"hljs-literal\":{color:\"#de935f\"},\"hljs-type\":{color:\"#de935f\"},\"hljs-params\":{color:\"#de935f\"},\"hljs-meta\":{color:\"#de935f\"},\"hljs-link\":{color:\"#de935f\"},\"hljs-attribute\":{color:\"#f0c674\"},\"hljs-string\":{color:\"#b5bd68\"},\"hljs-symbol\":{color:\"#b5bd68\"},\"hljs-bullet\":{color:\"#b5bd68\"},\"hljs-addition\":{color:\"#b5bd68\"},\"hljs-title\":{color:\"#81a2be\"},\"hljs-section\":{color:\"#81a2be\"},\"hljs-keyword\":{color:\"#b294bb\"},\"hljs-selector-tag\":{color:\"#b294bb\"},hljs:{display:\"block\",overflowX:\"auto\",background:\"#1d1f21\",color:\"#c5c8c6\",padding:\"0.5em\"},\"hljs-emphasis\":{fontStyle:\"italic\"},\"hljs-strong\":{fontWeight:\"bold\"}},idea:{hljs:{display:\"block\",overflowX:\"auto\",padding:\"0.5em\",color:\"#000\",background:\"#fff\"},\"hljs-subst\":{fontWeight:\"normal\",color:\"#000\"},\"hljs-title\":{fontWeight:\"normal\",color:\"#000\"},\"hljs-comment\":{color:\"#808080\",fontStyle:\"italic\"},\"hljs-quote\":{color:\"#808080\",fontStyle:\"italic\"},\"hljs-meta\":{color:\"#808000\"},\"hljs-tag\":{background:\"#efefef\"},\"hljs-section\":{fontWeight:\"bold\",color:\"#000080\"},\"hljs-name\":{fontWeight:\"bold\",color:\"#000080\"},\"hljs-literal\":{fontWeight:\"bold\",color:\"#000080\"},\"hljs-keyword\":{fontWeight:\"bold\",color:\"#000080\"},\"hljs-selector-tag\":{fontWeight:\"bold\",color:\"#000080\"},\"hljs-type\":{fontWeight:\"bold\",color:\"#000080\"},\"hljs-selector-id\":{fontWeight:\"bold\",color:\"#000080\"},\"hljs-selector-class\":{fontWeight:\"bold\",color:\"#000080\"},\"hljs-attribute\":{fontWeight:\"bold\",color:\"#0000ff\"},\"hljs-number\":{fontWeight:\"normal\",color:\"#0000ff\"},\"hljs-regexp\":{fontWeight:\"normal\",color:\"#0000ff\"},\"hljs-link\":{fontWeight:\"normal\",color:\"#0000ff\"},\"hljs-string\":{color:\"#008000\",fontWeight:\"bold\"},\"hljs-symbol\":{color:\"#000\",background:\"#d0eded\",fontStyle:\"italic\"},\"hljs-bullet\":{color:\"#000\",background:\"#d0eded\",fontStyle:\"italic\"},\"hljs-formula\":{color:\"#000\",background:\"#d0eded\",fontStyle:\"italic\"},\"hljs-doctag\":{textDecoration:\"underline\"},\"hljs-variable\":{color:\"#660e7a\"},\"hljs-template-variable\":{color:\"#660e7a\"},\"hljs-addition\":{background:\"#baeeba\"},\"hljs-deletion\":{background:\"#ffc8bd\"},\"hljs-emphasis\":{fontStyle:\"italic\"},\"hljs-strong\":{fontWeight:\"bold\"}}},qO=BO,components_SyntaxHighlighter=({language:s,className:o=\"\",getConfigs:i,syntaxHighlighting:a={},children:u=\"\"})=>{const _=i().syntaxHighlight.theme,{styles:w,defaultStyle:x}=a,C=w?.[_]??x;return Re.createElement(xO,{language:s,className:o,style:C},u)};var UO=__webpack_require__(5419),VO=__webpack_require__.n(UO);const components_HighlightCode=({fileName:s=\"response.txt\",className:o,downloadable:i,getComponent:a,canCopy:u,language:_,children:w})=>{const x=(0,Re.useRef)(null),C=a(\"SyntaxHighlighter\",!0),handlePreventYScrollingBeyondElement=s=>{const{target:o,deltaY:i}=s,{scrollHeight:a,offsetHeight:u,scrollTop:_}=o;a>u&&(0===_&&i<0||u+_>=a&&i>0)&&s.preventDefault()};return(0,Re.useEffect)((()=>{const s=Array.from(x.current.childNodes).filter((s=>!!s.nodeType&&s.classList.contains(\"microlight\")));return s.forEach((s=>s.addEventListener(\"mousewheel\",handlePreventYScrollingBeyondElement,{passive:!1}))),()=>{s.forEach((s=>s.removeEventListener(\"mousewheel\",handlePreventYScrollingBeyondElement)))}}),[w,o,_]),Re.createElement(\"div\",{className:\"highlight-code\",ref:x},u&&Re.createElement(\"div\",{className:\"copy-to-clipboard\"},Re.createElement(Hn.CopyToClipboard,{text:w},Re.createElement(\"button\",null))),i?Re.createElement(\"button\",{className:\"download-contents\",onClick:()=>{VO()(w,s)}},\"Download\"):null,Re.createElement(C,{language:_,className:Jn()(o,\"microlight\"),renderPlainText:({children:s,PlainTextViewer:i})=>Re.createElement(i,{className:o},s)},w))},components_PlainTextViewer=({className:s=\"\",children:o})=>Re.createElement(\"pre\",{className:Jn()(\"microlight\",s)},o),wrap_components_SyntaxHighlighter=(s,o)=>({renderPlainText:i,children:a,...u})=>{const _=o.getConfigs().syntaxHighlight.activated,w=o.getComponent(\"PlainTextViewer\");return _||\"function\"!=typeof i?_?Re.createElement(s,u,a):Re.createElement(w,null,a):i({children:a,PlainTextViewer:w})},SyntaxHighlightingPlugin1=()=>({afterLoad:after_load,rootInjects:{syntaxHighlighting:{styles:$O,defaultStyle:qO}},components:{SyntaxHighlighter:components_SyntaxHighlighter,HighlightCode:components_HighlightCode,PlainTextViewer:components_PlainTextViewer}}),SyntaxHighlightingPlugin2=()=>({wrapComponents:{SyntaxHighlighter:wrap_components_SyntaxHighlighter}}),syntax_highlighting=()=>[SyntaxHighlightingPlugin1,SyntaxHighlightingPlugin2],versions_after_load=()=>{const{GIT_DIRTY:s,GIT_COMMIT:o,PACKAGE_VERSION:i,BUILD_TIME:a}={PACKAGE_VERSION:\"5.29.5\",GIT_COMMIT:\"g583c4fbc\",GIT_DIRTY:!0,BUILD_TIME:\"Fri, 17 Oct 2025 13:12:29 GMT\"};lt.versions=lt.versions||{},lt.versions.swaggerUI={version:i,gitRevision:o,gitDirty:s,buildTimestamp:a}},versions=()=>({afterLoad:versions_after_load});var zO=__webpack_require__(47248),WO=__webpack_require__.n(zO);const JO=console.error,withErrorBoundary=s=>o=>{const{getComponent:i,fn:a}=s(),u=i(\"ErrorBoundary\"),_=a.getDisplayName(o);class WithErrorBoundary extends Re.Component{render(){return Re.createElement(u,{targetName:_,getComponent:i,fn:a},Re.createElement(o,Mn()({},this.props,this.context)))}}var w;return WithErrorBoundary.displayName=`WithErrorBoundary(${_})`,(w=o).prototype&&w.prototype.isReactComponent&&(WithErrorBoundary.prototype.mapStateToProps=o.prototype.mapStateToProps),WithErrorBoundary},fallback=({name:s})=>Re.createElement(\"div\",{className:\"fallback\"},\"😱 \",Re.createElement(\"i\",null,\"Could not render \",\"t\"===s?\"this component\":s,\", see the console.\"));class ErrorBoundary extends Re.Component{static defaultProps={targetName:\"this component\",getComponent:()=>fallback,fn:{componentDidCatch:JO},children:null};static getDerivedStateFromError(s){return{hasError:!0,error:s}}constructor(...s){super(...s),this.state={hasError:!1,error:null}}componentDidCatch(s,o){this.props.fn.componentDidCatch(s,o)}render(){const{getComponent:s,targetName:o,children:i}=this.props;if(this.state.hasError){const i=s(\"Fallback\");return Re.createElement(i,{name:o})}return i}}const HO=ErrorBoundary,safe_render=({componentList:s=[],fullOverride:o=!1}={})=>({getSystem:i})=>{const a=o?s:[\"App\",\"BaseLayout\",\"VersionPragmaFilter\",\"InfoContainer\",\"ServersContainer\",\"SchemesContainer\",\"AuthorizeBtnContainer\",\"FilterContainer\",\"Operations\",\"OperationContainer\",\"parameters\",\"responses\",\"OperationServers\",\"Models\",\"ModelWrapper\",...s],u=WO()(a,Array(a.length).fill(((s,{fn:o})=>o.withErrorBoundary(s))));return{fn:{componentDidCatch:JO,withErrorBoundary:withErrorBoundary(i)},components:{ErrorBoundary:HO,Fallback:fallback},wrapComponents:u}};class App extends Re.Component{getLayout(){const{getComponent:s,layoutSelectors:o}=this.props,i=o.current(),a=s(i,!0);return a||(()=>Re.createElement(\"h1\",null,' No layout defined for \"',i,'\" '))}render(){const s=this.getLayout();return Re.createElement(s,null)}}const KO=App;class AuthorizationPopup extends Re.Component{close=()=>{let{authActions:s}=this.props;s.showDefinitions(!1)};render(){let{authSelectors:s,authActions:o,getComponent:i,errSelectors:a,specSelectors:u,fn:{AST:_={}}}=this.props,w=s.shownDefinitions();const x=i(\"auths\"),C=i(\"CloseIcon\");return Re.createElement(\"div\",{className:\"dialog-ux\"},Re.createElement(\"div\",{className:\"backdrop-ux\"}),Re.createElement(\"div\",{className:\"modal-ux\"},Re.createElement(\"div\",{className:\"modal-dialog-ux\"},Re.createElement(\"div\",{className:\"modal-ux-inner\"},Re.createElement(\"div\",{className:\"modal-ux-header\"},Re.createElement(\"h3\",null,\"Available authorizations\"),Re.createElement(\"button\",{type:\"button\",className:\"close-modal\",onClick:this.close},Re.createElement(C,null))),Re.createElement(\"div\",{className:\"modal-ux-content\"},w.valueSeq().map(((w,C)=>Re.createElement(x,{key:C,AST:_,definitions:w,getComponent:i,errSelectors:a,authSelectors:s,authActions:o,specSelectors:u}))))))))}}class AuthorizeBtn extends Re.Component{render(){let{isAuthorized:s,showPopup:o,onClick:i,getComponent:a}=this.props;const u=a(\"authorizationPopup\",!0),_=a(\"LockAuthIcon\",!0),w=a(\"UnlockAuthIcon\",!0);return Re.createElement(\"div\",{className:\"auth-wrapper\"},Re.createElement(\"button\",{className:s?\"btn authorize locked\":\"btn authorize unlocked\",onClick:i},Re.createElement(\"span\",null,\"Authorize\"),s?Re.createElement(_,null):Re.createElement(w,null)),o&&Re.createElement(u,null))}}class AuthorizeBtnContainer extends Re.Component{render(){const{authActions:s,authSelectors:o,specSelectors:i,getComponent:a}=this.props,u=i.securityDefinitions(),_=o.definitionsToAuthorize(),w=a(\"authorizeBtn\");return u?Re.createElement(w,{onClick:()=>s.showDefinitions(_),isAuthorized:!!o.authorized().size,showPopup:!!o.shownDefinitions(),getComponent:a}):null}}class AuthorizeOperationBtn extends Re.Component{onClick=s=>{s.stopPropagation();let{onClick:o}=this.props;o&&o()};render(){let{isAuthorized:s,getComponent:o}=this.props;const i=o(\"LockAuthOperationIcon\",!0),a=o(\"UnlockAuthOperationIcon\",!0);return Re.createElement(\"button\",{className:\"authorization__btn\",\"aria-label\":s?\"authorization button locked\":\"authorization button unlocked\",onClick:this.onClick},s?Re.createElement(i,{className:\"locked\"}):Re.createElement(a,{className:\"unlocked\"}))}}class Auths extends Re.Component{constructor(s,o){super(s,o),this.state={}}onAuthChange=s=>{let{name:o}=s;this.setState({[o]:s})};submitAuth=s=>{s.preventDefault();let{authActions:o}=this.props;o.authorizeWithPersistOption(this.state)};logoutClick=s=>{s.preventDefault();let{authActions:o,definitions:i}=this.props,a=i.map(((s,o)=>o)).toArray();this.setState(a.reduce(((s,o)=>(s[o]=\"\",s)),{})),o.logoutWithPersistOption(a)};close=s=>{s.preventDefault();let{authActions:o}=this.props;o.showDefinitions(!1)};render(){let{definitions:s,getComponent:o,authSelectors:i,errSelectors:a}=this.props;const u=o(\"AuthItem\"),_=o(\"oauth2\",!0),w=o(\"Button\");let x=i.authorized(),C=s.filter(((s,o)=>!!x.get(o))),j=s.filter((s=>\"oauth2\"!==s.get(\"type\"))),L=s.filter((s=>\"oauth2\"===s.get(\"type\")));return Re.createElement(\"div\",{className:\"auth-container\"},!!j.size&&Re.createElement(\"form\",{onSubmit:this.submitAuth},j.map(((s,_)=>Re.createElement(u,{key:_,schema:s,name:_,getComponent:o,onAuthChange:this.onAuthChange,authorized:x,errSelectors:a,authSelectors:i}))).toArray(),Re.createElement(\"div\",{className:\"auth-btn-wrapper\"},j.size===C.size?Re.createElement(w,{className:\"btn modal-btn auth\",onClick:this.logoutClick,\"aria-label\":\"Remove authorization\"},\"Logout\"):Re.createElement(w,{type:\"submit\",className:\"btn modal-btn auth authorize\",\"aria-label\":\"Apply credentials\"},\"Authorize\"),Re.createElement(w,{className:\"btn modal-btn auth btn-done\",onClick:this.close},\"Close\"))),L&&L.size?Re.createElement(\"div\",null,Re.createElement(\"div\",{className:\"scope-def\"},Re.createElement(\"p\",null,\"Scopes are used to grant an application different levels of access to data on behalf of the end user. Each API may declare one or more scopes.\"),Re.createElement(\"p\",null,\"API requires the following scopes. Select which ones you want to grant to Swagger UI.\")),s.filter((s=>\"oauth2\"===s.get(\"type\"))).map(((s,o)=>Re.createElement(\"div\",{key:o},Re.createElement(_,{authorized:x,schema:s,name:o})))).toArray()):null)}}class auth_item_Auths extends Re.Component{render(){let{schema:s,name:o,getComponent:i,onAuthChange:a,authorized:u,errSelectors:_,authSelectors:w}=this.props;const x=i(\"apiKeyAuth\"),C=i(\"basicAuth\");let j;const L=s.get(\"type\");switch(L){case\"apiKey\":j=Re.createElement(x,{key:o,schema:s,name:o,errSelectors:_,authorized:u,getComponent:i,onChange:a,authSelectors:w});break;case\"basic\":j=Re.createElement(C,{key:o,schema:s,name:o,errSelectors:_,authorized:u,getComponent:i,onChange:a,authSelectors:w});break;default:j=Re.createElement(\"div\",{key:o},\"Unknown security definition type \",L)}return Re.createElement(\"div\",{key:`${o}-jump`},j)}}class AuthError extends Re.Component{render(){let{error:s}=this.props,o=s.get(\"level\"),i=s.get(\"message\"),a=s.get(\"source\");return Re.createElement(\"div\",{className:\"errors\"},Re.createElement(\"b\",null,a,\" \",o),Re.createElement(\"span\",null,i))}}class ApiKeyAuth extends Re.Component{constructor(s,o){super(s,o);let{name:i,schema:a}=this.props,u=this.getValue();this.state={name:i,schema:a,value:u}}getValue(){let{name:s,authorized:o}=this.props;return o&&o.getIn([s,\"value\"])}onChange=s=>{let{onChange:o}=this.props,i=s.target.value,a=Object.assign({},this.state,{value:i});this.setState(a),o(a)};render(){let{schema:s,getComponent:o,errSelectors:i,name:a,authSelectors:u}=this.props;const _=o(\"Input\"),w=o(\"Row\"),x=o(\"Col\"),C=o(\"authError\"),j=o(\"Markdown\",!0),L=o(\"JumpToPath\",!0),B=u.selectAuthPath(a);let $=this.getValue(),U=i.allErrors().filter((s=>s.get(\"authId\")===a));return Re.createElement(\"div\",null,Re.createElement(\"h4\",null,Re.createElement(\"code\",null,a||s.get(\"name\")),\" (apiKey)\",Re.createElement(L,{path:B})),$&&Re.createElement(\"h6\",null,\"Authorized\"),Re.createElement(w,null,Re.createElement(j,{source:s.get(\"description\")})),Re.createElement(w,null,Re.createElement(\"p\",null,\"Name: \",Re.createElement(\"code\",null,s.get(\"name\")))),Re.createElement(w,null,Re.createElement(\"p\",null,\"In: \",Re.createElement(\"code\",null,s.get(\"in\")))),Re.createElement(w,null,Re.createElement(\"label\",{htmlFor:\"api_key_value\"},\"Value:\"),$?Re.createElement(\"code\",null,\" ****** \"):Re.createElement(x,null,Re.createElement(_,{id:\"api_key_value\",type:\"text\",onChange:this.onChange,autoFocus:!0}))),U.valueSeq().map(((s,o)=>Re.createElement(C,{error:s,key:o}))))}}class BasicAuth extends Re.Component{constructor(s,o){super(s,o);let{schema:i,name:a}=this.props,u=this.getValue().username;this.state={name:a,schema:i,value:u?{username:u}:{}}}getValue(){let{authorized:s,name:o}=this.props;return s&&s.getIn([o,\"value\"])||{}}onChange=s=>{let{onChange:o}=this.props,{value:i,name:a}=s.target,u=this.state.value;u[a]=i,this.setState({value:u}),o(this.state)};render(){let{schema:s,getComponent:o,name:i,errSelectors:a,authSelectors:u}=this.props;const _=o(\"Input\"),w=o(\"Row\"),x=o(\"Col\"),C=o(\"authError\"),j=o(\"JumpToPath\",!0),L=o(\"Markdown\",!0),B=u.selectAuthPath(i);let $=this.getValue().username,U=a.allErrors().filter((s=>s.get(\"authId\")===i));return Re.createElement(\"div\",null,Re.createElement(\"h4\",null,\"Basic authorization\",Re.createElement(j,{path:B})),$&&Re.createElement(\"h6\",null,\"Authorized\"),Re.createElement(w,null,Re.createElement(L,{source:s.get(\"description\")})),Re.createElement(w,null,Re.createElement(\"label\",{htmlFor:\"auth_username\"},\"Username:\"),$?Re.createElement(\"code\",null,\" \",$,\" \"):Re.createElement(x,null,Re.createElement(_,{id:\"auth_username\",type:\"text\",required:\"required\",name:\"username\",onChange:this.onChange,autoFocus:!0}))),Re.createElement(w,null,Re.createElement(\"label\",{htmlFor:\"auth_password\"},\"Password:\"),$?Re.createElement(\"code\",null,\" ****** \"):Re.createElement(x,null,Re.createElement(_,{id:\"auth_password\",autoComplete:\"new-password\",name:\"password\",type:\"password\",onChange:this.onChange}))),U.valueSeq().map(((s,o)=>Re.createElement(C,{error:s,key:o}))))}}function example_Example(s){const{example:o,showValue:i,getComponent:a}=s,u=a(\"Markdown\",!0),_=a(\"HighlightCode\",!0);return o&&ze.Map.isMap(o)?Re.createElement(\"div\",{className:\"example\"},o.get(\"description\")?Re.createElement(\"section\",{className:\"example__section\"},Re.createElement(\"div\",{className:\"example__section-header\"},\"Example Description\"),Re.createElement(\"p\",null,Re.createElement(u,{source:o.get(\"description\")}))):null,i&&o.has(\"value\")?Re.createElement(\"section\",{className:\"example__section\"},Re.createElement(\"div\",{className:\"example__section-header\"},\"Example Value\"),Re.createElement(_,null,stringify(o.get(\"value\")))):null):null}class ExamplesSelect extends Re.PureComponent{static defaultProps={examples:(0,ze.Map)({}),onSelect:(...s)=>console.log(\"DEBUG: ExamplesSelect was not given an onSelect callback\",...s),currentExampleKey:null,showLabels:!0};_onSelect=(s,{isSyntheticChange:o=!1}={})=>{\"function\"==typeof this.props.onSelect&&this.props.onSelect(s,{isSyntheticChange:o})};_onDomSelect=s=>{if(\"function\"==typeof this.props.onSelect){const o=s.target.selectedOptions[0].getAttribute(\"value\");this._onSelect(o,{isSyntheticChange:!1})}};getCurrentExample=()=>{const{examples:s,currentExampleKey:o}=this.props,i=s.get(o),a=s.keySeq().first(),u=s.get(a);return i||u||(0,ze.Map)({})};componentDidMount(){const{onSelect:s,examples:o}=this.props;if(\"function\"==typeof s){const s=o.first(),i=o.keyOf(s);this._onSelect(i,{isSyntheticChange:!0})}}UNSAFE_componentWillReceiveProps(s){const{currentExampleKey:o,examples:i}=s;if(i!==this.props.examples&&!i.has(o)){const s=i.first(),o=i.keyOf(s);this._onSelect(o,{isSyntheticChange:!0})}}render(){const{examples:s,currentExampleKey:o,isValueModified:i,isModifiedValueAvailable:a,showLabels:u}=this.props;return Re.createElement(\"div\",{className:\"examples-select\"},u?Re.createElement(\"span\",{className:\"examples-select__section-label\"},\"Examples: \"):null,Re.createElement(\"select\",{className:\"examples-select-element\",onChange:this._onDomSelect,value:a&&i?\"__MODIFIED__VALUE__\":o||\"\"},a?Re.createElement(\"option\",{value:\"__MODIFIED__VALUE__\"},\"[Modified value]\"):null,s.map(((s,o)=>Re.createElement(\"option\",{key:o,value:o},ze.Map.isMap(s)&&s.get(\"summary\")||o))).valueSeq()))}}const stringifyUnlessList=s=>ze.List.isList(s)?s:stringify(s);class ExamplesSelectValueRetainer extends Re.PureComponent{static defaultProps={userHasEditedBody:!1,examples:(0,ze.Map)({}),currentNamespace:\"__DEFAULT__NAMESPACE__\",setRetainRequestBodyValueFlag:()=>{},onSelect:(...s)=>console.log(\"ExamplesSelectValueRetainer: no `onSelect` function was provided\",...s),updateValue:(...s)=>console.log(\"ExamplesSelectValueRetainer: no `updateValue` function was provided\",...s)};constructor(s){super(s);const o=this._getCurrentExampleValue();this.state={[s.currentNamespace]:(0,ze.Map)({lastUserEditedValue:this.props.currentUserInputValue,lastDownstreamValue:o,isModifiedValueSelected:this.props.userHasEditedBody||this.props.currentUserInputValue!==o})}}componentWillUnmount(){this.props.setRetainRequestBodyValueFlag(!1)}_getStateForCurrentNamespace=()=>{const{currentNamespace:s}=this.props;return(this.state[s]||(0,ze.Map)()).toObject()};_setStateForCurrentNamespace=s=>{const{currentNamespace:o}=this.props;return this._setStateForNamespace(o,s)};_setStateForNamespace=(s,o)=>{const i=(this.state[s]||(0,ze.Map)()).mergeDeep(o);return this.setState({[s]:i})};_isCurrentUserInputSameAsExampleValue=()=>{const{currentUserInputValue:s}=this.props;return this._getCurrentExampleValue()===s};_getValueForExample=(s,o)=>{const{examples:i}=o||this.props;return stringifyUnlessList((i||(0,ze.Map)({})).getIn([s,\"value\"]))};_getCurrentExampleValue=s=>{const{currentKey:o}=s||this.props;return this._getValueForExample(o,s||this.props)};_onExamplesSelect=(s,{isSyntheticChange:o}={},...i)=>{const{onSelect:a,updateValue:u,currentUserInputValue:_,userHasEditedBody:w}=this.props,{lastUserEditedValue:x}=this._getStateForCurrentNamespace(),C=this._getValueForExample(s);if(\"__MODIFIED__VALUE__\"===s)return u(stringifyUnlessList(x)),this._setStateForCurrentNamespace({isModifiedValueSelected:!0});\"function\"==typeof a&&a(s,{isSyntheticChange:o},...i),this._setStateForCurrentNamespace({lastDownstreamValue:C,isModifiedValueSelected:o&&w||!!_&&_!==C}),o||\"function\"==typeof u&&u(stringifyUnlessList(C))};UNSAFE_componentWillReceiveProps(s){const{currentUserInputValue:o,examples:i,onSelect:a,userHasEditedBody:u}=s,{lastUserEditedValue:_,lastDownstreamValue:w}=this._getStateForCurrentNamespace(),x=this._getValueForExample(s.currentKey,s),C=i.filter((s=>ze.Map.isMap(s)&&(s.get(\"value\")===o||stringify(s.get(\"value\"))===o)));if(C.size){let o;o=C.has(s.currentKey)?s.currentKey:C.keySeq().first(),a(o,{isSyntheticChange:!0})}else o!==this.props.currentUserInputValue&&o!==_&&o!==w&&(this.props.setRetainRequestBodyValueFlag(!0),this._setStateForNamespace(s.currentNamespace,{lastUserEditedValue:s.currentUserInputValue,isModifiedValueSelected:u||o!==x}))}render(){const{currentUserInputValue:s,examples:o,currentKey:i,getComponent:a,userHasEditedBody:u}=this.props,{lastDownstreamValue:_,lastUserEditedValue:w,isModifiedValueSelected:x}=this._getStateForCurrentNamespace(),C=a(\"ExamplesSelect\");return Re.createElement(C,{examples:o,currentExampleKey:i,onSelect:this._onExamplesSelect,isModifiedValueAvailable:!!w&&w!==_,isValueModified:void 0!==s&&x&&s!==this._getCurrentExampleValue()||u})}}function oauth2_authorize_authorize({auth:s,authActions:o,errActions:i,configs:a,authConfigs:u={},currentServer:_}){let{schema:w,scopes:x,name:C,clientId:j}=s,L=w.get(\"flow\"),B=[];switch(L){case\"password\":return void o.authorizePassword(s);case\"application\":case\"clientCredentials\":case\"client_credentials\":return void o.authorizeApplication(s);case\"accessCode\":case\"authorizationCode\":case\"authorization_code\":B.push(\"response_type=code\");break;case\"implicit\":B.push(\"response_type=token\")}\"string\"==typeof j&&B.push(\"client_id=\"+encodeURIComponent(j));let $=a.oauth2RedirectUrl;if(void 0===$)return void i.newAuthErr({authId:C,source:\"validation\",level:\"error\",message:\"oauth2RedirectUrl configuration is not passed. Oauth2 authorization cannot be performed.\"});B.push(\"redirect_uri=\"+encodeURIComponent($));let U=[];if(Array.isArray(x)?U=x:We().List.isList(x)&&(U=x.toArray()),U.length>0){let s=u.scopeSeparator||\" \";B.push(\"scope=\"+encodeURIComponent(U.join(s)))}let V=utils_btoa(new Date);if(B.push(\"state=\"+encodeURIComponent(V)),void 0!==u.realm&&B.push(\"realm=\"+encodeURIComponent(u.realm)),(\"authorizationCode\"===L||\"authorization_code\"===L||\"accessCode\"===L)&&u.usePkceWithAuthorizationCodeGrant){const o=function generateCodeVerifier(){return b64toB64UrlEncoded(xt()(32).toString(\"base64\"))}(),i=function createCodeChallenge(s){return b64toB64UrlEncoded(Ot()(\"sha256\").update(s).digest(\"base64\"))}(o);B.push(\"code_challenge=\"+i),B.push(\"code_challenge_method=S256\"),s.codeVerifier=o}let{additionalQueryStringParams:z}=u;for(let s in z)void 0!==z[s]&&B.push([s,z[s]].map(encodeURIComponent).join(\"=\"));const Y=w.get(\"authorizationUrl\");let Z;Z=_?Nt()(sanitizeUrl(Y),_,!0).toString():sanitizeUrl(Y);let ee,ie=[Z,B.join(\"&\")].join(\"string\"!=typeof Y||Y.includes(\"?\")?\"&\":\"?\");ee=\"implicit\"===L?o.preAuthorizeImplicit:u.useBasicAuthenticationWithAccessCodeGrant?o.authorizeAccessCodeWithBasicAuthentication:o.authorizeAccessCodeWithFormParams,o.authPopup(ie,{auth:s,state:V,redirectUrl:$,callback:ee,errCb:i.newAuthErr})}class Oauth2 extends Re.Component{constructor(s,o){super(s,o);let{name:i,schema:a,authorized:u,authSelectors:_}=this.props,w=u&&u.get(i),x=_.getConfigs()||{},C=w&&w.get(\"username\")||\"\",j=w&&w.get(\"clientId\")||x.clientId||\"\",L=w&&w.get(\"clientSecret\")||x.clientSecret||\"\",B=w&&w.get(\"passwordType\")||\"basic\",$=w&&w.get(\"scopes\")||x.scopes||[];\"string\"==typeof $&&($=$.split(x.scopeSeparator||\" \")),this.state={appName:x.appName,name:i,schema:a,scopes:$,clientId:j,clientSecret:L,username:C,password:\"\",passwordType:B}}close=s=>{s.preventDefault();let{authActions:o}=this.props;o.showDefinitions(!1)};authorize=()=>{let{authActions:s,errActions:o,getConfigs:i,authSelectors:a,oas3Selectors:u}=this.props,_=i(),w=a.getConfigs();o.clear({authId:name,type:\"auth\",source:\"auth\"}),oauth2_authorize_authorize({auth:this.state,currentServer:u.serverEffectiveValue(u.selectedServer()),authActions:s,errActions:o,configs:_,authConfigs:w})};onScopeChange=s=>{let{target:o}=s,{checked:i}=o,a=o.dataset.value;if(i&&-1===this.state.scopes.indexOf(a)){let s=this.state.scopes.concat([a]);this.setState({scopes:s})}else!i&&this.state.scopes.indexOf(a)>-1&&this.setState({scopes:this.state.scopes.filter((s=>s!==a))})};onInputChange=s=>{let{target:{dataset:{name:o},value:i}}=s,a={[o]:i};this.setState(a)};selectScopes=s=>{s.target.dataset.all?this.setState({scopes:Array.from((this.props.schema.get(\"allowedScopes\")||this.props.schema.get(\"scopes\")).keys())}):this.setState({scopes:[]})};logout=s=>{s.preventDefault();let{authActions:o,errActions:i,name:a}=this.props;i.clear({authId:a,type:\"auth\",source:\"auth\"}),o.logoutWithPersistOption([a])};render(){let{schema:s,getComponent:o,authSelectors:i,errSelectors:a,name:u,specSelectors:_}=this.props;const w=o(\"Input\"),x=o(\"Row\"),C=o(\"Col\"),j=o(\"Button\"),L=o(\"authError\"),B=o(\"JumpToPath\",!0),$=o(\"Markdown\",!0),U=o(\"InitializedInput\"),{isOAS3:V}=_;let z=V()?s.get(\"openIdConnectUrl\"):null;const Y=\"implicit\",Z=\"password\",ee=V()?z?\"authorization_code\":\"authorizationCode\":\"accessCode\",ie=V()?z?\"client_credentials\":\"clientCredentials\":\"application\",ae=i.selectAuthPath(u);let ce=!!(i.getConfigs()||{}).usePkceWithAuthorizationCodeGrant,le=s.get(\"flow\"),pe=le===ee&&ce?le+\" with PKCE\":le,de=s.get(\"allowedScopes\")||s.get(\"scopes\"),fe=!!i.authorized().get(u),ye=a.allErrors().filter((s=>s.get(\"authId\")===u)),be=!ye.filter((s=>\"validation\"===s.get(\"source\"))).size,_e=s.get(\"description\");return Re.createElement(\"div\",null,Re.createElement(\"h4\",null,u,\" (OAuth2, \",pe,\") \",Re.createElement(B,{path:ae})),this.state.appName?Re.createElement(\"h5\",null,\"Application: \",this.state.appName,\" \"):null,_e&&Re.createElement($,{source:s.get(\"description\")}),fe&&Re.createElement(\"h6\",null,\"Authorized\"),z&&Re.createElement(\"p\",null,\"OpenID Connect URL: \",Re.createElement(\"code\",null,z)),(le===Y||le===ee)&&Re.createElement(\"p\",null,\"Authorization URL: \",Re.createElement(\"code\",null,s.get(\"authorizationUrl\"))),(le===Z||le===ee||le===ie)&&Re.createElement(\"p\",null,\"Token URL:\",Re.createElement(\"code\",null,\" \",s.get(\"tokenUrl\"))),Re.createElement(\"p\",{className:\"flow\"},\"Flow: \",Re.createElement(\"code\",null,pe)),le!==Z?null:Re.createElement(x,null,Re.createElement(x,null,Re.createElement(\"label\",{htmlFor:\"oauth_username\"},\"username:\"),fe?Re.createElement(\"code\",null,\" \",this.state.username,\" \"):Re.createElement(C,{tablet:10,desktop:10},Re.createElement(\"input\",{id:\"oauth_username\",type:\"text\",\"data-name\":\"username\",onChange:this.onInputChange,autoFocus:!0}))),Re.createElement(x,null,Re.createElement(\"label\",{htmlFor:\"oauth_password\"},\"password:\"),fe?Re.createElement(\"code\",null,\" ****** \"):Re.createElement(C,{tablet:10,desktop:10},Re.createElement(\"input\",{id:\"oauth_password\",type:\"password\",\"data-name\":\"password\",onChange:this.onInputChange}))),Re.createElement(x,null,Re.createElement(\"label\",{htmlFor:\"password_type\"},\"Client credentials location:\"),fe?Re.createElement(\"code\",null,\" \",this.state.passwordType,\" \"):Re.createElement(C,{tablet:10,desktop:10},Re.createElement(\"select\",{id:\"password_type\",\"data-name\":\"passwordType\",onChange:this.onInputChange},Re.createElement(\"option\",{value:\"basic\"},\"Authorization header\"),Re.createElement(\"option\",{value:\"request-body\"},\"Request body\"))))),(le===ie||le===Y||le===ee||le===Z)&&(!fe||fe&&this.state.clientId)&&Re.createElement(x,null,Re.createElement(\"label\",{htmlFor:`client_id_${le}`},\"client_id:\"),fe?Re.createElement(\"code\",null,\" ****** \"):Re.createElement(C,{tablet:10,desktop:10},Re.createElement(U,{id:`client_id_${le}`,type:\"text\",required:le===Z,initialValue:this.state.clientId,\"data-name\":\"clientId\",onChange:this.onInputChange}))),(le===ie||le===ee||le===Z)&&Re.createElement(x,null,Re.createElement(\"label\",{htmlFor:`client_secret_${le}`},\"client_secret:\"),fe?Re.createElement(\"code\",null,\" ****** \"):Re.createElement(C,{tablet:10,desktop:10},Re.createElement(U,{id:`client_secret_${le}`,initialValue:this.state.clientSecret,type:\"password\",\"data-name\":\"clientSecret\",onChange:this.onInputChange}))),!fe&&de&&de.size?Re.createElement(\"div\",{className:\"scopes\"},Re.createElement(\"h2\",null,\"Scopes:\",Re.createElement(\"a\",{onClick:this.selectScopes,\"data-all\":!0},\"select all\"),Re.createElement(\"a\",{onClick:this.selectScopes},\"select none\")),de.map(((s,o)=>Re.createElement(x,{key:o},Re.createElement(\"div\",{className:\"checkbox\"},Re.createElement(w,{\"data-value\":o,id:`${o}-${le}-checkbox-${this.state.name}`,disabled:fe,checked:this.state.scopes.includes(o),type:\"checkbox\",onChange:this.onScopeChange}),Re.createElement(\"label\",{htmlFor:`${o}-${le}-checkbox-${this.state.name}`},Re.createElement(\"span\",{className:\"item\"}),Re.createElement(\"div\",{className:\"text\"},Re.createElement(\"p\",{className:\"name\"},o),Re.createElement(\"p\",{className:\"description\"},s))))))).toArray()):null,ye.valueSeq().map(((s,o)=>Re.createElement(L,{error:s,key:o}))),Re.createElement(\"div\",{className:\"auth-btn-wrapper\"},be&&(fe?Re.createElement(j,{className:\"btn modal-btn auth authorize\",onClick:this.logout,\"aria-label\":\"Remove authorization\"},\"Logout\"):Re.createElement(j,{className:\"btn modal-btn auth authorize\",onClick:this.authorize,\"aria-label\":\"Apply given OAuth2 credentials\"},\"Authorize\")),Re.createElement(j,{className:\"btn modal-btn auth btn-done\",onClick:this.close},\"Close\")))}}class Clear extends Re.Component{onClick=()=>{let{specActions:s,path:o,method:i}=this.props;s.clearResponse(o,i),s.clearRequest(o,i)};render(){return Re.createElement(\"button\",{className:\"btn btn-clear opblock-control__btn\",onClick:this.onClick},\"Clear\")}}const live_response_Headers=({headers:s})=>Re.createElement(\"div\",null,Re.createElement(\"h5\",null,\"Response headers\"),Re.createElement(\"pre\",{className:\"microlight\"},s)),Duration=({duration:s})=>Re.createElement(\"div\",null,Re.createElement(\"h5\",null,\"Request duration\"),Re.createElement(\"pre\",{className:\"microlight\"},s,\" ms\"));class LiveResponse extends Re.Component{shouldComponentUpdate(s){return this.props.response!==s.response||this.props.path!==s.path||this.props.method!==s.method||this.props.displayRequestDuration!==s.displayRequestDuration}render(){const{response:s,getComponent:o,getConfigs:i,displayRequestDuration:a,specSelectors:u,path:_,method:w}=this.props,{showMutatedRequest:x,requestSnippetsEnabled:C}=i(),j=x?u.mutatedRequestFor(_,w):u.requestFor(_,w),L=s.get(\"status\"),B=j.get(\"url\"),$=s.get(\"headers\").toJS(),U=s.get(\"notDocumented\"),V=s.get(\"error\"),z=s.get(\"text\"),Y=s.get(\"duration\"),Z=Object.keys($),ee=$[\"content-type\"]||$[\"Content-Type\"],ie=o(\"responseBody\"),ae=Z.map((s=>{var o=Array.isArray($[s])?$[s].join():$[s];return Re.createElement(\"span\",{className:\"headerline\",key:s},\" \",s,\": \",o,\" \")})),ce=0!==ae.length,le=o(\"Markdown\",!0),pe=o(\"RequestSnippets\",!0),de=o(\"curl\",!0);return Re.createElement(\"div\",null,j&&C?Re.createElement(pe,{request:j}):Re.createElement(de,{request:j}),B&&Re.createElement(\"div\",null,Re.createElement(\"div\",{className:\"request-url\"},Re.createElement(\"h4\",null,\"Request URL\"),Re.createElement(\"pre\",{className:\"microlight\"},B))),Re.createElement(\"h4\",null,\"Server response\"),Re.createElement(\"table\",{className:\"responses-table live-responses-table\"},Re.createElement(\"thead\",null,Re.createElement(\"tr\",{className:\"responses-header\"},Re.createElement(\"td\",{className:\"col_header response-col_status\"},\"Code\"),Re.createElement(\"td\",{className:\"col_header response-col_description\"},\"Details\"))),Re.createElement(\"tbody\",null,Re.createElement(\"tr\",{className:\"response\"},Re.createElement(\"td\",{className:\"response-col_status\"},L,U?Re.createElement(\"div\",{className:\"response-undocumented\"},Re.createElement(\"i\",null,\" Undocumented \")):null),Re.createElement(\"td\",{className:\"response-col_description\"},V?Re.createElement(le,{source:`${\"\"!==s.get(\"name\")?`${s.get(\"name\")}: `:\"\"}${s.get(\"message\")}`}):null,z?Re.createElement(ie,{content:z,contentType:ee,url:B,headers:$,getConfigs:i,getComponent:o}):null,ce?Re.createElement(live_response_Headers,{headers:ae}):null,a&&Y?Re.createElement(Duration,{duration:Y}):null)))))}}class OnlineValidatorBadge extends Re.Component{constructor(s,o){super(s,o);let{getConfigs:i}=s,{validatorUrl:a}=i();this.state={url:this.getDefinitionUrl(),validatorUrl:void 0===a?\"https://validator.swagger.io/validator\":a}}getDefinitionUrl=()=>{let{specSelectors:s}=this.props;return new(Nt())(s.url(),lt.location).toString()};UNSAFE_componentWillReceiveProps(s){let{getConfigs:o}=s,{validatorUrl:i}=o();this.setState({url:this.getDefinitionUrl(),validatorUrl:void 0===i?\"https://validator.swagger.io/validator\":i})}render(){let{getConfigs:s}=this.props,{spec:o}=s(),i=sanitizeUrl(this.state.validatorUrl);return\"object\"==typeof o&&Object.keys(o).length?null:this.state.url&&requiresValidationURL(this.state.validatorUrl)&&requiresValidationURL(this.state.url)?Re.createElement(\"span\",{className:\"float-right\"},Re.createElement(\"a\",{target:\"_blank\",rel:\"noopener noreferrer\",href:`${i}/debug?url=${encodeURIComponent(this.state.url)}`},Re.createElement(ValidatorImage,{src:`${i}?url=${encodeURIComponent(this.state.url)}`,alt:\"Online validator badge\"}))):null}}class ValidatorImage extends Re.Component{constructor(s){super(s),this.state={loaded:!1,error:!1}}componentDidMount(){const s=new Image;s.onload=()=>{this.setState({loaded:!0})},s.onerror=()=>{this.setState({error:!0})},s.src=this.props.src}UNSAFE_componentWillReceiveProps(s){if(s.src!==this.props.src){const o=new Image;o.onload=()=>{this.setState({loaded:!0})},o.onerror=()=>{this.setState({error:!0})},o.src=s.src}}render(){return this.state.error?Re.createElement(\"img\",{alt:\"Error\"}):this.state.loaded?Re.createElement(\"img\",{src:this.props.src,alt:this.props.alt}):null}}class Operations extends Re.Component{render(){let{specSelectors:s}=this.props;const o=s.taggedOperations();return 0===o.size?Re.createElement(\"h3\",null,\" No operations defined in spec!\"):Re.createElement(\"div\",null,o.map(this.renderOperationTag).toArray(),o.size<1?Re.createElement(\"h3\",null,\" No operations defined in spec! \"):null)}renderOperationTag=(s,o)=>{const{specSelectors:i,getComponent:a,oas3Selectors:u,layoutSelectors:_,layoutActions:w,getConfigs:x}=this.props,C=i.validOperationMethods(),j=a(\"OperationContainer\",!0),L=a(\"OperationTag\"),B=s.get(\"operations\");return Re.createElement(L,{key:\"operation-\"+o,tagObj:s,tag:o,oas3Selectors:u,layoutSelectors:_,layoutActions:w,getConfigs:x,getComponent:a,specUrl:i.url()},Re.createElement(\"div\",{className:\"operation-tag-content\"},B.map((s=>{const i=s.get(\"path\"),a=s.get(\"method\"),u=We().List([\"paths\",i,a]);return-1===C.indexOf(a)?null:Re.createElement(j,{key:`${i}-${a}`,specPath:u,op:s,path:i,method:a,tag:o})})).toArray()))}}class OperationTag extends Re.Component{static defaultProps={tagObj:We().fromJS({}),tag:\"\"};render(){const{tagObj:s,tag:o,children:i,oas3Selectors:a,layoutSelectors:u,layoutActions:_,getConfigs:w,getComponent:x,specUrl:C}=this.props;let{docExpansion:j,deepLinking:L}=w();const B=x(\"Collapse\"),$=x(\"Markdown\",!0),U=x(\"DeepLink\"),V=x(\"Link\"),z=x(\"ArrowUpIcon\"),Y=x(\"ArrowDownIcon\");let Z,ee=s.getIn([\"tagDetails\",\"description\"],null),ie=s.getIn([\"tagDetails\",\"externalDocs\",\"description\"]),ae=s.getIn([\"tagDetails\",\"externalDocs\",\"url\"]);Z=isFunc(a)&&isFunc(a.selectedServer)?safeBuildUrl(ae,C,{selectedServer:a.selectedServer()}):ae;let ce=[\"operations-tag\",o],le=u.isShown(ce,\"full\"===j||\"list\"===j);return Re.createElement(\"div\",{className:le?\"opblock-tag-section is-open\":\"opblock-tag-section\"},Re.createElement(\"h3\",{onClick:()=>_.show(ce,!le),className:ee?\"opblock-tag\":\"opblock-tag no-desc\",id:ce.map((s=>escapeDeepLinkPath(s))).join(\"-\"),\"data-tag\":o,\"data-is-open\":le},Re.createElement(U,{enabled:L,isShown:le,path:createDeepLinkPath(o),text:o}),ee?Re.createElement(\"small\",null,Re.createElement($,{source:ee})):Re.createElement(\"small\",null),Z?Re.createElement(\"div\",{className:\"info__externaldocs\"},Re.createElement(\"small\",null,Re.createElement(V,{href:sanitizeUrl(Z),onClick:s=>s.stopPropagation(),target:\"_blank\"},ie||Z))):null,Re.createElement(\"button\",{\"aria-expanded\":le,className:\"expand-operation\",title:le?\"Collapse operation\":\"Expand operation\",onClick:()=>_.show(ce,!le)},le?Re.createElement(z,{className:\"arrow\"}):Re.createElement(Y,{className:\"arrow\"}))),Re.createElement(B,{isOpened:le},i))}}class operation_Operation extends Re.PureComponent{static defaultProps={operation:null,response:null,request:null,specPath:(0,ze.List)(),summary:\"\"};render(){let{specPath:s,response:o,request:i,toggleShown:a,onTryoutClick:u,onResetClick:_,onCancelClick:w,onExecute:x,fn:C,getComponent:j,getConfigs:L,specActions:B,specSelectors:$,authActions:U,authSelectors:V,oas3Actions:z,oas3Selectors:Y}=this.props,Z=this.props.operation,{deprecated:ee,isShown:ie,path:ae,method:ce,op:le,tag:pe,operationId:de,allowTryItOut:fe,displayRequestDuration:ye,tryItOutEnabled:be,executeInProgress:_e}=Z.toJS(),{description:Se,externalDocs:we,schemes:xe}=le;const Pe=we?safeBuildUrl(we.url,$.url(),{selectedServer:Y.selectedServer()}):\"\";let Te=Z.getIn([\"op\"]),$e=Te.get(\"responses\"),qe=function getList(s,o){if(!We().Iterable.isIterable(s))return We().List();let i=s.getIn(Array.isArray(o)?o:[o]);return We().List.isList(i)?i:We().List()}(Te,[\"parameters\"]),ze=$.operationScheme(ae,ce),He=[\"operations\",pe,de],Ye=getExtensions(Te);const Xe=j(\"responses\"),Qe=j(\"parameters\"),et=j(\"execute\"),tt=j(\"clear\"),rt=j(\"Collapse\"),nt=j(\"Markdown\",!0),st=j(\"schemes\"),ot=j(\"OperationServers\"),it=j(\"OperationExt\"),at=j(\"OperationSummary\"),ct=j(\"Link\"),{showExtensions:lt}=L();if($e&&o&&o.size>0){let s=!$e.get(String(o.get(\"status\")))&&!$e.get(\"default\");o=o.set(\"notDocumented\",s)}let ut=[ae,ce];const pt=$.validationErrors([ae,ce]);return Re.createElement(\"div\",{className:ee?\"opblock opblock-deprecated\":ie?`opblock opblock-${ce} is-open`:`opblock opblock-${ce}`,id:escapeDeepLinkPath(He.join(\"-\"))},Re.createElement(at,{operationProps:Z,isShown:ie,toggleShown:a,getComponent:j,authActions:U,authSelectors:V,specPath:s}),Re.createElement(rt,{isOpened:ie},Re.createElement(\"div\",{className:\"opblock-body\"},Te&&Te.size||null===Te?null:Re.createElement(rolling_load,{height:\"32px\",width:\"32px\",className:\"opblock-loading-animation\"}),ee&&Re.createElement(\"h4\",{className:\"opblock-title_normal\"},\" Warning: Deprecated\"),Se&&Re.createElement(\"div\",{className:\"opblock-description-wrapper\"},Re.createElement(\"div\",{className:\"opblock-description\"},Re.createElement(nt,{source:Se}))),Pe?Re.createElement(\"div\",{className:\"opblock-external-docs-wrapper\"},Re.createElement(\"h4\",{className:\"opblock-title_normal\"},\"Find more details\"),Re.createElement(\"div\",{className:\"opblock-external-docs\"},we.description&&Re.createElement(\"span\",{className:\"opblock-external-docs__description\"},Re.createElement(nt,{source:we.description})),Re.createElement(ct,{target:\"_blank\",className:\"opblock-external-docs__link\",href:sanitizeUrl(Pe)},Pe))):null,Te&&Te.size?Re.createElement(Qe,{parameters:qe,specPath:s.push(\"parameters\"),operation:Te,onChangeKey:ut,onTryoutClick:u,onResetClick:_,onCancelClick:w,tryItOutEnabled:be,allowTryItOut:fe,fn:C,getComponent:j,specActions:B,specSelectors:$,pathMethod:[ae,ce],getConfigs:L,oas3Actions:z,oas3Selectors:Y}):null,be?Re.createElement(ot,{getComponent:j,path:ae,method:ce,operationServers:Te.get(\"servers\"),pathServers:$.paths().getIn([ae,\"servers\"]),getSelectedServer:Y.selectedServer,setSelectedServer:z.setSelectedServer,setServerVariableValue:z.setServerVariableValue,getServerVariable:Y.serverVariableValue,getEffectiveServerValue:Y.serverEffectiveValue}):null,be&&fe&&xe&&xe.size?Re.createElement(\"div\",{className:\"opblock-schemes\"},Re.createElement(st,{schemes:xe,path:ae,method:ce,specActions:B,currentScheme:ze})):null,!be||!fe||pt.length<=0?null:Re.createElement(\"div\",{className:\"validation-errors errors-wrapper\"},\"Please correct the following validation errors and try again.\",Re.createElement(\"ul\",null,pt.map(((s,o)=>Re.createElement(\"li\",{key:o},\" \",s,\" \"))))),Re.createElement(\"div\",{className:be&&o&&fe?\"btn-group\":\"execute-wrapper\"},be&&fe?Re.createElement(et,{operation:Te,specActions:B,specSelectors:$,oas3Selectors:Y,oas3Actions:z,path:ae,method:ce,onExecute:x,disabled:_e}):null,be&&o&&fe?Re.createElement(tt,{specActions:B,path:ae,method:ce}):null),_e?Re.createElement(\"div\",{className:\"loading-container\"},Re.createElement(\"div\",{className:\"loading\"})):null,$e?Re.createElement(Xe,{responses:$e,request:i,tryItOutResponse:o,getComponent:j,getConfigs:L,specSelectors:$,oas3Actions:z,oas3Selectors:Y,specActions:B,produces:$.producesOptionsFor([ae,ce]),producesValue:$.currentProducesFor([ae,ce]),specPath:s.push(\"responses\"),path:ae,method:ce,displayRequestDuration:ye,fn:C}):null,lt&&Ye.size?Re.createElement(it,{extensions:Ye,getComponent:j}):null)))}}class OperationContainer extends Re.PureComponent{constructor(s,o){super(s,o);const{tryItOutEnabled:i}=s.getConfigs();this.state={tryItOutEnabled:i,executeInProgress:!1}}static defaultProps={showSummary:!0,response:null,allowTryItOut:!0,displayOperationId:!1,displayRequestDuration:!1};mapStateToProps(s,o){const{op:i,layoutSelectors:a,getConfigs:u}=o,{docExpansion:_,deepLinking:w,displayOperationId:x,displayRequestDuration:C,supportedSubmitMethods:j}=u(),L=a.showSummary(),B=i.getIn([\"operation\",\"__originalOperationId\"])||i.getIn([\"operation\",\"operationId\"])||opId(i.get(\"operation\"),o.path,o.method)||i.get(\"id\"),$=[\"operations\",o.tag,B],U=j.indexOf(o.method)>=0&&(void 0===o.allowTryItOut?o.specSelectors.allowTryItOutFor(o.path,o.method):o.allowTryItOut),V=i.getIn([\"operation\",\"security\"])||o.specSelectors.security();return{operationId:B,isDeepLinkingEnabled:w,showSummary:L,displayOperationId:x,displayRequestDuration:C,allowTryItOut:U,security:V,isAuthorized:o.authSelectors.isAuthorized(V),isShown:a.isShown($,\"full\"===_),jumpToKey:`paths.${o.path}.${o.method}`,response:o.specSelectors.responseFor(o.path,o.method),request:o.specSelectors.requestFor(o.path,o.method)}}componentDidMount(){const{isShown:s}=this.props,o=this.getResolvedSubtree();s&&void 0===o&&this.requestResolvedSubtree()}componentDidUpdate(s){const{response:o,isShown:i}=this.props,a=this.getResolvedSubtree();o!==s.response&&this.setState({executeInProgress:!1}),i&&void 0===a&&!s.isShown&&this.requestResolvedSubtree()}toggleShown=()=>{let{layoutActions:s,tag:o,operationId:i,isShown:a}=this.props;const u=this.getResolvedSubtree();a||void 0!==u||this.requestResolvedSubtree(),s.show([\"operations\",o,i],!a)};onCancelClick=()=>{this.setState({tryItOutEnabled:!this.state.tryItOutEnabled})};onTryoutClick=()=>{this.setState({tryItOutEnabled:!this.state.tryItOutEnabled})};onResetClick=s=>{const o=this.props.oas3Selectors.selectDefaultRequestBodyValue(...s),i=this.props.oas3Selectors.requestContentType(...s);if(\"application/x-www-form-urlencoded\"===i||\"multipart/form-data\"===i){const i=JSON.parse(o);Object.entries(i).forEach((([s,o])=>{Array.isArray(o)?i[s]=i[s].map((s=>\"object\"==typeof s?JSON.stringify(s,null,2):s)):\"object\"==typeof o&&(i[s]=JSON.stringify(i[s],null,2))})),this.props.oas3Actions.setRequestBodyValue({value:(0,ze.fromJS)(i),pathMethod:s})}else this.props.oas3Actions.setRequestBodyValue({value:o,pathMethod:s})};onExecute=()=>{this.setState({executeInProgress:!0})};getResolvedSubtree=()=>{const{specSelectors:s,path:o,method:i,specPath:a}=this.props;return a?s.specResolvedSubtree(a.toJS()):s.specResolvedSubtree([\"paths\",o,i])};requestResolvedSubtree=()=>{const{specActions:s,path:o,method:i,specPath:a}=this.props;return a?s.requestResolvedSubtree(a.toJS()):s.requestResolvedSubtree([\"paths\",o,i])};render(){let{op:s,tag:o,path:i,method:a,security:u,isAuthorized:_,operationId:w,showSummary:x,isShown:C,jumpToKey:j,allowTryItOut:L,response:B,request:$,displayOperationId:U,displayRequestDuration:V,isDeepLinkingEnabled:z,specPath:Y,specSelectors:Z,specActions:ee,getComponent:ie,getConfigs:ae,layoutSelectors:ce,layoutActions:le,authActions:pe,authSelectors:de,oas3Actions:fe,oas3Selectors:ye,fn:be}=this.props;const _e=ie(\"operation\"),Se=this.getResolvedSubtree()||(0,ze.Map)(),we=(0,ze.fromJS)({op:Se,tag:o,path:i,summary:s.getIn([\"operation\",\"summary\"])||\"\",deprecated:Se.get(\"deprecated\")||s.getIn([\"operation\",\"deprecated\"])||!1,method:a,security:u,isAuthorized:_,operationId:w,originalOperationId:Se.getIn([\"operation\",\"__originalOperationId\"]),showSummary:x,isShown:C,jumpToKey:j,allowTryItOut:L,request:$,displayOperationId:U,displayRequestDuration:V,isDeepLinkingEnabled:z,executeInProgress:this.state.executeInProgress,tryItOutEnabled:this.state.tryItOutEnabled});return Re.createElement(_e,{operation:we,response:B,request:$,isShown:C,toggleShown:this.toggleShown,onTryoutClick:this.onTryoutClick,onResetClick:this.onResetClick,onCancelClick:this.onCancelClick,onExecute:this.onExecute,specPath:Y,specActions:ee,specSelectors:Z,oas3Actions:fe,oas3Selectors:ye,layoutActions:le,layoutSelectors:ce,authActions:pe,authSelectors:de,getComponent:ie,getConfigs:ae,fn:be})}}var GO=__webpack_require__(13222),YO=__webpack_require__.n(GO);class OperationSummary extends Re.PureComponent{static defaultProps={operationProps:null,specPath:(0,ze.List)(),summary:\"\"};render(){let{isShown:s,toggleShown:o,getComponent:i,authActions:a,authSelectors:u,operationProps:_,specPath:w}=this.props,{summary:x,isAuthorized:C,method:j,op:L,showSummary:B,path:$,operationId:U,originalOperationId:V,displayOperationId:z}=_.toJS(),{summary:Y}=L,Z=_.get(\"security\");const ee=i(\"authorizeOperationBtn\",!0),ie=i(\"OperationSummaryMethod\"),ae=i(\"OperationSummaryPath\"),ce=i(\"JumpToPath\",!0),le=i(\"CopyToClipboardBtn\",!0),pe=i(\"ArrowUpIcon\"),de=i(\"ArrowDownIcon\"),fe=Z&&!!Z.count(),ye=fe&&1===Z.size&&Z.first().isEmpty(),be=!fe||ye;return Re.createElement(\"div\",{className:`opblock-summary opblock-summary-${j}`},Re.createElement(\"button\",{\"aria-expanded\":s,className:\"opblock-summary-control\",onClick:o},Re.createElement(ie,{method:j}),Re.createElement(\"div\",{className:\"opblock-summary-path-description-wrapper\"},Re.createElement(ae,{getComponent:i,operationProps:_,specPath:w}),B?Re.createElement(\"div\",{className:\"opblock-summary-description\"},YO()(Y||x)):null),z&&(V||U)?Re.createElement(\"span\",{className:\"opblock-summary-operation-id\"},V||U):null),Re.createElement(le,{textToCopy:`${w.get(1)}`}),be?null:Re.createElement(ee,{isAuthorized:C,onClick:()=>{const s=u.definitionsForRequirements(Z);a.showDefinitions(s)}}),Re.createElement(ce,{path:w}),Re.createElement(\"button\",{\"aria-label\":`${j} ${$.replace(/\\//g,\"​/\")}`,className:\"opblock-control-arrow\",\"aria-expanded\":s,tabIndex:\"-1\",onClick:o},s?Re.createElement(pe,{className:\"arrow\"}):Re.createElement(de,{className:\"arrow\"})))}}class OperationSummaryMethod extends Re.PureComponent{static defaultProps={operationProps:null};render(){let{method:s}=this.props;return Re.createElement(\"span\",{className:\"opblock-summary-method\"},s.toUpperCase())}}class OperationSummaryPath extends Re.PureComponent{render(){let{getComponent:s,operationProps:o}=this.props,{deprecated:i,isShown:a,path:u,tag:_,operationId:w,isDeepLinkingEnabled:x}=o.toJS();const C=u.split(/(?=\\/)/g);for(let s=1;s<C.length;s+=2)C.splice(s,0,Re.createElement(\"wbr\",{key:s}));const j=s(\"DeepLink\");return Re.createElement(\"span\",{className:i?\"opblock-summary-path__deprecated\":\"opblock-summary-path\",\"data-path\":u},Re.createElement(j,{enabled:x,isShown:a,path:createDeepLinkPath(`${_}/${w}`),text:C}))}}const operation_extensions=({extensions:s,getComponent:o})=>{let i=o(\"OperationExtRow\");return Re.createElement(\"div\",{className:\"opblock-section\"},Re.createElement(\"div\",{className:\"opblock-section-header\"},Re.createElement(\"h4\",null,\"Extensions\")),Re.createElement(\"div\",{className:\"table-container\"},Re.createElement(\"table\",null,Re.createElement(\"thead\",null,Re.createElement(\"tr\",null,Re.createElement(\"td\",{className:\"col_header\"},\"Field\"),Re.createElement(\"td\",{className:\"col_header\"},\"Value\"))),Re.createElement(\"tbody\",null,s.entrySeq().map((([s,o])=>Re.createElement(i,{key:`${s}-${o}`,xKey:s,xVal:o})))))))},operation_extension_row=({xKey:s,xVal:o})=>{const i=o?o.toJS?o.toJS():o:null;return Re.createElement(\"tr\",null,Re.createElement(\"td\",null,s),Re.createElement(\"td\",null,JSON.stringify(i)))};function createHtmlReadyId(s,o=\"_\"){return s.replace(/[^\\w-]/g,o)}class responses_Responses extends Re.Component{static defaultProps={tryItOutResponse:null,produces:(0,ze.fromJS)([\"application/json\"]),displayRequestDuration:!1};onChangeProducesWrapper=s=>this.props.specActions.changeProducesValue([this.props.path,this.props.method],s);onResponseContentTypeChange=({controlsAcceptHeader:s,value:o})=>{const{oas3Actions:i,path:a,method:u}=this.props;s&&i.setResponseContentType({value:o,path:a,method:u})};render(){let{responses:s,tryItOutResponse:o,getComponent:i,getConfigs:a,specSelectors:u,fn:_,producesValue:w,displayRequestDuration:x,specPath:C,path:j,method:L,oas3Selectors:B,oas3Actions:$}=this.props,U=function defaultStatusCode(s){let o=s.keySeq();return o.contains(jt)?jt:o.filter((s=>\"2\"===(s+\"\")[0])).sort().first()}(s);const V=i(\"contentType\"),z=i(\"liveResponse\"),Y=i(\"response\");let Z=this.props.produces&&this.props.produces.size?this.props.produces:responses_Responses.defaultProps.produces;const ee=u.isOAS3()?function getAcceptControllingResponse(s){if(!We().OrderedMap.isOrderedMap(s))return null;if(!s.size)return null;const o=s.find(((s,o)=>o.startsWith(\"2\")&&Object.keys(s.get(\"content\")||{}).length>0)),i=s.get(\"default\")||We().OrderedMap(),a=(i.get(\"content\")||We().OrderedMap()).keySeq().toJS().length?i:null;return o||a}(s):null,ie=s.filter(((s,o)=>!isExtension(o))),ae=createHtmlReadyId(`${L}${j}_responses`),ce=`${ae}_select`;return ie&&ie.size?Re.createElement(\"div\",{className:\"responses-wrapper\"},Re.createElement(\"div\",{className:\"opblock-section-header\"},Re.createElement(\"h4\",null,\"Responses\"),u.isOAS3()?null:Re.createElement(\"label\",{htmlFor:ce},Re.createElement(\"span\",null,\"Response content type\"),Re.createElement(V,{value:w,ariaControls:ae,ariaLabel:\"Response content type\",className:\"execute-content-type\",contentTypes:Z,controlId:ce,onChange:this.onChangeProducesWrapper}))),Re.createElement(\"div\",{className:\"responses-inner\"},o?Re.createElement(\"div\",null,Re.createElement(z,{response:o,getComponent:i,getConfigs:a,specSelectors:u,path:this.props.path,method:this.props.method,displayRequestDuration:x}),Re.createElement(\"h4\",null,\"Responses\")):null,Re.createElement(\"table\",{\"aria-live\":\"polite\",className:\"responses-table\",id:ae,role:\"region\"},Re.createElement(\"thead\",null,Re.createElement(\"tr\",{className:\"responses-header\"},Re.createElement(\"td\",{className:\"col_header response-col_status\"},\"Code\"),Re.createElement(\"td\",{className:\"col_header response-col_description\"},\"Description\"),u.isOAS3()?Re.createElement(\"td\",{className:\"col col_header response-col_links\"},\"Links\"):null)),Re.createElement(\"tbody\",null,ie.entrySeq().map((([s,x])=>{let V=o&&o.get(\"status\")==s?\"response_current\":\"\";return Re.createElement(Y,{key:s,path:j,method:L,specPath:C.push(s),isDefault:U===s,fn:_,className:V,code:s,response:x,specSelectors:u,controlsAcceptHeader:x===ee,onContentTypeChange:this.onResponseContentTypeChange,contentType:w,getConfigs:a,activeExamplesKey:B.activeExamplesMember(j,L,\"responses\",s),oas3Actions:$,getComponent:i})})).toArray())))):null}}function getKnownSyntaxHighlighterLanguage(s){const o=function canJsonParse(s){try{return!!JSON.parse(s)}catch(s){return null}}(s);return o?\"json\":null}class response_Response extends Re.Component{constructor(s,o){super(s,o),this.state={responseContentType:\"\"}}static defaultProps={response:(0,ze.fromJS)({}),onContentTypeChange:()=>{}};_onContentTypeChange=s=>{const{onContentTypeChange:o,controlsAcceptHeader:i}=this.props;this.setState({responseContentType:s}),o({value:s,controlsAcceptHeader:i})};getTargetExamplesKey=()=>{const{response:s,contentType:o,activeExamplesKey:i}=this.props,a=this.state.responseContentType||o,u=s.getIn([\"content\",a],(0,ze.Map)({})).get(\"examples\",null).keySeq().first();return i||u};render(){let{path:s,method:o,code:i,response:a,className:u,specPath:_,fn:w,getComponent:x,getConfigs:C,specSelectors:j,contentType:L,controlsAcceptHeader:B,oas3Actions:$}=this.props,{inferSchema:U,getSampleSchema:V}=w,z=j.isOAS3();const{showExtensions:Y}=C();let Z=Y?getExtensions(a):null,ee=a.get(\"headers\"),ie=a.get(\"links\");const ae=x(\"ResponseExtension\"),ce=x(\"headers\"),le=x(\"HighlightCode\",!0),pe=x(\"modelExample\"),de=x(\"Markdown\",!0),fe=x(\"operationLink\"),ye=x(\"contentType\"),be=x(\"ExamplesSelect\"),_e=x(\"Example\");var Se,we;const xe=this.state.responseContentType||L,Pe=a.getIn([\"content\",xe],(0,ze.Map)({})),Te=Pe.get(\"examples\",null);if(z){const s=Pe.get(\"schema\");Se=s?U(s.toJS()):null,we=s?_.push(\"content\",this.state.responseContentType,\"schema\"):_}else Se=a.get(\"schema\"),we=a.has(\"schema\")?_.push(\"schema\"):_;let $e,qe,We=!1,He={includeReadOnly:!0};if(z)if(qe=Pe.get(\"schema\")?.toJS(),ze.Map.isMap(Te)&&!Te.isEmpty()){const s=this.getTargetExamplesKey(),getMediaTypeExample=s=>ze.Map.isMap(s)?s.get(\"value\"):void 0;$e=getMediaTypeExample(Te.get(s,(0,ze.Map)({}))),void 0===$e&&($e=getMediaTypeExample(Te.values().next().value)),We=!0}else void 0!==Pe.get(\"example\")&&($e=Pe.get(\"example\"),We=!0);else{qe=Se,He={...He,includeWriteOnly:!0};const s=a.getIn([\"examples\",xe]);s&&($e=s,We=!0)}const Ye=((s,o)=>{if(null==s)return null;const i=getKnownSyntaxHighlighterLanguage(s)?\"json\":null;return Re.createElement(\"div\",null,Re.createElement(o,{className:\"example\",language:i},stringify(s)))})(V(qe,xe,He,We?$e:void 0),le);return Re.createElement(\"tr\",{className:\"response \"+(u||\"\"),\"data-code\":i},Re.createElement(\"td\",{className:\"response-col_status\"},i),Re.createElement(\"td\",{className:\"response-col_description\"},Re.createElement(\"div\",{className:\"response-col_description__inner\"},Re.createElement(de,{source:a.get(\"description\")})),Y&&Z.size?Z.entrySeq().map((([s,o])=>Re.createElement(ae,{key:`${s}-${o}`,xKey:s,xVal:o}))):null,z&&a.get(\"content\")?Re.createElement(\"section\",{className:\"response-controls\"},Re.createElement(\"div\",{className:Jn()(\"response-control-media-type\",{\"response-control-media-type--accept-controller\":B})},Re.createElement(\"small\",{className:\"response-control-media-type__title\"},\"Media type\"),Re.createElement(ye,{value:this.state.responseContentType,contentTypes:a.get(\"content\")?a.get(\"content\").keySeq():(0,ze.Seq)(),onChange:this._onContentTypeChange,ariaLabel:\"Media Type\"}),B?Re.createElement(\"small\",{className:\"response-control-media-type__accept-message\"},\"Controls \",Re.createElement(\"code\",null,\"Accept\"),\" header.\"):null),ze.Map.isMap(Te)&&!Te.isEmpty()?Re.createElement(\"div\",{className:\"response-control-examples\"},Re.createElement(\"small\",{className:\"response-control-examples__title\"},\"Examples\"),Re.createElement(be,{examples:Te,currentExampleKey:this.getTargetExamplesKey(),onSelect:a=>$.setActiveExamplesMember({name:a,pathMethod:[s,o],contextType:\"responses\",contextName:i}),showLabels:!1})):null):null,Ye||Se?Re.createElement(pe,{specPath:we,getComponent:x,getConfigs:C,specSelectors:j,schema:fromJSOrdered(Se),example:Ye,includeReadOnly:!0}):null,z&&Te?Re.createElement(_e,{example:Te.get(this.getTargetExamplesKey(),(0,ze.Map)({})),getComponent:x,getConfigs:C,omitValue:!0}):null,ee?Re.createElement(ce,{headers:ee,getComponent:x}):null),z?Re.createElement(\"td\",{className:\"response-col_links\"},ie?ie.toSeq().entrySeq().map((([s,o])=>Re.createElement(fe,{key:s,name:s,link:o,getComponent:x}))):Re.createElement(\"i\",null,\"No links\")):null)}}const response_extension=({xKey:s,xVal:o})=>Re.createElement(\"div\",{className:\"response__extension\"},s,\": \",String(o));var XO=__webpack_require__(26657),QO=__webpack_require__.n(XO),ZO=__webpack_require__(80218),eA=__webpack_require__.n(ZO);class ResponseBody extends Re.PureComponent{state={parsedContent:null};updateParsedContent=s=>{const{content:o}=this.props;if(s!==o)if(o&&o instanceof Blob){var i=new FileReader;i.onload=()=>{this.setState({parsedContent:i.result})},i.readAsText(o)}else this.setState({parsedContent:o.toString()})};componentDidMount(){this.updateParsedContent(null)}componentDidUpdate(s){this.updateParsedContent(s.content)}render(){let{content:s,contentType:o,url:i,headers:a={},getComponent:u}=this.props;const{parsedContent:_}=this.state,w=u(\"HighlightCode\",!0),x=\"response_\"+(new Date).getTime();let C,j;if(i=i||\"\",(/^application\\/octet-stream/i.test(o)||a[\"Content-Disposition\"]&&/attachment/i.test(a[\"Content-Disposition\"])||a[\"content-disposition\"]&&/attachment/i.test(a[\"content-disposition\"])||a[\"Content-Description\"]&&/File Transfer/i.test(a[\"Content-Description\"])||a[\"content-description\"]&&/File Transfer/i.test(a[\"content-description\"]))&&(s.size>0||s.length>0))if(\"Blob\"in window){let u=o||\"text/html\",_=s instanceof Blob?s:new Blob([s],{type:u}),w=window.URL.createObjectURL(_),x=[u,i.substr(i.lastIndexOf(\"/\")+1),w].join(\":\"),C=a[\"content-disposition\"]||a[\"Content-Disposition\"];if(void 0!==C){let s=function extractFileNameFromContentDispositionHeader(s){let o;if([/filename\\*=[^']+'\\w*'\"([^\"]+)\";?/i,/filename\\*=[^']+'\\w*'([^;]+);?/i,/filename=\"([^;]*);?\"/i,/filename=([^;]*);?/i].some((i=>(o=i.exec(s),null!==o))),null!==o&&o.length>1)try{return decodeURIComponent(o[1])}catch(s){console.error(s)}return null}(C);null!==s&&(x=s)}j=lt.navigator&&lt.navigator.msSaveOrOpenBlob?Re.createElement(\"div\",null,Re.createElement(\"a\",{href:w,onClick:()=>lt.navigator.msSaveOrOpenBlob(_,x)},\"Download file\")):Re.createElement(\"div\",null,Re.createElement(\"a\",{href:w,download:x},\"Download file\"))}else j=Re.createElement(\"pre\",{className:\"microlight\"},\"Download headers detected but your browser does not support downloading binary via XHR (Blob).\");else if(/json/i.test(o)){let o=null;getKnownSyntaxHighlighterLanguage(s)&&(o=\"json\");try{C=JSON.stringify(JSON.parse(s),null,\"  \")}catch(o){C=\"can't parse JSON.  Raw result:\\n\\n\"+s}j=Re.createElement(w,{language:o,downloadable:!0,fileName:`${x}.json`,canCopy:!0},C)}else/xml/i.test(o)?(C=QO()(s,{textNodesOnSameLine:!0,indentor:\"  \"}),j=Re.createElement(w,{downloadable:!0,fileName:`${x}.xml`,canCopy:!0},C)):j=\"text/html\"===eA()(o)||/text\\/plain/.test(o)?Re.createElement(w,{downloadable:!0,fileName:`${x}.html`,canCopy:!0},s):\"text/csv\"===eA()(o)||/text\\/csv/.test(o)?Re.createElement(w,{downloadable:!0,fileName:`${x}.csv`,canCopy:!0},s):/^image\\//i.test(o)?o.includes(\"svg\")?Re.createElement(\"div\",null,\" \",s,\" \"):Re.createElement(\"img\",{src:window.URL.createObjectURL(s)}):/^audio\\//i.test(o)?Re.createElement(\"pre\",{className:\"microlight\"},Re.createElement(\"audio\",{controls:!0,key:i},Re.createElement(\"source\",{src:i,type:o}))):\"string\"==typeof s?Re.createElement(w,{downloadable:!0,fileName:`${x}.txt`,canCopy:!0},s):s.size>0?_?Re.createElement(\"div\",null,Re.createElement(\"p\",{className:\"i\"},\"Unrecognized response type; displaying content as text.\"),Re.createElement(w,{downloadable:!0,fileName:`${x}.txt`,canCopy:!0},_)):Re.createElement(\"p\",{className:\"i\"},\"Unrecognized response type; unable to display.\"):null;return j?Re.createElement(\"div\",null,Re.createElement(\"h5\",null,\"Response body\"),j):null}}class Parameters extends Re.Component{constructor(s){super(s),this.state={callbackVisible:!1,parametersVisible:!0}}static defaultProps={onTryoutClick:Function.prototype,onCancelClick:Function.prototype,tryItOutEnabled:!1,allowTryItOut:!0,onChangeKey:[],specPath:[]};onChange=(s,o,i)=>{let{specActions:{changeParamByIdentity:a},onChangeKey:u}=this.props;a(u,s,o,i)};onChangeConsumesWrapper=s=>{let{specActions:{changeConsumesValue:o},onChangeKey:i}=this.props;o(i,s)};toggleTab=s=>\"parameters\"===s?this.setState({parametersVisible:!0,callbackVisible:!1}):\"callbacks\"===s?this.setState({callbackVisible:!0,parametersVisible:!1}):void 0;onChangeMediaType=({value:s,pathMethod:o})=>{let{specActions:i,oas3Selectors:a,oas3Actions:u}=this.props;const _=a.hasUserEditedBody(...o),w=a.shouldRetainRequestBodyValue(...o);u.setRequestContentType({value:s,pathMethod:o}),u.initRequestBodyValidateError({pathMethod:o}),_||(w||u.setRequestBodyValue({value:void 0,pathMethod:o}),i.clearResponse(...o),i.clearRequest(...o),i.clearValidateParams(o))};render(){let{onTryoutClick:s,onResetClick:o,parameters:i,allowTryItOut:a,tryItOutEnabled:u,specPath:_,fn:w,getComponent:x,getConfigs:C,specSelectors:j,specActions:L,pathMethod:B,oas3Actions:$,oas3Selectors:U,operation:V}=this.props;const z=x(\"parameterRow\"),Y=x(\"TryItOutButton\"),Z=x(\"contentType\"),ee=x(\"Callbacks\",!0),ie=x(\"RequestBody\",!0),ae=u&&a,ce=j.isOAS3(),le=`${createHtmlReadyId(`${B[1]}${B[0]}_requests`)}_select`,pe=V.get(\"requestBody\"),de=Object.values(i.reduce(((s,o)=>{if(ze.Map.isMap(o)){const i=o.get(\"in\");s[i]??=[],s[i].push(o)}return s}),{})).reduce(((s,o)=>s.concat(o)),[]);return Re.createElement(\"div\",{className:\"opblock-section\"},Re.createElement(\"div\",{className:\"opblock-section-header\"},ce?Re.createElement(\"div\",{className:\"tab-header\"},Re.createElement(\"div\",{onClick:()=>this.toggleTab(\"parameters\"),className:`tab-item ${this.state.parametersVisible&&\"active\"}`},Re.createElement(\"h4\",{className:\"opblock-title\"},Re.createElement(\"span\",null,\"Parameters\"))),V.get(\"callbacks\")?Re.createElement(\"div\",{onClick:()=>this.toggleTab(\"callbacks\"),className:`tab-item ${this.state.callbackVisible&&\"active\"}`},Re.createElement(\"h4\",{className:\"opblock-title\"},Re.createElement(\"span\",null,\"Callbacks\"))):null):Re.createElement(\"div\",{className:\"tab-header\"},Re.createElement(\"h4\",{className:\"opblock-title\"},\"Parameters\")),a?Re.createElement(Y,{isOAS3:j.isOAS3(),hasUserEditedBody:U.hasUserEditedBody(...B),enabled:u,onCancelClick:this.props.onCancelClick,onTryoutClick:s,onResetClick:()=>o(B)}):null),this.state.parametersVisible?Re.createElement(\"div\",{className:\"parameters-container\"},de.length?Re.createElement(\"div\",{className:\"table-container\"},Re.createElement(\"table\",{className:\"parameters\"},Re.createElement(\"thead\",null,Re.createElement(\"tr\",null,Re.createElement(\"th\",{className:\"col_header parameters-col_name\"},\"Name\"),Re.createElement(\"th\",{className:\"col_header parameters-col_description\"},\"Description\"))),Re.createElement(\"tbody\",null,de.map(((s,o)=>Re.createElement(z,{fn:w,specPath:_.push(o.toString()),getComponent:x,getConfigs:C,rawParam:s,param:j.parameterWithMetaByIdentity(B,s),key:`${s.get(\"in\")}.${s.get(\"name\")}`,onChange:this.onChange,onChangeConsumes:this.onChangeConsumesWrapper,specSelectors:j,specActions:L,oas3Actions:$,oas3Selectors:U,pathMethod:B,isExecute:ae})))))):Re.createElement(\"div\",{className:\"opblock-description-wrapper\"},Re.createElement(\"p\",null,\"No parameters\"))):null,this.state.callbackVisible?Re.createElement(\"div\",{className:\"callbacks-container opblock-description-wrapper\"},Re.createElement(ee,{callbacks:(0,ze.Map)(V.get(\"callbacks\")),specPath:_.slice(0,-1).push(\"callbacks\")})):null,ce&&pe&&this.state.parametersVisible&&Re.createElement(\"div\",{className:\"opblock-section opblock-section-request-body\"},Re.createElement(\"div\",{className:\"opblock-section-header\"},Re.createElement(\"h4\",{className:`opblock-title parameter__name ${pe.get(\"required\")&&\"required\"}`},\"Request body\"),Re.createElement(\"label\",{id:le},Re.createElement(Z,{value:U.requestContentType(...B),contentTypes:pe.get(\"content\",(0,ze.List)()).keySeq(),onChange:s=>{this.onChangeMediaType({value:s,pathMethod:B})},className:\"body-param-content-type\",ariaLabel:\"Request content type\",controlId:le}))),Re.createElement(\"div\",{className:\"opblock-description-wrapper\"},Re.createElement(ie,{setRetainRequestBodyValueFlag:s=>$.setRetainRequestBodyValueFlag({value:s,pathMethod:B}),userHasEditedBody:U.hasUserEditedBody(...B),specPath:_.slice(0,-1).push(\"requestBody\"),requestBody:pe,requestBodyValue:U.requestBodyValue(...B),requestBodyInclusionSetting:U.requestBodyInclusionSetting(...B),requestBodyErrors:U.requestBodyErrors(...B),isExecute:ae,getConfigs:C,activeExamplesKey:U.activeExamplesMember(...B,\"requestBody\",\"requestBody\"),updateActiveExamplesKey:s=>{this.props.oas3Actions.setActiveExamplesMember({name:s,pathMethod:this.props.pathMethod,contextType:\"requestBody\",contextName:\"requestBody\"})},onChange:(s,o)=>{if(o){const i=U.requestBodyValue(...B),a=ze.Map.isMap(i)?i:(0,ze.Map)();return $.setRequestBodyValue({pathMethod:B,value:a.setIn(o,s)})}$.setRequestBodyValue({value:s,pathMethod:B})},onChangeIncludeEmpty:(s,o)=>{$.setRequestBodyInclusion({pathMethod:B,value:o,name:s})},contentType:U.requestContentType(...B)}))))}}const parameter_extension=({xKey:s,xVal:o})=>Re.createElement(\"div\",{className:\"parameter__extension\"},s,\": \",String(o)),tA={onChange:()=>{},isIncludedOptions:{}};class ParameterIncludeEmpty extends Re.Component{static defaultProps=tA;componentDidMount(){const{isIncludedOptions:s,onChange:o}=this.props,{shouldDispatchInit:i,defaultValue:a}=s;i&&o(a)}onCheckboxChange=s=>{const{onChange:o}=this.props;o(s.target.checked)};render(){let{isIncluded:s,isDisabled:o}=this.props;return Re.createElement(\"div\",null,Re.createElement(\"label\",{htmlFor:\"include_empty_value\",className:Jn()(\"parameter__empty_value_toggle\",{disabled:o})},Re.createElement(\"input\",{id:\"include_empty_value\",type:\"checkbox\",disabled:o,checked:!o&&s,onChange:this.onCheckboxChange}),\"Send empty value\"))}}class ParameterRow extends Re.Component{constructor(s,o){super(s,o),this.setDefaultValue()}UNSAFE_componentWillReceiveProps(s){let o,{specSelectors:i,pathMethod:a,rawParam:u}=s,_=i.isOAS3(),w=i.parameterWithMetaByIdentity(a,u)||new ze.Map;if(w=w.isEmpty()?u:w,_){let{schema:s}=getParameterSchema(w,{isOAS3:_});o=s?s.get(\"enum\"):void 0}else o=w?w.get(\"enum\"):void 0;let x,C=w?w.get(\"value\"):void 0;void 0!==C?x=C:u.get(\"required\")&&o&&o.size&&(x=o.first()),void 0!==x&&x!==C&&this.onChangeWrapper(function numberToString(s){return\"number\"==typeof s?s.toString():s}(x)),this.setDefaultValue()}onChangeWrapper=(s,o=!1)=>{let i,{onChange:a,rawParam:u}=this.props;return i=\"\"===s||s&&0===s.size?null:s,a(u,i,o)};_onExampleSelect=s=>{this.props.oas3Actions.setActiveExamplesMember({name:s,pathMethod:this.props.pathMethod,contextType:\"parameters\",contextName:this.getParamKey()})};onChangeIncludeEmpty=s=>{let{specActions:o,param:i,pathMethod:a}=this.props;const u=i.get(\"name\"),_=i.get(\"in\");return o.updateEmptyParamInclusion(a,u,_,s)};setDefaultValue=()=>{let{specSelectors:s,pathMethod:o,rawParam:i,oas3Selectors:a,fn:u}=this.props;const _=s.parameterWithMetaByIdentity(o,i)||(0,ze.Map)();let{schema:w}=getParameterSchema(_,{isOAS3:s.isOAS3()});const x=_.get(\"content\",(0,ze.Map)()).keySeq().first(),C=w?u.getSampleSchema(w.toJS(),x,{includeWriteOnly:!0}):null;if(_&&void 0===_.get(\"value\")&&\"body\"!==_.get(\"in\")){let i;if(s.isSwagger2())i=void 0!==_.get(\"x-example\")?_.get(\"x-example\"):void 0!==_.getIn([\"schema\",\"example\"])?_.getIn([\"schema\",\"example\"]):w&&w.getIn([\"default\"]);else if(s.isOAS3()){w=this.composeJsonSchema(w);const s=a.activeExamplesMember(...o,\"parameters\",this.getParamKey());i=void 0!==_.getIn([\"examples\",s,\"value\"])?_.getIn([\"examples\",s,\"value\"]):void 0!==_.getIn([\"content\",x,\"example\"])?_.getIn([\"content\",x,\"example\"]):void 0!==_.get(\"example\")?_.get(\"example\"):void 0!==(w&&w.get(\"example\"))?w&&w.get(\"example\"):void 0!==(w&&w.get(\"default\"))?w&&w.get(\"default\"):_.get(\"default\")}void 0===i||ze.List.isList(i)||(i=stringify(i));const j=u.getSchemaObjectType(w),L=u.getSchemaObjectType(w?.get(\"items\"));void 0!==i?this.onChangeWrapper(i):\"object\"===j&&C&&!_.get(\"examples\")?this.onChangeWrapper(ze.List.isList(C)?C:stringify(C)):\"array\"===j&&\"object\"===L&&C&&!_.get(\"examples\")&&this.onChangeWrapper(ze.List.isList(C)?C:(0,ze.List)(JSON.parse(C)))}};getParamKey(){const{param:s}=this.props;return s?`${s.get(\"name\")}-${s.get(\"in\")}`:null}composeJsonSchema(s){const{fn:o}=this.props,i=s.get(\"oneOf\")?.get(0)?.toJS(),a=s.get(\"anyOf\")?.get(0)?.toJS();return(0,ze.fromJS)(o.mergeJsonSchema(s.toJS(),i??a??{}))}render(){let{param:s,rawParam:o,getComponent:i,getConfigs:a,isExecute:u,fn:_,onChangeConsumes:w,specSelectors:x,pathMethod:C,specPath:j,oas3Selectors:L}=this.props,B=x.isOAS3();const{showExtensions:$,showCommonExtensions:U}=a();if(s||(s=o),!o)return null;const V=i(\"JsonSchemaForm\"),z=i(\"ParamBody\");let Y=s.get(\"in\"),Z=\"body\"!==Y?null:Re.createElement(z,{getComponent:i,getConfigs:a,fn:_,param:s,consumes:x.consumesOptionsFor(C),consumesValue:x.contentTypeValues(C).get(\"requestContentType\"),onChange:this.onChangeWrapper,onChangeConsumes:w,isExecute:u,specSelectors:x,pathMethod:C});const ee=i(\"modelExample\"),ie=i(\"Markdown\",!0),ae=i(\"ParameterExt\"),ce=i(\"ParameterIncludeEmpty\"),le=i(\"ExamplesSelectValueRetainer\"),pe=i(\"Example\");let{schema:de}=getParameterSchema(s,{isOAS3:B}),fe=x.parameterWithMetaByIdentity(C,o)||(0,ze.Map)();const ye=fe.get(\"content\",(0,ze.Map)()).keySeq().first();B&&(de=this.composeJsonSchema(de));let be=de?de.get(\"format\"):null,_e=\"formData\"===Y,Se=\"FormData\"in lt,we=s.get(\"required\");const xe=_.getSchemaObjectType(de),Pe=_.getSchemaObjectType(de?.get(\"items\")),Te=_.getSchemaObjectTypeLabel(de),$e=!Z&&\"object\"===xe,qe=!Z&&\"object\"===Pe;let We,He,Ye,Xe,Qe=fe?fe.get(\"value\"):\"\",et=U?getCommonExtensions(de):null,tt=$?getExtensions(s):null,rt=!1;void 0!==s&&de&&(We=de.get(\"items\")),void 0!==We?(He=We.get(\"enum\"),Ye=We.get(\"default\")):de&&(He=de.get(\"enum\")),He&&He.size&&He.size>0&&(rt=!0),void 0!==s&&(de&&(Ye=de.get(\"default\")),void 0===Ye&&(Ye=s.get(\"default\")),Xe=s.get(\"example\"),void 0===Xe&&(Xe=s.get(\"x-example\")));const nt=Z?null:Re.createElement(V,{fn:_,getComponent:i,value:Qe,required:we,disabled:!u,description:s.get(\"name\"),onChange:this.onChangeWrapper,errors:fe.get(\"errors\"),schema:de});return Re.createElement(\"tr\",{\"data-param-name\":s.get(\"name\"),\"data-param-in\":s.get(\"in\")},Re.createElement(\"td\",{className:\"parameters-col_name\"},Re.createElement(\"div\",{className:we?\"parameter__name required\":\"parameter__name\"},s.get(\"name\"),we?Re.createElement(\"span\",null,\" *\"):null),Re.createElement(\"div\",{className:\"parameter__type\"},Te,be&&Re.createElement(\"span\",{className:\"prop-format\"},\"($\",be,\")\")),Re.createElement(\"div\",{className:\"parameter__deprecated\"},B&&s.get(\"deprecated\")?\"deprecated\":null),Re.createElement(\"div\",{className:\"parameter__in\"},\"(\",s.get(\"in\"),\")\")),Re.createElement(\"td\",{className:\"parameters-col_description\"},s.get(\"description\")?Re.createElement(ie,{source:s.get(\"description\")}):null,!Z&&u||!rt?null:Re.createElement(ie,{className:\"parameter__enum\",source:\"<i>Available values</i> : \"+He.map((function(s){return s})).toArray().map(String).join(\", \")}),!Z&&u||void 0===Ye?null:Re.createElement(ie,{className:\"parameter__default\",source:\"<i>Default value</i> : \"+Ye}),!Z&&u||void 0===Xe?null:Re.createElement(ie,{source:\"<i>Example</i> : \"+Xe}),_e&&!Se&&Re.createElement(\"div\",null,\"Error: your browser does not support FormData\"),B&&s.get(\"examples\")?Re.createElement(\"section\",{className:\"parameter-controls\"},Re.createElement(le,{examples:s.get(\"examples\"),onSelect:this._onExampleSelect,updateValue:this.onChangeWrapper,getComponent:i,defaultToFirstExample:!0,currentKey:L.activeExamplesMember(...C,\"parameters\",this.getParamKey()),currentUserInputValue:Qe})):null,$e||qe?Re.createElement(ee,{getComponent:i,specPath:ye?j.push(\"content\",ye,\"schema\"):j.push(\"schema\"),getConfigs:a,isExecute:u,specSelectors:x,schema:de,example:nt}):nt,Z&&de?Re.createElement(ee,{getComponent:i,specPath:j.push(\"schema\"),getConfigs:a,isExecute:u,specSelectors:x,schema:de,example:Z,includeWriteOnly:!0}):null,!Z&&u&&s.get(\"allowEmptyValue\")?Re.createElement(ce,{onChange:this.onChangeIncludeEmpty,isIncluded:x.parameterInclusionSettingFor(C,s.get(\"name\"),s.get(\"in\")),isDisabled:!isEmptyValue(Qe)}):null,B&&s.get(\"examples\")?Re.createElement(pe,{example:s.getIn([\"examples\",L.activeExamplesMember(...C,\"parameters\",this.getParamKey())]),getComponent:i,getConfigs:a}):null,U&&et.size?et.entrySeq().map((([s,o])=>Re.createElement(ae,{key:`${s}-${o}`,xKey:s,xVal:o}))):null,$&&tt.size?tt.entrySeq().map((([s,o])=>Re.createElement(ae,{key:`${s}-${o}`,xKey:s,xVal:o}))):null))}}class Execute extends Re.Component{handleValidateParameters=()=>{let{specSelectors:s,specActions:o,path:i,method:a}=this.props;return o.validateParams([i,a]),s.validateBeforeExecute([i,a])};handleValidateRequestBody=()=>{let{path:s,method:o,specSelectors:i,oas3Selectors:a,oas3Actions:u}=this.props,_={missingBodyValue:!1,missingRequiredKeys:[]};u.clearRequestBodyValidateError({path:s,method:o});let w=i.getOAS3RequiredRequestBodyContentType([s,o]),x=a.requestBodyValue(s,o),C=a.validateBeforeExecute([s,o]),j=a.requestContentType(s,o);if(!C)return _.missingBodyValue=!0,u.setRequestBodyValidateError({path:s,method:o,validationErrors:_}),!1;if(!w)return!0;let L=a.validateShallowRequired({oas3RequiredRequestBodyContentType:w,oas3RequestContentType:j,oas3RequestBodyValue:x});return!L||L.length<1||(L.forEach((s=>{_.missingRequiredKeys.push(s)})),u.setRequestBodyValidateError({path:s,method:o,validationErrors:_}),!1)};handleValidationResultPass=()=>{let{specActions:s,operation:o,path:i,method:a}=this.props;this.props.onExecute&&this.props.onExecute(),s.execute({operation:o,path:i,method:a})};handleValidationResultFail=()=>{let{specActions:s,path:o,method:i}=this.props;s.clearValidateParams([o,i]),setTimeout((()=>{s.validateParams([o,i])}),40)};handleValidationResult=s=>{s?this.handleValidationResultPass():this.handleValidationResultFail()};onClick=()=>{let s=this.handleValidateParameters(),o=this.handleValidateRequestBody(),i=s&&o;this.handleValidationResult(i)};onChangeProducesWrapper=s=>this.props.specActions.changeProducesValue([this.props.path,this.props.method],s);render(){const{disabled:s}=this.props;return Re.createElement(\"button\",{className:\"btn execute opblock-control__btn\",onClick:this.onClick,disabled:s},\"Execute\")}}class headers_Headers extends Re.Component{render(){let{headers:s,getComponent:o}=this.props;const i=o(\"Property\"),a=o(\"Markdown\",!0);return s&&s.size?Re.createElement(\"div\",{className:\"headers-wrapper\"},Re.createElement(\"h4\",{className:\"headers__title\"},\"Headers:\"),Re.createElement(\"table\",{className:\"headers\"},Re.createElement(\"thead\",null,Re.createElement(\"tr\",{className:\"header-row\"},Re.createElement(\"th\",{className:\"header-col\"},\"Name\"),Re.createElement(\"th\",{className:\"header-col\"},\"Description\"),Re.createElement(\"th\",{className:\"header-col\"},\"Type\"))),Re.createElement(\"tbody\",null,s.entrySeq().map((([s,o])=>{if(!We().Map.isMap(o))return null;const u=o.get(\"description\"),_=o.getIn([\"schema\"])?o.getIn([\"schema\",\"type\"]):o.getIn([\"type\"]),w=o.getIn([\"schema\",\"example\"]);return Re.createElement(\"tr\",{key:s},Re.createElement(\"td\",{className:\"header-col\"},s),Re.createElement(\"td\",{className:\"header-col\"},u?Re.createElement(a,{source:u}):null),Re.createElement(\"td\",{className:\"header-col\"},_,\" \",w?Re.createElement(i,{propKey:\"Example\",propVal:w,propClass:\"header-example\"}):null))})).toArray()))):null}}class Errors extends Re.Component{render(){let{editorActions:s,errSelectors:o,layoutSelectors:i,layoutActions:a,getComponent:u}=this.props;const _=u(\"Collapse\");if(s&&s.jumpToLine)var w=s.jumpToLine;let x=o.allErrors().filter((s=>\"thrown\"===s.get(\"type\")||\"error\"===s.get(\"level\")));if(!x||x.count()<1)return null;let C=i.isShown([\"errorPane\"],!0),j=x.sortBy((s=>s.get(\"line\")));return Re.createElement(\"pre\",{className:\"errors-wrapper\"},Re.createElement(\"hgroup\",{className:\"error\"},Re.createElement(\"h4\",{className:\"errors__title\"},\"Errors\"),Re.createElement(\"button\",{className:\"btn errors__clear-btn\",onClick:()=>a.show([\"errorPane\"],!C)},C?\"Hide\":\"Show\")),Re.createElement(_,{isOpened:C,animated:!0},Re.createElement(\"div\",{className:\"errors\"},j.map(((s,o)=>{let i=s.get(\"type\");return\"thrown\"===i||\"auth\"===i?Re.createElement(ThrownErrorItem,{key:o,error:s.get(\"error\")||s,jumpToLine:w}):\"spec\"===i?Re.createElement(SpecErrorItem,{key:o,error:s,jumpToLine:w}):void 0})))))}}const ThrownErrorItem=({error:s,jumpToLine:o})=>{if(!s)return null;let i=s.get(\"line\");return Re.createElement(\"div\",{className:\"error-wrapper\"},s?Re.createElement(\"div\",null,Re.createElement(\"h4\",null,s.get(\"source\")&&s.get(\"level\")?toTitleCase(s.get(\"source\"))+\" \"+s.get(\"level\"):\"\",s.get(\"path\")?Re.createElement(\"small\",null,\" at \",s.get(\"path\")):null),Re.createElement(\"span\",{className:\"message thrown\"},s.get(\"message\")),Re.createElement(\"div\",{className:\"error-line\"},i&&o?Re.createElement(\"a\",{onClick:o.bind(null,i)},\"Jump to line \",i):null)):null)},SpecErrorItem=({error:s,jumpToLine:o=null})=>{let i=null;return s.get(\"path\")?i=ze.List.isList(s.get(\"path\"))?Re.createElement(\"small\",null,\"at \",s.get(\"path\").join(\".\")):Re.createElement(\"small\",null,\"at \",s.get(\"path\")):s.get(\"line\")&&!o&&(i=Re.createElement(\"small\",null,\"on line \",s.get(\"line\"))),Re.createElement(\"div\",{className:\"error-wrapper\"},s?Re.createElement(\"div\",null,Re.createElement(\"h4\",null,toTitleCase(s.get(\"source\"))+\" \"+s.get(\"level\"),\" \",i),Re.createElement(\"span\",{className:\"message\"},s.get(\"message\")),Re.createElement(\"div\",{className:\"error-line\"},o?Re.createElement(\"a\",{onClick:o.bind(null,s.get(\"line\"))},\"Jump to line \",s.get(\"line\")):null)):null)};function toTitleCase(s){return(s||\"\").split(\" \").map((s=>s[0].toUpperCase()+s.slice(1))).join(\" \")}const content_type_noop=()=>{};class ContentType extends Re.Component{static defaultProps={onChange:content_type_noop,value:null,contentTypes:(0,ze.fromJS)([\"application/json\"])};componentDidMount(){const{contentTypes:s,onChange:o}=this.props;s&&s.size&&o(s.first())}componentDidUpdate(){const{contentTypes:s,value:o,onChange:i}=this.props;s&&s.size&&(s.includes(o)||i(s.first()))}onChangeWrapper=s=>this.props.onChange(s.target.value);render(){let{ariaControls:s,ariaLabel:o,className:i,contentTypes:a,controlId:u,value:_}=this.props;return a&&a.size?Re.createElement(\"div\",{className:\"content-type-wrapper \"+(i||\"\")},Re.createElement(\"select\",{\"aria-controls\":s,\"aria-label\":o,className:\"content-type\",id:u,onChange:this.onChangeWrapper,value:_||\"\"},a.map((s=>Re.createElement(\"option\",{key:s,value:s},s))).toArray())):null}}function xclass(...s){return s.filter((s=>!!s)).join(\" \").trim()}class Container extends Re.Component{render(){let{fullscreen:s,full:o,...i}=this.props;if(s)return Re.createElement(\"section\",i);let a=\"swagger-container\"+(o?\"-full\":\"\");return Re.createElement(\"section\",Mn()({},i,{className:xclass(i.className,a)}))}}const rA={mobile:\"\",tablet:\"-tablet\",desktop:\"-desktop\",large:\"-hd\"};class Col extends Re.Component{render(){const{hide:s,keepContents:o,mobile:i,tablet:a,desktop:u,large:_,...w}=this.props;if(s&&!o)return Re.createElement(\"span\",null);let x=[];for(let s in rA){if(!Object.prototype.hasOwnProperty.call(rA,s))continue;let o=rA[s];if(s in this.props){let i=this.props[s];if(i<1){x.push(\"none\"+o);continue}x.push(\"block\"+o),x.push(\"col-\"+i+o)}}s&&x.push(\"hidden\");let C=xclass(w.className,...x);return Re.createElement(\"section\",Mn()({},w,{className:C}))}}class Row extends Re.Component{render(){return Re.createElement(\"div\",Mn()({},this.props,{className:xclass(this.props.className,\"wrapper\")}))}}class Button extends Re.Component{static defaultProps={className:\"\"};render(){return Re.createElement(\"button\",Mn()({},this.props,{className:xclass(this.props.className,\"button\")}))}}const TextArea=s=>Re.createElement(\"textarea\",s),Input=s=>Re.createElement(\"input\",s);class Select extends Re.Component{static defaultProps={multiple:!1,allowEmptyValue:!0};constructor(s,o){let i;super(s,o),i=s.value?s.value:s.multiple?[\"\"]:\"\",this.state={value:i}}onChange=s=>{let o,{onChange:i,multiple:a}=this.props,u=[].slice.call(s.target.options);o=a?u.filter((function(s){return s.selected})).map((function(s){return s.value})):s.target.value,this.setState({value:o}),i&&i(o)};UNSAFE_componentWillReceiveProps(s){s.value!==this.props.value&&this.setState({value:s.value})}render(){let{allowedValues:s,multiple:o,allowEmptyValue:i,disabled:a}=this.props,u=this.state.value?.toJS?.()||this.state.value;return Re.createElement(\"select\",{className:this.props.className,multiple:o,value:u,onChange:this.onChange,disabled:a},i?Re.createElement(\"option\",{value:\"\"},\"--\"):null,s.map((function(s,o){return Re.createElement(\"option\",{key:o,value:String(s)},String(s))})))}}class layout_utils_Link extends Re.Component{render(){return Re.createElement(\"a\",Mn()({},this.props,{rel:\"noopener noreferrer\",className:xclass(this.props.className,\"link\")}))}}const NoMargin=({children:s})=>Re.createElement(\"div\",{className:\"no-margin\"},\" \",s,\" \");class Collapse extends Re.Component{static defaultProps={isOpened:!1,animated:!1};renderNotAnimated(){return this.props.isOpened?Re.createElement(NoMargin,null,this.props.children):Re.createElement(\"noscript\",null)}render(){let{animated:s,isOpened:o,children:i}=this.props;return s?(i=o?i:null,Re.createElement(NoMargin,null,i)):this.renderNotAnimated()}}class Overview extends Re.Component{constructor(...s){super(...s),this.setTagShown=this._setTagShown.bind(this)}_setTagShown(s,o){this.props.layoutActions.show(s,o)}showOp(s,o){let{layoutActions:i}=this.props;i.show(s,o)}render(){let{specSelectors:s,layoutSelectors:o,layoutActions:i,getComponent:a}=this.props,u=s.taggedOperations();const _=a(\"Collapse\");return Re.createElement(\"div\",null,Re.createElement(\"h4\",{className:\"overview-title\"},\"Overview\"),u.map(((s,a)=>{let u=s.get(\"operations\"),w=[\"overview-tags\",a],x=o.isShown(w,!0);return Re.createElement(\"div\",{key:\"overview-\"+a},Re.createElement(\"h4\",{onClick:()=>i.show(w,!x),className:\"link overview-tag\"},\" \",x?\"-\":\"+\",a),Re.createElement(_,{isOpened:x,animated:!0},u.map((s=>{let{path:a,method:u,id:_}=s.toObject(),w=\"operations\",x=_,C=o.isShown([w,x]);return Re.createElement(OperationLink,{key:_,path:a,method:u,id:a+\"-\"+u,shown:C,showOpId:x,showOpIdPrefix:w,href:`#operation-${x}`,onClick:i.show})})).toArray()))})).toArray(),u.size<1&&Re.createElement(\"h3\",null,\" No operations defined in spec! \"))}}class OperationLink extends Re.Component{constructor(s){super(s),this.onClick=this._onClick.bind(this)}_onClick(){let{showOpId:s,showOpIdPrefix:o,onClick:i,shown:a}=this.props;i([o,s],!a)}render(){let{id:s,method:o,shown:i,href:a}=this.props;return Re.createElement(layout_utils_Link,{href:a,onClick:this.onClick,className:\"block opblock-link \"+(i?\"shown\":\"\")},Re.createElement(\"div\",null,Re.createElement(\"small\",{className:`bold-label-${o}`},o.toUpperCase()),Re.createElement(\"span\",{className:\"bold-label\"},s)))}}class InitializedInput extends Re.Component{componentDidMount(){this.props.initialValue&&(this.inputRef.value=this.props.initialValue)}render(){const{value:s,defaultValue:o,initialValue:i,...a}=this.props;return Re.createElement(\"input\",Mn()({},a,{ref:s=>this.inputRef=s}))}}class InfoBasePath extends Re.Component{render(){const{host:s,basePath:o}=this.props;return Re.createElement(\"pre\",{className:\"base-url\"},\"[ Base URL: \",s,o,\" ]\")}}class InfoUrl extends Re.PureComponent{render(){const{url:s,getComponent:o}=this.props,i=o(\"Link\");return Re.createElement(i,{target:\"_blank\",href:sanitizeUrl(s)},Re.createElement(\"span\",{className:\"url\"},\" \",s))}}class info_Info extends Re.Component{render(){const{info:s,url:o,host:i,basePath:a,getComponent:u,externalDocs:_,selectedServer:w,url:x}=this.props,C=s.get(\"version\"),j=s.get(\"description\"),L=s.get(\"title\"),B=safeBuildUrl(s.get(\"termsOfService\"),x,{selectedServer:w}),$=s.get(\"contact\"),U=s.get(\"license\"),V=safeBuildUrl(_&&_.get(\"url\"),x,{selectedServer:w}),z=_&&_.get(\"description\"),Y=u(\"Markdown\",!0),Z=u(\"Link\"),ee=u(\"VersionStamp\"),ie=u(\"OpenAPIVersion\"),ae=u(\"InfoUrl\"),ce=u(\"InfoBasePath\"),le=u(\"License\"),pe=u(\"Contact\");return Re.createElement(\"div\",{className:\"info\"},Re.createElement(\"hgroup\",{className:\"main\"},Re.createElement(\"h1\",{className:\"title\"},L,Re.createElement(\"span\",null,C&&Re.createElement(ee,{version:C}),Re.createElement(ie,{oasVersion:\"2.0\"}))),i||a?Re.createElement(ce,{host:i,basePath:a}):null,o&&Re.createElement(ae,{getComponent:u,url:o})),Re.createElement(\"div\",{className:\"description\"},Re.createElement(Y,{source:j})),B&&Re.createElement(\"div\",{className:\"info__tos\"},Re.createElement(Z,{target:\"_blank\",href:sanitizeUrl(B)},\"Terms of service\")),$?.size>0&&Re.createElement(pe,{getComponent:u,data:$,selectedServer:w,url:o}),U?.size>0&&Re.createElement(le,{getComponent:u,license:U,selectedServer:w,url:o}),V?Re.createElement(Z,{className:\"info__extdocs\",target:\"_blank\",href:sanitizeUrl(V)},z||V):null)}}const nA=info_Info;class InfoContainer extends Re.Component{render(){const{specSelectors:s,getComponent:o,oas3Selectors:i}=this.props,a=s.info(),u=s.url(),_=s.basePath(),w=s.host(),x=s.externalDocs(),C=i.selectedServer(),j=o(\"info\");return Re.createElement(\"div\",null,a&&a.count()?Re.createElement(j,{info:a,url:u,host:w,basePath:_,externalDocs:x,getComponent:o,selectedServer:C}):null)}}class contact_Contact extends Re.Component{render(){const{data:s,getComponent:o,selectedServer:i,url:a}=this.props,u=s.get(\"name\",\"the developer\"),_=safeBuildUrl(s.get(\"url\"),a,{selectedServer:i}),w=s.get(\"email\"),x=o(\"Link\");return Re.createElement(\"div\",{className:\"info__contact\"},_&&Re.createElement(\"div\",null,Re.createElement(x,{href:sanitizeUrl(_),target:\"_blank\"},u,\" - Website\")),w&&Re.createElement(x,{href:sanitizeUrl(`mailto:${w}`)},_?`Send email to ${u}`:`Contact ${u}`))}}const sA=contact_Contact;class license_License extends Re.Component{render(){const{license:s,getComponent:o,selectedServer:i,url:a}=this.props,u=s.get(\"name\",\"License\"),_=safeBuildUrl(s.get(\"url\"),a,{selectedServer:i}),w=o(\"Link\");return Re.createElement(\"div\",{className:\"info__license\"},_?Re.createElement(\"div\",{className:\"info__license__url\"},Re.createElement(w,{target:\"_blank\",href:sanitizeUrl(_)},u)):Re.createElement(\"span\",null,u))}}const oA=license_License;class JumpToPath extends Re.Component{render(){return null}}class CopyToClipboardBtn extends Re.Component{render(){let{getComponent:s}=this.props;const o=s(\"CopyIcon\");return Re.createElement(\"div\",{className:\"view-line-link copy-to-clipboard\",title:\"Copy to clipboard\"},Re.createElement(Hn.CopyToClipboard,{text:this.props.textToCopy},Re.createElement(o,null)))}}class Footer extends Re.Component{render(){return Re.createElement(\"div\",{className:\"footer\"})}}class FilterContainer extends Re.Component{onFilterChange=s=>{const{target:{value:o}}=s;this.props.layoutActions.updateFilter(o)};render(){const{specSelectors:s,layoutSelectors:o,getComponent:i}=this.props,a=i(\"Col\"),u=\"loading\"===s.loadingStatus(),_=\"failed\"===s.loadingStatus(),w=o.currentFilter(),x=[\"operation-filter-input\"];return _&&x.push(\"failed\"),u&&x.push(\"loading\"),Re.createElement(\"div\",null,!1===w?null:Re.createElement(\"div\",{className:\"filter-container\"},Re.createElement(a,{className:\"filter wrapper\",mobile:12},Re.createElement(\"input\",{className:x.join(\" \"),placeholder:\"Filter by tag\",type:\"text\",onChange:this.onFilterChange,value:\"string\"==typeof w?w:\"\",disabled:u}))))}}const iA=Function.prototype;class ParamBody extends Re.PureComponent{static defaultProp={consumes:(0,ze.fromJS)([\"application/json\"]),param:(0,ze.fromJS)({}),onChange:iA,onChangeConsumes:iA};constructor(s,o){super(s,o),this.state={isEditBox:!1,value:\"\"}}componentDidMount(){this.updateValues.call(this,this.props)}UNSAFE_componentWillReceiveProps(s){this.updateValues.call(this,s)}updateValues=s=>{let{param:o,isExecute:i,consumesValue:a=\"\"}=s,u=/xml/i.test(a),_=/json/i.test(a),w=u?o.get(\"value_xml\"):o.get(\"value\");if(void 0!==w){let s=!w&&_?\"{}\":w;this.setState({value:s}),this.onChange(s,{isXml:u,isEditBox:i})}else u?this.onChange(this.sample(\"xml\"),{isXml:u,isEditBox:i}):this.onChange(this.sample(),{isEditBox:i})};sample=s=>{let{param:o,fn:i}=this.props,a=i.inferSchema(o.toJS());return i.getSampleSchema(a,s,{includeWriteOnly:!0})};onChange=(s,{isEditBox:o,isXml:i})=>{this.setState({value:s,isEditBox:o}),this._onChange(s,i)};_onChange=(s,o)=>{(this.props.onChange||iA)(s,o)};handleOnChange=s=>{const{consumesValue:o}=this.props,i=/xml/i.test(o),a=s.target.value;this.onChange(a,{isXml:i,isEditBox:this.state.isEditBox})};toggleIsEditBox=()=>this.setState((s=>({isEditBox:!s.isEditBox})));render(){let{onChangeConsumes:s,param:o,isExecute:i,specSelectors:a,pathMethod:u,getComponent:_}=this.props;const w=_(\"Button\"),x=_(\"TextArea\"),C=_(\"HighlightCode\",!0),j=_(\"contentType\");let L=(a?a.parameterWithMetaByIdentity(u,o):o).get(\"errors\",(0,ze.List)()),B=a.contentTypeValues(u).get(\"requestContentType\"),$=this.props.consumes&&this.props.consumes.size?this.props.consumes:ParamBody.defaultProp.consumes,{value:U,isEditBox:V}=this.state,z=null;getKnownSyntaxHighlighterLanguage(U)&&(z=\"json\");const Y=`${createHtmlReadyId(`${u[1]}${u[0]}_parameters`)}_select`;return Re.createElement(\"div\",{className:\"body-param\",\"data-param-name\":o.get(\"name\"),\"data-param-in\":o.get(\"in\")},V&&i?Re.createElement(x,{className:\"body-param__text\"+(L.count()?\" invalid\":\"\"),value:U,onChange:this.handleOnChange}):U&&Re.createElement(C,{className:\"body-param__example\",language:z},U),Re.createElement(\"div\",{className:\"body-param-options\"},i?Re.createElement(\"div\",{className:\"body-param-edit\"},Re.createElement(w,{className:V?\"btn cancel body-param__example-edit\":\"btn edit body-param__example-edit\",onClick:this.toggleIsEditBox},V?\"Cancel\":\"Edit\")):null,Re.createElement(\"label\",{htmlFor:Y},Re.createElement(\"span\",null,\"Parameter content type\"),Re.createElement(j,{value:B,contentTypes:$,onChange:s,className:\"body-param-content-type\",ariaLabel:\"Parameter content type\",controlId:Y}))))}}class Curl extends Re.Component{render(){const{request:s,getComponent:o}=this.props,i=requestSnippetGenerator_curl_bash(s),a=o(\"SyntaxHighlighter\",!0);return Re.createElement(\"div\",{className:\"curl-command\"},Re.createElement(\"h4\",null,\"Curl\"),Re.createElement(\"div\",{className:\"copy-to-clipboard\"},Re.createElement(Hn.CopyToClipboard,{text:i},Re.createElement(\"button\",null))),Re.createElement(\"div\",null,Re.createElement(a,{language:\"bash\",className:\"curl microlight\",renderPlainText:({children:s,PlainTextViewer:o})=>Re.createElement(o,{className:\"curl\"},s)},i)))}}const property=({propKey:s,propVal:o,propClass:i})=>Re.createElement(\"span\",{className:i},Re.createElement(\"br\",null),s,\": \",stringify(o));class TryItOutButton extends Re.Component{static defaultProps={onTryoutClick:Function.prototype,onCancelClick:Function.prototype,onResetClick:Function.prototype,enabled:!1,hasUserEditedBody:!1,isOAS3:!1};render(){const{onTryoutClick:s,onCancelClick:o,onResetClick:i,enabled:a,hasUserEditedBody:u,isOAS3:_}=this.props,w=_&&u;return Re.createElement(\"div\",{className:w?\"try-out btn-group\":\"try-out\"},a?Re.createElement(\"button\",{className:\"btn try-out__btn cancel\",onClick:o},\"Cancel\"):Re.createElement(\"button\",{className:\"btn try-out__btn\",onClick:s},\"Try it out \"),w&&Re.createElement(\"button\",{className:\"btn try-out__btn reset\",onClick:i},\"Reset\"))}}class VersionPragmaFilter extends Re.PureComponent{static defaultProps={alsoShow:null,children:null,bypass:!1};render(){const{bypass:s,isSwagger2:o,isOAS3:i,alsoShow:a}=this.props;return s?Re.createElement(\"div\",null,this.props.children):o&&i?Re.createElement(\"div\",{className:\"version-pragma\"},a,Re.createElement(\"div\",{className:\"version-pragma__message version-pragma__message--ambiguous\"},Re.createElement(\"div\",null,Re.createElement(\"h3\",null,\"Unable to render this definition\"),Re.createElement(\"p\",null,Re.createElement(\"code\",null,\"swagger\"),\" and \",Re.createElement(\"code\",null,\"openapi\"),\" fields cannot be present in the same Swagger or OpenAPI definition. Please remove one of the fields.\"),Re.createElement(\"p\",null,\"Supported version fields are \",Re.createElement(\"code\",null,\"swagger: \",'\"2.0\"'),\" and those that match \",Re.createElement(\"code\",null,\"openapi: 3.0.n\"),\" (for example, \",Re.createElement(\"code\",null,\"openapi: 3.0.4\"),\").\")))):o||i?Re.createElement(\"div\",null,this.props.children):Re.createElement(\"div\",{className:\"version-pragma\"},a,Re.createElement(\"div\",{className:\"version-pragma__message version-pragma__message--missing\"},Re.createElement(\"div\",null,Re.createElement(\"h3\",null,\"Unable to render this definition\"),Re.createElement(\"p\",null,\"The provided definition does not specify a valid version field.\"),Re.createElement(\"p\",null,\"Please indicate a valid Swagger or OpenAPI version field. Supported version fields are \",Re.createElement(\"code\",null,\"swagger: \",'\"2.0\"'),\" and those that match \",Re.createElement(\"code\",null,\"openapi: 3.0.n\"),\" (for example, \",Re.createElement(\"code\",null,\"openapi: 3.0.4\"),\").\"))))}}const version_stamp=({version:s})=>Re.createElement(\"small\",null,Re.createElement(\"pre\",{className:\"version\"},\" \",s,\" \")),openapi_version=({oasVersion:s})=>Re.createElement(\"small\",{className:\"version-stamp\"},Re.createElement(\"pre\",{className:\"version\"},\"OAS \",s)),deep_link=({enabled:s,path:o,text:i})=>Re.createElement(\"a\",{className:\"nostyle\",onClick:s?s=>s.preventDefault():null,href:s?`#/${o}`:null},Re.createElement(\"span\",null,i)),svg_assets=()=>Re.createElement(\"div\",null,Re.createElement(\"svg\",{xmlns:\"http://www.w3.org/2000/svg\",xmlnsXlink:\"http://www.w3.org/1999/xlink\",className:\"svg-assets\"},Re.createElement(\"defs\",null,Re.createElement(\"symbol\",{viewBox:\"0 0 20 20\",id:\"unlocked\"},Re.createElement(\"path\",{d:\"M15.8 8H14V5.6C14 2.703 12.665 1 10 1 7.334 1 6 2.703 6 5.6V6h2v-.801C8 3.754 8.797 3 10 3c1.203 0 2 .754 2 2.199V8H4c-.553 0-1 .646-1 1.199V17c0 .549.428 1.139.951 1.307l1.197.387C5.672 18.861 6.55 19 7.1 19h5.8c.549 0 1.428-.139 1.951-.307l1.196-.387c.524-.167.953-.757.953-1.306V9.199C17 8.646 16.352 8 15.8 8z\"})),Re.createElement(\"symbol\",{viewBox:\"0 0 20 20\",id:\"locked\"},Re.createElement(\"path\",{d:\"M15.8 8H14V5.6C14 2.703 12.665 1 10 1 7.334 1 6 2.703 6 5.6V8H4c-.553 0-1 .646-1 1.199V17c0 .549.428 1.139.951 1.307l1.197.387C5.672 18.861 6.55 19 7.1 19h5.8c.549 0 1.428-.139 1.951-.307l1.196-.387c.524-.167.953-.757.953-1.306V9.199C17 8.646 16.352 8 15.8 8zM12 8H8V5.199C8 3.754 8.797 3 10 3c1.203 0 2 .754 2 2.199V8z\"})),Re.createElement(\"symbol\",{viewBox:\"0 0 20 20\",id:\"close\"},Re.createElement(\"path\",{d:\"M14.348 14.849c-.469.469-1.229.469-1.697 0L10 11.819l-2.651 3.029c-.469.469-1.229.469-1.697 0-.469-.469-.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-.469-.469-.469-1.228 0-1.697.469-.469 1.228-.469 1.697 0L10 8.183l2.651-3.031c.469-.469 1.228-.469 1.697 0 .469.469.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c.469.469.469 1.229 0 1.698z\"})),Re.createElement(\"symbol\",{viewBox:\"0 0 20 20\",id:\"large-arrow\"},Re.createElement(\"path\",{d:\"M13.25 10L6.109 2.58c-.268-.27-.268-.707 0-.979.268-.27.701-.27.969 0l7.83 7.908c.268.271.268.709 0 .979l-7.83 7.908c-.268.271-.701.27-.969 0-.268-.269-.268-.707 0-.979L13.25 10z\"})),Re.createElement(\"symbol\",{viewBox:\"0 0 20 20\",id:\"large-arrow-down\"},Re.createElement(\"path\",{d:\"M17.418 6.109c.272-.268.709-.268.979 0s.271.701 0 .969l-7.908 7.83c-.27.268-.707.268-.979 0l-7.908-7.83c-.27-.268-.27-.701 0-.969.271-.268.709-.268.979 0L10 13.25l7.418-7.141z\"})),Re.createElement(\"symbol\",{viewBox:\"0 0 20 20\",id:\"large-arrow-up\"},Re.createElement(\"path\",{d:\"M 17.418 14.908 C 17.69 15.176 18.127 15.176 18.397 14.908 C 18.667 14.64 18.668 14.207 18.397 13.939 L 10.489 6.109 C 10.219 5.841 9.782 5.841 9.51 6.109 L 1.602 13.939 C 1.332 14.207 1.332 14.64 1.602 14.908 C 1.873 15.176 2.311 15.176 2.581 14.908 L 10 7.767 L 17.418 14.908 Z\"})),Re.createElement(\"symbol\",{viewBox:\"0 0 24 24\",id:\"jump-to\"},Re.createElement(\"path\",{d:\"M19 7v4H5.83l3.58-3.59L8 6l-6 6 6 6 1.41-1.41L5.83 13H21V7z\"})),Re.createElement(\"symbol\",{viewBox:\"0 0 24 24\",id:\"expand\"},Re.createElement(\"path\",{d:\"M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z\"})),Re.createElement(\"symbol\",{viewBox:\"0 0 15 16\",id:\"copy\"},Re.createElement(\"g\",{transform:\"translate(2, -1)\"},Re.createElement(\"path\",{fill:\"#ffffff\",fillRule:\"evenodd\",d:\"M2 13h4v1H2v-1zm5-6H2v1h5V7zm2 3V8l-3 3 3 3v-2h5v-2H9zM4.5 9H2v1h2.5V9zM2 12h2.5v-1H2v1zm9 1h1v2c-.02.28-.11.52-.3.7-.19.18-.42.28-.7.3H1c-.55 0-1-.45-1-1V4c0-.55.45-1 1-1h3c0-1.11.89-2 2-2 1.11 0 2 .89 2 2h3c.55 0 1 .45 1 1v5h-1V6H1v9h10v-2zM2 5h8c0-.55-.45-1-1-1H8c-.55 0-1-.45-1-1s-.45-1-1-1-1 .45-1 1-.45 1-1 1H3c-.55 0-1 .45-1 1z\"}))))));var aA;function decodeEntity(s){return(aA=aA||document.createElement(\"textarea\")).innerHTML=\"&\"+s+\";\",aA.value}var cA=Object.prototype.hasOwnProperty;function index_browser_has(s,o){return!!s&&cA.call(s,o)}function index_browser_assign(s){return[].slice.call(arguments,1).forEach((function(o){if(o){if(\"object\"!=typeof o)throw new TypeError(o+\"must be object\");Object.keys(o).forEach((function(i){s[i]=o[i]}))}})),s}var lA=/\\\\([\\\\!\"#$%&'()*+,.\\/:;<=>?@[\\]^_`{|}~-])/g;function unescapeMd(s){return s.indexOf(\"\\\\\")<0?s:s.replace(lA,\"$1\")}function isValidEntityCode(s){return!(s>=55296&&s<=57343)&&(!(s>=64976&&s<=65007)&&(!!(65535&~s&&65534!=(65535&s))&&(!(s>=0&&s<=8)&&(11!==s&&(!(s>=14&&s<=31)&&(!(s>=127&&s<=159)&&!(s>1114111)))))))}function fromCodePoint(s){if(s>65535){var o=55296+((s-=65536)>>10),i=56320+(1023&s);return String.fromCharCode(o,i)}return String.fromCharCode(s)}var uA=/&([a-z#][a-z0-9]{1,31});/gi,pA=/^#((?:x[a-f0-9]{1,8}|[0-9]{1,8}))/i;function replaceEntityPattern(s,o){var i=0,a=decodeEntity(o);return o!==a?a:35===o.charCodeAt(0)&&pA.test(o)&&isValidEntityCode(i=\"x\"===o[1].toLowerCase()?parseInt(o.slice(2),16):parseInt(o.slice(1),10))?fromCodePoint(i):s}function replaceEntities(s){return s.indexOf(\"&\")<0?s:s.replace(uA,replaceEntityPattern)}var hA=/[&<>\"]/,dA=/[&<>\"]/g,fA={\"&\":\"&amp;\",\"<\":\"&lt;\",\">\":\"&gt;\",'\"':\"&quot;\"};function replaceUnsafeChar(s){return fA[s]}function escapeHtml(s){return hA.test(s)?s.replace(dA,replaceUnsafeChar):s}var mA={};function nextToken(s,o){return++o>=s.length-2?o:\"paragraph_open\"===s[o].type&&s[o].tight&&\"inline\"===s[o+1].type&&0===s[o+1].content.length&&\"paragraph_close\"===s[o+2].type&&s[o+2].tight?nextToken(s,o+2):o}mA.blockquote_open=function(){return\"<blockquote>\\n\"},mA.blockquote_close=function(s,o){return\"</blockquote>\"+gA(s,o)},mA.code=function(s,o){return s[o].block?\"<pre><code>\"+escapeHtml(s[o].content)+\"</code></pre>\"+gA(s,o):\"<code>\"+escapeHtml(s[o].content)+\"</code>\"},mA.fence=function(s,o,i,a,u){var _,w,x=s[o],C=\"\",j=i.langPrefix;if(x.params){if(w=(_=x.params.split(/\\s+/g)).join(\" \"),index_browser_has(u.rules.fence_custom,_[0]))return u.rules.fence_custom[_[0]](s,o,i,a,u);C=' class=\"'+j+escapeHtml(replaceEntities(unescapeMd(w)))+'\"'}return\"<pre><code\"+C+\">\"+(i.highlight&&i.highlight.apply(i.highlight,[x.content].concat(_))||escapeHtml(x.content))+\"</code></pre>\"+gA(s,o)},mA.fence_custom={},mA.heading_open=function(s,o){return\"<h\"+s[o].hLevel+\">\"},mA.heading_close=function(s,o){return\"</h\"+s[o].hLevel+\">\\n\"},mA.hr=function(s,o,i){return(i.xhtmlOut?\"<hr />\":\"<hr>\")+gA(s,o)},mA.bullet_list_open=function(){return\"<ul>\\n\"},mA.bullet_list_close=function(s,o){return\"</ul>\"+gA(s,o)},mA.list_item_open=function(){return\"<li>\"},mA.list_item_close=function(){return\"</li>\\n\"},mA.ordered_list_open=function(s,o){var i=s[o];return\"<ol\"+(i.order>1?' start=\"'+i.order+'\"':\"\")+\">\\n\"},mA.ordered_list_close=function(s,o){return\"</ol>\"+gA(s,o)},mA.paragraph_open=function(s,o){return s[o].tight?\"\":\"<p>\"},mA.paragraph_close=function(s,o){var i=!(s[o].tight&&o&&\"inline\"===s[o-1].type&&!s[o-1].content);return(s[o].tight?\"\":\"</p>\")+(i?gA(s,o):\"\")},mA.link_open=function(s,o,i){var a=s[o].title?' title=\"'+escapeHtml(replaceEntities(s[o].title))+'\"':\"\",u=i.linkTarget?' target=\"'+i.linkTarget+'\"':\"\";return'<a href=\"'+escapeHtml(s[o].href)+'\"'+a+u+\">\"},mA.link_close=function(){return\"</a>\"},mA.image=function(s,o,i){var a=' src=\"'+escapeHtml(s[o].src)+'\"',u=s[o].title?' title=\"'+escapeHtml(replaceEntities(s[o].title))+'\"':\"\";return\"<img\"+a+(' alt=\"'+(s[o].alt?escapeHtml(replaceEntities(unescapeMd(s[o].alt))):\"\")+'\"')+u+(i.xhtmlOut?\" /\":\"\")+\">\"},mA.table_open=function(){return\"<table>\\n\"},mA.table_close=function(){return\"</table>\\n\"},mA.thead_open=function(){return\"<thead>\\n\"},mA.thead_close=function(){return\"</thead>\\n\"},mA.tbody_open=function(){return\"<tbody>\\n\"},mA.tbody_close=function(){return\"</tbody>\\n\"},mA.tr_open=function(){return\"<tr>\"},mA.tr_close=function(){return\"</tr>\\n\"},mA.th_open=function(s,o){var i=s[o];return\"<th\"+(i.align?' style=\"text-align:'+i.align+'\"':\"\")+\">\"},mA.th_close=function(){return\"</th>\"},mA.td_open=function(s,o){var i=s[o];return\"<td\"+(i.align?' style=\"text-align:'+i.align+'\"':\"\")+\">\"},mA.td_close=function(){return\"</td>\"},mA.strong_open=function(){return\"<strong>\"},mA.strong_close=function(){return\"</strong>\"},mA.em_open=function(){return\"<em>\"},mA.em_close=function(){return\"</em>\"},mA.del_open=function(){return\"<del>\"},mA.del_close=function(){return\"</del>\"},mA.ins_open=function(){return\"<ins>\"},mA.ins_close=function(){return\"</ins>\"},mA.mark_open=function(){return\"<mark>\"},mA.mark_close=function(){return\"</mark>\"},mA.sub=function(s,o){return\"<sub>\"+escapeHtml(s[o].content)+\"</sub>\"},mA.sup=function(s,o){return\"<sup>\"+escapeHtml(s[o].content)+\"</sup>\"},mA.hardbreak=function(s,o,i){return i.xhtmlOut?\"<br />\\n\":\"<br>\\n\"},mA.softbreak=function(s,o,i){return i.breaks?i.xhtmlOut?\"<br />\\n\":\"<br>\\n\":\"\\n\"},mA.text=function(s,o){return escapeHtml(s[o].content)},mA.htmlblock=function(s,o){return s[o].content},mA.htmltag=function(s,o){return s[o].content},mA.abbr_open=function(s,o){return'<abbr title=\"'+escapeHtml(replaceEntities(s[o].title))+'\">'},mA.abbr_close=function(){return\"</abbr>\"},mA.footnote_ref=function(s,o){var i=Number(s[o].id+1).toString(),a=\"fnref\"+i;return s[o].subId>0&&(a+=\":\"+s[o].subId),'<sup class=\"footnote-ref\"><a href=\"#fn'+i+'\" id=\"'+a+'\">['+i+\"]</a></sup>\"},mA.footnote_block_open=function(s,o,i){return(i.xhtmlOut?'<hr class=\"footnotes-sep\" />\\n':'<hr class=\"footnotes-sep\">\\n')+'<section class=\"footnotes\">\\n<ol class=\"footnotes-list\">\\n'},mA.footnote_block_close=function(){return\"</ol>\\n</section>\\n\"},mA.footnote_open=function(s,o){return'<li id=\"fn'+Number(s[o].id+1).toString()+'\"  class=\"footnote-item\">'},mA.footnote_close=function(){return\"</li>\\n\"},mA.footnote_anchor=function(s,o){var i=\"fnref\"+Number(s[o].id+1).toString();return s[o].subId>0&&(i+=\":\"+s[o].subId),' <a href=\"#'+i+'\" class=\"footnote-backref\">↩</a>'},mA.dl_open=function(){return\"<dl>\\n\"},mA.dt_open=function(){return\"<dt>\"},mA.dd_open=function(){return\"<dd>\"},mA.dl_close=function(){return\"</dl>\\n\"},mA.dt_close=function(){return\"</dt>\\n\"},mA.dd_close=function(){return\"</dd>\\n\"};var gA=mA.getBreak=function getBreak(s,o){return(o=nextToken(s,o))<s.length&&\"list_item_close\"===s[o].type?\"\":\"\\n\"};function Renderer(){this.rules=index_browser_assign({},mA),this.getBreak=mA.getBreak}function Ruler(){this.__rules__=[],this.__cache__=null}function StateInline(s,o,i,a,u){this.src=s,this.env=a,this.options=i,this.parser=o,this.tokens=u,this.pos=0,this.posMax=this.src.length,this.level=0,this.pending=\"\",this.pendingLevel=0,this.cache=[],this.isInLabel=!1,this.linkLevel=0,this.linkContent=\"\",this.labelUnmatchedScopes=0}function parseLinkLabel(s,o){var i,a,u,_=-1,w=s.posMax,x=s.pos,C=s.isInLabel;if(s.isInLabel)return-1;if(s.labelUnmatchedScopes)return s.labelUnmatchedScopes--,-1;for(s.pos=o+1,s.isInLabel=!0,i=1;s.pos<w;){if(91===(u=s.src.charCodeAt(s.pos)))i++;else if(93===u&&0===--i){a=!0;break}s.parser.skipToken(s)}return a?(_=s.pos,s.labelUnmatchedScopes=0):s.labelUnmatchedScopes=i-1,s.pos=x,s.isInLabel=C,_}function parseAbbr(s,o,i,a){var u,_,w,x,C,j;if(42!==s.charCodeAt(0))return-1;if(91!==s.charCodeAt(1))return-1;if(-1===s.indexOf(\"]:\"))return-1;if((_=parseLinkLabel(u=new StateInline(s,o,i,a,[]),1))<0||58!==s.charCodeAt(_+1))return-1;for(x=u.posMax,w=_+2;w<x&&10!==u.src.charCodeAt(w);w++);return C=s.slice(2,_),0===(j=s.slice(_+2,w).trim()).length?-1:(a.abbreviations||(a.abbreviations={}),void 0===a.abbreviations[\":\"+C]&&(a.abbreviations[\":\"+C]=j),w)}function normalizeLink(s){var o=replaceEntities(s);try{o=decodeURI(o)}catch(s){}return encodeURI(o)}function parseLinkDestination(s,o){var i,a,u,_=o,w=s.posMax;if(60===s.src.charCodeAt(o)){for(o++;o<w;){if(10===(i=s.src.charCodeAt(o)))return!1;if(62===i)return u=normalizeLink(unescapeMd(s.src.slice(_+1,o))),!!s.parser.validateLink(u)&&(s.pos=o+1,s.linkContent=u,!0);92===i&&o+1<w?o+=2:o++}return!1}for(a=0;o<w&&32!==(i=s.src.charCodeAt(o))&&!(i<32||127===i);)if(92===i&&o+1<w)o+=2;else{if(40===i&&++a>1)break;if(41===i&&--a<0)break;o++}return _!==o&&(u=unescapeMd(s.src.slice(_,o)),!!s.parser.validateLink(u)&&(s.linkContent=u,s.pos=o,!0))}function parseLinkTitle(s,o){var i,a=o,u=s.posMax,_=s.src.charCodeAt(o);if(34!==_&&39!==_&&40!==_)return!1;for(o++,40===_&&(_=41);o<u;){if((i=s.src.charCodeAt(o))===_)return s.pos=o+1,s.linkContent=unescapeMd(s.src.slice(a+1,o)),!0;92===i&&o+1<u?o+=2:o++}return!1}function normalizeReference(s){return s.trim().replace(/\\s+/g,\" \").toUpperCase()}function parseReference(s,o,i,a){var u,_,w,x,C,j,L,B,$;if(91!==s.charCodeAt(0))return-1;if(-1===s.indexOf(\"]:\"))return-1;if((_=parseLinkLabel(u=new StateInline(s,o,i,a,[]),0))<0||58!==s.charCodeAt(_+1))return-1;for(x=u.posMax,w=_+2;w<x&&(32===(C=u.src.charCodeAt(w))||10===C);w++);if(!parseLinkDestination(u,w))return-1;for(L=u.linkContent,j=w=u.pos,w+=1;w<x&&(32===(C=u.src.charCodeAt(w))||10===C);w++);for(w<x&&j!==w&&parseLinkTitle(u,w)?(B=u.linkContent,w=u.pos):(B=\"\",w=j);w<x&&32===u.src.charCodeAt(w);)w++;return w<x&&10!==u.src.charCodeAt(w)?-1:($=normalizeReference(s.slice(1,_)),void 0===a.references[$]&&(a.references[$]={title:B,href:L}),w)}Renderer.prototype.renderInline=function(s,o,i){for(var a=this.rules,u=s.length,_=0,w=\"\";u--;)w+=a[s[_].type](s,_++,o,i,this);return w},Renderer.prototype.render=function(s,o,i){for(var a=this.rules,u=s.length,_=-1,w=\"\";++_<u;)\"inline\"===s[_].type?w+=this.renderInline(s[_].children,o,i):w+=a[s[_].type](s,_,o,i,this);return w},Ruler.prototype.__find__=function(s){for(var o=this.__rules__.length,i=-1;o--;)if(this.__rules__[++i].name===s)return i;return-1},Ruler.prototype.__compile__=function(){var s=this,o=[\"\"];s.__rules__.forEach((function(s){s.enabled&&s.alt.forEach((function(s){o.indexOf(s)<0&&o.push(s)}))})),s.__cache__={},o.forEach((function(o){s.__cache__[o]=[],s.__rules__.forEach((function(i){i.enabled&&(o&&i.alt.indexOf(o)<0||s.__cache__[o].push(i.fn))}))}))},Ruler.prototype.at=function(s,o,i){var a=this.__find__(s),u=i||{};if(-1===a)throw new Error(\"Parser rule not found: \"+s);this.__rules__[a].fn=o,this.__rules__[a].alt=u.alt||[],this.__cache__=null},Ruler.prototype.before=function(s,o,i,a){var u=this.__find__(s),_=a||{};if(-1===u)throw new Error(\"Parser rule not found: \"+s);this.__rules__.splice(u,0,{name:o,enabled:!0,fn:i,alt:_.alt||[]}),this.__cache__=null},Ruler.prototype.after=function(s,o,i,a){var u=this.__find__(s),_=a||{};if(-1===u)throw new Error(\"Parser rule not found: \"+s);this.__rules__.splice(u+1,0,{name:o,enabled:!0,fn:i,alt:_.alt||[]}),this.__cache__=null},Ruler.prototype.push=function(s,o,i){var a=i||{};this.__rules__.push({name:s,enabled:!0,fn:o,alt:a.alt||[]}),this.__cache__=null},Ruler.prototype.enable=function(s,o){s=Array.isArray(s)?s:[s],o&&this.__rules__.forEach((function(s){s.enabled=!1})),s.forEach((function(s){var o=this.__find__(s);if(o<0)throw new Error(\"Rules manager: invalid rule name \"+s);this.__rules__[o].enabled=!0}),this),this.__cache__=null},Ruler.prototype.disable=function(s){(s=Array.isArray(s)?s:[s]).forEach((function(s){var o=this.__find__(s);if(o<0)throw new Error(\"Rules manager: invalid rule name \"+s);this.__rules__[o].enabled=!1}),this),this.__cache__=null},Ruler.prototype.getRules=function(s){return null===this.__cache__&&this.__compile__(),this.__cache__[s]||[]},StateInline.prototype.pushPending=function(){this.tokens.push({type:\"text\",content:this.pending,level:this.pendingLevel}),this.pending=\"\"},StateInline.prototype.push=function(s){this.pending&&this.pushPending(),this.tokens.push(s),this.pendingLevel=this.level},StateInline.prototype.cacheSet=function(s,o){for(var i=this.cache.length;i<=s;i++)this.cache.push(0);this.cache[s]=o},StateInline.prototype.cacheGet=function(s){return s<this.cache.length?this.cache[s]:0};var yA=\" \\n()[]'\\\".,!?-\";function regEscape(s){return s.replace(/([-()\\[\\]{}+?*.$\\^|,:#<!\\\\])/g,\"\\\\$1\")}var vA=/\\+-|\\.\\.|\\?\\?\\?\\?|!!!!|,,|--/,bA=/\\((c|tm|r|p)\\)/gi,_A={c:\"©\",r:\"®\",p:\"§\",tm:\"™\"};function replaceScopedAbbr(s){return s.indexOf(\"(\")<0?s:s.replace(bA,(function(s,o){return _A[o.toLowerCase()]}))}var SA=/['\"]/,EA=/['\"]/g,wA=/[-\\s()\\[\\]]/;function isLetter(s,o){return!(o<0||o>=s.length)&&!wA.test(s[o])}function replaceAt(s,o,i){return s.substr(0,o)+i+s.substr(o+1)}var xA=[[\"block\",function block(s){s.inlineMode?s.tokens.push({type:\"inline\",content:s.src.replace(/\\n/g,\" \").trim(),level:0,lines:[0,1],children:[]}):s.block.parse(s.src,s.options,s.env,s.tokens)}],[\"abbr\",function abbr(s){var o,i,a,u,_=s.tokens;if(!s.inlineMode)for(o=1,i=_.length-1;o<i;o++)if(\"paragraph_open\"===_[o-1].type&&\"inline\"===_[o].type&&\"paragraph_close\"===_[o+1].type){for(a=_[o].content;a.length&&!((u=parseAbbr(a,s.inline,s.options,s.env))<0);)a=a.slice(u).trim();_[o].content=a,a.length||(_[o-1].tight=!0,_[o+1].tight=!0)}}],[\"references\",function references(s){var o,i,a,u,_=s.tokens;if(s.env.references=s.env.references||{},!s.inlineMode)for(o=1,i=_.length-1;o<i;o++)if(\"inline\"===_[o].type&&\"paragraph_open\"===_[o-1].type&&\"paragraph_close\"===_[o+1].type){for(a=_[o].content;a.length&&!((u=parseReference(a,s.inline,s.options,s.env))<0);)a=a.slice(u).trim();_[o].content=a,a.length||(_[o-1].tight=!0,_[o+1].tight=!0)}}],[\"inline\",function inline(s){var o,i,a,u=s.tokens;for(i=0,a=u.length;i<a;i++)\"inline\"===(o=u[i]).type&&s.inline.parse(o.content,s.options,s.env,o.children)}],[\"footnote_tail\",function footnote_block(s){var o,i,a,u,_,w,x,C,j,L=0,B=!1,$={};if(s.env.footnotes&&(s.tokens=s.tokens.filter((function(s){return\"footnote_reference_open\"===s.type?(B=!0,C=[],j=s.label,!1):\"footnote_reference_close\"===s.type?(B=!1,$[\":\"+j]=C,!1):(B&&C.push(s),!B)})),s.env.footnotes.list)){for(w=s.env.footnotes.list,s.tokens.push({type:\"footnote_block_open\",level:L++}),o=0,i=w.length;o<i;o++){for(s.tokens.push({type:\"footnote_open\",id:o,level:L++}),w[o].tokens?((x=[]).push({type:\"paragraph_open\",tight:!1,level:L++}),x.push({type:\"inline\",content:\"\",level:L,children:w[o].tokens}),x.push({type:\"paragraph_close\",tight:!1,level:--L})):w[o].label&&(x=$[\":\"+w[o].label]),s.tokens=s.tokens.concat(x),_=\"paragraph_close\"===s.tokens[s.tokens.length-1].type?s.tokens.pop():null,u=w[o].count>0?w[o].count:1,a=0;a<u;a++)s.tokens.push({type:\"footnote_anchor\",id:o,subId:a,level:L});_&&s.tokens.push(_),s.tokens.push({type:\"footnote_close\",level:--L})}s.tokens.push({type:\"footnote_block_close\",level:--L})}}],[\"abbr2\",function abbr2(s){var o,i,a,u,_,w,x,C,j,L,B,$,U=s.tokens;if(s.env.abbreviations)for(s.env.abbrRegExp||($=\"(^|[\"+yA.split(\"\").map(regEscape).join(\"\")+\"])(\"+Object.keys(s.env.abbreviations).map((function(s){return s.substr(1)})).sort((function(s,o){return o.length-s.length})).map(regEscape).join(\"|\")+\")($|[\"+yA.split(\"\").map(regEscape).join(\"\")+\"])\",s.env.abbrRegExp=new RegExp($,\"g\")),L=s.env.abbrRegExp,i=0,a=U.length;i<a;i++)if(\"inline\"===U[i].type)for(o=(u=U[i].children).length-1;o>=0;o--)if(\"text\"===(_=u[o]).type){for(C=0,w=_.content,L.lastIndex=0,j=_.level,x=[];B=L.exec(w);)L.lastIndex>C&&x.push({type:\"text\",content:w.slice(C,B.index+B[1].length),level:j}),x.push({type:\"abbr_open\",title:s.env.abbreviations[\":\"+B[2]],level:j++}),x.push({type:\"text\",content:B[2],level:j}),x.push({type:\"abbr_close\",level:--j}),C=L.lastIndex-B[3].length;x.length&&(C<w.length&&x.push({type:\"text\",content:w.slice(C),level:j}),U[i].children=u=[].concat(u.slice(0,o),x,u.slice(o+1)))}}],[\"replacements\",function index_browser_replace(s){var o,i,a,u,_;if(s.options.typographer)for(_=s.tokens.length-1;_>=0;_--)if(\"inline\"===s.tokens[_].type)for(o=(u=s.tokens[_].children).length-1;o>=0;o--)\"text\"===(i=u[o]).type&&(a=replaceScopedAbbr(a=i.content),vA.test(a)&&(a=a.replace(/\\+-/g,\"±\").replace(/\\.{2,}/g,\"…\").replace(/([?!])…/g,\"$1..\").replace(/([?!]){4,}/g,\"$1$1$1\").replace(/,{2,}/g,\",\").replace(/(^|[^-])---([^-]|$)/gm,\"$1—$2\").replace(/(^|\\s)--(\\s|$)/gm,\"$1–$2\").replace(/(^|[^-\\s])--([^-\\s]|$)/gm,\"$1–$2\")),i.content=a)}],[\"smartquotes\",function smartquotes(s){var o,i,a,u,_,w,x,C,j,L,B,$,U,V,z,Y,Z;if(s.options.typographer)for(Z=[],z=s.tokens.length-1;z>=0;z--)if(\"inline\"===s.tokens[z].type)for(Y=s.tokens[z].children,Z.length=0,o=0;o<Y.length;o++)if(\"text\"===(i=Y[o]).type&&!SA.test(i.text)){for(x=Y[o].level,U=Z.length-1;U>=0&&!(Z[U].level<=x);U--);Z.length=U+1,_=0,w=(a=i.content).length;e:for(;_<w&&(EA.lastIndex=_,u=EA.exec(a));)if(C=!isLetter(a,u.index-1),_=u.index+1,V=\"'\"===u[0],(j=!isLetter(a,_))||C){if(B=!j,$=!C)for(U=Z.length-1;U>=0&&(L=Z[U],!(Z[U].level<x));U--)if(L.single===V&&Z[U].level===x){L=Z[U],V?(Y[L.token].content=replaceAt(Y[L.token].content,L.pos,s.options.quotes[2]),i.content=replaceAt(i.content,u.index,s.options.quotes[3])):(Y[L.token].content=replaceAt(Y[L.token].content,L.pos,s.options.quotes[0]),i.content=replaceAt(i.content,u.index,s.options.quotes[1])),Z.length=U;continue e}B?Z.push({token:o,pos:u.index,single:V,level:x}):$&&V&&(i.content=replaceAt(i.content,u.index,\"’\"))}else V&&(i.content=replaceAt(i.content,u.index,\"’\"))}}]];function Core(){this.options={},this.ruler=new Ruler;for(var s=0;s<xA.length;s++)this.ruler.push(xA[s][0],xA[s][1])}function StateBlock(s,o,i,a,u){var _,w,x,C,j,L,B;for(this.src=s,this.parser=o,this.options=i,this.env=a,this.tokens=u,this.bMarks=[],this.eMarks=[],this.tShift=[],this.blkIndent=0,this.line=0,this.lineMax=0,this.tight=!1,this.parentType=\"root\",this.ddIndent=-1,this.level=0,this.result=\"\",L=0,B=!1,x=C=L=0,j=(w=this.src).length;C<j;C++){if(_=w.charCodeAt(C),!B){if(32===_){L++;continue}B=!0}10!==_&&C!==j-1||(10!==_&&C++,this.bMarks.push(x),this.eMarks.push(C),this.tShift.push(L),B=!1,L=0,x=C+1)}this.bMarks.push(w.length),this.eMarks.push(w.length),this.tShift.push(0),this.lineMax=this.bMarks.length-1}function skipBulletListMarker(s,o){var i,a,u;return(a=s.bMarks[o]+s.tShift[o])>=(u=s.eMarks[o])||42!==(i=s.src.charCodeAt(a++))&&45!==i&&43!==i||a<u&&32!==s.src.charCodeAt(a)?-1:a}function skipOrderedListMarker(s,o){var i,a=s.bMarks[o]+s.tShift[o],u=s.eMarks[o];if(a+1>=u)return-1;if((i=s.src.charCodeAt(a++))<48||i>57)return-1;for(;;){if(a>=u)return-1;if(!((i=s.src.charCodeAt(a++))>=48&&i<=57)){if(41===i||46===i)break;return-1}}return a<u&&32!==s.src.charCodeAt(a)?-1:a}Core.prototype.process=function(s){var o,i,a;for(o=0,i=(a=this.ruler.getRules(\"\")).length;o<i;o++)a[o](s)},StateBlock.prototype.isEmpty=function isEmpty(s){return this.bMarks[s]+this.tShift[s]>=this.eMarks[s]},StateBlock.prototype.skipEmptyLines=function skipEmptyLines(s){for(var o=this.lineMax;s<o&&!(this.bMarks[s]+this.tShift[s]<this.eMarks[s]);s++);return s},StateBlock.prototype.skipSpaces=function skipSpaces(s){for(var o=this.src.length;s<o&&32===this.src.charCodeAt(s);s++);return s},StateBlock.prototype.skipChars=function skipChars(s,o){for(var i=this.src.length;s<i&&this.src.charCodeAt(s)===o;s++);return s},StateBlock.prototype.skipCharsBack=function skipCharsBack(s,o,i){if(s<=i)return s;for(;s>i;)if(o!==this.src.charCodeAt(--s))return s+1;return s},StateBlock.prototype.getLines=function getLines(s,o,i,a){var u,_,w,x,C,j=s;if(s>=o)return\"\";if(j+1===o)return _=this.bMarks[j]+Math.min(this.tShift[j],i),w=a?this.eMarks[j]+1:this.eMarks[j],this.src.slice(_,w);for(x=new Array(o-s),u=0;j<o;j++,u++)(C=this.tShift[j])>i&&(C=i),C<0&&(C=0),_=this.bMarks[j]+C,w=j+1<o||a?this.eMarks[j]+1:this.eMarks[j],x[u]=this.src.slice(_,w);return x.join(\"\")};var kA={};[\"article\",\"aside\",\"button\",\"blockquote\",\"body\",\"canvas\",\"caption\",\"col\",\"colgroup\",\"dd\",\"div\",\"dl\",\"dt\",\"embed\",\"fieldset\",\"figcaption\",\"figure\",\"footer\",\"form\",\"h1\",\"h2\",\"h3\",\"h4\",\"h5\",\"h6\",\"header\",\"hgroup\",\"hr\",\"iframe\",\"li\",\"map\",\"object\",\"ol\",\"output\",\"p\",\"pre\",\"progress\",\"script\",\"section\",\"style\",\"table\",\"tbody\",\"td\",\"textarea\",\"tfoot\",\"th\",\"tr\",\"thead\",\"ul\",\"video\"].forEach((function(s){kA[s]=!0}));var OA=/^<([a-zA-Z]{1,15})[\\s\\/>]/,AA=/^<\\/([a-zA-Z]{1,15})[\\s>]/;function index_browser_getLine(s,o){var i=s.bMarks[o]+s.blkIndent,a=s.eMarks[o];return s.src.substr(i,a-i)}function skipMarker(s,o){var i,a,u=s.bMarks[o]+s.tShift[o],_=s.eMarks[o];return u>=_||126!==(a=s.src.charCodeAt(u++))&&58!==a||u===(i=s.skipSpaces(u))||i>=_?-1:i}var CA=[[\"code\",function code(s,o,i){var a,u;if(s.tShift[o]-s.blkIndent<4)return!1;for(u=a=o+1;a<i;)if(s.isEmpty(a))a++;else{if(!(s.tShift[a]-s.blkIndent>=4))break;u=++a}return s.line=a,s.tokens.push({type:\"code\",content:s.getLines(o,u,4+s.blkIndent,!0),block:!0,lines:[o,s.line],level:s.level}),!0}],[\"fences\",function fences(s,o,i,a){var u,_,w,x,C,j=!1,L=s.bMarks[o]+s.tShift[o],B=s.eMarks[o];if(L+3>B)return!1;if(126!==(u=s.src.charCodeAt(L))&&96!==u)return!1;if(C=L,(_=(L=s.skipChars(L,u))-C)<3)return!1;if((w=s.src.slice(L,B).trim()).indexOf(\"`\")>=0)return!1;if(a)return!0;for(x=o;!(++x>=i)&&!((L=C=s.bMarks[x]+s.tShift[x])<(B=s.eMarks[x])&&s.tShift[x]<s.blkIndent);)if(s.src.charCodeAt(L)===u&&!(s.tShift[x]-s.blkIndent>=4||(L=s.skipChars(L,u))-C<_||(L=s.skipSpaces(L))<B)){j=!0;break}return _=s.tShift[o],s.line=x+(j?1:0),s.tokens.push({type:\"fence\",params:w,content:s.getLines(o+1,x,_,!0),lines:[o,s.line],level:s.level}),!0},[\"paragraph\",\"blockquote\",\"list\"]],[\"blockquote\",function blockquote(s,o,i,a){var u,_,w,x,C,j,L,B,$,U,V,z=s.bMarks[o]+s.tShift[o],Y=s.eMarks[o];if(z>Y)return!1;if(62!==s.src.charCodeAt(z++))return!1;if(s.level>=s.options.maxNesting)return!1;if(a)return!0;for(32===s.src.charCodeAt(z)&&z++,C=s.blkIndent,s.blkIndent=0,x=[s.bMarks[o]],s.bMarks[o]=z,_=(z=z<Y?s.skipSpaces(z):z)>=Y,w=[s.tShift[o]],s.tShift[o]=z-s.bMarks[o],B=s.parser.ruler.getRules(\"blockquote\"),u=o+1;u<i&&!((z=s.bMarks[u]+s.tShift[u])>=(Y=s.eMarks[u]));u++)if(62!==s.src.charCodeAt(z++)){if(_)break;for(V=!1,$=0,U=B.length;$<U;$++)if(B[$](s,u,i,!0)){V=!0;break}if(V)break;x.push(s.bMarks[u]),w.push(s.tShift[u]),s.tShift[u]=-1337}else 32===s.src.charCodeAt(z)&&z++,x.push(s.bMarks[u]),s.bMarks[u]=z,_=(z=z<Y?s.skipSpaces(z):z)>=Y,w.push(s.tShift[u]),s.tShift[u]=z-s.bMarks[u];for(j=s.parentType,s.parentType=\"blockquote\",s.tokens.push({type:\"blockquote_open\",lines:L=[o,0],level:s.level++}),s.parser.tokenize(s,o,u),s.tokens.push({type:\"blockquote_close\",level:--s.level}),s.parentType=j,L[1]=s.line,$=0;$<w.length;$++)s.bMarks[$+o]=x[$],s.tShift[$+o]=w[$];return s.blkIndent=C,!0},[\"paragraph\",\"blockquote\",\"list\"]],[\"hr\",function hr(s,o,i,a){var u,_,w,x=s.bMarks[o],C=s.eMarks[o];if((x+=s.tShift[o])>C)return!1;if(42!==(u=s.src.charCodeAt(x++))&&45!==u&&95!==u)return!1;for(_=1;x<C;){if((w=s.src.charCodeAt(x++))!==u&&32!==w)return!1;w===u&&_++}return!(_<3)&&(a||(s.line=o+1,s.tokens.push({type:\"hr\",lines:[o,s.line],level:s.level})),!0)},[\"paragraph\",\"blockquote\",\"list\"]],[\"list\",function index_browser_list(s,o,i,a){var u,_,w,x,C,j,L,B,$,U,V,z,Y,Z,ee,ie,ae,ce,le,pe,de,fe=!0;if((B=skipOrderedListMarker(s,o))>=0)z=!0;else{if(!((B=skipBulletListMarker(s,o))>=0))return!1;z=!1}if(s.level>=s.options.maxNesting)return!1;if(V=s.src.charCodeAt(B-1),a)return!0;for(Z=s.tokens.length,z?(L=s.bMarks[o]+s.tShift[o],U=Number(s.src.substr(L,B-L-1)),s.tokens.push({type:\"ordered_list_open\",order:U,lines:ie=[o,0],level:s.level++})):s.tokens.push({type:\"bullet_list_open\",lines:ie=[o,0],level:s.level++}),u=o,ee=!1,ce=s.parser.ruler.getRules(\"list\");!(!(u<i)||(($=(Y=s.skipSpaces(B))>=s.eMarks[u]?1:Y-B)>4&&($=1),$<1&&($=1),_=B-s.bMarks[u]+$,s.tokens.push({type:\"list_item_open\",lines:ae=[o,0],level:s.level++}),x=s.blkIndent,C=s.tight,w=s.tShift[o],j=s.parentType,s.tShift[o]=Y-s.bMarks[o],s.blkIndent=_,s.tight=!0,s.parentType=\"list\",s.parser.tokenize(s,o,i,!0),s.tight&&!ee||(fe=!1),ee=s.line-o>1&&s.isEmpty(s.line-1),s.blkIndent=x,s.tShift[o]=w,s.tight=C,s.parentType=j,s.tokens.push({type:\"list_item_close\",level:--s.level}),u=o=s.line,ae[1]=u,Y=s.bMarks[o],u>=i)||s.isEmpty(u)||s.tShift[u]<s.blkIndent);){for(de=!1,le=0,pe=ce.length;le<pe;le++)if(ce[le](s,u,i,!0)){de=!0;break}if(de)break;if(z){if((B=skipOrderedListMarker(s,u))<0)break}else if((B=skipBulletListMarker(s,u))<0)break;if(V!==s.src.charCodeAt(B-1))break}return s.tokens.push({type:z?\"ordered_list_close\":\"bullet_list_close\",level:--s.level}),ie[1]=u,s.line=u,fe&&function markTightParagraphs(s,o){var i,a,u=s.level+2;for(i=o+2,a=s.tokens.length-2;i<a;i++)s.tokens[i].level===u&&\"paragraph_open\"===s.tokens[i].type&&(s.tokens[i+2].tight=!0,s.tokens[i].tight=!0,i+=2)}(s,Z),!0},[\"paragraph\",\"blockquote\"]],[\"footnote\",function footnote(s,o,i,a){var u,_,w,x,C,j=s.bMarks[o]+s.tShift[o],L=s.eMarks[o];if(j+4>L)return!1;if(91!==s.src.charCodeAt(j))return!1;if(94!==s.src.charCodeAt(j+1))return!1;if(s.level>=s.options.maxNesting)return!1;for(x=j+2;x<L;x++){if(32===s.src.charCodeAt(x))return!1;if(93===s.src.charCodeAt(x))break}return x!==j+2&&(!(x+1>=L||58!==s.src.charCodeAt(++x))&&(a||(x++,s.env.footnotes||(s.env.footnotes={}),s.env.footnotes.refs||(s.env.footnotes.refs={}),C=s.src.slice(j+2,x-2),s.env.footnotes.refs[\":\"+C]=-1,s.tokens.push({type:\"footnote_reference_open\",label:C,level:s.level++}),u=s.bMarks[o],_=s.tShift[o],w=s.parentType,s.tShift[o]=s.skipSpaces(x)-x,s.bMarks[o]=x,s.blkIndent+=4,s.parentType=\"footnote\",s.tShift[o]<s.blkIndent&&(s.tShift[o]+=s.blkIndent,s.bMarks[o]-=s.blkIndent),s.parser.tokenize(s,o,i,!0),s.parentType=w,s.blkIndent-=4,s.tShift[o]=_,s.bMarks[o]=u,s.tokens.push({type:\"footnote_reference_close\",level:--s.level})),!0))},[\"paragraph\"]],[\"heading\",function heading(s,o,i,a){var u,_,w,x=s.bMarks[o]+s.tShift[o],C=s.eMarks[o];if(x>=C)return!1;if(35!==(u=s.src.charCodeAt(x))||x>=C)return!1;for(_=1,u=s.src.charCodeAt(++x);35===u&&x<C&&_<=6;)_++,u=s.src.charCodeAt(++x);return!(_>6||x<C&&32!==u)&&(a||(C=s.skipCharsBack(C,32,x),(w=s.skipCharsBack(C,35,x))>x&&32===s.src.charCodeAt(w-1)&&(C=w),s.line=o+1,s.tokens.push({type:\"heading_open\",hLevel:_,lines:[o,s.line],level:s.level}),x<C&&s.tokens.push({type:\"inline\",content:s.src.slice(x,C).trim(),level:s.level+1,lines:[o,s.line],children:[]}),s.tokens.push({type:\"heading_close\",hLevel:_,level:s.level})),!0)},[\"paragraph\",\"blockquote\"]],[\"lheading\",function lheading(s,o,i){var a,u,_,w=o+1;return!(w>=i)&&(!(s.tShift[w]<s.blkIndent)&&(!(s.tShift[w]-s.blkIndent>3)&&(!((u=s.bMarks[w]+s.tShift[w])>=(_=s.eMarks[w]))&&((45===(a=s.src.charCodeAt(u))||61===a)&&(u=s.skipChars(u,a),!((u=s.skipSpaces(u))<_)&&(u=s.bMarks[o]+s.tShift[o],s.line=w+1,s.tokens.push({type:\"heading_open\",hLevel:61===a?1:2,lines:[o,s.line],level:s.level}),s.tokens.push({type:\"inline\",content:s.src.slice(u,s.eMarks[o]).trim(),level:s.level+1,lines:[o,s.line-1],children:[]}),s.tokens.push({type:\"heading_close\",hLevel:61===a?1:2,level:s.level}),!0))))))}],[\"htmlblock\",function htmlblock(s,o,i,a){var u,_,w,x=s.bMarks[o],C=s.eMarks[o],j=s.tShift[o];if(x+=j,!s.options.html)return!1;if(j>3||x+2>=C)return!1;if(60!==s.src.charCodeAt(x))return!1;if(33===(u=s.src.charCodeAt(x+1))||63===u){if(a)return!0}else{if(47!==u&&!function isLetter$1(s){var o=32|s;return o>=97&&o<=122}(u))return!1;if(47===u){if(!(_=s.src.slice(x,C).match(AA)))return!1}else if(!(_=s.src.slice(x,C).match(OA)))return!1;if(!0!==kA[_[1].toLowerCase()])return!1;if(a)return!0}for(w=o+1;w<s.lineMax&&!s.isEmpty(w);)w++;return s.line=w,s.tokens.push({type:\"htmlblock\",level:s.level,lines:[o,s.line],content:s.getLines(o,w,0,!0)}),!0},[\"paragraph\",\"blockquote\"]],[\"table\",function table(s,o,i,a){var u,_,w,x,C,j,L,B,$,U,V;if(o+2>i)return!1;if(C=o+1,s.tShift[C]<s.blkIndent)return!1;if((w=s.bMarks[C]+s.tShift[C])>=s.eMarks[C])return!1;if(124!==(u=s.src.charCodeAt(w))&&45!==u&&58!==u)return!1;if(_=index_browser_getLine(s,o+1),!/^[-:| ]+$/.test(_))return!1;if((j=_.split(\"|\"))<=2)return!1;for(B=[],x=0;x<j.length;x++){if(!($=j[x].trim())){if(0===x||x===j.length-1)continue;return!1}if(!/^:?-+:?$/.test($))return!1;58===$.charCodeAt($.length-1)?B.push(58===$.charCodeAt(0)?\"center\":\"right\"):58===$.charCodeAt(0)?B.push(\"left\"):B.push(\"\")}if(-1===(_=index_browser_getLine(s,o).trim()).indexOf(\"|\"))return!1;if(j=_.replace(/^\\||\\|$/g,\"\").split(\"|\"),B.length!==j.length)return!1;if(a)return!0;for(s.tokens.push({type:\"table_open\",lines:U=[o,0],level:s.level++}),s.tokens.push({type:\"thead_open\",lines:[o,o+1],level:s.level++}),s.tokens.push({type:\"tr_open\",lines:[o,o+1],level:s.level++}),x=0;x<j.length;x++)s.tokens.push({type:\"th_open\",align:B[x],lines:[o,o+1],level:s.level++}),s.tokens.push({type:\"inline\",content:j[x].trim(),lines:[o,o+1],level:s.level,children:[]}),s.tokens.push({type:\"th_close\",level:--s.level});for(s.tokens.push({type:\"tr_close\",level:--s.level}),s.tokens.push({type:\"thead_close\",level:--s.level}),s.tokens.push({type:\"tbody_open\",lines:V=[o+2,0],level:s.level++}),C=o+2;C<i&&!(s.tShift[C]<s.blkIndent)&&-1!==(_=index_browser_getLine(s,C).trim()).indexOf(\"|\");C++){for(j=_.replace(/^\\||\\|$/g,\"\").split(\"|\"),s.tokens.push({type:\"tr_open\",level:s.level++}),x=0;x<j.length;x++)s.tokens.push({type:\"td_open\",align:B[x],level:s.level++}),L=j[x].substring(124===j[x].charCodeAt(0)?1:0,124===j[x].charCodeAt(j[x].length-1)?j[x].length-1:j[x].length).trim(),s.tokens.push({type:\"inline\",content:L,level:s.level,children:[]}),s.tokens.push({type:\"td_close\",level:--s.level});s.tokens.push({type:\"tr_close\",level:--s.level})}return s.tokens.push({type:\"tbody_close\",level:--s.level}),s.tokens.push({type:\"table_close\",level:--s.level}),U[1]=V[1]=C,s.line=C,!0},[\"paragraph\"]],[\"deflist\",function deflist(s,o,i,a){var u,_,w,x,C,j,L,B,$,U,V,z,Y,Z;if(a)return!(s.ddIndent<0)&&skipMarker(s,o)>=0;if(L=o+1,s.isEmpty(L)&&++L>i)return!1;if(s.tShift[L]<s.blkIndent)return!1;if((u=skipMarker(s,L))<0)return!1;if(s.level>=s.options.maxNesting)return!1;j=s.tokens.length,s.tokens.push({type:\"dl_open\",lines:C=[o,0],level:s.level++}),w=o,_=L;e:for(;;){for(Z=!0,Y=!1,s.tokens.push({type:\"dt_open\",lines:[w,w],level:s.level++}),s.tokens.push({type:\"inline\",content:s.getLines(w,w+1,s.blkIndent,!1).trim(),level:s.level+1,lines:[w,w],children:[]}),s.tokens.push({type:\"dt_close\",level:--s.level});;){if(s.tokens.push({type:\"dd_open\",lines:x=[L,0],level:s.level++}),z=s.tight,$=s.ddIndent,B=s.blkIndent,V=s.tShift[_],U=s.parentType,s.blkIndent=s.ddIndent=s.tShift[_]+2,s.tShift[_]=u-s.bMarks[_],s.tight=!0,s.parentType=\"deflist\",s.parser.tokenize(s,_,i,!0),s.tight&&!Y||(Z=!1),Y=s.line-_>1&&s.isEmpty(s.line-1),s.tShift[_]=V,s.tight=z,s.parentType=U,s.blkIndent=B,s.ddIndent=$,s.tokens.push({type:\"dd_close\",level:--s.level}),x[1]=L=s.line,L>=i)break e;if(s.tShift[L]<s.blkIndent)break e;if((u=skipMarker(s,L))<0)break;_=L}if(L>=i)break;if(w=L,s.isEmpty(w))break;if(s.tShift[w]<s.blkIndent)break;if((_=w+1)>=i)break;if(s.isEmpty(_)&&_++,_>=i)break;if(s.tShift[_]<s.blkIndent)break;if((u=skipMarker(s,_))<0)break}return s.tokens.push({type:\"dl_close\",level:--s.level}),C[1]=L,s.line=L,Z&&function markTightParagraphs$1(s,o){var i,a,u=s.level+2;for(i=o+2,a=s.tokens.length-2;i<a;i++)s.tokens[i].level===u&&\"paragraph_open\"===s.tokens[i].type&&(s.tokens[i+2].tight=!0,s.tokens[i].tight=!0,i+=2)}(s,j),!0},[\"paragraph\"]],[\"paragraph\",function paragraph(s,o){var i,a,u,_,w,x,C=o+1;if(C<(i=s.lineMax)&&!s.isEmpty(C))for(x=s.parser.ruler.getRules(\"paragraph\");C<i&&!s.isEmpty(C);C++)if(!(s.tShift[C]-s.blkIndent>3)){for(u=!1,_=0,w=x.length;_<w;_++)if(x[_](s,C,i,!0)){u=!0;break}if(u)break}return a=s.getLines(o,C,s.blkIndent,!1).trim(),s.line=C,a.length&&(s.tokens.push({type:\"paragraph_open\",tight:!1,lines:[o,s.line],level:s.level}),s.tokens.push({type:\"inline\",content:a,level:s.level+1,lines:[o,s.line],children:[]}),s.tokens.push({type:\"paragraph_close\",tight:!1,level:s.level})),!0}]];function ParserBlock(){this.ruler=new Ruler;for(var s=0;s<CA.length;s++)this.ruler.push(CA[s][0],CA[s][1],{alt:(CA[s][2]||[]).slice()})}ParserBlock.prototype.tokenize=function(s,o,i){for(var a,u=this.ruler.getRules(\"\"),_=u.length,w=o,x=!1;w<i&&(s.line=w=s.skipEmptyLines(w),!(w>=i))&&!(s.tShift[w]<s.blkIndent);){for(a=0;a<_&&!u[a](s,w,i,!1);a++);if(s.tight=!x,s.isEmpty(s.line-1)&&(x=!0),(w=s.line)<i&&s.isEmpty(w)){if(x=!0,++w<i&&\"list\"===s.parentType&&s.isEmpty(w))break;s.line=w}}};var jA=/[\\n\\t]/g,PA=/\\r[\\n\\u0085]|[\\u2424\\u2028\\u0085]/g,IA=/\\u00a0/g;function isTerminatorChar(s){switch(s){case 10:case 92:case 96:case 42:case 95:case 94:case 91:case 93:case 33:case 38:case 60:case 62:case 123:case 125:case 36:case 37:case 64:case 126:case 43:case 61:case 58:return!0;default:return!1}}ParserBlock.prototype.parse=function(s,o,i,a){var u,_=0,w=0;if(!s)return[];(s=(s=s.replace(IA,\" \")).replace(PA,\"\\n\")).indexOf(\"\\t\")>=0&&(s=s.replace(jA,(function(o,i){var a;return 10===s.charCodeAt(i)?(_=i+1,w=0,o):(a=\"    \".slice((i-_-w)%4),w=i-_+1,a)}))),u=new StateBlock(s,this,o,i,a),this.tokenize(u,u.line,u.lineMax)};for(var TA=[],NA=0;NA<256;NA++)TA.push(0);function isAlphaNum(s){return s>=48&&s<=57||s>=65&&s<=90||s>=97&&s<=122}function scanDelims(s,o){var i,a,u,_=o,w=!0,x=!0,C=s.posMax,j=s.src.charCodeAt(o);for(i=o>0?s.src.charCodeAt(o-1):-1;_<C&&s.src.charCodeAt(_)===j;)_++;return _>=C&&(w=!1),(u=_-o)>=4?w=x=!1:(32!==(a=_<C?s.src.charCodeAt(_):-1)&&10!==a||(w=!1),32!==i&&10!==i||(x=!1),95===j&&(isAlphaNum(i)&&(w=!1),isAlphaNum(a)&&(x=!1))),{can_open:w,can_close:x,delims:u}}\"\\\\!\\\"#$%&'()*+,./:;<=>?@[]^_`{|}~-\".split(\"\").forEach((function(s){TA[s.charCodeAt(0)]=1}));var MA=/\\\\([ \\\\!\"#$%&'()*+,.\\/:;<=>?@[\\]^_`{|}~-])/g;var RA=/\\\\([ \\\\!\"#$%&'()*+,.\\/:;<=>?@[\\]^_`{|}~-])/g;var DA=[\"coap\",\"doi\",\"javascript\",\"aaa\",\"aaas\",\"about\",\"acap\",\"cap\",\"cid\",\"crid\",\"data\",\"dav\",\"dict\",\"dns\",\"file\",\"ftp\",\"geo\",\"go\",\"gopher\",\"h323\",\"http\",\"https\",\"iax\",\"icap\",\"im\",\"imap\",\"info\",\"ipp\",\"iris\",\"iris.beep\",\"iris.xpc\",\"iris.xpcs\",\"iris.lwz\",\"ldap\",\"mailto\",\"mid\",\"msrp\",\"msrps\",\"mtqp\",\"mupdate\",\"news\",\"nfs\",\"ni\",\"nih\",\"nntp\",\"opaquelocktoken\",\"pop\",\"pres\",\"rtsp\",\"service\",\"session\",\"shttp\",\"sieve\",\"sip\",\"sips\",\"sms\",\"snmp\",\"soap.beep\",\"soap.beeps\",\"tag\",\"tel\",\"telnet\",\"tftp\",\"thismessage\",\"tn3270\",\"tip\",\"tv\",\"urn\",\"vemmi\",\"ws\",\"wss\",\"xcon\",\"xcon-userid\",\"xmlrpc.beep\",\"xmlrpc.beeps\",\"xmpp\",\"z39.50r\",\"z39.50s\",\"adiumxtra\",\"afp\",\"afs\",\"aim\",\"apt\",\"attachment\",\"aw\",\"beshare\",\"bitcoin\",\"bolo\",\"callto\",\"chrome\",\"chrome-extension\",\"com-eventbrite-attendee\",\"content\",\"cvs\",\"dlna-playsingle\",\"dlna-playcontainer\",\"dtn\",\"dvb\",\"ed2k\",\"facetime\",\"feed\",\"finger\",\"fish\",\"gg\",\"git\",\"gizmoproject\",\"gtalk\",\"hcp\",\"icon\",\"ipn\",\"irc\",\"irc6\",\"ircs\",\"itms\",\"jar\",\"jms\",\"keyparc\",\"lastfm\",\"ldaps\",\"magnet\",\"maps\",\"market\",\"message\",\"mms\",\"ms-help\",\"msnim\",\"mumble\",\"mvn\",\"notes\",\"oid\",\"palm\",\"paparazzi\",\"platform\",\"proxy\",\"psyc\",\"query\",\"res\",\"resource\",\"rmi\",\"rsync\",\"rtmp\",\"secondlife\",\"sftp\",\"sgn\",\"skype\",\"smb\",\"soldat\",\"spotify\",\"ssh\",\"steam\",\"svn\",\"teamspeak\",\"things\",\"udp\",\"unreal\",\"ut2004\",\"ventrilo\",\"view-source\",\"webcal\",\"wtai\",\"wyciwyg\",\"xfire\",\"xri\",\"ymsgr\"],LA=/^<([a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)>/,FA=/^<([a-zA-Z.\\-]{1,25}):([^<>\\x00-\\x20]*)>/;function replace$1(s,o){return s=s.source,o=o||\"\",function self(i,a){return i?(a=a.source||a,s=s.replace(i,a),self):new RegExp(s,o)}}var BA=replace$1(/(?:unquoted|single_quoted|double_quoted)/)(\"unquoted\",/[^\"'=<>`\\x00-\\x20]+/)(\"single_quoted\",/'[^']*'/)(\"double_quoted\",/\"[^\"]*\"/)(),$A=replace$1(/(?:\\s+attr_name(?:\\s*=\\s*attr_value)?)/)(\"attr_name\",/[a-zA-Z_:][a-zA-Z0-9:._-]*/)(\"attr_value\",BA)(),qA=replace$1(/<[A-Za-z][A-Za-z0-9]*attribute*\\s*\\/?>/)(\"attribute\",$A)(),UA=replace$1(/^(?:open_tag|close_tag|comment|processing|declaration|cdata)/)(\"open_tag\",qA)(\"close_tag\",/<\\/[A-Za-z][A-Za-z0-9]*\\s*>/)(\"comment\",/<!---->|<!--(?:-?[^>-])(?:-?[^-])*-->/)(\"processing\",/<[?].*?[?]>/)(\"declaration\",/<![A-Z]+\\s+[^>]*>/)(\"cdata\",/<!\\[CDATA\\[[\\s\\S]*?\\]\\]>/)();var VA=/^&#((?:x[a-f0-9]{1,8}|[0-9]{1,8}));/i,zA=/^&([a-z][a-z0-9]{1,31});/i;var WA=[[\"text\",function index_browser_text(s,o){for(var i=s.pos;i<s.posMax&&!isTerminatorChar(s.src.charCodeAt(i));)i++;return i!==s.pos&&(o||(s.pending+=s.src.slice(s.pos,i)),s.pos=i,!0)}],[\"newline\",function newline(s,o){var i,a,u=s.pos;if(10!==s.src.charCodeAt(u))return!1;if(i=s.pending.length-1,a=s.posMax,!o)if(i>=0&&32===s.pending.charCodeAt(i))if(i>=1&&32===s.pending.charCodeAt(i-1)){for(var _=i-2;_>=0;_--)if(32!==s.pending.charCodeAt(_)){s.pending=s.pending.substring(0,_+1);break}s.push({type:\"hardbreak\",level:s.level})}else s.pending=s.pending.slice(0,-1),s.push({type:\"softbreak\",level:s.level});else s.push({type:\"softbreak\",level:s.level});for(u++;u<a&&32===s.src.charCodeAt(u);)u++;return s.pos=u,!0}],[\"escape\",function index_browser_escape(s,o){var i,a=s.pos,u=s.posMax;if(92!==s.src.charCodeAt(a))return!1;if(++a<u){if((i=s.src.charCodeAt(a))<256&&0!==TA[i])return o||(s.pending+=s.src[a]),s.pos+=2,!0;if(10===i){for(o||s.push({type:\"hardbreak\",level:s.level}),a++;a<u&&32===s.src.charCodeAt(a);)a++;return s.pos=a,!0}}return o||(s.pending+=\"\\\\\"),s.pos++,!0}],[\"backticks\",function backticks(s,o){var i,a,u,_,w,x=s.pos;if(96!==s.src.charCodeAt(x))return!1;for(i=x,x++,a=s.posMax;x<a&&96===s.src.charCodeAt(x);)x++;for(u=s.src.slice(i,x),_=w=x;-1!==(_=s.src.indexOf(\"`\",w));){for(w=_+1;w<a&&96===s.src.charCodeAt(w);)w++;if(w-_===u.length)return o||s.push({type:\"code\",content:s.src.slice(x,_).replace(/[ \\n]+/g,\" \").trim(),block:!1,level:s.level}),s.pos=w,!0}return o||(s.pending+=u),s.pos+=u.length,!0}],[\"del\",function del(s,o){var i,a,u,_,w,x=s.posMax,C=s.pos;if(126!==s.src.charCodeAt(C))return!1;if(o)return!1;if(C+4>=x)return!1;if(126!==s.src.charCodeAt(C+1))return!1;if(s.level>=s.options.maxNesting)return!1;if(_=C>0?s.src.charCodeAt(C-1):-1,w=s.src.charCodeAt(C+2),126===_)return!1;if(126===w)return!1;if(32===w||10===w)return!1;for(a=C+2;a<x&&126===s.src.charCodeAt(a);)a++;if(a>C+3)return s.pos+=a-C,o||(s.pending+=s.src.slice(C,a)),!0;for(s.pos=C+2,u=1;s.pos+1<x;){if(126===s.src.charCodeAt(s.pos)&&126===s.src.charCodeAt(s.pos+1)&&(_=s.src.charCodeAt(s.pos-1),126!==(w=s.pos+2<x?s.src.charCodeAt(s.pos+2):-1)&&126!==_&&(32!==_&&10!==_?u--:32!==w&&10!==w&&u++,u<=0))){i=!0;break}s.parser.skipToken(s)}return i?(s.posMax=s.pos,s.pos=C+2,o||(s.push({type:\"del_open\",level:s.level++}),s.parser.tokenize(s),s.push({type:\"del_close\",level:--s.level})),s.pos=s.posMax+2,s.posMax=x,!0):(s.pos=C,!1)}],[\"ins\",function ins(s,o){var i,a,u,_,w,x=s.posMax,C=s.pos;if(43!==s.src.charCodeAt(C))return!1;if(o)return!1;if(C+4>=x)return!1;if(43!==s.src.charCodeAt(C+1))return!1;if(s.level>=s.options.maxNesting)return!1;if(_=C>0?s.src.charCodeAt(C-1):-1,w=s.src.charCodeAt(C+2),43===_)return!1;if(43===w)return!1;if(32===w||10===w)return!1;for(a=C+2;a<x&&43===s.src.charCodeAt(a);)a++;if(a!==C+2)return s.pos+=a-C,o||(s.pending+=s.src.slice(C,a)),!0;for(s.pos=C+2,u=1;s.pos+1<x;){if(43===s.src.charCodeAt(s.pos)&&43===s.src.charCodeAt(s.pos+1)&&(_=s.src.charCodeAt(s.pos-1),43!==(w=s.pos+2<x?s.src.charCodeAt(s.pos+2):-1)&&43!==_&&(32!==_&&10!==_?u--:32!==w&&10!==w&&u++,u<=0))){i=!0;break}s.parser.skipToken(s)}return i?(s.posMax=s.pos,s.pos=C+2,o||(s.push({type:\"ins_open\",level:s.level++}),s.parser.tokenize(s),s.push({type:\"ins_close\",level:--s.level})),s.pos=s.posMax+2,s.posMax=x,!0):(s.pos=C,!1)}],[\"mark\",function mark(s,o){var i,a,u,_,w,x=s.posMax,C=s.pos;if(61!==s.src.charCodeAt(C))return!1;if(o)return!1;if(C+4>=x)return!1;if(61!==s.src.charCodeAt(C+1))return!1;if(s.level>=s.options.maxNesting)return!1;if(_=C>0?s.src.charCodeAt(C-1):-1,w=s.src.charCodeAt(C+2),61===_)return!1;if(61===w)return!1;if(32===w||10===w)return!1;for(a=C+2;a<x&&61===s.src.charCodeAt(a);)a++;if(a!==C+2)return s.pos+=a-C,o||(s.pending+=s.src.slice(C,a)),!0;for(s.pos=C+2,u=1;s.pos+1<x;){if(61===s.src.charCodeAt(s.pos)&&61===s.src.charCodeAt(s.pos+1)&&(_=s.src.charCodeAt(s.pos-1),61!==(w=s.pos+2<x?s.src.charCodeAt(s.pos+2):-1)&&61!==_&&(32!==_&&10!==_?u--:32!==w&&10!==w&&u++,u<=0))){i=!0;break}s.parser.skipToken(s)}return i?(s.posMax=s.pos,s.pos=C+2,o||(s.push({type:\"mark_open\",level:s.level++}),s.parser.tokenize(s),s.push({type:\"mark_close\",level:--s.level})),s.pos=s.posMax+2,s.posMax=x,!0):(s.pos=C,!1)}],[\"emphasis\",function emphasis(s,o){var i,a,u,_,w,x,C,j=s.posMax,L=s.pos,B=s.src.charCodeAt(L);if(95!==B&&42!==B)return!1;if(o)return!1;if(i=(C=scanDelims(s,L)).delims,!C.can_open)return s.pos+=i,o||(s.pending+=s.src.slice(L,s.pos)),!0;if(s.level>=s.options.maxNesting)return!1;for(s.pos=L+i,x=[i];s.pos<j;)if(s.src.charCodeAt(s.pos)!==B)s.parser.skipToken(s);else{if(a=(C=scanDelims(s,s.pos)).delims,C.can_close){for(_=x.pop(),w=a;_!==w;){if(w<_){x.push(_-w);break}if(w-=_,0===x.length)break;s.pos+=_,_=x.pop()}if(0===x.length){i=_,u=!0;break}s.pos+=a;continue}C.can_open&&x.push(a),s.pos+=a}return u?(s.posMax=s.pos,s.pos=L+i,o||(2!==i&&3!==i||s.push({type:\"strong_open\",level:s.level++}),1!==i&&3!==i||s.push({type:\"em_open\",level:s.level++}),s.parser.tokenize(s),1!==i&&3!==i||s.push({type:\"em_close\",level:--s.level}),2!==i&&3!==i||s.push({type:\"strong_close\",level:--s.level})),s.pos=s.posMax+i,s.posMax=j,!0):(s.pos=L,!1)}],[\"sub\",function sub(s,o){var i,a,u=s.posMax,_=s.pos;if(126!==s.src.charCodeAt(_))return!1;if(o)return!1;if(_+2>=u)return!1;if(s.level>=s.options.maxNesting)return!1;for(s.pos=_+1;s.pos<u;){if(126===s.src.charCodeAt(s.pos)){i=!0;break}s.parser.skipToken(s)}return i&&_+1!==s.pos?(a=s.src.slice(_+1,s.pos)).match(/(^|[^\\\\])(\\\\\\\\)*\\s/)?(s.pos=_,!1):(s.posMax=s.pos,s.pos=_+1,o||s.push({type:\"sub\",level:s.level,content:a.replace(MA,\"$1\")}),s.pos=s.posMax+1,s.posMax=u,!0):(s.pos=_,!1)}],[\"sup\",function sup(s,o){var i,a,u=s.posMax,_=s.pos;if(94!==s.src.charCodeAt(_))return!1;if(o)return!1;if(_+2>=u)return!1;if(s.level>=s.options.maxNesting)return!1;for(s.pos=_+1;s.pos<u;){if(94===s.src.charCodeAt(s.pos)){i=!0;break}s.parser.skipToken(s)}return i&&_+1!==s.pos?(a=s.src.slice(_+1,s.pos)).match(/(^|[^\\\\])(\\\\\\\\)*\\s/)?(s.pos=_,!1):(s.posMax=s.pos,s.pos=_+1,o||s.push({type:\"sup\",level:s.level,content:a.replace(RA,\"$1\")}),s.pos=s.posMax+1,s.posMax=u,!0):(s.pos=_,!1)}],[\"links\",function links(s,o){var i,a,u,_,w,x,C,j,L=!1,B=s.pos,$=s.posMax,U=s.pos,V=s.src.charCodeAt(U);if(33===V&&(L=!0,V=s.src.charCodeAt(++U)),91!==V)return!1;if(s.level>=s.options.maxNesting)return!1;if(i=U+1,(a=parseLinkLabel(s,U))<0)return!1;if((x=a+1)<$&&40===s.src.charCodeAt(x)){for(x++;x<$&&(32===(j=s.src.charCodeAt(x))||10===j);x++);if(x>=$)return!1;for(U=x,parseLinkDestination(s,x)?(_=s.linkContent,x=s.pos):_=\"\",U=x;x<$&&(32===(j=s.src.charCodeAt(x))||10===j);x++);if(x<$&&U!==x&&parseLinkTitle(s,x))for(w=s.linkContent,x=s.pos;x<$&&(32===(j=s.src.charCodeAt(x))||10===j);x++);else w=\"\";if(x>=$||41!==s.src.charCodeAt(x))return s.pos=B,!1;x++}else{if(s.linkLevel>0)return!1;for(;x<$&&(32===(j=s.src.charCodeAt(x))||10===j);x++);if(x<$&&91===s.src.charCodeAt(x)&&(U=x+1,(x=parseLinkLabel(s,x))>=0?u=s.src.slice(U,x++):x=U-1),u||(void 0===u&&(x=a+1),u=s.src.slice(i,a)),!(C=s.env.references[normalizeReference(u)]))return s.pos=B,!1;_=C.href,w=C.title}return o||(s.pos=i,s.posMax=a,L?s.push({type:\"image\",src:_,title:w,alt:s.src.substr(i,a-i),level:s.level}):(s.push({type:\"link_open\",href:_,title:w,level:s.level++}),s.linkLevel++,s.parser.tokenize(s),s.linkLevel--,s.push({type:\"link_close\",level:--s.level}))),s.pos=x,s.posMax=$,!0}],[\"footnote_inline\",function footnote_inline(s,o){var i,a,u,_,w=s.posMax,x=s.pos;return!(x+2>=w)&&(94===s.src.charCodeAt(x)&&(91===s.src.charCodeAt(x+1)&&(!(s.level>=s.options.maxNesting)&&(i=x+2,!((a=parseLinkLabel(s,x+1))<0)&&(o||(s.env.footnotes||(s.env.footnotes={}),s.env.footnotes.list||(s.env.footnotes.list=[]),u=s.env.footnotes.list.length,s.pos=i,s.posMax=a,s.push({type:\"footnote_ref\",id:u,level:s.level}),s.linkLevel++,_=s.tokens.length,s.parser.tokenize(s),s.env.footnotes.list[u]={tokens:s.tokens.splice(_)},s.linkLevel--),s.pos=a+1,s.posMax=w,!0)))))}],[\"footnote_ref\",function footnote_ref(s,o){var i,a,u,_,w=s.posMax,x=s.pos;if(x+3>w)return!1;if(!s.env.footnotes||!s.env.footnotes.refs)return!1;if(91!==s.src.charCodeAt(x))return!1;if(94!==s.src.charCodeAt(x+1))return!1;if(s.level>=s.options.maxNesting)return!1;for(a=x+2;a<w;a++){if(32===s.src.charCodeAt(a))return!1;if(10===s.src.charCodeAt(a))return!1;if(93===s.src.charCodeAt(a))break}return a!==x+2&&(!(a>=w)&&(a++,i=s.src.slice(x+2,a-1),void 0!==s.env.footnotes.refs[\":\"+i]&&(o||(s.env.footnotes.list||(s.env.footnotes.list=[]),s.env.footnotes.refs[\":\"+i]<0?(u=s.env.footnotes.list.length,s.env.footnotes.list[u]={label:i,count:0},s.env.footnotes.refs[\":\"+i]=u):u=s.env.footnotes.refs[\":\"+i],_=s.env.footnotes.list[u].count,s.env.footnotes.list[u].count++,s.push({type:\"footnote_ref\",id:u,subId:_,level:s.level})),s.pos=a,s.posMax=w,!0)))}],[\"autolink\",function autolink(s,o){var i,a,u,_,w,x=s.pos;return 60===s.src.charCodeAt(x)&&(!((i=s.src.slice(x)).indexOf(\">\")<0)&&((a=i.match(FA))?!(DA.indexOf(a[1].toLowerCase())<0)&&(w=normalizeLink(_=a[0].slice(1,-1)),!!s.parser.validateLink(_)&&(o||(s.push({type:\"link_open\",href:w,level:s.level}),s.push({type:\"text\",content:_,level:s.level+1}),s.push({type:\"link_close\",level:s.level})),s.pos+=a[0].length,!0)):!!(u=i.match(LA))&&(w=normalizeLink(\"mailto:\"+(_=u[0].slice(1,-1))),!!s.parser.validateLink(w)&&(o||(s.push({type:\"link_open\",href:w,level:s.level}),s.push({type:\"text\",content:_,level:s.level+1}),s.push({type:\"link_close\",level:s.level})),s.pos+=u[0].length,!0))))}],[\"htmltag\",function htmltag(s,o){var i,a,u,_=s.pos;return!!s.options.html&&(u=s.posMax,!(60!==s.src.charCodeAt(_)||_+2>=u)&&(!(33!==(i=s.src.charCodeAt(_+1))&&63!==i&&47!==i&&!function isLetter$2(s){var o=32|s;return o>=97&&o<=122}(i))&&(!!(a=s.src.slice(_).match(UA))&&(o||s.push({type:\"htmltag\",content:s.src.slice(_,_+a[0].length),level:s.level}),s.pos+=a[0].length,!0))))}],[\"entity\",function entity(s,o){var i,a,u=s.pos,_=s.posMax;if(38!==s.src.charCodeAt(u))return!1;if(u+1<_)if(35===s.src.charCodeAt(u+1)){if(a=s.src.slice(u).match(VA))return o||(i=\"x\"===a[1][0].toLowerCase()?parseInt(a[1].slice(1),16):parseInt(a[1],10),s.pending+=isValidEntityCode(i)?fromCodePoint(i):fromCodePoint(65533)),s.pos+=a[0].length,!0}else if(a=s.src.slice(u).match(zA)){var w=decodeEntity(a[1]);if(a[1]!==w)return o||(s.pending+=w),s.pos+=a[0].length,!0}return o||(s.pending+=\"&\"),s.pos++,!0}]];function ParserInline(){this.ruler=new Ruler;for(var s=0;s<WA.length;s++)this.ruler.push(WA[s][0],WA[s][1]);this.validateLink=validateLink}function validateLink(s){var o=s.trim().toLowerCase();return-1===(o=replaceEntities(o)).indexOf(\":\")||-1===[\"vbscript\",\"javascript\",\"file\",\"data\"].indexOf(o.split(\":\")[0])}ParserInline.prototype.skipToken=function(s){var o,i,a=this.ruler.getRules(\"\"),u=a.length,_=s.pos;if((i=s.cacheGet(_))>0)s.pos=i;else{for(o=0;o<u;o++)if(a[o](s,!0))return void s.cacheSet(_,s.pos);s.pos++,s.cacheSet(_,s.pos)}},ParserInline.prototype.tokenize=function(s){for(var o,i,a=this.ruler.getRules(\"\"),u=a.length,_=s.posMax;s.pos<_;){for(i=0;i<u&&!(o=a[i](s,!1));i++);if(o){if(s.pos>=_)break}else s.pending+=s.src[s.pos++]}s.pending&&s.pushPending()},ParserInline.prototype.parse=function(s,o,i,a){var u=new StateInline(s,this,o,i,a);this.tokenize(u)};var JA={default:{options:{html:!1,xhtmlOut:!1,breaks:!1,langPrefix:\"language-\",linkTarget:\"\",typographer:!1,quotes:\"“”‘’\",highlight:null,maxNesting:20},components:{core:{rules:[\"block\",\"inline\",\"references\",\"replacements\",\"smartquotes\",\"references\",\"abbr2\",\"footnote_tail\"]},block:{rules:[\"blockquote\",\"code\",\"fences\",\"footnote\",\"heading\",\"hr\",\"htmlblock\",\"lheading\",\"list\",\"paragraph\",\"table\"]},inline:{rules:[\"autolink\",\"backticks\",\"del\",\"emphasis\",\"entity\",\"escape\",\"footnote_ref\",\"htmltag\",\"links\",\"newline\",\"text\"]}}},full:{options:{html:!1,xhtmlOut:!1,breaks:!1,langPrefix:\"language-\",linkTarget:\"\",typographer:!1,quotes:\"“”‘’\",highlight:null,maxNesting:20},components:{core:{},block:{},inline:{}}},commonmark:{options:{html:!0,xhtmlOut:!0,breaks:!1,langPrefix:\"language-\",linkTarget:\"\",typographer:!1,quotes:\"“”‘’\",highlight:null,maxNesting:20},components:{core:{rules:[\"block\",\"inline\",\"references\",\"abbr2\"]},block:{rules:[\"blockquote\",\"code\",\"fences\",\"heading\",\"hr\",\"htmlblock\",\"lheading\",\"list\",\"paragraph\"]},inline:{rules:[\"autolink\",\"backticks\",\"emphasis\",\"entity\",\"escape\",\"htmltag\",\"links\",\"newline\",\"text\"]}}}};function StateCore(s,o,i){this.src=o,this.env=i,this.options=s.options,this.tokens=[],this.inlineMode=!1,this.inline=s.inline,this.block=s.block,this.renderer=s.renderer,this.typographer=s.typographer}function Remarkable(s,o){\"string\"!=typeof s&&(o=s,s=\"default\"),o&&null!=o.linkify&&console.warn(\"linkify option is removed. Use linkify plugin instead:\\n\\nimport Remarkable from 'remarkable';\\nimport linkify from 'remarkable/linkify';\\nnew Remarkable().use(linkify)\\n\"),this.inline=new ParserInline,this.block=new ParserBlock,this.core=new Core,this.renderer=new Renderer,this.ruler=new Ruler,this.options={},this.configure(JA[s]),this.set(o||{})}Remarkable.prototype.set=function(s){index_browser_assign(this.options,s)},Remarkable.prototype.configure=function(s){var o=this;if(!s)throw new Error(\"Wrong `remarkable` preset, check name/content\");s.options&&o.set(s.options),s.components&&Object.keys(s.components).forEach((function(i){s.components[i].rules&&o[i].ruler.enable(s.components[i].rules,!0)}))},Remarkable.prototype.use=function(s,o){return s(this,o),this},Remarkable.prototype.parse=function(s,o){var i=new StateCore(this,s,o);return this.core.process(i),i.tokens},Remarkable.prototype.render=function(s,o){return o=o||{},this.renderer.render(this.parse(s,o),this.options,o)},Remarkable.prototype.parseInline=function(s,o){var i=new StateCore(this,s,o);return i.inlineMode=!0,this.core.process(i),i.tokens},Remarkable.prototype.renderInline=function(s,o){return o=o||{},this.renderer.render(this.parseInline(s,o),this.options,o)};function indexOf(s,o){if(Array.prototype.indexOf)return s.indexOf(o);for(var i=0,a=s.length;i<a;i++)if(s[i]===o)return i;return-1}function utils_remove(s,o){for(var i=s.length-1;i>=0;i--)!0===o(s[i])&&s.splice(i,1)}function throwUnhandledCaseError(s){throw new Error(\"Unhandled case for value: '\".concat(s,\"'\"))}var HA=function(){function HtmlTag(s){void 0===s&&(s={}),this.tagName=\"\",this.attrs={},this.innerHTML=\"\",this.whitespaceRegex=/\\s+/,this.tagName=s.tagName||\"\",this.attrs=s.attrs||{},this.innerHTML=s.innerHtml||s.innerHTML||\"\"}return HtmlTag.prototype.setTagName=function(s){return this.tagName=s,this},HtmlTag.prototype.getTagName=function(){return this.tagName||\"\"},HtmlTag.prototype.setAttr=function(s,o){return this.getAttrs()[s]=o,this},HtmlTag.prototype.getAttr=function(s){return this.getAttrs()[s]},HtmlTag.prototype.setAttrs=function(s){return Object.assign(this.getAttrs(),s),this},HtmlTag.prototype.getAttrs=function(){return this.attrs||(this.attrs={})},HtmlTag.prototype.setClass=function(s){return this.setAttr(\"class\",s)},HtmlTag.prototype.addClass=function(s){for(var o,i=this.getClass(),a=this.whitespaceRegex,u=i?i.split(a):[],_=s.split(a);o=_.shift();)-1===indexOf(u,o)&&u.push(o);return this.getAttrs().class=u.join(\" \"),this},HtmlTag.prototype.removeClass=function(s){for(var o,i=this.getClass(),a=this.whitespaceRegex,u=i?i.split(a):[],_=s.split(a);u.length&&(o=_.shift());){var w=indexOf(u,o);-1!==w&&u.splice(w,1)}return this.getAttrs().class=u.join(\" \"),this},HtmlTag.prototype.getClass=function(){return this.getAttrs().class||\"\"},HtmlTag.prototype.hasClass=function(s){return-1!==(\" \"+this.getClass()+\" \").indexOf(\" \"+s+\" \")},HtmlTag.prototype.setInnerHTML=function(s){return this.innerHTML=s,this},HtmlTag.prototype.setInnerHtml=function(s){return this.setInnerHTML(s)},HtmlTag.prototype.getInnerHTML=function(){return this.innerHTML||\"\"},HtmlTag.prototype.getInnerHtml=function(){return this.getInnerHTML()},HtmlTag.prototype.toAnchorString=function(){var s=this.getTagName(),o=this.buildAttrsStr();return[\"<\",s,o=o?\" \"+o:\"\",\">\",this.getInnerHtml(),\"</\",s,\">\"].join(\"\")},HtmlTag.prototype.buildAttrsStr=function(){if(!this.attrs)return\"\";var s=this.getAttrs(),o=[];for(var i in s)s.hasOwnProperty(i)&&o.push(i+'=\"'+s[i]+'\"');return o.join(\" \")},HtmlTag}();var KA=function(){function AnchorTagBuilder(s){void 0===s&&(s={}),this.newWindow=!1,this.truncate={},this.className=\"\",this.newWindow=s.newWindow||!1,this.truncate=s.truncate||{},this.className=s.className||\"\"}return AnchorTagBuilder.prototype.build=function(s){return new HA({tagName:\"a\",attrs:this.createAttrs(s),innerHtml:this.processAnchorText(s.getAnchorText())})},AnchorTagBuilder.prototype.createAttrs=function(s){var o={href:s.getAnchorHref()},i=this.createCssClass(s);return i&&(o.class=i),this.newWindow&&(o.target=\"_blank\",o.rel=\"noopener noreferrer\"),this.truncate&&this.truncate.length&&this.truncate.length<s.getAnchorText().length&&(o.title=s.getAnchorHref()),o},AnchorTagBuilder.prototype.createCssClass=function(s){var o=this.className;if(o){for(var i=[o],a=s.getCssClassSuffixes(),u=0,_=a.length;u<_;u++)i.push(o+\"-\"+a[u]);return i.join(\" \")}return\"\"},AnchorTagBuilder.prototype.processAnchorText=function(s){return s=this.doTruncate(s)},AnchorTagBuilder.prototype.doTruncate=function(s){var o=this.truncate;if(!o||!o.length)return s;var i=o.length,a=o.location;return\"smart\"===a?function truncateSmart(s,o,i){var a,u;null==i?(i=\"&hellip;\",u=3,a=8):(u=i.length,a=i.length);var buildUrl=function(s){var o=\"\";return s.scheme&&s.host&&(o+=s.scheme+\"://\"),s.host&&(o+=s.host),s.path&&(o+=\"/\"+s.path),s.query&&(o+=\"?\"+s.query),s.fragment&&(o+=\"#\"+s.fragment),o},buildSegment=function(s,o){var a=o/2,u=Math.ceil(a),_=-1*Math.floor(a),w=\"\";return _<0&&(w=s.substr(_)),s.substr(0,u)+i+w};if(s.length<=o)return s;var _=o-u,w=function(s){var o={},i=s,a=i.match(/^([a-z]+):\\/\\//i);return a&&(o.scheme=a[1],i=i.substr(a[0].length)),(a=i.match(/^(.*?)(?=(\\?|#|\\/|$))/i))&&(o.host=a[1],i=i.substr(a[0].length)),(a=i.match(/^\\/(.*?)(?=(\\?|#|$))/i))&&(o.path=a[1],i=i.substr(a[0].length)),(a=i.match(/^\\?(.*?)(?=(#|$))/i))&&(o.query=a[1],i=i.substr(a[0].length)),(a=i.match(/^#(.*?)$/i))&&(o.fragment=a[1]),o}(s);if(w.query){var x=w.query.match(/^(.*?)(?=(\\?|\\#))(.*?)$/i);x&&(w.query=w.query.substr(0,x[1].length),s=buildUrl(w))}if(s.length<=o)return s;if(w.host&&(w.host=w.host.replace(/^www\\./,\"\"),s=buildUrl(w)),s.length<=o)return s;var C=\"\";if(w.host&&(C+=w.host),C.length>=_)return w.host.length==o?(w.host.substr(0,o-u)+i).substr(0,_+a):buildSegment(C,_).substr(0,_+a);var j=\"\";if(w.path&&(j+=\"/\"+w.path),w.query&&(j+=\"?\"+w.query),j){if((C+j).length>=_)return(C+j).length==o?(C+j).substr(0,o):(C+buildSegment(j,_-C.length)).substr(0,_+a);C+=j}if(w.fragment){var L=\"#\"+w.fragment;if((C+L).length>=_)return(C+L).length==o?(C+L).substr(0,o):(C+buildSegment(L,_-C.length)).substr(0,_+a);C+=L}if(w.scheme&&w.host){var B=w.scheme+\"://\";if((C+B).length<_)return(B+C).substr(0,o)}if(C.length<=o)return C;var $=\"\";return _>0&&($=C.substr(-1*Math.floor(_/2))),(C.substr(0,Math.ceil(_/2))+i+$).substr(0,_+a)}(s,i):\"middle\"===a?function truncateMiddle(s,o,i){if(s.length<=o)return s;var a,u;null==i?(i=\"&hellip;\",a=8,u=3):(a=i.length,u=i.length);var _=o-u,w=\"\";return _>0&&(w=s.substr(-1*Math.floor(_/2))),(s.substr(0,Math.ceil(_/2))+i+w).substr(0,_+a)}(s,i):function truncateEnd(s,o,i){return function ellipsis(s,o,i){var a;return s.length>o&&(null==i?(i=\"&hellip;\",a=3):a=i.length,s=s.substring(0,o-a)+i),s}(s,o,i)}(s,i)},AnchorTagBuilder}(),GA=function(){function Match(s){this.__jsduckDummyDocProp=null,this.matchedText=\"\",this.offset=0,this.tagBuilder=s.tagBuilder,this.matchedText=s.matchedText,this.offset=s.offset}return Match.prototype.getMatchedText=function(){return this.matchedText},Match.prototype.setOffset=function(s){this.offset=s},Match.prototype.getOffset=function(){return this.offset},Match.prototype.getCssClassSuffixes=function(){return[this.getType()]},Match.prototype.buildTag=function(){return this.tagBuilder.build(this)},Match}(),extendStatics=function(s,o){return extendStatics=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(s,o){s.__proto__=o}||function(s,o){for(var i in o)Object.prototype.hasOwnProperty.call(o,i)&&(s[i]=o[i])},extendStatics(s,o)};function tslib_es6_extends(s,o){if(\"function\"!=typeof o&&null!==o)throw new TypeError(\"Class extends value \"+String(o)+\" is not a constructor or null\");function __(){this.constructor=s}extendStatics(s,o),s.prototype=null===o?Object.create(o):(__.prototype=o.prototype,new __)}var __assign=function(){return __assign=Object.assign||function __assign(s){for(var o,i=1,a=arguments.length;i<a;i++)for(var u in o=arguments[i])Object.prototype.hasOwnProperty.call(o,u)&&(s[u]=o[u]);return s},__assign.apply(this,arguments)};Object.create;Object.create;\"function\"==typeof SuppressedError&&SuppressedError;var YA,XA=function(s){function EmailMatch(o){var i=s.call(this,o)||this;return i.email=\"\",i.email=o.email,i}return tslib_es6_extends(EmailMatch,s),EmailMatch.prototype.getType=function(){return\"email\"},EmailMatch.prototype.getEmail=function(){return this.email},EmailMatch.prototype.getAnchorHref=function(){return\"mailto:\"+this.email},EmailMatch.prototype.getAnchorText=function(){return this.email},EmailMatch}(GA),QA=function(s){function HashtagMatch(o){var i=s.call(this,o)||this;return i.serviceName=\"\",i.hashtag=\"\",i.serviceName=o.serviceName,i.hashtag=o.hashtag,i}return tslib_es6_extends(HashtagMatch,s),HashtagMatch.prototype.getType=function(){return\"hashtag\"},HashtagMatch.prototype.getServiceName=function(){return this.serviceName},HashtagMatch.prototype.getHashtag=function(){return this.hashtag},HashtagMatch.prototype.getAnchorHref=function(){var s=this.serviceName,o=this.hashtag;switch(s){case\"twitter\":return\"https://twitter.com/hashtag/\"+o;case\"facebook\":return\"https://www.facebook.com/hashtag/\"+o;case\"instagram\":return\"https://instagram.com/explore/tags/\"+o;case\"tiktok\":return\"https://www.tiktok.com/tag/\"+o;default:throw new Error(\"Unknown service name to point hashtag to: \"+s)}},HashtagMatch.prototype.getAnchorText=function(){return\"#\"+this.hashtag},HashtagMatch}(GA),ZA=function(s){function MentionMatch(o){var i=s.call(this,o)||this;return i.serviceName=\"twitter\",i.mention=\"\",i.mention=o.mention,i.serviceName=o.serviceName,i}return tslib_es6_extends(MentionMatch,s),MentionMatch.prototype.getType=function(){return\"mention\"},MentionMatch.prototype.getMention=function(){return this.mention},MentionMatch.prototype.getServiceName=function(){return this.serviceName},MentionMatch.prototype.getAnchorHref=function(){switch(this.serviceName){case\"twitter\":return\"https://twitter.com/\"+this.mention;case\"instagram\":return\"https://instagram.com/\"+this.mention;case\"soundcloud\":return\"https://soundcloud.com/\"+this.mention;case\"tiktok\":return\"https://www.tiktok.com/@\"+this.mention;default:throw new Error(\"Unknown service name to point mention to: \"+this.serviceName)}},MentionMatch.prototype.getAnchorText=function(){return\"@\"+this.mention},MentionMatch.prototype.getCssClassSuffixes=function(){var o=s.prototype.getCssClassSuffixes.call(this),i=this.getServiceName();return i&&o.push(i),o},MentionMatch}(GA),eC=function(s){function PhoneMatch(o){var i=s.call(this,o)||this;return i.number=\"\",i.plusSign=!1,i.number=o.number,i.plusSign=o.plusSign,i}return tslib_es6_extends(PhoneMatch,s),PhoneMatch.prototype.getType=function(){return\"phone\"},PhoneMatch.prototype.getPhoneNumber=function(){return this.number},PhoneMatch.prototype.getNumber=function(){return this.getPhoneNumber()},PhoneMatch.prototype.getAnchorHref=function(){return\"tel:\"+(this.plusSign?\"+\":\"\")+this.number},PhoneMatch.prototype.getAnchorText=function(){return this.matchedText},PhoneMatch}(GA),tC=function(s){function UrlMatch(o){var i=s.call(this,o)||this;return i.url=\"\",i.urlMatchType=\"scheme\",i.protocolUrlMatch=!1,i.protocolRelativeMatch=!1,i.stripPrefix={scheme:!0,www:!0},i.stripTrailingSlash=!0,i.decodePercentEncoding=!0,i.schemePrefixRegex=/^(https?:\\/\\/)?/i,i.wwwPrefixRegex=/^(https?:\\/\\/)?(www\\.)?/i,i.protocolRelativeRegex=/^\\/\\//,i.protocolPrepended=!1,i.urlMatchType=o.urlMatchType,i.url=o.url,i.protocolUrlMatch=o.protocolUrlMatch,i.protocolRelativeMatch=o.protocolRelativeMatch,i.stripPrefix=o.stripPrefix,i.stripTrailingSlash=o.stripTrailingSlash,i.decodePercentEncoding=o.decodePercentEncoding,i}return tslib_es6_extends(UrlMatch,s),UrlMatch.prototype.getType=function(){return\"url\"},UrlMatch.prototype.getUrlMatchType=function(){return this.urlMatchType},UrlMatch.prototype.getUrl=function(){var s=this.url;return this.protocolRelativeMatch||this.protocolUrlMatch||this.protocolPrepended||(s=this.url=\"http://\"+s,this.protocolPrepended=!0),s},UrlMatch.prototype.getAnchorHref=function(){return this.getUrl().replace(/&amp;/g,\"&\")},UrlMatch.prototype.getAnchorText=function(){var s=this.getMatchedText();return this.protocolRelativeMatch&&(s=this.stripProtocolRelativePrefix(s)),this.stripPrefix.scheme&&(s=this.stripSchemePrefix(s)),this.stripPrefix.www&&(s=this.stripWwwPrefix(s)),this.stripTrailingSlash&&(s=this.removeTrailingSlash(s)),this.decodePercentEncoding&&(s=this.removePercentEncoding(s)),s},UrlMatch.prototype.stripSchemePrefix=function(s){return s.replace(this.schemePrefixRegex,\"\")},UrlMatch.prototype.stripWwwPrefix=function(s){return s.replace(this.wwwPrefixRegex,\"$1\")},UrlMatch.prototype.stripProtocolRelativePrefix=function(s){return s.replace(this.protocolRelativeRegex,\"\")},UrlMatch.prototype.removeTrailingSlash=function(s){return\"/\"===s.charAt(s.length-1)&&(s=s.slice(0,-1)),s},UrlMatch.prototype.removePercentEncoding=function(s){var o=s.replace(/%22/gi,\"&quot;\").replace(/%26/gi,\"&amp;\").replace(/%27/gi,\"&#39;\").replace(/%3C/gi,\"&lt;\").replace(/%3E/gi,\"&gt;\");try{return decodeURIComponent(o)}catch(s){return o}},UrlMatch}(GA),rC=function rC(s){this.__jsduckDummyDocProp=null,this.tagBuilder=s.tagBuilder},nC=/[A-Za-z]/,sC=/[\\d]/,oC=/[\\D]/,iC=/\\s/,aC=/['\"]/,cC=/[\\x00-\\x1F\\x7F]/,lC=/A-Za-z\\xAA\\xB5\\xBA\\xC0-\\xD6\\xD8-\\xF6\\xF8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE\\u0370-\\u0374\\u0376\\u0377\\u037A-\\u037D\\u037F\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03F5\\u03F7-\\u0481\\u048A-\\u052F\\u0531-\\u0556\\u0559\\u0561-\\u0587\\u05D0-\\u05EA\\u05F0-\\u05F2\\u0620-\\u064A\\u066E\\u066F\\u0671-\\u06D3\\u06D5\\u06E5\\u06E6\\u06EE\\u06EF\\u06FA-\\u06FC\\u06FF\\u0710\\u0712-\\u072F\\u074D-\\u07A5\\u07B1\\u07CA-\\u07EA\\u07F4\\u07F5\\u07FA\\u0800-\\u0815\\u081A\\u0824\\u0828\\u0840-\\u0858\\u08A0-\\u08B4\\u08B6-\\u08BD\\u0904-\\u0939\\u093D\\u0950\\u0958-\\u0961\\u0971-\\u0980\\u0985-\\u098C\\u098F\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09BD\\u09CE\\u09DC\\u09DD\\u09DF-\\u09E1\\u09F0\\u09F1\\u0A05-\\u0A0A\\u0A0F\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32\\u0A33\\u0A35\\u0A36\\u0A38\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A72-\\u0A74\\u0A85-\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AD0\\u0AE0\\u0AE1\\u0AF9\\u0B05-\\u0B0C\\u0B0F\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32\\u0B33\\u0B35-\\u0B39\\u0B3D\\u0B5C\\u0B5D\\u0B5F-\\u0B61\\u0B71\\u0B83\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99\\u0B9A\\u0B9C\\u0B9E\\u0B9F\\u0BA3\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB9\\u0BD0\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C39\\u0C3D\\u0C58-\\u0C5A\\u0C60\\u0C61\\u0C80\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CBD\\u0CDE\\u0CE0\\u0CE1\\u0CF1\\u0CF2\\u0D05-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D3A\\u0D3D\\u0D4E\\u0D54-\\u0D56\\u0D5F-\\u0D61\\u0D7A-\\u0D7F\\u0D85-\\u0D96\\u0D9A-\\u0DB1\\u0DB3-\\u0DBB\\u0DBD\\u0DC0-\\u0DC6\\u0E01-\\u0E30\\u0E32\\u0E33\\u0E40-\\u0E46\\u0E81\\u0E82\\u0E84\\u0E87\\u0E88\\u0E8A\\u0E8D\\u0E94-\\u0E97\\u0E99-\\u0E9F\\u0EA1-\\u0EA3\\u0EA5\\u0EA7\\u0EAA\\u0EAB\\u0EAD-\\u0EB0\\u0EB2\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0EC6\\u0EDC-\\u0EDF\\u0F00\\u0F40-\\u0F47\\u0F49-\\u0F6C\\u0F88-\\u0F8C\\u1000-\\u102A\\u103F\\u1050-\\u1055\\u105A-\\u105D\\u1061\\u1065\\u1066\\u106E-\\u1070\\u1075-\\u1081\\u108E\\u10A0-\\u10C5\\u10C7\\u10CD\\u10D0-\\u10FA\\u10FC-\\u1248\\u124A-\\u124D\\u1250-\\u1256\\u1258\\u125A-\\u125D\\u1260-\\u1288\\u128A-\\u128D\\u1290-\\u12B0\\u12B2-\\u12B5\\u12B8-\\u12BE\\u12C0\\u12C2-\\u12C5\\u12C8-\\u12D6\\u12D8-\\u1310\\u1312-\\u1315\\u1318-\\u135A\\u1380-\\u138F\\u13A0-\\u13F5\\u13F8-\\u13FD\\u1401-\\u166C\\u166F-\\u167F\\u1681-\\u169A\\u16A0-\\u16EA\\u16F1-\\u16F8\\u1700-\\u170C\\u170E-\\u1711\\u1720-\\u1731\\u1740-\\u1751\\u1760-\\u176C\\u176E-\\u1770\\u1780-\\u17B3\\u17D7\\u17DC\\u1820-\\u1877\\u1880-\\u1884\\u1887-\\u18A8\\u18AA\\u18B0-\\u18F5\\u1900-\\u191E\\u1950-\\u196D\\u1970-\\u1974\\u1980-\\u19AB\\u19B0-\\u19C9\\u1A00-\\u1A16\\u1A20-\\u1A54\\u1AA7\\u1B05-\\u1B33\\u1B45-\\u1B4B\\u1B83-\\u1BA0\\u1BAE\\u1BAF\\u1BBA-\\u1BE5\\u1C00-\\u1C23\\u1C4D-\\u1C4F\\u1C5A-\\u1C7D\\u1C80-\\u1C88\\u1CE9-\\u1CEC\\u1CEE-\\u1CF1\\u1CF5\\u1CF6\\u1D00-\\u1DBF\\u1E00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2071\\u207F\\u2090-\\u209C\\u2102\\u2107\\u210A-\\u2113\\u2115\\u2119-\\u211D\\u2124\\u2126\\u2128\\u212A-\\u212D\\u212F-\\u2139\\u213C-\\u213F\\u2145-\\u2149\\u214E\\u2183\\u2184\\u2C00-\\u2C2E\\u2C30-\\u2C5E\\u2C60-\\u2CE4\\u2CEB-\\u2CEE\\u2CF2\\u2CF3\\u2D00-\\u2D25\\u2D27\\u2D2D\\u2D30-\\u2D67\\u2D6F\\u2D80-\\u2D96\\u2DA0-\\u2DA6\\u2DA8-\\u2DAE\\u2DB0-\\u2DB6\\u2DB8-\\u2DBE\\u2DC0-\\u2DC6\\u2DC8-\\u2DCE\\u2DD0-\\u2DD6\\u2DD8-\\u2DDE\\u2E2F\\u3005\\u3006\\u3031-\\u3035\\u303B\\u303C\\u3041-\\u3096\\u309D-\\u309F\\u30A1-\\u30FA\\u30FC-\\u30FF\\u3105-\\u312D\\u3131-\\u318E\\u31A0-\\u31BA\\u31F0-\\u31FF\\u3400-\\u4DB5\\u4E00-\\u9FD5\\uA000-\\uA48C\\uA4D0-\\uA4FD\\uA500-\\uA60C\\uA610-\\uA61F\\uA62A\\uA62B\\uA640-\\uA66E\\uA67F-\\uA69D\\uA6A0-\\uA6E5\\uA717-\\uA71F\\uA722-\\uA788\\uA78B-\\uA7AE\\uA7B0-\\uA7B7\\uA7F7-\\uA801\\uA803-\\uA805\\uA807-\\uA80A\\uA80C-\\uA822\\uA840-\\uA873\\uA882-\\uA8B3\\uA8F2-\\uA8F7\\uA8FB\\uA8FD\\uA90A-\\uA925\\uA930-\\uA946\\uA960-\\uA97C\\uA984-\\uA9B2\\uA9CF\\uA9E0-\\uA9E4\\uA9E6-\\uA9EF\\uA9FA-\\uA9FE\\uAA00-\\uAA28\\uAA40-\\uAA42\\uAA44-\\uAA4B\\uAA60-\\uAA76\\uAA7A\\uAA7E-\\uAAAF\\uAAB1\\uAAB5\\uAAB6\\uAAB9-\\uAABD\\uAAC0\\uAAC2\\uAADB-\\uAADD\\uAAE0-\\uAAEA\\uAAF2-\\uAAF4\\uAB01-\\uAB06\\uAB09-\\uAB0E\\uAB11-\\uAB16\\uAB20-\\uAB26\\uAB28-\\uAB2E\\uAB30-\\uAB5A\\uAB5C-\\uAB65\\uAB70-\\uABE2\\uAC00-\\uD7A3\\uD7B0-\\uD7C6\\uD7CB-\\uD7FB\\uF900-\\uFA6D\\uFA70-\\uFAD9\\uFB00-\\uFB06\\uFB13-\\uFB17\\uFB1D\\uFB1F-\\uFB28\\uFB2A-\\uFB36\\uFB38-\\uFB3C\\uFB3E\\uFB40\\uFB41\\uFB43\\uFB44\\uFB46-\\uFBB1\\uFBD3-\\uFD3D\\uFD50-\\uFD8F\\uFD92-\\uFDC7\\uFDF0-\\uFDFB\\uFE70-\\uFE74\\uFE76-\\uFEFC\\uFF21-\\uFF3A\\uFF41-\\uFF5A\\uFF66-\\uFFBE\\uFFC2-\\uFFC7\\uFFCA-\\uFFCF\\uFFD2-\\uFFD7\\uFFDA-\\uFFDC/.source,uC=lC+/\\u2700-\\u27bf\\udde6-\\uddff\\ud800-\\udbff\\udc00-\\udfff\\ufe0e\\ufe0f\\u0300-\\u036f\\ufe20-\\ufe23\\u20d0-\\u20f0\\ud83c\\udffb-\\udfff\\u200d\\u3299\\u3297\\u303d\\u3030\\u24c2\\ud83c\\udd70-\\udd71\\udd7e-\\udd7f\\udd8e\\udd91-\\udd9a\\udde6-\\uddff\\ude01-\\ude02\\ude1a\\ude2f\\ude32-\\ude3a\\ude50-\\ude51\\u203c\\u2049\\u25aa-\\u25ab\\u25b6\\u25c0\\u25fb-\\u25fe\\u00a9\\u00ae\\u2122\\u2139\\udc04\\u2600-\\u26FF\\u2b05\\u2b06\\u2b07\\u2b1b\\u2b1c\\u2b50\\u2b55\\u231a\\u231b\\u2328\\u23cf\\u23e9-\\u23f3\\u23f8-\\u23fa\\udccf\\u2935\\u2934\\u2190-\\u21ff/.source+/\\u0300-\\u036F\\u0483-\\u0489\\u0591-\\u05BD\\u05BF\\u05C1\\u05C2\\u05C4\\u05C5\\u05C7\\u0610-\\u061A\\u064B-\\u065F\\u0670\\u06D6-\\u06DC\\u06DF-\\u06E4\\u06E7\\u06E8\\u06EA-\\u06ED\\u0711\\u0730-\\u074A\\u07A6-\\u07B0\\u07EB-\\u07F3\\u0816-\\u0819\\u081B-\\u0823\\u0825-\\u0827\\u0829-\\u082D\\u0859-\\u085B\\u08D4-\\u08E1\\u08E3-\\u0903\\u093A-\\u093C\\u093E-\\u094F\\u0951-\\u0957\\u0962\\u0963\\u0981-\\u0983\\u09BC\\u09BE-\\u09C4\\u09C7\\u09C8\\u09CB-\\u09CD\\u09D7\\u09E2\\u09E3\\u0A01-\\u0A03\\u0A3C\\u0A3E-\\u0A42\\u0A47\\u0A48\\u0A4B-\\u0A4D\\u0A51\\u0A70\\u0A71\\u0A75\\u0A81-\\u0A83\\u0ABC\\u0ABE-\\u0AC5\\u0AC7-\\u0AC9\\u0ACB-\\u0ACD\\u0AE2\\u0AE3\\u0B01-\\u0B03\\u0B3C\\u0B3E-\\u0B44\\u0B47\\u0B48\\u0B4B-\\u0B4D\\u0B56\\u0B57\\u0B62\\u0B63\\u0B82\\u0BBE-\\u0BC2\\u0BC6-\\u0BC8\\u0BCA-\\u0BCD\\u0BD7\\u0C00-\\u0C03\\u0C3E-\\u0C44\\u0C46-\\u0C48\\u0C4A-\\u0C4D\\u0C55\\u0C56\\u0C62\\u0C63\\u0C81-\\u0C83\\u0CBC\\u0CBE-\\u0CC4\\u0CC6-\\u0CC8\\u0CCA-\\u0CCD\\u0CD5\\u0CD6\\u0CE2\\u0CE3\\u0D01-\\u0D03\\u0D3E-\\u0D44\\u0D46-\\u0D48\\u0D4A-\\u0D4D\\u0D57\\u0D62\\u0D63\\u0D82\\u0D83\\u0DCA\\u0DCF-\\u0DD4\\u0DD6\\u0DD8-\\u0DDF\\u0DF2\\u0DF3\\u0E31\\u0E34-\\u0E3A\\u0E47-\\u0E4E\\u0EB1\\u0EB4-\\u0EB9\\u0EBB\\u0EBC\\u0EC8-\\u0ECD\\u0F18\\u0F19\\u0F35\\u0F37\\u0F39\\u0F3E\\u0F3F\\u0F71-\\u0F84\\u0F86\\u0F87\\u0F8D-\\u0F97\\u0F99-\\u0FBC\\u0FC6\\u102B-\\u103E\\u1056-\\u1059\\u105E-\\u1060\\u1062-\\u1064\\u1067-\\u106D\\u1071-\\u1074\\u1082-\\u108D\\u108F\\u109A-\\u109D\\u135D-\\u135F\\u1712-\\u1714\\u1732-\\u1734\\u1752\\u1753\\u1772\\u1773\\u17B4-\\u17D3\\u17DD\\u180B-\\u180D\\u1885\\u1886\\u18A9\\u1920-\\u192B\\u1930-\\u193B\\u1A17-\\u1A1B\\u1A55-\\u1A5E\\u1A60-\\u1A7C\\u1A7F\\u1AB0-\\u1ABE\\u1B00-\\u1B04\\u1B34-\\u1B44\\u1B6B-\\u1B73\\u1B80-\\u1B82\\u1BA1-\\u1BAD\\u1BE6-\\u1BF3\\u1C24-\\u1C37\\u1CD0-\\u1CD2\\u1CD4-\\u1CE8\\u1CED\\u1CF2-\\u1CF4\\u1CF8\\u1CF9\\u1DC0-\\u1DF5\\u1DFB-\\u1DFF\\u20D0-\\u20F0\\u2CEF-\\u2CF1\\u2D7F\\u2DE0-\\u2DFF\\u302A-\\u302F\\u3099\\u309A\\uA66F-\\uA672\\uA674-\\uA67D\\uA69E\\uA69F\\uA6F0\\uA6F1\\uA802\\uA806\\uA80B\\uA823-\\uA827\\uA880\\uA881\\uA8B4-\\uA8C5\\uA8E0-\\uA8F1\\uA926-\\uA92D\\uA947-\\uA953\\uA980-\\uA983\\uA9B3-\\uA9C0\\uA9E5\\uAA29-\\uAA36\\uAA43\\uAA4C\\uAA4D\\uAA7B-\\uAA7D\\uAAB0\\uAAB2-\\uAAB4\\uAAB7\\uAAB8\\uAABE\\uAABF\\uAAC1\\uAAEB-\\uAAEF\\uAAF5\\uAAF6\\uABE3-\\uABEA\\uABEC\\uABED\\uFB1E\\uFE00-\\uFE0F\\uFE20-\\uFE2F/.source,pC=/0-9\\u0660-\\u0669\\u06F0-\\u06F9\\u07C0-\\u07C9\\u0966-\\u096F\\u09E6-\\u09EF\\u0A66-\\u0A6F\\u0AE6-\\u0AEF\\u0B66-\\u0B6F\\u0BE6-\\u0BEF\\u0C66-\\u0C6F\\u0CE6-\\u0CEF\\u0D66-\\u0D6F\\u0DE6-\\u0DEF\\u0E50-\\u0E59\\u0ED0-\\u0ED9\\u0F20-\\u0F29\\u1040-\\u1049\\u1090-\\u1099\\u17E0-\\u17E9\\u1810-\\u1819\\u1946-\\u194F\\u19D0-\\u19D9\\u1A80-\\u1A89\\u1A90-\\u1A99\\u1B50-\\u1B59\\u1BB0-\\u1BB9\\u1C40-\\u1C49\\u1C50-\\u1C59\\uA620-\\uA629\\uA8D0-\\uA8D9\\uA900-\\uA909\\uA9D0-\\uA9D9\\uA9F0-\\uA9F9\\uAA50-\\uAA59\\uABF0-\\uABF9\\uFF10-\\uFF19/.source,hC=uC+pC,dC=uC+pC,fC=new RegExp(\"[\".concat(dC,\"]\")),mC=\"(?:[\"+pC+\"]{1,3}\\\\.){3}[\"+pC+\"]{1,3}\",gC=\"[\"+dC+\"](?:[\"+dC+\"\\\\-_]{0,61}[\"+dC+\"])?\",getDomainLabelStr=function(s){return\"(?=(\"+gC+\"))\\\\\"+s},getDomainNameStr=function(s){return\"(?:\"+getDomainLabelStr(s)+\"(?:\\\\.\"+getDomainLabelStr(s+1)+\"){0,126}|\"+mC+\")\"},yC=(new RegExp(\"[\"+dC+\".\\\\-]*[\"+dC+\"\\\\-]\"),fC),vC=/(?:xn--vermgensberatung-pwb|xn--vermgensberater-ctb|xn--clchc0ea0b2g2a9gcd|xn--w4r85el8fhu5dnra|northwesternmutual|travelersinsurance|vermögensberatung|xn--5su34j936bgsg|xn--bck1b9a5dre4c|xn--mgbah1a3hjkrd|xn--mgbai9azgqp6j|xn--mgberp4a5d4ar|xn--xkc2dl3a5ee0h|vermögensberater|xn--fzys8d69uvgm|xn--mgba7c0bbn0a|xn--mgbcpq6gpa1a|xn--xkc2al3hye2a|americanexpress|kerryproperties|sandvikcoromant|xn--i1b6b1a6a2e|xn--kcrx77d1x4a|xn--lgbbat1ad8j|xn--mgba3a4f16a|xn--mgbaakc7dvf|xn--mgbc0a9azcg|xn--nqv7fs00ema|americanfamily|bananarepublic|cancerresearch|cookingchannel|kerrylogistics|weatherchannel|xn--54b7fta0cc|xn--6qq986b3xl|xn--80aqecdr1a|xn--b4w605ferd|xn--fiq228c5hs|xn--h2breg3eve|xn--jlq480n2rg|xn--jlq61u9w7b|xn--mgba3a3ejt|xn--mgbaam7a8h|xn--mgbayh7gpa|xn--mgbbh1a71e|xn--mgbca7dzdo|xn--mgbi4ecexp|xn--mgbx4cd0ab|xn--rvc1e0am3e|international|lifeinsurance|travelchannel|wolterskluwer|xn--cckwcxetd|xn--eckvdtc9d|xn--fpcrj9c3d|xn--fzc2c9e2c|xn--h2brj9c8c|xn--tiq49xqyj|xn--yfro4i67o|xn--ygbi2ammx|construction|lplfinancial|scholarships|versicherung|xn--3e0b707e|xn--45br5cyl|xn--4dbrk0ce|xn--80adxhks|xn--80asehdb|xn--8y0a063a|xn--gckr3f0f|xn--mgb9awbf|xn--mgbab2bd|xn--mgbgu82a|xn--mgbpl2fh|xn--mgbt3dhd|xn--mk1bu44c|xn--ngbc5azd|xn--ngbe9e0a|xn--ogbpf8fl|xn--qcka1pmc|accountants|barclaycard|blackfriday|blockbuster|bridgestone|calvinklein|contractors|creditunion|engineering|enterprises|foodnetwork|investments|kerryhotels|lamborghini|motorcycles|olayangroup|photography|playstation|productions|progressive|redumbrella|williamhill|xn--11b4c3d|xn--1ck2e1b|xn--1qqw23a|xn--2scrj9c|xn--3bst00m|xn--3ds443g|xn--3hcrj9c|xn--42c2d9a|xn--45brj9c|xn--55qw42g|xn--6frz82g|xn--80ao21a|xn--9krt00a|xn--cck2b3b|xn--czr694b|xn--d1acj3b|xn--efvy88h|xn--fct429k|xn--fjq720a|xn--flw351e|xn--g2xx48c|xn--gecrj9c|xn--gk3at1e|xn--h2brj9c|xn--hxt814e|xn--imr513n|xn--j6w193g|xn--jvr189m|xn--kprw13d|xn--kpry57d|xn--mgbbh1a|xn--mgbtx2b|xn--mix891f|xn--nyqy26a|xn--otu796d|xn--pgbs0dh|xn--q9jyb4c|xn--rhqv96g|xn--rovu88b|xn--s9brj9c|xn--ses554g|xn--t60b56a|xn--vuq861b|xn--w4rs40l|xn--xhq521b|xn--zfr164b|சிங்கப்பூர்|accountant|apartments|associates|basketball|bnpparibas|boehringer|capitalone|consulting|creditcard|cuisinella|eurovision|extraspace|foundation|healthcare|immobilien|industries|management|mitsubishi|nextdirect|properties|protection|prudential|realestate|republican|restaurant|schaeffler|tatamotors|technology|university|vlaanderen|volkswagen|xn--30rr7y|xn--3pxu8k|xn--45q11c|xn--4gbrim|xn--55qx5d|xn--5tzm5g|xn--80aswg|xn--90a3ac|xn--9dbq2a|xn--9et52u|xn--c2br7g|xn--cg4bki|xn--czrs0t|xn--czru2d|xn--fiq64b|xn--fiqs8s|xn--fiqz9s|xn--io0a7i|xn--kput3i|xn--mxtq1m|xn--o3cw4h|xn--pssy2u|xn--q7ce6a|xn--unup4y|xn--wgbh1c|xn--wgbl6a|xn--y9a3aq|accenture|alfaromeo|allfinanz|amsterdam|analytics|aquarelle|barcelona|bloomberg|christmas|community|directory|education|equipment|fairwinds|financial|firestone|fresenius|frontdoor|furniture|goldpoint|hisamitsu|homedepot|homegoods|homesense|institute|insurance|kuokgroup|lancaster|landrover|lifestyle|marketing|marshalls|melbourne|microsoft|panasonic|passagens|pramerica|richardli|shangrila|solutions|statebank|statefarm|stockholm|travelers|vacations|xn--90ais|xn--c1avg|xn--d1alf|xn--e1a4c|xn--fhbei|xn--j1aef|xn--j1amh|xn--l1acc|xn--ngbrx|xn--nqv7f|xn--p1acf|xn--qxa6a|xn--tckwe|xn--vhquv|yodobashi|موريتانيا|abudhabi|airforce|allstate|attorney|barclays|barefoot|bargains|baseball|boutique|bradesco|broadway|brussels|builders|business|capetown|catering|catholic|cipriani|cityeats|cleaning|clinique|clothing|commbank|computer|delivery|deloitte|democrat|diamonds|discount|discover|download|engineer|ericsson|etisalat|exchange|feedback|fidelity|firmdale|football|frontier|goodyear|grainger|graphics|guardian|hdfcbank|helsinki|holdings|hospital|infiniti|ipiranga|istanbul|jpmorgan|lighting|lundbeck|marriott|maserati|mckinsey|memorial|merckmsd|mortgage|observer|partners|pharmacy|pictures|plumbing|property|redstone|reliance|saarland|samsclub|security|services|shopping|showtime|softbank|software|stcgroup|supplies|training|vanguard|ventures|verisign|woodside|xn--90ae|xn--node|xn--p1ai|xn--qxam|yokohama|السعودية|abogado|academy|agakhan|alibaba|android|athleta|auction|audible|auspost|avianca|banamex|bauhaus|bentley|bestbuy|booking|brother|bugatti|capital|caravan|careers|channel|charity|chintai|citadel|clubmed|college|cologne|comcast|company|compare|contact|cooking|corsica|country|coupons|courses|cricket|cruises|dentist|digital|domains|exposed|express|farmers|fashion|ferrari|ferrero|finance|fishing|fitness|flights|florist|flowers|forsale|frogans|fujitsu|gallery|genting|godaddy|grocery|guitars|hamburg|hangout|hitachi|holiday|hosting|hoteles|hotmail|hyundai|ismaili|jewelry|juniper|kitchen|komatsu|lacaixa|lanxess|lasalle|latrobe|leclerc|limited|lincoln|markets|monster|netbank|netflix|network|neustar|okinawa|oldnavy|organic|origins|philips|pioneer|politie|realtor|recipes|rentals|reviews|rexroth|samsung|sandvik|schmidt|schwarz|science|shiksha|singles|staples|storage|support|surgery|systems|temasek|theater|theatre|tickets|tiffany|toshiba|trading|walmart|wanggou|watches|weather|website|wedding|whoswho|windows|winners|xfinity|yamaxun|youtube|zuerich|католик|اتصالات|البحرين|الجزائر|العليان|پاکستان|كاثوليك|இந்தியா|abarth|abbott|abbvie|africa|agency|airbus|airtel|alipay|alsace|alstom|amazon|anquan|aramco|author|bayern|beauty|berlin|bharti|bostik|boston|broker|camera|career|casino|center|chanel|chrome|church|circle|claims|clinic|coffee|comsec|condos|coupon|credit|cruise|dating|datsun|dealer|degree|dental|design|direct|doctor|dunlop|dupont|durban|emerck|energy|estate|events|expert|family|flickr|futbol|gallup|garden|george|giving|global|google|gratis|health|hermes|hiphop|hockey|hotels|hughes|imamat|insure|intuit|jaguar|joburg|juegos|kaufen|kinder|kindle|kosher|lancia|latino|lawyer|lefrak|living|locker|london|luxury|madrid|maison|makeup|market|mattel|mobile|monash|mormon|moscow|museum|mutual|nagoya|natura|nissan|nissay|norton|nowruz|office|olayan|online|oracle|orange|otsuka|pfizer|photos|physio|pictet|quebec|racing|realty|reisen|repair|report|review|rocher|rogers|ryukyu|safety|sakura|sanofi|school|schule|search|secure|select|shouji|soccer|social|stream|studio|supply|suzuki|swatch|sydney|taipei|taobao|target|tattoo|tennis|tienda|tjmaxx|tkmaxx|toyota|travel|unicom|viajes|viking|villas|virgin|vision|voting|voyage|vuelos|walter|webcam|xihuan|yachts|yandex|zappos|москва|онлайн|ابوظبي|ارامكو|الاردن|المغرب|امارات|فلسطين|مليسيا|भारतम्|இலங்கை|ファッション|actor|adult|aetna|amfam|amica|apple|archi|audio|autos|azure|baidu|beats|bible|bingo|black|boats|bosch|build|canon|cards|chase|cheap|cisco|citic|click|cloud|coach|codes|crown|cymru|dabur|dance|deals|delta|drive|dubai|earth|edeka|email|epson|faith|fedex|final|forex|forum|gallo|games|gifts|gives|glass|globo|gmail|green|gripe|group|gucci|guide|homes|honda|horse|house|hyatt|ikano|irish|jetzt|koeln|kyoto|lamer|lease|legal|lexus|lilly|linde|lipsy|loans|locus|lotte|lotto|macys|mango|media|miami|money|movie|music|nexus|nikon|ninja|nokia|nowtv|omega|osaka|paris|parts|party|phone|photo|pizza|place|poker|praxi|press|prime|promo|quest|radio|rehab|reise|ricoh|rocks|rodeo|rugby|salon|sener|seven|sharp|shell|shoes|skype|sling|smart|smile|solar|space|sport|stada|store|study|style|sucks|swiss|tatar|tires|tirol|tmall|today|tokyo|tools|toray|total|tours|trade|trust|tunes|tushu|ubank|vegas|video|vodka|volvo|wales|watch|weber|weibo|works|world|xerox|yahoo|ישראל|ایران|بازار|بھارت|سودان|سورية|همراه|भारोत|संगठन|বাংলা|భారత్|ഭാരതം|嘉里大酒店|aarp|able|adac|aero|akdn|ally|amex|arab|army|arpa|arte|asda|asia|audi|auto|baby|band|bank|bbva|beer|best|bike|bing|blog|blue|bofa|bond|book|buzz|cafe|call|camp|care|cars|casa|case|cash|cbre|cern|chat|citi|city|club|cool|coop|cyou|data|date|dclk|deal|dell|desi|diet|dish|docs|dvag|erni|fage|fail|fans|farm|fast|fiat|fido|film|fire|fish|flir|food|ford|free|fund|game|gbiz|gent|ggee|gift|gmbh|gold|golf|goog|guge|guru|hair|haus|hdfc|help|here|hgtv|host|hsbc|icbc|ieee|imdb|immo|info|itau|java|jeep|jobs|jprs|kddi|kids|kiwi|kpmg|kred|land|lego|lgbt|lidl|life|like|limo|link|live|loan|loft|love|ltda|luxe|maif|meet|meme|menu|mini|mint|mobi|moda|moto|name|navy|news|next|nico|nike|ollo|open|page|pars|pccw|pics|ping|pink|play|plus|pohl|porn|post|prod|prof|qpon|read|reit|rent|rest|rich|room|rsvp|ruhr|safe|sale|sarl|save|saxo|scot|seat|seek|sexy|shaw|shia|shop|show|silk|sina|site|skin|sncf|sohu|song|sony|spot|star|surf|talk|taxi|team|tech|teva|tiaa|tips|town|toys|tube|vana|visa|viva|vivo|vote|voto|wang|weir|wien|wiki|wine|work|xbox|yoga|zara|zero|zone|дети|сайт|بارت|بيتك|ڀارت|تونس|شبكة|عراق|عمان|موقع|भारत|ভারত|ভাৰত|ਭਾਰਤ|ભારત|ଭାରତ|ಭಾರತ|ලංකා|アマゾン|グーグル|クラウド|ポイント|组织机构|電訊盈科|香格里拉|aaa|abb|abc|aco|ads|aeg|afl|aig|anz|aol|app|art|aws|axa|bar|bbc|bbt|bcg|bcn|bet|bid|bio|biz|bms|bmw|bom|boo|bot|box|buy|bzh|cab|cal|cam|car|cat|cba|cbn|cbs|ceo|cfa|cfd|com|cpa|crs|dad|day|dds|dev|dhl|diy|dnp|dog|dot|dtv|dvr|eat|eco|edu|esq|eus|fan|fit|fly|foo|fox|frl|ftr|fun|fyi|gal|gap|gay|gdn|gea|gle|gmo|gmx|goo|gop|got|gov|hbo|hiv|hkt|hot|how|ibm|ice|icu|ifm|inc|ing|ink|int|ist|itv|jcb|jio|jll|jmp|jnj|jot|joy|kfh|kia|kim|kpn|krd|lat|law|lds|llc|llp|lol|lpl|ltd|man|map|mba|med|men|mil|mit|mlb|mls|mma|moe|moi|mom|mov|msd|mtn|mtr|nab|nba|nec|net|new|nfl|ngo|nhk|now|nra|nrw|ntt|nyc|obi|one|ong|onl|ooo|org|ott|ovh|pay|pet|phd|pid|pin|pnc|pro|pru|pub|pwc|red|ren|ril|rio|rip|run|rwe|sap|sas|sbi|sbs|sca|scb|ses|sew|sex|sfr|ski|sky|soy|spa|srl|stc|tab|tax|tci|tdk|tel|thd|tjx|top|trv|tui|tvs|ubs|uno|uol|ups|vet|vig|vin|vip|wed|win|wme|wow|wtc|wtf|xin|xxx|xyz|you|yun|zip|бел|ком|қаз|мкд|мон|орг|рус|срб|укр|հայ|קום|عرب|قطر|كوم|مصر|कॉम|नेट|คอม|ไทย|ລາວ|ストア|セール|みんな|中文网|亚马逊|天主教|我爱你|新加坡|淡马锡|诺基亚|飞利浦|ac|ad|ae|af|ag|ai|al|am|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cu|cv|cw|cx|cy|cz|de|dj|dk|dm|do|dz|ec|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|ss|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|za|zm|zw|ελ|ευ|бг|ею|рф|გე|닷넷|닷컴|삼성|한국|コム|世界|中信|中国|中國|企业|佛山|信息|健康|八卦|公司|公益|台湾|台灣|商城|商店|商标|嘉里|在线|大拿|娱乐|家電|广东|微博|慈善|手机|招聘|政务|政府|新闻|时尚|書籍|机构|游戏|澳門|点看|移动|网址|网店|网站|网络|联通|谷歌|购物|通販|集团|食品|餐厅|香港)/,bC=new RegExp(\"[\".concat(dC,\"!#$%&'*+/=?^_`{|}~-]\")),_C=new RegExp(\"^\".concat(vC.source,\"$\")),SC=function(s){function EmailMatcher(){var o=null!==s&&s.apply(this,arguments)||this;return o.localPartCharRegex=bC,o.strictTldRegex=_C,o}return tslib_es6_extends(EmailMatcher,s),EmailMatcher.prototype.parseMatches=function(s){for(var o=this.tagBuilder,i=this.localPartCharRegex,a=this.strictTldRegex,u=[],_=s.length,w=new EC,x={m:\"a\",a:\"i\",i:\"l\",l:\"t\",t:\"o\",o:\":\"},C=0,j=0,L=w;C<_;){var B=s.charAt(C);switch(j){case 0:stateNonEmailAddress(B);break;case 1:stateMailTo(s.charAt(C-1),B);break;case 2:stateLocalPart(B);break;case 3:stateLocalPartDot(B);break;case 4:stateAtSign(B);break;case 5:stateDomainChar(B);break;case 6:stateDomainHyphen(B);break;case 7:stateDomainDot(B);break;default:throwUnhandledCaseError(j)}C++}return captureMatchIfValidAndReset(),u;function stateNonEmailAddress(s){\"m\"===s?beginEmailMatch(1):i.test(s)&&beginEmailMatch()}function stateMailTo(s,o){\":\"===s?i.test(o)?(j=2,L=new EC(__assign(__assign({},L),{hasMailtoPrefix:!0}))):resetToNonEmailMatchState():x[s]===o||(i.test(o)?j=2:\".\"===o?j=3:\"@\"===o?j=4:resetToNonEmailMatchState())}function stateLocalPart(s){\".\"===s?j=3:\"@\"===s?j=4:i.test(s)||resetToNonEmailMatchState()}function stateLocalPartDot(s){\".\"===s||\"@\"===s?resetToNonEmailMatchState():i.test(s)?j=2:resetToNonEmailMatchState()}function stateAtSign(s){yC.test(s)?j=5:resetToNonEmailMatchState()}function stateDomainChar(s){\".\"===s?j=7:\"-\"===s?j=6:yC.test(s)||captureMatchIfValidAndReset()}function stateDomainHyphen(s){\"-\"===s||\".\"===s?captureMatchIfValidAndReset():yC.test(s)?j=5:captureMatchIfValidAndReset()}function stateDomainDot(s){\".\"===s||\"-\"===s?captureMatchIfValidAndReset():yC.test(s)?(j=5,L=new EC(__assign(__assign({},L),{hasDomainDot:!0}))):captureMatchIfValidAndReset()}function beginEmailMatch(s){void 0===s&&(s=2),j=s,L=new EC({idx:C})}function resetToNonEmailMatchState(){j=0,L=w}function captureMatchIfValidAndReset(){if(L.hasDomainDot){var i=s.slice(L.idx,C);/[-.]$/.test(i)&&(i=i.slice(0,-1));var _=L.hasMailtoPrefix?i.slice(7):i;(function doesEmailHaveValidTld(s){var o=s.split(\".\").pop()||\"\",i=o.toLowerCase();return a.test(i)})(_)&&u.push(new XA({tagBuilder:o,matchedText:i,offset:L.idx,email:_}))}resetToNonEmailMatchState()}},EmailMatcher}(rC),EC=function EC(s){void 0===s&&(s={}),this.idx=void 0!==s.idx?s.idx:-1,this.hasMailtoPrefix=!!s.hasMailtoPrefix,this.hasDomainDot=!!s.hasDomainDot},wC=function(){function UrlMatchValidator(){}return UrlMatchValidator.isValid=function(s,o){return!(o&&!this.isValidUriScheme(o)||this.urlMatchDoesNotHaveProtocolOrDot(s,o)||this.urlMatchDoesNotHaveAtLeastOneWordChar(s,o)&&!this.isValidIpAddress(s)||this.containsMultipleDots(s))},UrlMatchValidator.isValidIpAddress=function(s){var o=new RegExp(this.hasFullProtocolRegex.source+this.ipRegex.source);return null!==s.match(o)},UrlMatchValidator.containsMultipleDots=function(s){var o=s;return this.hasFullProtocolRegex.test(s)&&(o=s.split(\"://\")[1]),o.split(\"/\")[0].indexOf(\"..\")>-1},UrlMatchValidator.isValidUriScheme=function(s){var o=s.match(this.uriSchemeRegex),i=o&&o[0].toLowerCase();return\"javascript:\"!==i&&\"vbscript:\"!==i},UrlMatchValidator.urlMatchDoesNotHaveProtocolOrDot=function(s,o){return!(!s||o&&this.hasFullProtocolRegex.test(o)||-1!==s.indexOf(\".\"))},UrlMatchValidator.urlMatchDoesNotHaveAtLeastOneWordChar=function(s,o){return!(!s||!o)&&(!this.hasFullProtocolRegex.test(o)&&!this.hasWordCharAfterProtocolRegex.test(s))},UrlMatchValidator.hasFullProtocolRegex=/^[A-Za-z][-.+A-Za-z0-9]*:\\/\\//,UrlMatchValidator.uriSchemeRegex=/^[A-Za-z][-.+A-Za-z0-9]*:/,UrlMatchValidator.hasWordCharAfterProtocolRegex=new RegExp(\":[^\\\\s]*?[\"+lC+\"]\"),UrlMatchValidator.ipRegex=/[0-9][0-9]?[0-9]?\\.[0-9][0-9]?[0-9]?\\.[0-9][0-9]?[0-9]?\\.[0-9][0-9]?[0-9]?(:[0-9]*)?\\/?$/,UrlMatchValidator}(),xC=(YA=new RegExp(\"[/?#](?:[\"+dC+\"\\\\-+&@#/%=~_()|'$*\\\\[\\\\]{}?!:,.;^✓]*[\"+dC+\"\\\\-+&@#/%=~_()|'$*\\\\[\\\\]{}✓])?\"),new RegExp([\"(?:\",\"(\",/(?:[A-Za-z][-.+A-Za-z0-9]{0,63}:(?![A-Za-z][-.+A-Za-z0-9]{0,63}:\\/\\/)(?!\\d+\\/?)(?:\\/\\/)?)/.source,getDomainNameStr(2),\")\",\"|\",\"(\",\"(//)?\",/(?:www\\.)/.source,getDomainNameStr(6),\")\",\"|\",\"(\",\"(//)?\",getDomainNameStr(10)+\"\\\\.\",vC.source,\"(?![-\"+hC+\"])\",\")\",\")\",\"(?::[0-9]+)?\",\"(?:\"+YA.source+\")?\"].join(\"\"),\"gi\")),kC=new RegExp(\"[\"+dC+\"]\"),OC=function(s){function UrlMatcher(o){var i=s.call(this,o)||this;return i.stripPrefix={scheme:!0,www:!0},i.stripTrailingSlash=!0,i.decodePercentEncoding=!0,i.matcherRegex=xC,i.wordCharRegExp=kC,i.stripPrefix=o.stripPrefix,i.stripTrailingSlash=o.stripTrailingSlash,i.decodePercentEncoding=o.decodePercentEncoding,i}return tslib_es6_extends(UrlMatcher,s),UrlMatcher.prototype.parseMatches=function(s){for(var o,i=this.matcherRegex,a=this.stripPrefix,u=this.stripTrailingSlash,_=this.decodePercentEncoding,w=this.tagBuilder,x=[],_loop_1=function(){var i=o[0],j=o[1],L=o[4],B=o[5],$=o[9],U=o.index,V=B||$,z=s.charAt(U-1);if(!wC.isValid(i,j))return\"continue\";if(U>0&&\"@\"===z)return\"continue\";if(U>0&&V&&C.wordCharRegExp.test(z))return\"continue\";if(/\\?$/.test(i)&&(i=i.substr(0,i.length-1)),C.matchHasUnbalancedClosingParen(i))i=i.substr(0,i.length-1);else{var Y=C.matchHasInvalidCharAfterTld(i,j);Y>-1&&(i=i.substr(0,Y))}var Z=[\"http://\",\"https://\"].find((function(s){return!!j&&-1!==j.indexOf(s)}));if(Z){var ee=i.indexOf(Z);i=i.substr(ee),j=j.substr(ee),U+=ee}var ie=j?\"scheme\":L?\"www\":\"tld\",ae=!!j;x.push(new tC({tagBuilder:w,matchedText:i,offset:U,urlMatchType:ie,url:i,protocolUrlMatch:ae,protocolRelativeMatch:!!V,stripPrefix:a,stripTrailingSlash:u,decodePercentEncoding:_}))},C=this;null!==(o=i.exec(s));)_loop_1();return x},UrlMatcher.prototype.matchHasUnbalancedClosingParen=function(s){var o,i=s.charAt(s.length-1);if(\")\"===i)o=\"(\";else if(\"]\"===i)o=\"[\";else{if(\"}\"!==i)return!1;o=\"{\"}for(var a=0,u=0,_=s.length-1;u<_;u++){var w=s.charAt(u);w===o?a++:w===i&&(a=Math.max(a-1,0))}return 0===a},UrlMatcher.prototype.matchHasInvalidCharAfterTld=function(s,o){if(!s)return-1;var i=0;o&&(i=s.indexOf(\":\"),s=s.slice(i));var a=new RegExp(\"^((.?//)?[-.\"+dC+\"]*[-\"+dC+\"]\\\\.[-\"+dC+\"]+)\").exec(s);return null===a?-1:(i+=a[1].length,s=s.slice(a[1].length),/^[^-.A-Za-z0-9:\\/?#]/.test(s)?i:-1)},UrlMatcher}(rC),AC=new RegExp(\"[_\".concat(dC,\"]\")),CC=function(s){function HashtagMatcher(o){var i=s.call(this,o)||this;return i.serviceName=\"twitter\",i.serviceName=o.serviceName,i}return tslib_es6_extends(HashtagMatcher,s),HashtagMatcher.prototype.parseMatches=function(s){for(var o=this.tagBuilder,i=this.serviceName,a=[],u=s.length,_=0,w=-1,x=0;_<u;){var C=s.charAt(_);switch(x){case 0:stateNone(C);break;case 1:stateNonHashtagWordChar(C);break;case 2:stateHashtagHashChar(C);break;case 3:stateHashtagTextChar(C);break;default:throwUnhandledCaseError(x)}_++}return captureMatchIfValid(),a;function stateNone(s){\"#\"===s?(x=2,w=_):fC.test(s)&&(x=1)}function stateNonHashtagWordChar(s){fC.test(s)||(x=0)}function stateHashtagHashChar(s){x=AC.test(s)?3:fC.test(s)?1:0}function stateHashtagTextChar(s){AC.test(s)||(captureMatchIfValid(),w=-1,x=fC.test(s)?1:0)}function captureMatchIfValid(){if(w>-1&&_-w<=140){var u=s.slice(w,_),x=new QA({tagBuilder:o,matchedText:u,offset:w,serviceName:i,hashtag:u.slice(1)});a.push(x)}}},HashtagMatcher}(rC),jC=[\"twitter\",\"facebook\",\"instagram\",\"tiktok\"],PC=new RegExp(\"\".concat(/(?:(?:(?:(\\+)?\\d{1,3}[-\\040.]?)?\\(?\\d{3}\\)?[-\\040.]?\\d{3}[-\\040.]?\\d{4})|(?:(\\+)(?:9[976]\\d|8[987530]\\d|6[987]\\d|5[90]\\d|42\\d|3[875]\\d|2[98654321]\\d|9[8543210]|8[6421]|6[6543210]|5[87654321]|4[987654310]|3[9643210]|2[70]|7|1)[-\\040.]?(?:\\d[-\\040.]?){6,12}\\d+))([,;]+[0-9]+#?)*/.source,\"|\").concat(/(0([1-9]{1}-?[1-9]\\d{3}|[1-9]{2}-?\\d{3}|[1-9]{2}\\d{1}-?\\d{2}|[1-9]{2}\\d{2}-?\\d{1})-?\\d{4}|0[789]0-?\\d{4}-?\\d{4}|050-?\\d{4}-?\\d{4})/.source),\"g\"),IC=function(s){function PhoneMatcher(){var o=null!==s&&s.apply(this,arguments)||this;return o.matcherRegex=PC,o}return tslib_es6_extends(PhoneMatcher,s),PhoneMatcher.prototype.parseMatches=function(s){for(var o,i=this.matcherRegex,a=this.tagBuilder,u=[];null!==(o=i.exec(s));){var _=o[0],w=_.replace(/[^0-9,;#]/g,\"\"),x=!(!o[1]&&!o[2]),C=0==o.index?\"\":s.substr(o.index-1,1),j=s.substr(o.index+_.length,1),L=!C.match(/\\d/)&&!j.match(/\\d/);this.testMatch(o[3])&&this.testMatch(_)&&L&&u.push(new eC({tagBuilder:a,matchedText:_,offset:o.index,number:w,plusSign:x}))}return u},PhoneMatcher.prototype.testMatch=function(s){return oC.test(s)},PhoneMatcher}(rC),TC=new RegExp(\"@[_\".concat(dC,\"]{1,50}(?![_\").concat(dC,\"])\"),\"g\"),NC=new RegExp(\"@[_.\".concat(dC,\"]{1,30}(?![_\").concat(dC,\"])\"),\"g\"),MC=new RegExp(\"@[-_.\".concat(dC,\"]{1,50}(?![-_\").concat(dC,\"])\"),\"g\"),RC=new RegExp(\"@[_.\".concat(dC,\"]{1,23}[_\").concat(dC,\"](?![_\").concat(dC,\"])\"),\"g\"),DC=new RegExp(\"[^\"+dC+\"]\"),LC=function(s){function MentionMatcher(o){var i=s.call(this,o)||this;return i.serviceName=\"twitter\",i.matcherRegexes={twitter:TC,instagram:NC,soundcloud:MC,tiktok:RC},i.nonWordCharRegex=DC,i.serviceName=o.serviceName,i}return tslib_es6_extends(MentionMatcher,s),MentionMatcher.prototype.parseMatches=function(s){var o,i=this.serviceName,a=this.matcherRegexes[this.serviceName],u=this.nonWordCharRegex,_=this.tagBuilder,w=[];if(!a)return w;for(;null!==(o=a.exec(s));){var x=o.index,C=s.charAt(x-1);if(0===x||u.test(C)){var j=o[0].replace(/\\.+$/g,\"\"),L=j.slice(1);w.push(new ZA({tagBuilder:_,matchedText:j,offset:x,serviceName:i,mention:L}))}}return w},MentionMatcher}(rC);function parseHtml(s,o){for(var i=o.onOpenTag,a=o.onCloseTag,u=o.onText,_=o.onComment,w=o.onDoctype,x=new FC,C=0,j=s.length,L=0,B=0,$=x;C<j;){var U=s.charAt(C);switch(L){case 0:stateData(U);break;case 1:stateTagOpen(U);break;case 2:stateEndTagOpen(U);break;case 3:stateTagName(U);break;case 4:stateBeforeAttributeName(U);break;case 5:stateAttributeName(U);break;case 6:stateAfterAttributeName(U);break;case 7:stateBeforeAttributeValue(U);break;case 8:stateAttributeValueDoubleQuoted(U);break;case 9:stateAttributeValueSingleQuoted(U);break;case 10:stateAttributeValueUnquoted(U);break;case 11:stateAfterAttributeValueQuoted(U);break;case 12:stateSelfClosingStartTag(U);break;case 13:stateMarkupDeclarationOpen(U);break;case 14:stateCommentStart(U);break;case 15:stateCommentStartDash(U);break;case 16:stateComment(U);break;case 17:stateCommentEndDash(U);break;case 18:stateCommentEnd(U);break;case 19:stateCommentEndBang(U);break;case 20:stateDoctype(U);break;default:throwUnhandledCaseError(L)}C++}function stateData(s){\"<\"===s&&startNewTag()}function stateTagOpen(s){\"!\"===s?L=13:\"/\"===s?(L=2,$=new FC(__assign(__assign({},$),{isClosing:!0}))):\"<\"===s?startNewTag():nC.test(s)?(L=3,$=new FC(__assign(__assign({},$),{isOpening:!0}))):(L=0,$=x)}function stateTagName(s){iC.test(s)?($=new FC(__assign(__assign({},$),{name:captureTagName()})),L=4):\"<\"===s?startNewTag():\"/\"===s?($=new FC(__assign(__assign({},$),{name:captureTagName()})),L=12):\">\"===s?($=new FC(__assign(__assign({},$),{name:captureTagName()})),emitTagAndPreviousTextNode()):nC.test(s)||sC.test(s)||\":\"===s||resetToDataState()}function stateEndTagOpen(s){\">\"===s?resetToDataState():nC.test(s)?L=3:resetToDataState()}function stateBeforeAttributeName(s){iC.test(s)||(\"/\"===s?L=12:\">\"===s?emitTagAndPreviousTextNode():\"<\"===s?startNewTag():\"=\"===s||aC.test(s)||cC.test(s)?resetToDataState():L=5)}function stateAttributeName(s){iC.test(s)?L=6:\"/\"===s?L=12:\"=\"===s?L=7:\">\"===s?emitTagAndPreviousTextNode():\"<\"===s?startNewTag():aC.test(s)&&resetToDataState()}function stateAfterAttributeName(s){iC.test(s)||(\"/\"===s?L=12:\"=\"===s?L=7:\">\"===s?emitTagAndPreviousTextNode():\"<\"===s?startNewTag():aC.test(s)?resetToDataState():L=5)}function stateBeforeAttributeValue(s){iC.test(s)||('\"'===s?L=8:\"'\"===s?L=9:/[>=`]/.test(s)?resetToDataState():\"<\"===s?startNewTag():L=10)}function stateAttributeValueDoubleQuoted(s){'\"'===s&&(L=11)}function stateAttributeValueSingleQuoted(s){\"'\"===s&&(L=11)}function stateAttributeValueUnquoted(s){iC.test(s)?L=4:\">\"===s?emitTagAndPreviousTextNode():\"<\"===s&&startNewTag()}function stateAfterAttributeValueQuoted(s){iC.test(s)?L=4:\"/\"===s?L=12:\">\"===s?emitTagAndPreviousTextNode():\"<\"===s?startNewTag():(L=4,function reconsumeCurrentCharacter(){C--}())}function stateSelfClosingStartTag(s){\">\"===s?($=new FC(__assign(__assign({},$),{isClosing:!0})),emitTagAndPreviousTextNode()):L=4}function stateMarkupDeclarationOpen(o){\"--\"===s.substr(C,2)?(C+=2,$=new FC(__assign(__assign({},$),{type:\"comment\"})),L=14):\"DOCTYPE\"===s.substr(C,7).toUpperCase()?(C+=7,$=new FC(__assign(__assign({},$),{type:\"doctype\"})),L=20):resetToDataState()}function stateCommentStart(s){\"-\"===s?L=15:\">\"===s?resetToDataState():L=16}function stateCommentStartDash(s){\"-\"===s?L=18:\">\"===s?resetToDataState():L=16}function stateComment(s){\"-\"===s&&(L=17)}function stateCommentEndDash(s){L=\"-\"===s?18:16}function stateCommentEnd(s){\">\"===s?emitTagAndPreviousTextNode():\"!\"===s?L=19:\"-\"===s||(L=16)}function stateCommentEndBang(s){\"-\"===s?L=17:\">\"===s?emitTagAndPreviousTextNode():L=16}function stateDoctype(s){\">\"===s?emitTagAndPreviousTextNode():\"<\"===s&&startNewTag()}function resetToDataState(){L=0,$=x}function startNewTag(){L=1,$=new FC({idx:C})}function emitTagAndPreviousTextNode(){var o=s.slice(B,$.idx);o&&u(o,B),\"comment\"===$.type?_($.idx):\"doctype\"===$.type?w($.idx):($.isOpening&&i($.name,$.idx),$.isClosing&&a($.name,$.idx)),resetToDataState(),B=C+1}function captureTagName(){var o=$.idx+($.isClosing?2:1);return s.slice(o,C).toLowerCase()}B<C&&function emitText(){var o=s.slice(B,C);u(o,B),B=C+1}()}var FC=function FC(s){void 0===s&&(s={}),this.idx=void 0!==s.idx?s.idx:-1,this.type=s.type||\"tag\",this.name=s.name||\"\",this.isOpening=!!s.isOpening,this.isClosing=!!s.isClosing},BC=function(){function Autolinker(s){void 0===s&&(s={}),this.version=Autolinker.version,this.urls={},this.email=!0,this.phone=!0,this.hashtag=!1,this.mention=!1,this.newWindow=!0,this.stripPrefix={scheme:!0,www:!0},this.stripTrailingSlash=!0,this.decodePercentEncoding=!0,this.truncate={length:0,location:\"end\"},this.className=\"\",this.replaceFn=null,this.context=void 0,this.sanitizeHtml=!1,this.matchers=null,this.tagBuilder=null,this.urls=this.normalizeUrlsCfg(s.urls),this.email=\"boolean\"==typeof s.email?s.email:this.email,this.phone=\"boolean\"==typeof s.phone?s.phone:this.phone,this.hashtag=s.hashtag||this.hashtag,this.mention=s.mention||this.mention,this.newWindow=\"boolean\"==typeof s.newWindow?s.newWindow:this.newWindow,this.stripPrefix=this.normalizeStripPrefixCfg(s.stripPrefix),this.stripTrailingSlash=\"boolean\"==typeof s.stripTrailingSlash?s.stripTrailingSlash:this.stripTrailingSlash,this.decodePercentEncoding=\"boolean\"==typeof s.decodePercentEncoding?s.decodePercentEncoding:this.decodePercentEncoding,this.sanitizeHtml=s.sanitizeHtml||!1;var o=this.mention;if(!1!==o&&-1===[\"twitter\",\"instagram\",\"soundcloud\",\"tiktok\"].indexOf(o))throw new Error(\"invalid `mention` cfg '\".concat(o,\"' - see docs\"));var i=this.hashtag;if(!1!==i&&-1===jC.indexOf(i))throw new Error(\"invalid `hashtag` cfg '\".concat(i,\"' - see docs\"));this.truncate=this.normalizeTruncateCfg(s.truncate),this.className=s.className||this.className,this.replaceFn=s.replaceFn||this.replaceFn,this.context=s.context||this}return Autolinker.link=function(s,o){return new Autolinker(o).link(s)},Autolinker.parse=function(s,o){return new Autolinker(o).parse(s)},Autolinker.prototype.normalizeUrlsCfg=function(s){return null==s&&(s=!0),\"boolean\"==typeof s?{schemeMatches:s,wwwMatches:s,tldMatches:s}:{schemeMatches:\"boolean\"!=typeof s.schemeMatches||s.schemeMatches,wwwMatches:\"boolean\"!=typeof s.wwwMatches||s.wwwMatches,tldMatches:\"boolean\"!=typeof s.tldMatches||s.tldMatches}},Autolinker.prototype.normalizeStripPrefixCfg=function(s){return null==s&&(s=!0),\"boolean\"==typeof s?{scheme:s,www:s}:{scheme:\"boolean\"!=typeof s.scheme||s.scheme,www:\"boolean\"!=typeof s.www||s.www}},Autolinker.prototype.normalizeTruncateCfg=function(s){return\"number\"==typeof s?{length:s,location:\"end\"}:function defaults(s,o){for(var i in o)o.hasOwnProperty(i)&&void 0===s[i]&&(s[i]=o[i]);return s}(s||{},{length:Number.POSITIVE_INFINITY,location:\"end\"})},Autolinker.prototype.parse=function(s){var o=this,i=[\"a\",\"style\",\"script\"],a=0,u=[];return parseHtml(s,{onOpenTag:function(s){i.indexOf(s)>=0&&a++},onText:function(s,i){if(0===a){var _=function splitAndCapture(s,o){if(!o.global)throw new Error(\"`splitRegex` must have the 'g' flag set\");for(var i,a=[],u=0;i=o.exec(s);)a.push(s.substring(u,i.index)),a.push(i[0]),u=i.index+i[0].length;return a.push(s.substring(u)),a}(s,/(&nbsp;|&#160;|&lt;|&#60;|&gt;|&#62;|&quot;|&#34;|&#39;)/gi),w=i;_.forEach((function(s,i){if(i%2==0){var a=o.parseText(s,w);u.push.apply(u,a)}w+=s.length}))}},onCloseTag:function(s){i.indexOf(s)>=0&&(a=Math.max(a-1,0))},onComment:function(s){},onDoctype:function(s){}}),u=this.compactMatches(u),u=this.removeUnwantedMatches(u)},Autolinker.prototype.compactMatches=function(s){s.sort((function(s,o){return s.getOffset()-o.getOffset()}));for(var o=0;o<s.length-1;){var i=s[o],a=i.getOffset(),u=i.getMatchedText().length,_=a+u;if(o+1<s.length){if(s[o+1].getOffset()===a){var w=s[o+1].getMatchedText().length>u?o:o+1;s.splice(w,1);continue}if(s[o+1].getOffset()<_){s.splice(o+1,1);continue}}o++}return s},Autolinker.prototype.removeUnwantedMatches=function(s){return this.hashtag||utils_remove(s,(function(s){return\"hashtag\"===s.getType()})),this.email||utils_remove(s,(function(s){return\"email\"===s.getType()})),this.phone||utils_remove(s,(function(s){return\"phone\"===s.getType()})),this.mention||utils_remove(s,(function(s){return\"mention\"===s.getType()})),this.urls.schemeMatches||utils_remove(s,(function(s){return\"url\"===s.getType()&&\"scheme\"===s.getUrlMatchType()})),this.urls.wwwMatches||utils_remove(s,(function(s){return\"url\"===s.getType()&&\"www\"===s.getUrlMatchType()})),this.urls.tldMatches||utils_remove(s,(function(s){return\"url\"===s.getType()&&\"tld\"===s.getUrlMatchType()})),s},Autolinker.prototype.parseText=function(s,o){void 0===o&&(o=0),o=o||0;for(var i=this.getMatchers(),a=[],u=0,_=i.length;u<_;u++){for(var w=i[u].parseMatches(s),x=0,C=w.length;x<C;x++)w[x].setOffset(o+w[x].getOffset());a.push.apply(a,w)}return a},Autolinker.prototype.link=function(s){if(!s)return\"\";this.sanitizeHtml&&(s=s.replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\"));for(var o=this.parse(s),i=[],a=0,u=0,_=o.length;u<_;u++){var w=o[u];i.push(s.substring(a,w.getOffset())),i.push(this.createMatchReturnVal(w)),a=w.getOffset()+w.getMatchedText().length}return i.push(s.substring(a)),i.join(\"\")},Autolinker.prototype.createMatchReturnVal=function(s){var o;return this.replaceFn&&(o=this.replaceFn.call(this.context,s)),\"string\"==typeof o?o:!1===o?s.getMatchedText():o instanceof HA?o.toAnchorString():s.buildTag().toAnchorString()},Autolinker.prototype.getMatchers=function(){if(this.matchers)return this.matchers;var s=this.getTagBuilder(),o=[new CC({tagBuilder:s,serviceName:this.hashtag}),new SC({tagBuilder:s}),new IC({tagBuilder:s}),new LC({tagBuilder:s,serviceName:this.mention}),new OC({tagBuilder:s,stripPrefix:this.stripPrefix,stripTrailingSlash:this.stripTrailingSlash,decodePercentEncoding:this.decodePercentEncoding})];return this.matchers=o},Autolinker.prototype.getTagBuilder=function(){var s=this.tagBuilder;return s||(s=this.tagBuilder=new KA({newWindow:this.newWindow,truncate:this.truncate,className:this.className})),s},Autolinker.version=\"3.16.2\",Autolinker.AnchorTagBuilder=KA,Autolinker.HtmlTag=HA,Autolinker.matcher={Email:SC,Hashtag:CC,Matcher:rC,Mention:LC,Phone:IC,Url:OC},Autolinker.match={Email:XA,Hashtag:QA,Match:GA,Mention:ZA,Phone:eC,Url:tC},Autolinker}();const $C=BC;var qC=/www|@|\\:\\/\\//;function isLinkOpen(s){return/^<a[>\\s]/i.test(s)}function isLinkClose(s){return/^<\\/a\\s*>/i.test(s)}function createLinkifier(){var s=[],o=new $C({stripPrefix:!1,url:!0,email:!0,replaceFn:function(o){switch(o.getType()){case\"url\":s.push({text:o.matchedText,url:o.getUrl()});break;case\"email\":s.push({text:o.matchedText,url:\"mailto:\"+o.getEmail().replace(/^mailto:/i,\"\")})}return!1}});return{links:s,autolinker:o}}function parseTokens(s){var o,i,a,u,_,w,x,C,j,L,B,$,U,V=s.tokens,z=null;for(i=0,a=V.length;i<a;i++)if(\"inline\"===V[i].type)for(B=0,o=(u=V[i].children).length-1;o>=0;o--)if(\"link_close\"!==(_=u[o]).type){if(\"htmltag\"===_.type&&(isLinkOpen(_.content)&&B>0&&B--,isLinkClose(_.content)&&B++),!(B>0)&&\"text\"===_.type&&qC.test(_.content)){if(z||($=(z=createLinkifier()).links,U=z.autolinker),w=_.content,$.length=0,U.link(w),!$.length)continue;for(x=[],L=_.level,C=0;C<$.length;C++)s.inline.validateLink($[C].url)&&((j=w.indexOf($[C].text))&&x.push({type:\"text\",content:w.slice(0,j),level:L}),x.push({type:\"link_open\",href:$[C].url,title:\"\",level:L++}),x.push({type:\"text\",content:$[C].text,level:L}),x.push({type:\"link_close\",level:--L}),w=w.slice(j+$[C].text.length));w.length&&x.push({type:\"text\",content:w,level:L}),V[i].children=u=[].concat(u.slice(0,o),x,u.slice(o+1))}}else for(o--;u[o].level!==_.level&&\"link_open\"!==u[o].type;)o--}function linkify(s){s.core.ruler.push(\"linkify\",parseTokens)}const{entries:UC,setPrototypeOf:VC,isFrozen:zC,getPrototypeOf:WC,getOwnPropertyDescriptor:JC}=Object;let{freeze:HC,seal:KC,create:GC}=Object,{apply:YC,construct:XC}=\"undefined\"!=typeof Reflect&&Reflect;HC||(HC=function freeze(s){return s}),KC||(KC=function seal(s){return s}),YC||(YC=function apply(s,o,i){return s.apply(o,i)}),XC||(XC=function construct(s,o){return new s(...o)});const QC=unapply(Array.prototype.forEach),ZC=unapply(Array.prototype.lastIndexOf),ej=unapply(Array.prototype.pop),fj=unapply(Array.prototype.push),mj=unapply(Array.prototype.splice),_j=unapply(String.prototype.toLowerCase),Aj=unapply(String.prototype.toString),Cj=unapply(String.prototype.match),Nj=unapply(String.prototype.replace),Bj=unapply(String.prototype.indexOf),$j=unapply(String.prototype.trim),zj=unapply(Object.prototype.hasOwnProperty),Jj=unapply(RegExp.prototype.test),Kj=function unconstruct(s){return function(){for(var o=arguments.length,i=new Array(o),a=0;a<o;a++)i[a]=arguments[a];return XC(s,i)}}(TypeError);function unapply(s){return function(o){o instanceof RegExp&&(o.lastIndex=0);for(var i=arguments.length,a=new Array(i>1?i-1:0),u=1;u<i;u++)a[u-1]=arguments[u];return YC(s,o,a)}}function addToSet(s,o){let i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:_j;VC&&VC(s,null);let a=o.length;for(;a--;){let u=o[a];if(\"string\"==typeof u){const s=i(u);s!==u&&(zC(o)||(o[a]=s),u=s)}s[u]=!0}return s}function purify_es_cleanArray(s){for(let o=0;o<s.length;o++){zj(s,o)||(s[o]=null)}return s}function clone(s){const o=GC(null);for(const[i,a]of UC(s)){zj(s,i)&&(Array.isArray(a)?o[i]=purify_es_cleanArray(a):a&&\"object\"==typeof a&&a.constructor===Object?o[i]=clone(a):o[i]=a)}return o}function lookupGetter(s,o){for(;null!==s;){const i=JC(s,o);if(i){if(i.get)return unapply(i.get);if(\"function\"==typeof i.value)return unapply(i.value)}s=WC(s)}return function fallbackValue(){return null}}const Gj=HC([\"a\",\"abbr\",\"acronym\",\"address\",\"area\",\"article\",\"aside\",\"audio\",\"b\",\"bdi\",\"bdo\",\"big\",\"blink\",\"blockquote\",\"body\",\"br\",\"button\",\"canvas\",\"caption\",\"center\",\"cite\",\"code\",\"col\",\"colgroup\",\"content\",\"data\",\"datalist\",\"dd\",\"decorator\",\"del\",\"details\",\"dfn\",\"dialog\",\"dir\",\"div\",\"dl\",\"dt\",\"element\",\"em\",\"fieldset\",\"figcaption\",\"figure\",\"font\",\"footer\",\"form\",\"h1\",\"h2\",\"h3\",\"h4\",\"h5\",\"h6\",\"head\",\"header\",\"hgroup\",\"hr\",\"html\",\"i\",\"img\",\"input\",\"ins\",\"kbd\",\"label\",\"legend\",\"li\",\"main\",\"map\",\"mark\",\"marquee\",\"menu\",\"menuitem\",\"meter\",\"nav\",\"nobr\",\"ol\",\"optgroup\",\"option\",\"output\",\"p\",\"picture\",\"pre\",\"progress\",\"q\",\"rp\",\"rt\",\"ruby\",\"s\",\"samp\",\"section\",\"select\",\"shadow\",\"small\",\"source\",\"spacer\",\"span\",\"strike\",\"strong\",\"style\",\"sub\",\"summary\",\"sup\",\"table\",\"tbody\",\"td\",\"template\",\"textarea\",\"tfoot\",\"th\",\"thead\",\"time\",\"tr\",\"track\",\"tt\",\"u\",\"ul\",\"var\",\"video\",\"wbr\"]),Xj=HC([\"svg\",\"a\",\"altglyph\",\"altglyphdef\",\"altglyphitem\",\"animatecolor\",\"animatemotion\",\"animatetransform\",\"circle\",\"clippath\",\"defs\",\"desc\",\"ellipse\",\"filter\",\"font\",\"g\",\"glyph\",\"glyphref\",\"hkern\",\"image\",\"line\",\"lineargradient\",\"marker\",\"mask\",\"metadata\",\"mpath\",\"path\",\"pattern\",\"polygon\",\"polyline\",\"radialgradient\",\"rect\",\"stop\",\"style\",\"switch\",\"symbol\",\"text\",\"textpath\",\"title\",\"tref\",\"tspan\",\"view\",\"vkern\"]),eP=HC([\"feBlend\",\"feColorMatrix\",\"feComponentTransfer\",\"feComposite\",\"feConvolveMatrix\",\"feDiffuseLighting\",\"feDisplacementMap\",\"feDistantLight\",\"feDropShadow\",\"feFlood\",\"feFuncA\",\"feFuncB\",\"feFuncG\",\"feFuncR\",\"feGaussianBlur\",\"feImage\",\"feMerge\",\"feMergeNode\",\"feMorphology\",\"feOffset\",\"fePointLight\",\"feSpecularLighting\",\"feSpotLight\",\"feTile\",\"feTurbulence\"]),tP=HC([\"animate\",\"color-profile\",\"cursor\",\"discard\",\"font-face\",\"font-face-format\",\"font-face-name\",\"font-face-src\",\"font-face-uri\",\"foreignobject\",\"hatch\",\"hatchpath\",\"mesh\",\"meshgradient\",\"meshpatch\",\"meshrow\",\"missing-glyph\",\"script\",\"set\",\"solidcolor\",\"unknown\",\"use\"]),rP=HC([\"math\",\"menclose\",\"merror\",\"mfenced\",\"mfrac\",\"mglyph\",\"mi\",\"mlabeledtr\",\"mmultiscripts\",\"mn\",\"mo\",\"mover\",\"mpadded\",\"mphantom\",\"mroot\",\"mrow\",\"ms\",\"mspace\",\"msqrt\",\"mstyle\",\"msub\",\"msup\",\"msubsup\",\"mtable\",\"mtd\",\"mtext\",\"mtr\",\"munder\",\"munderover\",\"mprescripts\"]),nP=HC([\"maction\",\"maligngroup\",\"malignmark\",\"mlongdiv\",\"mscarries\",\"mscarry\",\"msgroup\",\"mstack\",\"msline\",\"msrow\",\"semantics\",\"annotation\",\"annotation-xml\",\"mprescripts\",\"none\"]),sP=HC([\"#text\"]),oP=HC([\"accept\",\"action\",\"align\",\"alt\",\"autocapitalize\",\"autocomplete\",\"autopictureinpicture\",\"autoplay\",\"background\",\"bgcolor\",\"border\",\"capture\",\"cellpadding\",\"cellspacing\",\"checked\",\"cite\",\"class\",\"clear\",\"color\",\"cols\",\"colspan\",\"controls\",\"controlslist\",\"coords\",\"crossorigin\",\"datetime\",\"decoding\",\"default\",\"dir\",\"disabled\",\"disablepictureinpicture\",\"disableremoteplayback\",\"download\",\"draggable\",\"enctype\",\"enterkeyhint\",\"face\",\"for\",\"headers\",\"height\",\"hidden\",\"high\",\"href\",\"hreflang\",\"id\",\"inputmode\",\"integrity\",\"ismap\",\"kind\",\"label\",\"lang\",\"list\",\"loading\",\"loop\",\"low\",\"max\",\"maxlength\",\"media\",\"method\",\"min\",\"minlength\",\"multiple\",\"muted\",\"name\",\"nonce\",\"noshade\",\"novalidate\",\"nowrap\",\"open\",\"optimum\",\"pattern\",\"placeholder\",\"playsinline\",\"popover\",\"popovertarget\",\"popovertargetaction\",\"poster\",\"preload\",\"pubdate\",\"radiogroup\",\"readonly\",\"rel\",\"required\",\"rev\",\"reversed\",\"role\",\"rows\",\"rowspan\",\"spellcheck\",\"scope\",\"selected\",\"shape\",\"size\",\"sizes\",\"span\",\"srclang\",\"start\",\"src\",\"srcset\",\"step\",\"style\",\"summary\",\"tabindex\",\"title\",\"translate\",\"type\",\"usemap\",\"valign\",\"value\",\"width\",\"wrap\",\"xmlns\",\"slot\"]),iP=HC([\"accent-height\",\"accumulate\",\"additive\",\"alignment-baseline\",\"amplitude\",\"ascent\",\"attributename\",\"attributetype\",\"azimuth\",\"basefrequency\",\"baseline-shift\",\"begin\",\"bias\",\"by\",\"class\",\"clip\",\"clippathunits\",\"clip-path\",\"clip-rule\",\"color\",\"color-interpolation\",\"color-interpolation-filters\",\"color-profile\",\"color-rendering\",\"cx\",\"cy\",\"d\",\"dx\",\"dy\",\"diffuseconstant\",\"direction\",\"display\",\"divisor\",\"dur\",\"edgemode\",\"elevation\",\"end\",\"exponent\",\"fill\",\"fill-opacity\",\"fill-rule\",\"filter\",\"filterunits\",\"flood-color\",\"flood-opacity\",\"font-family\",\"font-size\",\"font-size-adjust\",\"font-stretch\",\"font-style\",\"font-variant\",\"font-weight\",\"fx\",\"fy\",\"g1\",\"g2\",\"glyph-name\",\"glyphref\",\"gradientunits\",\"gradienttransform\",\"height\",\"href\",\"id\",\"image-rendering\",\"in\",\"in2\",\"intercept\",\"k\",\"k1\",\"k2\",\"k3\",\"k4\",\"kerning\",\"keypoints\",\"keysplines\",\"keytimes\",\"lang\",\"lengthadjust\",\"letter-spacing\",\"kernelmatrix\",\"kernelunitlength\",\"lighting-color\",\"local\",\"marker-end\",\"marker-mid\",\"marker-start\",\"markerheight\",\"markerunits\",\"markerwidth\",\"maskcontentunits\",\"maskunits\",\"max\",\"mask\",\"media\",\"method\",\"mode\",\"min\",\"name\",\"numoctaves\",\"offset\",\"operator\",\"opacity\",\"order\",\"orient\",\"orientation\",\"origin\",\"overflow\",\"paint-order\",\"path\",\"pathlength\",\"patterncontentunits\",\"patterntransform\",\"patternunits\",\"points\",\"preservealpha\",\"preserveaspectratio\",\"primitiveunits\",\"r\",\"rx\",\"ry\",\"radius\",\"refx\",\"refy\",\"repeatcount\",\"repeatdur\",\"restart\",\"result\",\"rotate\",\"scale\",\"seed\",\"shape-rendering\",\"slope\",\"specularconstant\",\"specularexponent\",\"spreadmethod\",\"startoffset\",\"stddeviation\",\"stitchtiles\",\"stop-color\",\"stop-opacity\",\"stroke-dasharray\",\"stroke-dashoffset\",\"stroke-linecap\",\"stroke-linejoin\",\"stroke-miterlimit\",\"stroke-opacity\",\"stroke\",\"stroke-width\",\"style\",\"surfacescale\",\"systemlanguage\",\"tabindex\",\"tablevalues\",\"targetx\",\"targety\",\"transform\",\"transform-origin\",\"text-anchor\",\"text-decoration\",\"text-rendering\",\"textlength\",\"type\",\"u1\",\"u2\",\"unicode\",\"values\",\"viewbox\",\"visibility\",\"version\",\"vert-adv-y\",\"vert-origin-x\",\"vert-origin-y\",\"width\",\"word-spacing\",\"wrap\",\"writing-mode\",\"xchannelselector\",\"ychannelselector\",\"x\",\"x1\",\"x2\",\"xmlns\",\"y\",\"y1\",\"y2\",\"z\",\"zoomandpan\"]),aP=HC([\"accent\",\"accentunder\",\"align\",\"bevelled\",\"close\",\"columnsalign\",\"columnlines\",\"columnspan\",\"denomalign\",\"depth\",\"dir\",\"display\",\"displaystyle\",\"encoding\",\"fence\",\"frame\",\"height\",\"href\",\"id\",\"largeop\",\"length\",\"linethickness\",\"lspace\",\"lquote\",\"mathbackground\",\"mathcolor\",\"mathsize\",\"mathvariant\",\"maxsize\",\"minsize\",\"movablelimits\",\"notation\",\"numalign\",\"open\",\"rowalign\",\"rowlines\",\"rowspacing\",\"rowspan\",\"rspace\",\"rquote\",\"scriptlevel\",\"scriptminsize\",\"scriptsizemultiplier\",\"selection\",\"separator\",\"separators\",\"stretchy\",\"subscriptshift\",\"supscriptshift\",\"symmetric\",\"voffset\",\"width\",\"xmlns\"]),cP=HC([\"xlink:href\",\"xml:id\",\"xlink:title\",\"xml:space\",\"xmlns:xlink\"]),lP=KC(/\\{\\{[\\w\\W]*|[\\w\\W]*\\}\\}/gm),uP=KC(/<%[\\w\\W]*|[\\w\\W]*%>/gm),pP=KC(/\\$\\{[\\w\\W]*/gm),hP=KC(/^data-[\\-\\w.\\u00B7-\\uFFFF]+$/),dP=KC(/^aria-[\\-\\w]+$/),fP=KC(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\\-]+(?:[^a-z+.\\-:]|$))/i),mP=KC(/^(?:\\w+script|data):/i),gP=KC(/[\\u0000-\\u0020\\u00A0\\u1680\\u180E\\u2000-\\u2029\\u205F\\u3000]/g),yP=KC(/^html$/i),vP=KC(/^[a-z][.\\w]*(-[.\\w]+)+$/i);var bP=Object.freeze({__proto__:null,ARIA_ATTR:dP,ATTR_WHITESPACE:gP,CUSTOM_ELEMENT:vP,DATA_ATTR:hP,DOCTYPE_NAME:yP,ERB_EXPR:uP,IS_ALLOWED_URI:fP,IS_SCRIPT_OR_DATA:mP,MUSTACHE_EXPR:lP,TMPLIT_EXPR:pP});const _P=1,SP=3,EP=7,wP=8,xP=9,kP=function getGlobal(){return\"undefined\"==typeof window?null:window};var OP=function createDOMPurify(){let s=arguments.length>0&&void 0!==arguments[0]?arguments[0]:kP();const DOMPurify=s=>createDOMPurify(s);if(DOMPurify.version=\"3.2.6\",DOMPurify.removed=[],!s||!s.document||s.document.nodeType!==xP||!s.Element)return DOMPurify.isSupported=!1,DOMPurify;let{document:o}=s;const i=o,a=i.currentScript,{DocumentFragment:u,HTMLTemplateElement:_,Node:w,Element:x,NodeFilter:C,NamedNodeMap:j=s.NamedNodeMap||s.MozNamedAttrMap,HTMLFormElement:L,DOMParser:B,trustedTypes:$}=s,U=x.prototype,V=lookupGetter(U,\"cloneNode\"),z=lookupGetter(U,\"remove\"),Y=lookupGetter(U,\"nextSibling\"),Z=lookupGetter(U,\"childNodes\"),ee=lookupGetter(U,\"parentNode\");if(\"function\"==typeof _){const s=o.createElement(\"template\");s.content&&s.content.ownerDocument&&(o=s.content.ownerDocument)}let ie,ae=\"\";const{implementation:ce,createNodeIterator:le,createDocumentFragment:pe,getElementsByTagName:de}=o,{importNode:fe}=i;let ye={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};DOMPurify.isSupported=\"function\"==typeof UC&&\"function\"==typeof ee&&ce&&void 0!==ce.createHTMLDocument;const{MUSTACHE_EXPR:be,ERB_EXPR:_e,TMPLIT_EXPR:Se,DATA_ATTR:we,ARIA_ATTR:xe,IS_SCRIPT_OR_DATA:Pe,ATTR_WHITESPACE:Te,CUSTOM_ELEMENT:Re}=bP;let{IS_ALLOWED_URI:$e}=bP,qe=null;const ze=addToSet({},[...Gj,...Xj,...eP,...rP,...sP]);let We=null;const He=addToSet({},[...oP,...iP,...aP,...cP]);let Ye=Object.seal(GC(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Xe=null,Qe=null,et=!0,tt=!0,rt=!1,nt=!0,st=!1,ot=!0,it=!1,at=!1,ct=!1,lt=!1,ut=!1,pt=!1,ht=!0,dt=!1,mt=!0,gt=!1,yt={},vt=null;const bt=addToSet({},[\"annotation-xml\",\"audio\",\"colgroup\",\"desc\",\"foreignobject\",\"head\",\"iframe\",\"math\",\"mi\",\"mn\",\"mo\",\"ms\",\"mtext\",\"noembed\",\"noframes\",\"noscript\",\"plaintext\",\"script\",\"style\",\"svg\",\"template\",\"thead\",\"title\",\"video\",\"xmp\"]);let _t=null;const St=addToSet({},[\"audio\",\"video\",\"img\",\"source\",\"image\",\"track\"]);let Et=null;const wt=addToSet({},[\"alt\",\"class\",\"for\",\"id\",\"label\",\"name\",\"pattern\",\"placeholder\",\"role\",\"summary\",\"title\",\"value\",\"style\",\"xmlns\"]),xt=\"http://www.w3.org/1998/Math/MathML\",kt=\"http://www.w3.org/2000/svg\",Ot=\"http://www.w3.org/1999/xhtml\";let At=Ot,Ct=!1,jt=null;const Pt=addToSet({},[xt,kt,Ot],Aj);let It=addToSet({},[\"mi\",\"mo\",\"mn\",\"ms\",\"mtext\"]),Tt=addToSet({},[\"annotation-xml\"]);const Nt=addToSet({},[\"title\",\"style\",\"font\",\"a\",\"script\"]);let Mt=null;const Rt=[\"application/xhtml+xml\",\"text/html\"];let Dt=null,Lt=null;const Ft=o.createElement(\"form\"),Bt=function isRegexOrFunction(s){return s instanceof RegExp||s instanceof Function},$t=function _parseConfig(){let s=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!Lt||Lt!==s){if(s&&\"object\"==typeof s||(s={}),s=clone(s),Mt=-1===Rt.indexOf(s.PARSER_MEDIA_TYPE)?\"text/html\":s.PARSER_MEDIA_TYPE,Dt=\"application/xhtml+xml\"===Mt?Aj:_j,qe=zj(s,\"ALLOWED_TAGS\")?addToSet({},s.ALLOWED_TAGS,Dt):ze,We=zj(s,\"ALLOWED_ATTR\")?addToSet({},s.ALLOWED_ATTR,Dt):He,jt=zj(s,\"ALLOWED_NAMESPACES\")?addToSet({},s.ALLOWED_NAMESPACES,Aj):Pt,Et=zj(s,\"ADD_URI_SAFE_ATTR\")?addToSet(clone(wt),s.ADD_URI_SAFE_ATTR,Dt):wt,_t=zj(s,\"ADD_DATA_URI_TAGS\")?addToSet(clone(St),s.ADD_DATA_URI_TAGS,Dt):St,vt=zj(s,\"FORBID_CONTENTS\")?addToSet({},s.FORBID_CONTENTS,Dt):bt,Xe=zj(s,\"FORBID_TAGS\")?addToSet({},s.FORBID_TAGS,Dt):clone({}),Qe=zj(s,\"FORBID_ATTR\")?addToSet({},s.FORBID_ATTR,Dt):clone({}),yt=!!zj(s,\"USE_PROFILES\")&&s.USE_PROFILES,et=!1!==s.ALLOW_ARIA_ATTR,tt=!1!==s.ALLOW_DATA_ATTR,rt=s.ALLOW_UNKNOWN_PROTOCOLS||!1,nt=!1!==s.ALLOW_SELF_CLOSE_IN_ATTR,st=s.SAFE_FOR_TEMPLATES||!1,ot=!1!==s.SAFE_FOR_XML,it=s.WHOLE_DOCUMENT||!1,lt=s.RETURN_DOM||!1,ut=s.RETURN_DOM_FRAGMENT||!1,pt=s.RETURN_TRUSTED_TYPE||!1,ct=s.FORCE_BODY||!1,ht=!1!==s.SANITIZE_DOM,dt=s.SANITIZE_NAMED_PROPS||!1,mt=!1!==s.KEEP_CONTENT,gt=s.IN_PLACE||!1,$e=s.ALLOWED_URI_REGEXP||fP,At=s.NAMESPACE||Ot,It=s.MATHML_TEXT_INTEGRATION_POINTS||It,Tt=s.HTML_INTEGRATION_POINTS||Tt,Ye=s.CUSTOM_ELEMENT_HANDLING||{},s.CUSTOM_ELEMENT_HANDLING&&Bt(s.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Ye.tagNameCheck=s.CUSTOM_ELEMENT_HANDLING.tagNameCheck),s.CUSTOM_ELEMENT_HANDLING&&Bt(s.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Ye.attributeNameCheck=s.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),s.CUSTOM_ELEMENT_HANDLING&&\"boolean\"==typeof s.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(Ye.allowCustomizedBuiltInElements=s.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),st&&(tt=!1),ut&&(lt=!0),yt&&(qe=addToSet({},sP),We=[],!0===yt.html&&(addToSet(qe,Gj),addToSet(We,oP)),!0===yt.svg&&(addToSet(qe,Xj),addToSet(We,iP),addToSet(We,cP)),!0===yt.svgFilters&&(addToSet(qe,eP),addToSet(We,iP),addToSet(We,cP)),!0===yt.mathMl&&(addToSet(qe,rP),addToSet(We,aP),addToSet(We,cP))),s.ADD_TAGS&&(qe===ze&&(qe=clone(qe)),addToSet(qe,s.ADD_TAGS,Dt)),s.ADD_ATTR&&(We===He&&(We=clone(We)),addToSet(We,s.ADD_ATTR,Dt)),s.ADD_URI_SAFE_ATTR&&addToSet(Et,s.ADD_URI_SAFE_ATTR,Dt),s.FORBID_CONTENTS&&(vt===bt&&(vt=clone(vt)),addToSet(vt,s.FORBID_CONTENTS,Dt)),mt&&(qe[\"#text\"]=!0),it&&addToSet(qe,[\"html\",\"head\",\"body\"]),qe.table&&(addToSet(qe,[\"tbody\"]),delete Xe.tbody),s.TRUSTED_TYPES_POLICY){if(\"function\"!=typeof s.TRUSTED_TYPES_POLICY.createHTML)throw Kj('TRUSTED_TYPES_POLICY configuration option must provide a \"createHTML\" hook.');if(\"function\"!=typeof s.TRUSTED_TYPES_POLICY.createScriptURL)throw Kj('TRUSTED_TYPES_POLICY configuration option must provide a \"createScriptURL\" hook.');ie=s.TRUSTED_TYPES_POLICY,ae=ie.createHTML(\"\")}else void 0===ie&&(ie=function _createTrustedTypesPolicy(s,o){if(\"object\"!=typeof s||\"function\"!=typeof s.createPolicy)return null;let i=null;const a=\"data-tt-policy-suffix\";o&&o.hasAttribute(a)&&(i=o.getAttribute(a));const u=\"dompurify\"+(i?\"#\"+i:\"\");try{return s.createPolicy(u,{createHTML:s=>s,createScriptURL:s=>s})}catch(s){return console.warn(\"TrustedTypes policy \"+u+\" could not be created.\"),null}}($,a)),null!==ie&&\"string\"==typeof ae&&(ae=ie.createHTML(\"\"));HC&&HC(s),Lt=s}},qt=addToSet({},[...Xj,...eP,...tP]),Ut=addToSet({},[...rP,...nP]),Vt=function _forceRemove(s){fj(DOMPurify.removed,{element:s});try{ee(s).removeChild(s)}catch(o){z(s)}},zt=function _removeAttribute(s,o){try{fj(DOMPurify.removed,{attribute:o.getAttributeNode(s),from:o})}catch(s){fj(DOMPurify.removed,{attribute:null,from:o})}if(o.removeAttribute(s),\"is\"===s)if(lt||ut)try{Vt(o)}catch(s){}else try{o.setAttribute(s,\"\")}catch(s){}},Wt=function _initDocument(s){let i=null,a=null;if(ct)s=\"<remove></remove>\"+s;else{const o=Cj(s,/^[\\r\\n\\t ]+/);a=o&&o[0]}\"application/xhtml+xml\"===Mt&&At===Ot&&(s='<html xmlns=\"http://www.w3.org/1999/xhtml\"><head></head><body>'+s+\"</body></html>\");const u=ie?ie.createHTML(s):s;if(At===Ot)try{i=(new B).parseFromString(u,Mt)}catch(s){}if(!i||!i.documentElement){i=ce.createDocument(At,\"template\",null);try{i.documentElement.innerHTML=Ct?ae:u}catch(s){}}const _=i.body||i.documentElement;return s&&a&&_.insertBefore(o.createTextNode(a),_.childNodes[0]||null),At===Ot?de.call(i,it?\"html\":\"body\")[0]:it?i.documentElement:_},Jt=function _createNodeIterator(s){return le.call(s.ownerDocument||s,s,C.SHOW_ELEMENT|C.SHOW_COMMENT|C.SHOW_TEXT|C.SHOW_PROCESSING_INSTRUCTION|C.SHOW_CDATA_SECTION,null)},Ht=function _isClobbered(s){return s instanceof L&&(\"string\"!=typeof s.nodeName||\"string\"!=typeof s.textContent||\"function\"!=typeof s.removeChild||!(s.attributes instanceof j)||\"function\"!=typeof s.removeAttribute||\"function\"!=typeof s.setAttribute||\"string\"!=typeof s.namespaceURI||\"function\"!=typeof s.insertBefore||\"function\"!=typeof s.hasChildNodes)},Kt=function _isNode(s){return\"function\"==typeof w&&s instanceof w};function _executeHooks(s,o,i){QC(s,(s=>{s.call(DOMPurify,o,i,Lt)}))}const Gt=function _sanitizeElements(s){let o=null;if(_executeHooks(ye.beforeSanitizeElements,s,null),Ht(s))return Vt(s),!0;const i=Dt(s.nodeName);if(_executeHooks(ye.uponSanitizeElement,s,{tagName:i,allowedTags:qe}),ot&&s.hasChildNodes()&&!Kt(s.firstElementChild)&&Jj(/<[/\\w!]/g,s.innerHTML)&&Jj(/<[/\\w!]/g,s.textContent))return Vt(s),!0;if(s.nodeType===EP)return Vt(s),!0;if(ot&&s.nodeType===wP&&Jj(/<[/\\w]/g,s.data))return Vt(s),!0;if(!qe[i]||Xe[i]){if(!Xe[i]&&Xt(i)){if(Ye.tagNameCheck instanceof RegExp&&Jj(Ye.tagNameCheck,i))return!1;if(Ye.tagNameCheck instanceof Function&&Ye.tagNameCheck(i))return!1}if(mt&&!vt[i]){const o=ee(s)||s.parentNode,i=Z(s)||s.childNodes;if(i&&o){for(let a=i.length-1;a>=0;--a){const u=V(i[a],!0);u.__removalCount=(s.__removalCount||0)+1,o.insertBefore(u,Y(s))}}}return Vt(s),!0}return s instanceof x&&!function _checkValidNamespace(s){let o=ee(s);o&&o.tagName||(o={namespaceURI:At,tagName:\"template\"});const i=_j(s.tagName),a=_j(o.tagName);return!!jt[s.namespaceURI]&&(s.namespaceURI===kt?o.namespaceURI===Ot?\"svg\"===i:o.namespaceURI===xt?\"svg\"===i&&(\"annotation-xml\"===a||It[a]):Boolean(qt[i]):s.namespaceURI===xt?o.namespaceURI===Ot?\"math\"===i:o.namespaceURI===kt?\"math\"===i&&Tt[a]:Boolean(Ut[i]):s.namespaceURI===Ot?!(o.namespaceURI===kt&&!Tt[a])&&!(o.namespaceURI===xt&&!It[a])&&!Ut[i]&&(Nt[i]||!qt[i]):!(\"application/xhtml+xml\"!==Mt||!jt[s.namespaceURI]))}(s)?(Vt(s),!0):\"noscript\"!==i&&\"noembed\"!==i&&\"noframes\"!==i||!Jj(/<\\/no(script|embed|frames)/i,s.innerHTML)?(st&&s.nodeType===SP&&(o=s.textContent,QC([be,_e,Se],(s=>{o=Nj(o,s,\" \")})),s.textContent!==o&&(fj(DOMPurify.removed,{element:s.cloneNode()}),s.textContent=o)),_executeHooks(ye.afterSanitizeElements,s,null),!1):(Vt(s),!0)},Yt=function _isValidAttribute(s,i,a){if(ht&&(\"id\"===i||\"name\"===i)&&(a in o||a in Ft))return!1;if(tt&&!Qe[i]&&Jj(we,i));else if(et&&Jj(xe,i));else if(!We[i]||Qe[i]){if(!(Xt(s)&&(Ye.tagNameCheck instanceof RegExp&&Jj(Ye.tagNameCheck,s)||Ye.tagNameCheck instanceof Function&&Ye.tagNameCheck(s))&&(Ye.attributeNameCheck instanceof RegExp&&Jj(Ye.attributeNameCheck,i)||Ye.attributeNameCheck instanceof Function&&Ye.attributeNameCheck(i))||\"is\"===i&&Ye.allowCustomizedBuiltInElements&&(Ye.tagNameCheck instanceof RegExp&&Jj(Ye.tagNameCheck,a)||Ye.tagNameCheck instanceof Function&&Ye.tagNameCheck(a))))return!1}else if(Et[i]);else if(Jj($e,Nj(a,Te,\"\")));else if(\"src\"!==i&&\"xlink:href\"!==i&&\"href\"!==i||\"script\"===s||0!==Bj(a,\"data:\")||!_t[s]){if(rt&&!Jj(Pe,Nj(a,Te,\"\")));else if(a)return!1}else;return!0},Xt=function _isBasicCustomElement(s){return\"annotation-xml\"!==s&&Cj(s,Re)},Qt=function _sanitizeAttributes(s){_executeHooks(ye.beforeSanitizeAttributes,s,null);const{attributes:o}=s;if(!o||Ht(s))return;const i={attrName:\"\",attrValue:\"\",keepAttr:!0,allowedAttributes:We,forceKeepAttr:void 0};let a=o.length;for(;a--;){const u=o[a],{name:_,namespaceURI:w,value:x}=u,C=Dt(_),j=x;let L=\"value\"===_?j:$j(j);if(i.attrName=C,i.attrValue=L,i.keepAttr=!0,i.forceKeepAttr=void 0,_executeHooks(ye.uponSanitizeAttribute,s,i),L=i.attrValue,!dt||\"id\"!==C&&\"name\"!==C||(zt(_,s),L=\"user-content-\"+L),ot&&Jj(/((--!?|])>)|<\\/(style|title)/i,L)){zt(_,s);continue}if(i.forceKeepAttr)continue;if(!i.keepAttr){zt(_,s);continue}if(!nt&&Jj(/\\/>/i,L)){zt(_,s);continue}st&&QC([be,_e,Se],(s=>{L=Nj(L,s,\" \")}));const B=Dt(s.nodeName);if(Yt(B,C,L)){if(ie&&\"object\"==typeof $&&\"function\"==typeof $.getAttributeType)if(w);else switch($.getAttributeType(B,C)){case\"TrustedHTML\":L=ie.createHTML(L);break;case\"TrustedScriptURL\":L=ie.createScriptURL(L)}if(L!==j)try{w?s.setAttributeNS(w,_,L):s.setAttribute(_,L),Ht(s)?Vt(s):ej(DOMPurify.removed)}catch(o){zt(_,s)}}else zt(_,s)}_executeHooks(ye.afterSanitizeAttributes,s,null)},Zt=function _sanitizeShadowDOM(s){let o=null;const i=Jt(s);for(_executeHooks(ye.beforeSanitizeShadowDOM,s,null);o=i.nextNode();)_executeHooks(ye.uponSanitizeShadowNode,o,null),Gt(o),Qt(o),o.content instanceof u&&_sanitizeShadowDOM(o.content);_executeHooks(ye.afterSanitizeShadowDOM,s,null)};return DOMPurify.sanitize=function(s){let o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},a=null,_=null,x=null,C=null;if(Ct=!s,Ct&&(s=\"\\x3c!--\\x3e\"),\"string\"!=typeof s&&!Kt(s)){if(\"function\"!=typeof s.toString)throw Kj(\"toString is not a function\");if(\"string\"!=typeof(s=s.toString()))throw Kj(\"dirty is not a string, aborting\")}if(!DOMPurify.isSupported)return s;if(at||$t(o),DOMPurify.removed=[],\"string\"==typeof s&&(gt=!1),gt){if(s.nodeName){const o=Dt(s.nodeName);if(!qe[o]||Xe[o])throw Kj(\"root node is forbidden and cannot be sanitized in-place\")}}else if(s instanceof w)a=Wt(\"\\x3c!----\\x3e\"),_=a.ownerDocument.importNode(s,!0),_.nodeType===_P&&\"BODY\"===_.nodeName||\"HTML\"===_.nodeName?a=_:a.appendChild(_);else{if(!lt&&!st&&!it&&-1===s.indexOf(\"<\"))return ie&&pt?ie.createHTML(s):s;if(a=Wt(s),!a)return lt?null:pt?ae:\"\"}a&&ct&&Vt(a.firstChild);const j=Jt(gt?s:a);for(;x=j.nextNode();)Gt(x),Qt(x),x.content instanceof u&&Zt(x.content);if(gt)return s;if(lt){if(ut)for(C=pe.call(a.ownerDocument);a.firstChild;)C.appendChild(a.firstChild);else C=a;return(We.shadowroot||We.shadowrootmode)&&(C=fe.call(i,C,!0)),C}let L=it?a.outerHTML:a.innerHTML;return it&&qe[\"!doctype\"]&&a.ownerDocument&&a.ownerDocument.doctype&&a.ownerDocument.doctype.name&&Jj(yP,a.ownerDocument.doctype.name)&&(L=\"<!DOCTYPE \"+a.ownerDocument.doctype.name+\">\\n\"+L),st&&QC([be,_e,Se],(s=>{L=Nj(L,s,\" \")})),ie&&pt?ie.createHTML(L):L},DOMPurify.setConfig=function(){$t(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),at=!0},DOMPurify.clearConfig=function(){Lt=null,at=!1},DOMPurify.isValidAttribute=function(s,o,i){Lt||$t({});const a=Dt(s),u=Dt(o);return Yt(a,u,i)},DOMPurify.addHook=function(s,o){\"function\"==typeof o&&fj(ye[s],o)},DOMPurify.removeHook=function(s,o){if(void 0!==o){const i=ZC(ye[s],o);return-1===i?void 0:mj(ye[s],i,1)[0]}return ej(ye[s])},DOMPurify.removeHooks=function(s){ye[s]=[]},DOMPurify.removeAllHooks=function(){ye={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},DOMPurify}();OP.addHook&&OP.addHook(\"beforeSanitizeElements\",(function(s){return s.href&&s.setAttribute(\"rel\",\"noopener noreferrer\"),s}));const AP=function Markdown({source:s,className:o=\"\",getConfigs:i=()=>({useUnsafeMarkdown:!1})}){if(\"string\"!=typeof s)return null;const a=new Remarkable({html:!0,typographer:!0,breaks:!0,linkTarget:\"_blank\"}).use(linkify);a.core.ruler.disable([\"replacements\",\"smartquotes\"]);const{useUnsafeMarkdown:u}=i(),_=a.render(s),w=sanitizer(_,{useUnsafeMarkdown:u});return s&&_&&w?Re.createElement(\"div\",{className:Jn()(o,\"markdown\"),dangerouslySetInnerHTML:{__html:w}}):null};function sanitizer(s,{useUnsafeMarkdown:o=!1}={}){const i=o,a=o?[]:[\"style\",\"class\"];return o&&!sanitizer.hasWarnedAboutDeprecation&&(console.warn(\"useUnsafeMarkdown display configuration parameter is deprecated since >3.26.0 and will be removed in v4.0.0.\"),sanitizer.hasWarnedAboutDeprecation=!0),OP.sanitize(s,{ADD_ATTR:[\"target\"],FORBID_TAGS:[\"style\",\"form\"],ALLOW_DATA_ATTR:i,FORBID_ATTR:a})}sanitizer.hasWarnedAboutDeprecation=!1;class BaseLayout extends Re.Component{render(){const{errSelectors:s,specSelectors:o,getComponent:i}=this.props,a=i(\"SvgAssets\"),u=i(\"InfoContainer\",!0),_=i(\"VersionPragmaFilter\"),w=i(\"operations\",!0),x=i(\"Models\",!0),C=i(\"Webhooks\",!0),j=i(\"Row\"),L=i(\"Col\"),B=i(\"errors\",!0),$=i(\"ServersContainer\",!0),U=i(\"SchemesContainer\",!0),V=i(\"AuthorizeBtnContainer\",!0),z=i(\"FilterContainer\",!0),Y=o.isSwagger2(),Z=o.isOAS3(),ee=o.isOAS31(),ie=!o.specStr(),ae=o.loadingStatus();let ce=null;if(\"loading\"===ae&&(ce=Re.createElement(\"div\",{className:\"info\"},Re.createElement(\"div\",{className:\"loading-container\"},Re.createElement(\"div\",{className:\"loading\"})))),\"failed\"===ae&&(ce=Re.createElement(\"div\",{className:\"info\"},Re.createElement(\"div\",{className:\"loading-container\"},Re.createElement(\"h4\",{className:\"title\"},\"Failed to load API definition.\"),Re.createElement(B,null)))),\"failedConfig\"===ae){const o=s.lastError(),i=o?o.get(\"message\"):\"\";ce=Re.createElement(\"div\",{className:\"info failed-config\"},Re.createElement(\"div\",{className:\"loading-container\"},Re.createElement(\"h4\",{className:\"title\"},\"Failed to load remote configuration.\"),Re.createElement(\"p\",null,i)))}if(!ce&&ie&&(ce=Re.createElement(\"h4\",null,\"No API definition provided.\")),ce)return Re.createElement(\"div\",{className:\"swagger-ui\"},Re.createElement(\"div\",{className:\"loading-container\"},ce));const le=o.servers(),pe=o.schemes(),de=le&&le.size,fe=pe&&pe.size,ye=!!o.securityDefinitions();return Re.createElement(\"div\",{className:\"swagger-ui\"},Re.createElement(a,null),Re.createElement(_,{isSwagger2:Y,isOAS3:Z,alsoShow:Re.createElement(B,null)},Re.createElement(B,null),Re.createElement(j,{className:\"information-container\"},Re.createElement(L,{mobile:12},Re.createElement(u,null))),de||fe||ye?Re.createElement(\"div\",{className:\"scheme-container\"},Re.createElement(L,{className:\"schemes wrapper\",mobile:12},de||fe?Re.createElement(\"div\",{className:\"schemes-server-container\"},de?Re.createElement($,null):null,fe?Re.createElement(U,null):null):null,ye?Re.createElement(V,null):null)):null,Re.createElement(z,null),Re.createElement(j,null,Re.createElement(L,{mobile:12,desktop:12},Re.createElement(w,null))),ee&&Re.createElement(j,{className:\"webhooks-container\"},Re.createElement(L,{mobile:12,desktop:12},Re.createElement(C,null))),Re.createElement(j,null,Re.createElement(L,{mobile:12,desktop:12},Re.createElement(x,null)))))}}const core_components=()=>({components:{App:KO,authorizationPopup:AuthorizationPopup,authorizeBtn:AuthorizeBtn,AuthorizeBtnContainer,authorizeOperationBtn:AuthorizeOperationBtn,auths:Auths,AuthItem:auth_item_Auths,authError:AuthError,oauth2:Oauth2,apiKeyAuth:ApiKeyAuth,basicAuth:BasicAuth,clear:Clear,liveResponse:LiveResponse,InitializedInput,info:nA,InfoContainer,InfoUrl,InfoBasePath,Contact:sA,License:oA,JumpToPath,CopyToClipboardBtn,onlineValidatorBadge:OnlineValidatorBadge,operations:Operations,operation:operation_Operation,OperationSummary,OperationSummaryMethod,OperationSummaryPath,responses:responses_Responses,response:response_Response,ResponseExtension:response_extension,responseBody:ResponseBody,parameters:Parameters,parameterRow:ParameterRow,execute:Execute,headers:headers_Headers,errors:Errors,contentType:ContentType,overview:Overview,footer:Footer,FilterContainer,ParamBody,curl:Curl,Property:property,TryItOutButton,Markdown:AP,BaseLayout,VersionPragmaFilter,VersionStamp:version_stamp,OperationExt:operation_extensions,OperationExtRow:operation_extension_row,ParameterExt:parameter_extension,ParameterIncludeEmpty,OperationTag,OperationContainer,OpenAPIVersion:openapi_version,DeepLink:deep_link,SvgAssets:svg_assets,Example:example_Example,ExamplesSelect,ExamplesSelectValueRetainer}}),form_components=()=>({components:{..._e}}),base=()=>[configsPlugin,util,logs,view,view_legacy,plugins_spec,err,icons,plugins_layout,json_schema_5,json_schema_5_samples,core_components,form_components,swagger_client,auth,downloadUrlPlugin,deep_linking,filter,on_complete,plugins_request_snippets,syntax_highlighting,versions,safe_render()],CP=(0,ze.Map)();function onlyOAS3(s){return(o,i)=>(...a)=>{if(i.getSystem().specSelectors.isOAS3()){const o=s(...a);return\"function\"==typeof o?o(i):o}return o(...a)}}const jP=onlyOAS3(xs()(null)),PP=onlyOAS3(((s,o)=>s=>s.getSystem().specSelectors.findSchema(o))),IP=onlyOAS3((()=>s=>{const o=s.getSystem().specSelectors.specJson().getIn([\"components\",\"schemas\"]);return ze.Map.isMap(o)?o:CP})),TP=onlyOAS3((()=>s=>s.getSystem().specSelectors.specJson().hasIn([\"servers\",0]))),NP=onlyOAS3(Ut(Ns,(s=>s.getIn([\"components\",\"securitySchemes\"])||null))),wrap_selectors_validOperationMethods=(s,o)=>(i,...a)=>o.specSelectors.isOAS3()?o.oas3Selectors.validOperationMethods():s(...a),MP=jP,RP=jP,DP=jP,LP=jP,FP=jP;const BP=function wrap_selectors_onlyOAS3(s){return(o,i)=>(...a)=>{if(i.getSystem().specSelectors.isOAS3()){let o=i.getState().getIn([\"spec\",\"resolvedSubtrees\",\"components\",\"securitySchemes\"]);return s(i,o,...a)}return o(...a)}}(Ut((s=>s),(({specSelectors:s})=>s.securityDefinitions()),((s,o)=>{let i=(0,ze.List)();return o?(o.entrySeq().forEach((([s,o])=>{const a=o?.get(\"type\");if(\"oauth2\"===a&&o.get(\"flows\").entrySeq().forEach((([a,u])=>{let _=(0,ze.fromJS)({flow:a,authorizationUrl:u.get(\"authorizationUrl\"),tokenUrl:u.get(\"tokenUrl\"),scopes:u.get(\"scopes\"),type:o.get(\"type\"),description:o.get(\"description\")});i=i.push(new ze.Map({[s]:_.filter((s=>void 0!==s))}))})),\"http\"!==a&&\"apiKey\"!==a||(i=i.push(new ze.Map({[s]:o}))),\"openIdConnect\"===a&&o.get(\"openIdConnectData\")){let a=o.get(\"openIdConnectData\");(a.get(\"grant_types_supported\")||[\"authorization_code\",\"implicit\"]).forEach((u=>{let _=a.get(\"scopes_supported\")&&a.get(\"scopes_supported\").reduce(((s,o)=>s.set(o,\"\")),new ze.Map),w=(0,ze.fromJS)({flow:u,authorizationUrl:a.get(\"authorization_endpoint\"),tokenUrl:a.get(\"token_endpoint\"),scopes:_,type:\"oauth2\",openIdConnectUrl:o.get(\"openIdConnectUrl\")});i=i.push(new ze.Map({[s]:w.filter((s=>void 0!==s))}))}))}})),i):i})));function OAS3ComponentWrapFactory(s){return(o,i)=>a=>\"function\"==typeof i.specSelectors?.isOAS3?i.specSelectors.isOAS3()?Re.createElement(s,Mn()({},a,i,{Ori:o})):Re.createElement(o,a):(console.warn(\"OAS3 wrapper: couldn't get spec\"),null)}const $P=(0,ze.Map)(),selectors_isSwagger2=()=>s=>function isSwagger2(s){const o=s.get(\"swagger\");return\"string\"==typeof o&&\"2.0\"===o}(s.getSystem().specSelectors.specJson()),selectors_isOAS30=()=>s=>function isOAS30(s){const o=s.get(\"openapi\");return\"string\"==typeof o&&/^3\\.0\\.(?:[1-9]\\d*|0)$/.test(o)}(s.getSystem().specSelectors.specJson()),selectors_isOAS3=()=>s=>s.getSystem().specSelectors.isOAS30();function selectors_onlyOAS3(s){return(o,...i)=>a=>{if(a.specSelectors.isOAS3()){const u=s(o,...i);return\"function\"==typeof u?u(a):u}return null}}const qP=selectors_onlyOAS3((()=>s=>s.specSelectors.specJson().get(\"servers\",$P))),findSchema=(s,o)=>{const i=s.getIn([\"resolvedSubtrees\",\"components\",\"schemas\",o],null),a=s.getIn([\"json\",\"components\",\"schemas\",o],null);return i||a||null},UP=selectors_onlyOAS3(((s,{callbacks:o,specPath:i})=>s=>{const a=s.specSelectors.validOperationMethods();return ze.Map.isMap(o)?o.reduce(((s,o,u)=>{if(!ze.Map.isMap(o))return s;const _=o.reduce(((s,o,_)=>{if(!ze.Map.isMap(o))return s;const w=o.entrySeq().filter((([s])=>a.includes(s))).map((([s,o])=>({operation:(0,ze.Map)({operation:o}),method:s,path:_,callbackName:u,specPath:i.concat([u,_,s])})));return s.concat(w)}),(0,ze.List)());return s.concat(_)}),(0,ze.List)()).groupBy((s=>s.callbackName)).map((s=>s.toArray())).toObject():{}})),callbacks=({callbacks:s,specPath:o,specSelectors:i,getComponent:a})=>{const u=i.callbacksOperations({callbacks:s,specPath:o}),_=Object.keys(u),w=a(\"OperationContainer\",!0);return 0===_.length?Re.createElement(\"span\",null,\"No callbacks\"):Re.createElement(\"div\",null,_.map((s=>Re.createElement(\"div\",{key:`${s}`},Re.createElement(\"h2\",null,s),u[s].map((o=>Re.createElement(w,{key:`${s}-${o.path}-${o.method}`,op:o.operation,tag:\"callbacks\",method:o.method,path:o.path,specPath:o.specPath,allowTryItOut:!1})))))))},getDefaultRequestBodyValue=(s,o,i,a)=>{const u=s.getIn([\"content\",o])??(0,ze.OrderedMap)(),_=u.get(\"schema\",(0,ze.OrderedMap)()).toJS(),w=void 0!==u.get(\"examples\"),x=u.get(\"example\"),C=w?u.getIn([\"examples\",i,\"value\"]):x;return stringify(a.getSampleSchema(_,o,{includeWriteOnly:!0},C))},components_request_body=({userHasEditedBody:s,requestBody:o,requestBodyValue:i,requestBodyInclusionSetting:a,requestBodyErrors:u,getComponent:_,getConfigs:w,specSelectors:x,fn:C,contentType:j,isExecute:L,specPath:B,onChange:$,onChangeIncludeEmpty:U,activeExamplesKey:V,updateActiveExamplesKey:z,setRetainRequestBodyValueFlag:Y})=>{const handleFile=s=>{$(s.target.files[0])},setIsIncludedOptions=s=>{let o={key:s,shouldDispatchInit:!1,defaultValue:!0};return\"no value\"===a.get(s,\"no value\")&&(o.shouldDispatchInit=!0),o},Z=_(\"Markdown\",!0),ee=_(\"modelExample\"),ie=_(\"RequestBodyEditor\"),ae=_(\"HighlightCode\",!0),ce=_(\"ExamplesSelectValueRetainer\"),le=_(\"Example\"),pe=_(\"ParameterIncludeEmpty\"),{showCommonExtensions:de}=w(),fe=o?.get(\"description\")??null,ye=o?.get(\"content\")??new ze.OrderedMap;j=j||ye.keySeq().first()||\"\";const be=ye.get(j)??(0,ze.OrderedMap)(),_e=be.get(\"schema\",(0,ze.OrderedMap)()),Se=be.get(\"examples\",null),we=Se?.map(((s,i)=>{const a=s?.get(\"value\",null);return a&&(s=s.set(\"value\",getDefaultRequestBodyValue(o,j,i,C),a)),s}));u=ze.List.isList(u)?u:(0,ze.List)();if(C.isFileUploadIntended(be?.get(\"schema\"),j)){const s=_(\"Input\");return L?Re.createElement(s,{type:\"file\",onChange:handleFile}):Re.createElement(\"i\",null,\"Example values are not available for \",Re.createElement(\"code\",null,j),\" media types.\")}if(!be.size)return null;if(C.hasSchemaType(be.get(\"schema\"),\"object\")&&(\"application/x-www-form-urlencoded\"===j||0===j.indexOf(\"multipart/\"))&&_e.get(\"properties\",(0,ze.OrderedMap)()).size>0){const s=_(\"JsonSchemaForm\"),o=_(\"ParameterExt\"),j=_e.get(\"properties\",(0,ze.OrderedMap)());return i=ze.Map.isMap(i)?i:(0,ze.OrderedMap)(),Re.createElement(\"div\",{className:\"table-container\"},fe&&Re.createElement(Z,{source:fe}),Re.createElement(\"table\",null,Re.createElement(\"tbody\",null,ze.Map.isMap(j)&&j.entrySeq().map((([j,V])=>{if(V.get(\"readOnly\"))return;const z=V.get(\"oneOf\")?.get(0)?.toJS(),Y=V.get(\"anyOf\")?.get(0)?.toJS();V=(0,ze.fromJS)(C.mergeJsonSchema(V.toJS(),z??Y??{}));let ie=de?getCommonExtensions(V):null;const ae=_e.get(\"required\",(0,ze.List)()).includes(j),ce=C.getSchemaObjectType(V),le=C.getSchemaObjectTypeLabel(V),fe=C.getSchemaObjectType(V?.get(\"items\")),ye=V.get(\"format\"),be=V.get(\"description\"),Se=i.getIn([j,\"value\"]),we=i.getIn([j,\"errors\"])||u,xe=a.get(j)||!1;let Pe=C.getSampleSchema(V,!1,{includeWriteOnly:!0});!1===Pe&&(Pe=\"false\"),0===Pe&&(Pe=\"0\"),\"string\"!=typeof Pe&&\"object\"===ce&&(Pe=stringify(Pe)),\"string\"==typeof Pe&&\"array\"===ce&&(Pe=JSON.parse(Pe));const Te=C.isFileUploadIntended(V),$e=Re.createElement(s,{fn:C,dispatchInitialValue:!Te,schema:V,description:j,getComponent:_,value:void 0===Se?Pe:Se,required:ae,errors:we,onChange:s=>{$(s,[j])}});return Re.createElement(\"tr\",{key:j,className:\"parameters\",\"data-property-name\":j},Re.createElement(\"td\",{className:\"parameters-col_name\"},Re.createElement(\"div\",{className:ae?\"parameter__name required\":\"parameter__name\"},j,ae?Re.createElement(\"span\",null,\" *\"):null),Re.createElement(\"div\",{className:\"parameter__type\"},le,ye&&Re.createElement(\"span\",{className:\"prop-format\"},\"($\",ye,\")\"),de&&ie.size?ie.entrySeq().map((([s,i])=>Re.createElement(o,{key:`${s}-${i}`,xKey:s,xVal:i}))):null),Re.createElement(\"div\",{className:\"parameter__deprecated\"},V.get(\"deprecated\")?\"deprecated\":null)),Re.createElement(\"td\",{className:\"parameters-col_description\"},Re.createElement(Z,{source:be}),L?Re.createElement(\"div\",null,\"object\"===ce||\"object\"===fe?Re.createElement(ee,{getComponent:_,specPath:B.push(\"schema\"),getConfigs:w,isExecute:L,specSelectors:x,schema:V,example:$e}):$e,ae?null:Re.createElement(pe,{onChange:s=>U(j,s),isIncluded:xe,isIncludedOptions:setIsIncludedOptions(j),isDisabled:Array.isArray(Se)?0!==Se.length:!isEmptyValue(Se)})):null))})))))}const xe=getDefaultRequestBodyValue(o,j,V,C);let Pe=null;getKnownSyntaxHighlighterLanguage(xe)&&(Pe=\"json\");const Te=L?Re.createElement(ie,{value:i,errors:u,defaultValue:xe,onChange:$,getComponent:_}):Re.createElement(ae,{className:\"body-param__example\",language:Pe},stringify(i)||xe);return Re.createElement(\"div\",null,fe&&Re.createElement(Z,{source:fe}),we?Re.createElement(ce,{userHasEditedBody:s,examples:we,currentKey:V,currentUserInputValue:i,onSelect:s=>{z(s)},updateValue:$,defaultToFirstExample:!0,getComponent:_,setRetainRequestBodyValueFlag:Y}):null,Re.createElement(ee,{getComponent:_,getConfigs:w,specSelectors:x,expandDepth:1,isExecute:L,schema:be.get(\"schema\"),specPath:B.push(\"content\",j,\"schema\"),example:Te,includeWriteOnly:!0}),we?Re.createElement(le,{example:we.get(V),getComponent:_,getConfigs:w}):null)};class operation_link_OperationLink extends Re.Component{render(){const{link:s,name:o,getComponent:i}=this.props,a=i(\"Markdown\",!0);let u=s.get(\"operationId\")||s.get(\"operationRef\"),_=s.get(\"parameters\")&&s.get(\"parameters\").toJS(),w=s.get(\"description\");return Re.createElement(\"div\",{className:\"operation-link\"},Re.createElement(\"div\",{className:\"description\"},Re.createElement(\"b\",null,Re.createElement(\"code\",null,o)),w?Re.createElement(a,{source:w}):null),Re.createElement(\"pre\",null,\"Operation `\",u,\"`\",Re.createElement(\"br\",null),Re.createElement(\"br\",null),\"Parameters \",function padString(s,o){if(\"string\"!=typeof o)return\"\";return o.split(\"\\n\").map(((o,i)=>i>0?Array(s+1).join(\" \")+o:o)).join(\"\\n\")}(0,JSON.stringify(_,null,2))||\"{}\",Re.createElement(\"br\",null)))}}const VP=operation_link_OperationLink,components_servers=({servers:s,currentServer:o,setSelectedServer:i,setServerVariableValue:a,getServerVariable:u,getEffectiveServerValue:_})=>{const w=(s.find((s=>s.get(\"url\")===o))||(0,ze.OrderedMap)()).get(\"variables\")||(0,ze.OrderedMap)(),x=0!==w.size;(0,Re.useEffect)((()=>{o||i(s.first()?.get(\"url\"))}),[]),(0,Re.useEffect)((()=>{const u=s.find((s=>s.get(\"url\")===o));if(!u)return void i(s.first().get(\"url\"));(u.get(\"variables\")||(0,ze.OrderedMap)()).map(((s,i)=>{a({server:o,key:i,val:s.get(\"default\")||\"\"})}))}),[o,s]);const C=(0,Re.useCallback)((s=>{i(s.target.value)}),[i]),j=(0,Re.useCallback)((s=>{const i=s.target.getAttribute(\"data-variable\"),u=s.target.value;a({server:o,key:i,val:u})}),[a,o]);return Re.createElement(\"div\",{className:\"servers\"},Re.createElement(\"label\",{htmlFor:\"servers\"},Re.createElement(\"select\",{onChange:C,value:o,id:\"servers\"},s.valueSeq().map((s=>Re.createElement(\"option\",{value:s.get(\"url\"),key:s.get(\"url\")},s.get(\"url\"),s.get(\"description\")&&` - ${s.get(\"description\")}`))).toArray())),x&&Re.createElement(\"div\",null,Re.createElement(\"div\",{className:\"computed-url\"},\"Computed URL:\",Re.createElement(\"code\",null,_(o))),Re.createElement(\"h4\",null,\"Server variables\"),Re.createElement(\"table\",null,Re.createElement(\"tbody\",null,w.entrySeq().map((([s,i])=>Re.createElement(\"tr\",{key:s},Re.createElement(\"td\",null,s),Re.createElement(\"td\",null,i.get(\"enum\")?Re.createElement(\"select\",{\"data-variable\":s,onChange:j},i.get(\"enum\").map((i=>Re.createElement(\"option\",{selected:i===u(o,s),key:i,value:i},i)))):Re.createElement(\"input\",{type:\"text\",value:u(o,s)||\"\",onChange:j,\"data-variable\":s})))))))))};class ServersContainer extends Re.Component{render(){const{specSelectors:s,oas3Selectors:o,oas3Actions:i,getComponent:a}=this.props,u=s.servers(),_=a(\"Servers\");return u&&u.size?Re.createElement(\"div\",null,Re.createElement(\"span\",{className:\"servers-title\"},\"Servers\"),Re.createElement(_,{servers:u,currentServer:o.selectedServer(),setSelectedServer:i.setSelectedServer,setServerVariableValue:i.setServerVariableValue,getServerVariable:o.serverVariableValue,getEffectiveServerValue:o.serverEffectiveValue})):null}}const zP=Function.prototype;class RequestBodyEditor extends Re.PureComponent{static defaultProps={onChange:zP,userHasEditedBody:!1};constructor(s,o){super(s,o),this.state={value:stringify(s.value)||s.defaultValue},s.onChange(s.value)}applyDefaultValue=s=>{const{onChange:o,defaultValue:i}=s||this.props;return this.setState({value:i}),o(i)};onChange=s=>{this.props.onChange(stringify(s))};onDomChange=s=>{const o=s.target.value;this.setState({value:o},(()=>this.onChange(o)))};UNSAFE_componentWillReceiveProps(s){this.props.value!==s.value&&s.value!==this.state.value&&this.setState({value:stringify(s.value)}),!s.value&&s.defaultValue&&this.state.value&&this.applyDefaultValue(s)}render(){let{getComponent:s,errors:o}=this.props,{value:i}=this.state,a=o.size>0;const u=s(\"TextArea\");return Re.createElement(\"div\",{className:\"body-param\"},Re.createElement(u,{className:Jn()(\"body-param__text\",{invalid:a}),title:o.size?o.join(\", \"):\"\",value:i,onChange:this.onDomChange}))}}class HttpAuth extends Re.Component{constructor(s,o){super(s,o);let{name:i,schema:a}=this.props,u=this.getValue();this.state={name:i,schema:a,value:u}}getValue(){let{name:s,authorized:o}=this.props;return o&&o.getIn([s,\"value\"])}onChange=s=>{let{onChange:o}=this.props,{value:i,name:a}=s.target,u=Object.assign({},this.state.value);a?u[a]=i:u=i,this.setState({value:u},(()=>o(this.state)))};render(){let{schema:s,getComponent:o,errSelectors:i,name:a,authSelectors:u}=this.props;const _=o(\"Input\"),w=o(\"Row\"),x=o(\"Col\"),C=o(\"authError\"),j=o(\"Markdown\",!0),L=o(\"JumpToPath\",!0),B=(s.get(\"scheme\")||\"\").toLowerCase(),$=u.selectAuthPath(a);let U=this.getValue(),V=i.allErrors().filter((s=>s.get(\"authId\")===a));if(\"basic\"===B){let o=U?U.get(\"username\"):null;return Re.createElement(\"div\",null,Re.createElement(\"h4\",null,Re.createElement(\"code\",null,a),\"  (http, Basic)\",Re.createElement(L,{path:$})),o&&Re.createElement(\"h6\",null,\"Authorized\"),Re.createElement(w,null,Re.createElement(j,{source:s.get(\"description\")})),Re.createElement(w,null,Re.createElement(\"label\",{htmlFor:\"auth-basic-username\"},\"Username:\"),o?Re.createElement(\"code\",null,\" \",o,\" \"):Re.createElement(x,null,Re.createElement(_,{id:\"auth-basic-username\",type:\"text\",required:\"required\",name:\"username\",\"aria-label\":\"auth-basic-username\",onChange:this.onChange,autoFocus:!0}))),Re.createElement(w,null,Re.createElement(\"label\",{htmlFor:\"auth-basic-password\"},\"Password:\"),o?Re.createElement(\"code\",null,\" ****** \"):Re.createElement(x,null,Re.createElement(_,{id:\"auth-basic-password\",autoComplete:\"new-password\",name:\"password\",type:\"password\",\"aria-label\":\"auth-basic-password\",onChange:this.onChange}))),V.valueSeq().map(((s,o)=>Re.createElement(C,{error:s,key:o}))))}return\"bearer\"===B?Re.createElement(\"div\",null,Re.createElement(\"h4\",null,Re.createElement(\"code\",null,a),\"  (http, Bearer)\",Re.createElement(L,{path:$})),U&&Re.createElement(\"h6\",null,\"Authorized\"),Re.createElement(w,null,Re.createElement(j,{source:s.get(\"description\")})),Re.createElement(w,null,Re.createElement(\"label\",{htmlFor:\"auth-bearer-value\"},\"Value:\"),U?Re.createElement(\"code\",null,\" ****** \"):Re.createElement(x,null,Re.createElement(_,{id:\"auth-bearer-value\",type:\"text\",\"aria-label\":\"auth-bearer-value\",onChange:this.onChange,autoFocus:!0}))),V.valueSeq().map(((s,o)=>Re.createElement(C,{error:s,key:o})))):Re.createElement(\"div\",null,Re.createElement(\"em\",null,Re.createElement(\"b\",null,a),\" HTTP authentication: unsupported scheme \",`'${B}'`))}}class operation_servers_OperationServers extends Re.Component{setSelectedServer=s=>{const{path:o,method:i}=this.props;return this.forceUpdate(),this.props.setSelectedServer(s,`${o}:${i}`)};setServerVariableValue=s=>{const{path:o,method:i}=this.props;return this.forceUpdate(),this.props.setServerVariableValue({...s,namespace:`${o}:${i}`})};getSelectedServer=()=>{const{path:s,method:o}=this.props;return this.props.getSelectedServer(`${s}:${o}`)};getServerVariable=(s,o)=>{const{path:i,method:a}=this.props;return this.props.getServerVariable({namespace:`${i}:${a}`,server:s},o)};getEffectiveServerValue=s=>{const{path:o,method:i}=this.props;return this.props.getEffectiveServerValue({server:s,namespace:`${o}:${i}`})};render(){const{operationServers:s,pathServers:o,getComponent:i}=this.props;if(!s&&!o)return null;const a=i(\"Servers\"),u=s||o,_=s?\"operation\":\"path\";return Re.createElement(\"div\",{className:\"opblock-section operation-servers\"},Re.createElement(\"div\",{className:\"opblock-section-header\"},Re.createElement(\"div\",{className:\"tab-header\"},Re.createElement(\"h4\",{className:\"opblock-title\"},\"Servers\"))),Re.createElement(\"div\",{className:\"opblock-description-wrapper\"},Re.createElement(\"h4\",{className:\"message\"},\"These \",_,\"-level options override the global server options.\"),Re.createElement(a,{servers:u,currentServer:this.getSelectedServer(),setSelectedServer:this.setSelectedServer,setServerVariableValue:this.setServerVariableValue,getServerVariable:this.getServerVariable,getEffectiveServerValue:this.getEffectiveServerValue})))}}const WP={Callbacks:callbacks,HttpAuth,RequestBody:components_request_body,Servers:components_servers,ServersContainer,RequestBodyEditor,OperationServers:operation_servers_OperationServers,operationLink:VP},JP=new Remarkable(\"commonmark\");JP.block.ruler.enable([\"table\"]),JP.set({linkTarget:\"_blank\"});const HP=OAS3ComponentWrapFactory((({source:s,className:o=\"\",getConfigs:i=()=>({useUnsafeMarkdown:!1})})=>{if(\"string\"!=typeof s)return null;if(s){const{useUnsafeMarkdown:a}=i(),u=sanitizer(JP.render(s),{useUnsafeMarkdown:a});let _;return\"string\"==typeof u&&(_=u.trim()),Re.createElement(\"div\",{dangerouslySetInnerHTML:{__html:_},className:Jn()(o,\"renderedMarkdown\")})}return null})),KP=OAS3ComponentWrapFactory((({Ori:s,...o})=>{const{schema:i,getComponent:a,errSelectors:u,authorized:_,onAuthChange:w,name:x,authSelectors:C}=o,j=a(\"HttpAuth\");return\"http\"===i.get(\"type\")?Re.createElement(j,{key:x,schema:i,name:x,errSelectors:u,authorized:_,getComponent:a,onChange:w,authSelectors:C}):Re.createElement(s,o)})),GP=OAS3ComponentWrapFactory(OnlineValidatorBadge);class ModelComponent extends Re.Component{render(){let{getConfigs:s,schema:o,Ori:i}=this.props,a=[\"model-box\"],u=null;return!0===o.get(\"deprecated\")&&(a.push(\"deprecated\"),u=Re.createElement(\"span\",{className:\"model-deprecated-warning\"},\"Deprecated:\")),Re.createElement(\"div\",{className:a.join(\" \")},u,Re.createElement(i,Mn()({},this.props,{getConfigs:s,depth:1,expandDepth:this.props.expandDepth||0})))}}const YP=OAS3ComponentWrapFactory(ModelComponent),XP=OAS3ComponentWrapFactory((({Ori:s,...o})=>{const{schema:i,getComponent:a,errors:u,onChange:_,fn:w}=o,x=w.isFileUploadIntended(i),C=a(\"Input\");return x?Re.createElement(C,{type:\"file\",className:u.length?\"invalid\":\"\",title:u.length?u:\"\",onChange:s=>{_(s.target.files[0])},disabled:s.isDisabled}):Re.createElement(s,o)})),QP={Markdown:HP,AuthItem:KP,OpenAPIVersion:function OAS30ComponentWrapFactory(s){return(o,i)=>a=>\"function\"==typeof i.specSelectors?.isOAS30?i.specSelectors.isOAS30()?Re.createElement(s,Mn()({},a,i,{Ori:o})):Re.createElement(o,a):(console.warn(\"OAS30 wrapper: couldn't get spec\"),null)}((s=>{const{Ori:o}=s;return Re.createElement(o,{oasVersion:\"3.0\"})})),JsonSchema_string:XP,model:YP,onlineValidatorBadge:GP},ZP=\"oas3_set_servers\",eI=\"oas3_set_request_body_value\",tI=\"oas3_set_request_body_retain_flag\",rI=\"oas3_set_request_body_inclusion\",nI=\"oas3_set_active_examples_member\",sI=\"oas3_set_request_content_type\",oI=\"oas3_set_response_content_type\",iI=\"oas3_set_server_variable_value\",aI=\"oas3_set_request_body_validate_error\",cI=\"oas3_clear_request_body_validate_error\",lI=\"oas3_clear_request_body_value\";function setSelectedServer(s,o){return{type:ZP,payload:{selectedServerUrl:s,namespace:o}}}function setRequestBodyValue({value:s,pathMethod:o}){return{type:eI,payload:{value:s,pathMethod:o}}}const setRetainRequestBodyValueFlag=({value:s,pathMethod:o})=>({type:tI,payload:{value:s,pathMethod:o}});function setRequestBodyInclusion({value:s,pathMethod:o,name:i}){return{type:rI,payload:{value:s,pathMethod:o,name:i}}}function setActiveExamplesMember({name:s,pathMethod:o,contextType:i,contextName:a}){return{type:nI,payload:{name:s,pathMethod:o,contextType:i,contextName:a}}}function setRequestContentType({value:s,pathMethod:o}){return{type:sI,payload:{value:s,pathMethod:o}}}function setResponseContentType({value:s,path:o,method:i}){return{type:oI,payload:{value:s,path:o,method:i}}}function setServerVariableValue({server:s,namespace:o,key:i,val:a}){return{type:iI,payload:{server:s,namespace:o,key:i,val:a}}}const setRequestBodyValidateError=({path:s,method:o,validationErrors:i})=>({type:aI,payload:{path:s,method:o,validationErrors:i}}),clearRequestBodyValidateError=({path:s,method:o})=>({type:cI,payload:{path:s,method:o}}),initRequestBodyValidateError=({pathMethod:s})=>({type:cI,payload:{path:s[0],method:s[1]}}),clearRequestBodyValue=({pathMethod:s})=>({type:lI,payload:{pathMethod:s}});var uI=__webpack_require__(60680),pI=__webpack_require__.n(uI);const oas3_selectors_onlyOAS3=s=>(o,...i)=>a=>{if(a.getSystem().specSelectors.isOAS3()){const u=s(o,...i);return\"function\"==typeof u?u(a):u}return null};const hI=oas3_selectors_onlyOAS3(((s,o)=>{const i=o?[o,\"selectedServer\"]:[\"selectedServer\"];return s.getIn(i)||\"\"})),dI=oas3_selectors_onlyOAS3(((s,o,i)=>s.getIn([\"requestData\",o,i,\"bodyValue\"])||null)),fI=oas3_selectors_onlyOAS3(((s,o,i)=>s.getIn([\"requestData\",o,i,\"retainBodyValue\"])||!1)),selectDefaultRequestBodyValue=(s,o,i)=>s=>{const{oas3Selectors:a,specSelectors:u,fn:_}=s.getSystem();if(u.isOAS3()){const s=a.requestContentType(o,i);if(s)return getDefaultRequestBodyValue(u.specResolvedSubtree([\"paths\",o,i,\"requestBody\"]),s,a.activeExamplesMember(o,i,\"requestBody\",\"requestBody\"),_)}return null},mI=oas3_selectors_onlyOAS3(((s,o,i)=>s=>{const{oas3Selectors:a,specSelectors:u,fn:_}=s;let w=!1;const x=a.requestContentType(o,i);let C=a.requestBodyValue(o,i);const j=u.specResolvedSubtree([\"paths\",o,i,\"requestBody\"]);if(!j)return!1;if(ze.Map.isMap(C)&&(C=stringify(C.mapEntries((s=>ze.Map.isMap(s[1])?[s[0],s[1].get(\"value\")]:s)).toJS())),ze.List.isList(C)&&(C=stringify(C)),x){const s=getDefaultRequestBodyValue(j,x,a.activeExamplesMember(o,i,\"requestBody\",\"requestBody\"),_);w=!!C&&C!==s}return w})),gI=oas3_selectors_onlyOAS3(((s,o,i)=>s.getIn([\"requestData\",o,i,\"bodyInclusion\"])||(0,ze.Map)())),yI=oas3_selectors_onlyOAS3(((s,o,i)=>s.getIn([\"requestData\",o,i,\"errors\"])||null)),vI=oas3_selectors_onlyOAS3(((s,o,i,a,u)=>s.getIn([\"examples\",o,i,a,u,\"activeExample\"])||null)),bI=oas3_selectors_onlyOAS3(((s,o,i)=>s.getIn([\"requestData\",o,i,\"requestContentType\"])||null)),_I=oas3_selectors_onlyOAS3(((s,o,i)=>s.getIn([\"requestData\",o,i,\"responseContentType\"])||null)),SI=oas3_selectors_onlyOAS3(((s,o,i)=>{let a;if(\"string\"!=typeof o){const{server:s,namespace:u}=o;a=u?[u,\"serverVariableValues\",s,i]:[\"serverVariableValues\",s,i]}else{a=[\"serverVariableValues\",o,i]}return s.getIn(a)||null})),EI=oas3_selectors_onlyOAS3(((s,o)=>{let i;if(\"string\"!=typeof o){const{server:s,namespace:a}=o;i=a?[a,\"serverVariableValues\",s]:[\"serverVariableValues\",s]}else{i=[\"serverVariableValues\",o]}return s.getIn(i)||(0,ze.OrderedMap)()})),wI=oas3_selectors_onlyOAS3(((s,o)=>{var i,a;if(\"string\"!=typeof o){const{server:u,namespace:_}=o;a=u,i=_?s.getIn([_,\"serverVariableValues\",a]):s.getIn([\"serverVariableValues\",a])}else a=o,i=s.getIn([\"serverVariableValues\",a]);i=i||(0,ze.OrderedMap)();let u=a;return i.map(((s,o)=>{u=u.replace(new RegExp(`{${pI()(o)}}`,\"g\"),s)})),u})),xI=function validateRequestBodyIsRequired(s){return(...o)=>i=>{const a=i.getSystem().specSelectors.specJson();let u=[...o][1]||[];return!a.getIn([\"paths\",...u,\"requestBody\",\"required\"])||s(...o)}}(((s,o)=>((s,o)=>(o=o||[],!!s.getIn([\"requestData\",...o,\"bodyValue\"])))(s,o))),validateShallowRequired=(s,{oas3RequiredRequestBodyContentType:o,oas3RequestContentType:i,oas3RequestBodyValue:a})=>{let u=[];if(!ze.Map.isMap(a))return u;let _=[];return Object.keys(o.requestContentType).forEach((s=>{if(s===i){o.requestContentType[s].forEach((s=>{_.indexOf(s)<0&&_.push(s)}))}})),_.forEach((s=>{a.getIn([s,\"value\"])||u.push(s)})),u},kI=xs()([\"get\",\"put\",\"post\",\"delete\",\"options\",\"head\",\"patch\",\"trace\"]),OI={[ZP]:(s,{payload:{selectedServerUrl:o,namespace:i}})=>{const a=i?[i,\"selectedServer\"]:[\"selectedServer\"];return s.setIn(a,o)},[eI]:(s,{payload:{value:o,pathMethod:i}})=>{let[a,u]=i;if(!ze.Map.isMap(o))return s.setIn([\"requestData\",a,u,\"bodyValue\"],o);let _=s.getIn([\"requestData\",a,u,\"bodyValue\"])||(0,ze.Map)();ze.Map.isMap(_)||(_=(0,ze.Map)());let w=_;const[...x]=o.keys();return x.forEach((s=>{let i=o.getIn([s]);w.has(s)&&ze.Map.isMap(i)||(w=w.setIn([s,\"value\"],i))})),s.setIn([\"requestData\",a,u,\"bodyValue\"],w)},[tI]:(s,{payload:{value:o,pathMethod:i}})=>{let[a,u]=i;return s.setIn([\"requestData\",a,u,\"retainBodyValue\"],o)},[rI]:(s,{payload:{value:o,pathMethod:i,name:a}})=>{let[u,_]=i;return s.setIn([\"requestData\",u,_,\"bodyInclusion\",a],o)},[nI]:(s,{payload:{name:o,pathMethod:i,contextType:a,contextName:u}})=>{let[_,w]=i;return s.setIn([\"examples\",_,w,a,u,\"activeExample\"],o)},[sI]:(s,{payload:{value:o,pathMethod:i}})=>{let[a,u]=i;return s.setIn([\"requestData\",a,u,\"requestContentType\"],o)},[oI]:(s,{payload:{value:o,path:i,method:a}})=>s.setIn([\"requestData\",i,a,\"responseContentType\"],o),[iI]:(s,{payload:{server:o,namespace:i,key:a,val:u}})=>{const _=i?[i,\"serverVariableValues\",o,a]:[\"serverVariableValues\",o,a];return s.setIn(_,u)},[aI]:(s,{payload:{path:o,method:i,validationErrors:a}})=>{let u=[];if(u.push(\"Required field is not provided\"),a.missingBodyValue)return s.setIn([\"requestData\",o,i,\"errors\"],(0,ze.fromJS)(u));if(a.missingRequiredKeys&&a.missingRequiredKeys.length>0){const{missingRequiredKeys:_}=a;return s.updateIn([\"requestData\",o,i,\"bodyValue\"],(0,ze.fromJS)({}),(s=>_.reduce(((s,o)=>s.setIn([o,\"errors\"],(0,ze.fromJS)(u))),s)))}return console.warn(\"unexpected result: SET_REQUEST_BODY_VALIDATE_ERROR\"),s},[cI]:(s,{payload:{path:o,method:i}})=>{const a=s.getIn([\"requestData\",o,i,\"bodyValue\"]);if(!ze.Map.isMap(a))return s.setIn([\"requestData\",o,i,\"errors\"],(0,ze.fromJS)([]));const[...u]=a.keys();return u?s.updateIn([\"requestData\",o,i,\"bodyValue\"],(0,ze.fromJS)({}),(s=>u.reduce(((s,o)=>s.setIn([o,\"errors\"],(0,ze.fromJS)([]))),s))):s},[lI]:(s,{payload:{pathMethod:o}})=>{let[i,a]=o;const u=s.getIn([\"requestData\",i,a,\"bodyValue\"]);return u?ze.Map.isMap(u)?s.setIn([\"requestData\",i,a,\"bodyValue\"],(0,ze.Map)()):s.setIn([\"requestData\",i,a,\"bodyValue\"],\"\"):s}};function oas3({getSystem:s}){const o=(s=>(o,i=null)=>{const{getConfigs:a,fn:u}=s(),{fileUploadMediaTypes:_}=a();if(\"string\"==typeof i&&_.some((s=>i.startsWith(s))))return!0;const w=ze.Map.isMap(o);if(!w&&!as()(o))return!1;const x=w?o.get(\"format\"):o.format;return u.hasSchemaType(o,\"string\")&&[\"binary\",\"byte\"].includes(x)})(s);return{components:WP,wrapComponents:QP,statePlugins:{spec:{wrapSelectors:Se,selectors:xe},auth:{wrapSelectors:we},oas3:{actions:{...Pe},reducers:OI,selectors:{...Te}}},fn:{isFileUploadIntended:o,isFileUploadIntendedOAS30:o}}}const webhooks=({specSelectors:s,getComponent:o})=>{const i=s.selectWebhooksOperations(),a=Object.keys(i),u=o(\"OperationContainer\",!0);return 0===a.length?null:Re.createElement(\"div\",{className:\"webhooks\"},Re.createElement(\"h2\",null,\"Webhooks\"),a.map((s=>Re.createElement(\"div\",{key:`${s}-webhook`},i[s].map((o=>Re.createElement(u,{key:`${s}-${o.method}-webhook`,op:o.operation,tag:\"webhooks\",method:o.method,path:s,specPath:(0,ze.List)(o.specPath),allowTryItOut:!1})))))))},oas31_components_license=({getComponent:s,specSelectors:o})=>{const i=o.selectLicenseNameField(),a=o.selectLicenseUrl(),u=s(\"Link\");return Re.createElement(\"div\",{className:\"info__license\"},a?Re.createElement(\"div\",{className:\"info__license__url\"},Re.createElement(u,{target:\"_blank\",href:sanitizeUrl(a)},i)):Re.createElement(\"span\",null,i))},oas31_components_contact=({getComponent:s,specSelectors:o})=>{const i=o.selectContactNameField(),a=o.selectContactUrl(),u=o.selectContactEmailField(),_=s(\"Link\");return Re.createElement(\"div\",{className:\"info__contact\"},a&&Re.createElement(\"div\",null,Re.createElement(_,{href:sanitizeUrl(a),target:\"_blank\"},i,\" - Website\")),u&&Re.createElement(_,{href:sanitizeUrl(`mailto:${u}`)},a?`Send email to ${i}`:`Contact ${i}`))},oas31_components_info=({getComponent:s,specSelectors:o})=>{const i=o.version(),a=o.url(),u=o.basePath(),_=o.host(),w=o.selectInfoSummaryField(),x=o.selectInfoDescriptionField(),C=o.selectInfoTitleField(),j=o.selectInfoTermsOfServiceUrl(),L=o.selectExternalDocsUrl(),B=o.selectExternalDocsDescriptionField(),$=o.contact(),U=o.license(),V=s(\"Markdown\",!0),z=s(\"Link\"),Y=s(\"VersionStamp\"),Z=s(\"OpenAPIVersion\"),ee=s(\"InfoUrl\"),ie=s(\"InfoBasePath\"),ae=s(\"License\",!0),ce=s(\"Contact\",!0),le=s(\"JsonSchemaDialect\",!0);return Re.createElement(\"div\",{className:\"info\"},Re.createElement(\"hgroup\",{className:\"main\"},Re.createElement(\"h1\",{className:\"title\"},C,Re.createElement(\"span\",null,i&&Re.createElement(Y,{version:i}),Re.createElement(Z,{oasVersion:\"3.1\"}))),(_||u)&&Re.createElement(ie,{host:_,basePath:u}),a&&Re.createElement(ee,{getComponent:s,url:a})),w&&Re.createElement(\"p\",{className:\"info__summary\"},w),Re.createElement(\"div\",{className:\"info__description description\"},Re.createElement(V,{source:x})),j&&Re.createElement(\"div\",{className:\"info__tos\"},Re.createElement(z,{target:\"_blank\",href:sanitizeUrl(j)},\"Terms of service\")),$.size>0&&Re.createElement(ce,null),U.size>0&&Re.createElement(ae,null),L&&Re.createElement(z,{className:\"info__extdocs\",target:\"_blank\",href:sanitizeUrl(L)},B||L),Re.createElement(le,null))},json_schema_dialect=({getComponent:s,specSelectors:o})=>{const i=o.selectJsonSchemaDialectField(),a=o.selectJsonSchemaDialectDefault(),u=s(\"Link\");return Re.createElement(Re.Fragment,null,i&&i===a&&Re.createElement(\"p\",{className:\"info__jsonschemadialect\"},\"JSON Schema dialect:\",\" \",Re.createElement(u,{target:\"_blank\",href:sanitizeUrl(i)},i)),i&&i!==a&&Re.createElement(\"div\",{className:\"error-wrapper\"},Re.createElement(\"div\",{className:\"no-margin\"},Re.createElement(\"div\",{className:\"errors\"},Re.createElement(\"div\",{className:\"errors-wrapper\"},Re.createElement(\"h4\",{className:\"center\"},\"Warning\"),Re.createElement(\"p\",{className:\"message\"},Re.createElement(\"strong\",null,\"OpenAPI.jsonSchemaDialect\"),\" field contains a value different from the default value of\",\" \",Re.createElement(u,{target:\"_blank\",href:a},a),\". Values different from the default one are currently not supported. Please either omit the field or provide it with the default value.\"))))))},version_pragma_filter=({bypass:s,isSwagger2:o,isOAS3:i,isOAS31:a,alsoShow:u,children:_})=>s?Re.createElement(\"div\",null,_):o&&(i||a)?Re.createElement(\"div\",{className:\"version-pragma\"},u,Re.createElement(\"div\",{className:\"version-pragma__message version-pragma__message--ambiguous\"},Re.createElement(\"div\",null,Re.createElement(\"h3\",null,\"Unable to render this definition\"),Re.createElement(\"p\",null,Re.createElement(\"code\",null,\"swagger\"),\" and \",Re.createElement(\"code\",null,\"openapi\"),\" fields cannot be present in the same Swagger or OpenAPI definition. Please remove one of the fields.\"),Re.createElement(\"p\",null,\"Supported version fields are \",Re.createElement(\"code\",null,'swagger: \"2.0\"'),\" and those that match \",Re.createElement(\"code\",null,\"openapi: 3.x.y\"),\" (for example,\",\" \",Re.createElement(\"code\",null,\"openapi: 3.1.0\"),\").\")))):o||i||a?Re.createElement(\"div\",null,_):Re.createElement(\"div\",{className:\"version-pragma\"},u,Re.createElement(\"div\",{className:\"version-pragma__message version-pragma__message--missing\"},Re.createElement(\"div\",null,Re.createElement(\"h3\",null,\"Unable to render this definition\"),Re.createElement(\"p\",null,\"The provided definition does not specify a valid version field.\"),Re.createElement(\"p\",null,\"Please indicate a valid Swagger or OpenAPI version field. Supported version fields are \",Re.createElement(\"code\",null,'swagger: \"2.0\"'),\" and those that match \",Re.createElement(\"code\",null,\"openapi: 3.x.y\"),\" (for example,\",\" \",Re.createElement(\"code\",null,\"openapi: 3.1.0\"),\").\")))),getModelName=s=>\"string\"==typeof s&&s.includes(\"#/components/schemas/\")?(s=>{const o=s.replace(/~1/g,\"/\").replace(/~0/g,\"~\");try{return decodeURIComponent(o)}catch{return o}})(s.replace(/^.*#\\/components\\/schemas\\//,\"\")):null,AI=(0,Re.forwardRef)((({schema:s,getComponent:o,onToggle:i=()=>{},specPath:a},u)=>{const _=o(\"JSONSchema202012\"),w=getModelName(s.get(\"$$ref\")),x=(0,Re.useCallback)(((s,o)=>{i(w,o)}),[w,i]);return Re.createElement(_,{name:w,schema:s.toJS(),ref:u,onExpand:x,identifier:a.toJS().join(\"_\")})})),CI=AI,models=({specActions:s,specSelectors:o,layoutSelectors:i,layoutActions:a,getComponent:u,getConfigs:_,fn:w})=>{const x=o.selectSchemas(),C=Object.keys(x).length>0,j=[\"components\",\"schemas\"],{docExpansion:L,defaultModelsExpandDepth:B}=_(),$=B>0&&\"none\"!==L,U=i.isShown(j,$),V=u(\"Collapse\"),z=u(\"JSONSchema202012\"),Y=u(\"ArrowUpIcon\"),Z=u(\"ArrowDownIcon\"),{getTitle:ee}=w.jsonSchema202012.useFn();(0,Re.useEffect)((()=>{const a=Object.entries(x).some((([s])=>i.isShown([...j,s],!1))),u=U&&(B>1||a),_=null!=o.specResolvedSubtree(j);u&&!_&&s.requestResolvedSubtree(j)}),[U,B]);const ie=(0,Re.useCallback)((()=>{a.show(j,!U)}),[U]),ae=(0,Re.useCallback)((s=>{null!==s&&a.readyToScroll(j,s)}),[]),handleJSONSchema202012Ref=s=>o=>{null!==o&&a.readyToScroll([...j,s],o)},handleJSONSchema202012Expand=i=>(u,_)=>{const w=[...j,i];if(_){null!=o.specResolvedSubtree(w)||s.requestResolvedSubtree([...j,i]),a.show(w,!0)}else a.show(w,!1)};return!C||B<0?null:Re.createElement(\"section\",{className:Jn()(\"models\",{\"is-open\":U}),ref:ae},Re.createElement(\"h4\",null,Re.createElement(\"button\",{\"aria-expanded\":U,className:\"models-control\",onClick:ie},Re.createElement(\"span\",null,\"Schemas\"),U?Re.createElement(Y,null):Re.createElement(Z,null))),Re.createElement(V,{isOpened:U},Object.entries(x).map((([s,o])=>{const i=ee(o,{lookup:\"basic\"})||s;return Re.createElement(z,{key:s,ref:handleJSONSchema202012Ref(s),schema:o,name:i,onExpand:handleJSONSchema202012Expand(s)})}))))},mutual_tls_auth=({schema:s,getComponent:o,name:i,authSelectors:a})=>{const u=o(\"JumpToPath\",!0),_=a.selectAuthPath(i);return Re.createElement(\"div\",null,Re.createElement(\"h4\",null,i,\" (mutualTLS) \",Re.createElement(u,{path:_})),Re.createElement(\"p\",null,\"Mutual TLS is required by this API/Operation. Certificates are managed via your Operating System and/or your browser.\"),Re.createElement(\"p\",null,s.get(\"description\")))};class auths_Auths extends Re.Component{constructor(s,o){super(s,o),this.state={}}onAuthChange=s=>{let{name:o}=s;this.setState({[o]:s})};submitAuth=s=>{s.preventDefault();let{authActions:o}=this.props;o.authorizeWithPersistOption(this.state)};logoutClick=s=>{s.preventDefault();let{authActions:o,definitions:i}=this.props,a=i.map(((s,o)=>o)).toArray();this.setState(a.reduce(((s,o)=>(s[o]=\"\",s)),{})),o.logoutWithPersistOption(a)};close=s=>{s.preventDefault();let{authActions:o}=this.props;o.showDefinitions(!1)};render(){let{definitions:s,getComponent:o,authSelectors:i,errSelectors:a}=this.props;const u=o(\"AuthItem\"),_=o(\"oauth2\",!0),w=o(\"Button\"),x=i.authorized(),C=s.filter(((s,o)=>!!x.get(o))),j=s.filter((s=>\"oauth2\"!==s.get(\"type\")&&\"mutualTLS\"!==s.get(\"type\"))),L=s.filter((s=>\"oauth2\"===s.get(\"type\"))),B=s.filter((s=>\"mutualTLS\"===s.get(\"type\")));return Re.createElement(\"div\",{className:\"auth-container\"},j.size>0&&Re.createElement(\"form\",{onSubmit:this.submitAuth},j.map(((s,_)=>Re.createElement(u,{key:_,schema:s,name:_,getComponent:o,onAuthChange:this.onAuthChange,authorized:x,errSelectors:a,authSelectors:i}))).toArray(),Re.createElement(\"div\",{className:\"auth-btn-wrapper\"},j.size===C.size?Re.createElement(w,{className:\"btn modal-btn auth\",onClick:this.logoutClick,\"aria-label\":\"Remove authorization\"},\"Logout\"):Re.createElement(w,{type:\"submit\",className:\"btn modal-btn auth authorize\",\"aria-label\":\"Apply credentials\"},\"Authorize\"),Re.createElement(w,{className:\"btn modal-btn auth btn-done\",onClick:this.close},\"Close\"))),L.size>0?Re.createElement(\"div\",null,Re.createElement(\"div\",{className:\"scope-def\"},Re.createElement(\"p\",null,\"Scopes are used to grant an application different levels of access to data on behalf of the end user. Each API may declare one or more scopes.\"),Re.createElement(\"p\",null,\"API requires the following scopes. Select which ones you want to grant to Swagger UI.\")),s.filter((s=>\"oauth2\"===s.get(\"type\"))).map(((s,o)=>Re.createElement(\"div\",{key:o},Re.createElement(_,{authorized:x,schema:s,name:o})))).toArray()):null,B.size>0&&Re.createElement(\"div\",null,B.map(((s,_)=>Re.createElement(u,{key:_,schema:s,name:_,getComponent:o,onAuthChange:this.onAuthChange,authorized:x,errSelectors:a,authSelectors:i}))).toArray()))}}const jI=auths_Auths,isOAS31=s=>{const o=s.get(\"openapi\");return\"string\"==typeof o&&/^3\\.1\\.(?:[1-9]\\d*|0)$/.test(o)},fn_createOnlyOAS31Selector=s=>(o,...i)=>a=>{if(a.getSystem().specSelectors.isOAS31()){const u=s(o,...i);return\"function\"==typeof u?u(a):u}return null},createOnlyOAS31SelectorWrapper=s=>(o,i)=>(a,...u)=>{if(i.getSystem().specSelectors.isOAS31()){const _=s(a,...u);return\"function\"==typeof _?_(o,i):_}return o(...u)},fn_createSystemSelector=s=>(o,...i)=>a=>{const u=s(o,a,...i);return\"function\"==typeof u?u(a):u},createOnlyOAS31ComponentWrapper=s=>(o,i)=>a=>i.specSelectors.isOAS31()?Re.createElement(s,Mn()({},a,{originalComponent:o,getSystem:i.getSystem})):Re.createElement(o,a),wrapOAS31Fn=(s,o)=>{const{fn:i,specSelectors:a}=o;return Object.fromEntries(Object.entries(s).map((([s,o])=>{const u=i[s];return[s,(...s)=>a.isOAS31()?o(...s):\"function\"==typeof u?u(...s):void 0]})))},PI=createOnlyOAS31ComponentWrapper((({getSystem:s})=>{const o=s().getComponent(\"OAS31License\",!0);return Re.createElement(o,null)})),II=createOnlyOAS31ComponentWrapper((({getSystem:s})=>{const o=s().getComponent(\"OAS31Contact\",!0);return Re.createElement(o,null)})),TI=createOnlyOAS31ComponentWrapper((({getSystem:s})=>{const o=s().getComponent(\"OAS31Info\",!0);return Re.createElement(o,null)})),getProperties=(s,{includeReadOnly:o,includeWriteOnly:i})=>{if(!s?.properties)return{};const a=Object.entries(s.properties).filter((([,s])=>(!(!0===s?.readOnly)||o)&&(!(!0===s?.writeOnly)||i)));return Object.fromEntries(a)},makeGetSchemaKeywords=s=>{if(\"function\"!=typeof s)return null;const o=s();return()=>[...o,\"discriminator\",\"xml\",\"externalDocs\",\"example\",\"$$ref\"]},NI=createOnlyOAS31ComponentWrapper((({getSystem:s,...o})=>{const i=s(),{getComponent:a,fn:u,getConfigs:_}=i,w=_(),x=a(\"OAS31Model\"),C=a(\"withJSONSchema202012SystemContext\");return NI.ModelWithJSONSchemaContext??=C(x,{config:{default$schema:\"https://spec.openapis.org/oas/3.1/dialect/base\",defaultExpandedLevels:w.defaultModelExpandDepth,includeReadOnly:o.includeReadOnly,includeWriteOnly:o.includeWriteOnly},fn:{getProperties:u.jsonSchema202012.getProperties,isExpandable:u.jsonSchema202012.isExpandable,getSchemaKeywords:makeGetSchemaKeywords(u.jsonSchema202012.getSchemaKeywords)}}),Re.createElement(NI.ModelWithJSONSchemaContext,o)})),MI=NI,RI=createOnlyOAS31ComponentWrapper((({getSystem:s})=>{const{getComponent:o,fn:i,getConfigs:a}=s(),u=a();if(RI.ModelsWithJSONSchemaContext)return Re.createElement(RI.ModelsWithJSONSchemaContext,null);const _=o(\"OAS31Models\",!0),w=o(\"withJSONSchema202012SystemContext\");return RI.ModelsWithJSONSchemaContext??=w(_,{config:{default$schema:\"https://spec.openapis.org/oas/3.1/dialect/base\",defaultExpandedLevels:u.defaultModelsExpandDepth-1,includeReadOnly:!0,includeWriteOnly:!0},fn:{getProperties:i.jsonSchema202012.getProperties,isExpandable:i.jsonSchema202012.isExpandable,getSchemaKeywords:makeGetSchemaKeywords(i.jsonSchema202012.getSchemaKeywords)}}),Re.createElement(RI.ModelsWithJSONSchemaContext,null)}));RI.ModelsWithJSONSchemaContext=null;const DI=RI,wrap_components_version_pragma_filter=(s,o)=>s=>{const i=o.specSelectors.isOAS31(),a=o.getComponent(\"OAS31VersionPragmaFilter\");return Re.createElement(a,Mn()({isOAS31:i},s))},LI=createOnlyOAS31ComponentWrapper((({originalComponent:s,...o})=>{const{getComponent:i,schema:a,name:u}=o,_=i(\"MutualTLSAuth\",!0);return\"mutualTLS\"===a.get(\"type\")?Re.createElement(_,{schema:a,name:u}):Re.createElement(s,o)})),FI=LI,BI=createOnlyOAS31ComponentWrapper((({getSystem:s,...o})=>{const i=s().getComponent(\"OAS31Auths\",!0);return Re.createElement(i,o)})),$I=(0,ze.Map)(),qI=Ut(((s,o)=>o.specSelectors.specJson()),isOAS31),selectors_webhooks=()=>s=>{const o=s.specSelectors.specJson().get(\"webhooks\");return ze.Map.isMap(o)?o:$I},UI=Ut([(s,o)=>o.specSelectors.webhooks(),(s,o)=>o.specSelectors.validOperationMethods(),(s,o)=>o.specSelectors.specResolvedSubtree([\"webhooks\"])],((s,o)=>s.reduce(((s,i,a)=>{if(!ze.Map.isMap(i))return s;const u=i.entrySeq().filter((([s])=>o.includes(s))).map((([s,o])=>({operation:(0,ze.Map)({operation:o}),method:s,path:a,specPath:[\"webhooks\",a,s]})));return s.concat(u)}),(0,ze.List)()).groupBy((s=>s.path)).map((s=>s.toArray())).toObject())),selectors_license=()=>s=>{const o=s.specSelectors.info().get(\"license\");return ze.Map.isMap(o)?o:$I},selectLicenseNameField=()=>s=>s.specSelectors.license().get(\"name\",\"License\"),selectLicenseUrlField=()=>s=>s.specSelectors.license().get(\"url\"),VI=Ut([(s,o)=>o.specSelectors.url(),(s,o)=>o.oas3Selectors.selectedServer(),(s,o)=>o.specSelectors.selectLicenseUrlField()],((s,o,i)=>{if(i)return safeBuildUrl(i,s,{selectedServer:o})})),selectLicenseIdentifierField=()=>s=>s.specSelectors.license().get(\"identifier\"),selectors_contact=()=>s=>{const o=s.specSelectors.info().get(\"contact\");return ze.Map.isMap(o)?o:$I},selectContactNameField=()=>s=>s.specSelectors.contact().get(\"name\",\"the developer\"),selectContactEmailField=()=>s=>s.specSelectors.contact().get(\"email\"),selectContactUrlField=()=>s=>s.specSelectors.contact().get(\"url\"),zI=Ut([(s,o)=>o.specSelectors.url(),(s,o)=>o.oas3Selectors.selectedServer(),(s,o)=>o.specSelectors.selectContactUrlField()],((s,o,i)=>{if(i)return safeBuildUrl(i,s,{selectedServer:o})})),selectInfoTitleField=()=>s=>s.specSelectors.info().get(\"title\"),selectInfoSummaryField=()=>s=>s.specSelectors.info().get(\"summary\"),selectInfoDescriptionField=()=>s=>s.specSelectors.info().get(\"description\"),selectInfoTermsOfServiceField=()=>s=>s.specSelectors.info().get(\"termsOfService\"),WI=Ut([(s,o)=>o.specSelectors.url(),(s,o)=>o.oas3Selectors.selectedServer(),(s,o)=>o.specSelectors.selectInfoTermsOfServiceField()],((s,o,i)=>{if(i)return safeBuildUrl(i,s,{selectedServer:o})})),selectExternalDocsDescriptionField=()=>s=>s.specSelectors.externalDocs().get(\"description\"),selectExternalDocsUrlField=()=>s=>s.specSelectors.externalDocs().get(\"url\"),JI=Ut([(s,o)=>o.specSelectors.url(),(s,o)=>o.oas3Selectors.selectedServer(),(s,o)=>o.specSelectors.selectExternalDocsUrlField()],((s,o,i)=>{if(i)return safeBuildUrl(i,s,{selectedServer:o})})),selectJsonSchemaDialectField=()=>s=>s.specSelectors.specJson().get(\"jsonSchemaDialect\"),selectJsonSchemaDialectDefault=()=>\"https://spec.openapis.org/oas/3.1/dialect/base\",HI=Ut(((s,o)=>o.specSelectors.definitions()),((s,o)=>o.specSelectors.specResolvedSubtree([\"components\",\"schemas\"])),((s,o)=>ze.Map.isMap(s)?ze.Map.isMap(o)?Object.entries(s.toJS()).reduce(((s,[i,a])=>{const u=o.get(i);return s[i]=u?.toJS()||a,s}),{}):s.toJS():{})),wrap_selectors_isOAS3=(s,o)=>(i,...a)=>o.specSelectors.isOAS31()||s(...a),KI=createOnlyOAS31SelectorWrapper((()=>(s,o)=>o.oas31Selectors.selectLicenseUrl())),GI=createOnlyOAS31SelectorWrapper((()=>(s,o)=>{const i=o.specSelectors.securityDefinitions();let a=s();return i?(i.entrySeq().forEach((([s,o])=>{const i=o?.get(\"type\");\"mutualTLS\"===i&&(a=a.push(new ze.Map({[s]:o})))})),a):a})),YI=Ut([(s,o)=>o.specSelectors.url(),(s,o)=>o.oas3Selectors.selectedServer(),(s,o)=>o.specSelectors.selectLicenseUrlField(),(s,o)=>o.specSelectors.selectLicenseIdentifierField()],((s,o,i,a)=>i?safeBuildUrl(i,s,{selectedServer:o}):a?`https://spdx.org/licenses/${a}.html`:void 0)),keywords_Example=({schema:s,getSystem:o})=>{const{fn:i,getComponent:a}=o(),{hasKeyword:u}=i.jsonSchema202012.useFn(),_=a(\"JSONSchema202012JSONViewer\");return u(s,\"example\")?Re.createElement(_,{name:\"Example\",value:s.example,className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--example\"}):null},keywords_Xml=({schema:s,getSystem:o})=>{const i=s?.xml||{},{fn:a,getComponent:u,getConfigs:_}=o(),{showExtensions:w}=_(),{useComponent:x,useIsExpanded:C,usePath:j,useLevel:L}=a.jsonSchema202012,{path:B}=j(\"xml\"),{isExpanded:$,setExpanded:U,setCollapsed:V}=C(\"xml\"),[z,Y]=L(),Z=w?getExtensions(i):[],ee=!!(i.name||i.namespace||i.prefix||Z.length>0),ie=x(\"Accordion\"),ae=x(\"ExpandDeepButton\"),ce=u(\"OpenAPI31Extensions\"),le=u(\"JSONSchema202012PathContext\")(),pe=u(\"JSONSchema202012LevelContext\")(),de=(0,Re.useCallback)((()=>{$?V():U()}),[$,U,V]),fe=(0,Re.useCallback)(((s,o)=>{o?U({deep:!0}):V({deep:!0})}),[U,V]);return 0===Object.keys(i).length?null:Re.createElement(le.Provider,{value:B},Re.createElement(pe.Provider,{value:Y},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--xml\",\"data-json-schema-level\":z},ee?Re.createElement(Re.Fragment,null,Re.createElement(ie,{expanded:$,onChange:de},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"XML\")),Re.createElement(ae,{expanded:$,onClick:fe})):Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"XML\"),!0===i.attribute&&Re.createElement(\"span\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--muted\"},\"attribute\"),!0===i.wrapped&&Re.createElement(\"span\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--muted\"},\"wrapped\"),Re.createElement(\"strong\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary\"},\"object\"),Re.createElement(\"ul\",{className:Jn()(\"json-schema-2020-12-keyword__children\",{\"json-schema-2020-12-keyword__children--collapsed\":!$})},$&&Re.createElement(Re.Fragment,null,i.name&&Re.createElement(\"li\",{className:\"json-schema-2020-12-property\"},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword\"},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"name\"),Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary\"},i.name))),i.namespace&&Re.createElement(\"li\",{className:\"json-schema-2020-12-property\"},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword\"},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"namespace\"),Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary\"},i.namespace))),i.prefix&&Re.createElement(\"li\",{className:\"json-schema-2020-12-property\"},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword\"},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"prefix\"),Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary\"},i.prefix)))),Z.length>0&&Re.createElement(ce,{openAPISpecObj:i,openAPIExtensions:Z,getSystem:o})))))},Discriminator_DiscriminatorMapping=({discriminator:s})=>{const o=s?.mapping||{};return 0===Object.keys(o).length?null:Object.entries(o).map((([s,o])=>Re.createElement(\"div\",{key:`${s}-${o}`,className:\"json-schema-2020-12-keyword\"},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},s),Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary\"},o))))},keywords_Discriminator_Discriminator=({schema:s,getSystem:o})=>{const i=s?.discriminator||{},{fn:a,getComponent:u,getConfigs:_}=o(),{showExtensions:w}=_(),{useComponent:x,useIsExpanded:C,usePath:j,useLevel:L}=a.jsonSchema202012,B=\"discriminator\",{path:$}=j(B),{isExpanded:U,setExpanded:V,setCollapsed:z}=C(B),[Y,Z]=L(),ee=w?getExtensions(i):[],ie=!!(i.mapping||ee.length>0),ae=x(\"Accordion\"),ce=x(\"ExpandDeepButton\"),le=u(\"OpenAPI31Extensions\"),pe=u(\"JSONSchema202012PathContext\")(),de=u(\"JSONSchema202012LevelContext\")(),fe=(0,Re.useCallback)((()=>{U?z():V()}),[U,V,z]),ye=(0,Re.useCallback)(((s,o)=>{o?V({deep:!0}):z({deep:!0})}),[V,z]);return 0===Object.keys(i).length?null:Re.createElement(pe.Provider,{value:$},Re.createElement(de.Provider,{value:Z},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--discriminator\",\"data-json-schema-level\":Y},ie?Re.createElement(Re.Fragment,null,Re.createElement(ae,{expanded:U,onChange:fe},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"Discriminator\")),Re.createElement(ce,{expanded:U,onClick:ye})):Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"Discriminator\"),i.propertyName&&Re.createElement(\"span\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--muted\"},i.propertyName),Re.createElement(\"strong\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary\"},\"object\"),Re.createElement(\"ul\",{className:Jn()(\"json-schema-2020-12-keyword__children\",{\"json-schema-2020-12-keyword__children--collapsed\":!U})},U&&Re.createElement(\"li\",{className:\"json-schema-2020-12-property\"},Re.createElement(Discriminator_DiscriminatorMapping,{discriminator:i})),ee.length>0&&Re.createElement(le,{openAPISpecObj:i,openAPIExtensions:ee,getSystem:o})))))},keywords_OpenAPIExtensions=({openAPISpecObj:s,getSystem:o,openAPIExtensions:i})=>{const{fn:a}=o(),{useComponent:u}=a.jsonSchema202012,_=u(\"JSONViewer\");return i.map((o=>Re.createElement(_,{key:o,name:o,value:s[o],className:\"json-schema-2020-12-json-viewer-extension-keyword\"})))},keywords_ExternalDocs=({schema:s,getSystem:o})=>{const i=s?.externalDocs||{},{fn:a,getComponent:u,getConfigs:_}=o(),{showExtensions:w}=_(),{useComponent:x,useIsExpanded:C,usePath:j,useLevel:L}=a.jsonSchema202012,B=\"externalDocs\",{path:$}=j(B),{isExpanded:U,setExpanded:V,setCollapsed:z}=C(B),[Y,Z]=L(),ee=w?getExtensions(i):[],ie=!!(i.description||i.url||ee.length>0),ae=x(\"Accordion\"),ce=x(\"ExpandDeepButton\"),le=u(\"JSONSchema202012KeywordDescription\"),pe=u(\"Link\"),de=u(\"OpenAPI31Extensions\"),fe=u(\"JSONSchema202012PathContext\")(),ye=u(\"JSONSchema202012LevelContext\")(),be=(0,Re.useCallback)((()=>{U?z():V()}),[U,V,z]),_e=(0,Re.useCallback)(((s,o)=>{o?V({deep:!0}):z({deep:!0})}),[V,z]);return 0===Object.keys(i).length?null:Re.createElement(fe.Provider,{value:$},Re.createElement(ye.Provider,{value:Z},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--externalDocs\",\"data-json-schema-level\":Y},ie?Re.createElement(Re.Fragment,null,Re.createElement(ae,{expanded:U,onChange:be},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"External documentation\")),Re.createElement(ce,{expanded:U,onClick:_e})):Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"External documentation\"),Re.createElement(\"strong\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary\"},\"object\"),Re.createElement(\"ul\",{className:Jn()(\"json-schema-2020-12-keyword__children\",{\"json-schema-2020-12-keyword__children--collapsed\":!U})},U&&Re.createElement(Re.Fragment,null,i.description&&Re.createElement(\"li\",{className:\"json-schema-2020-12-property\"},Re.createElement(le,{schema:i,getSystem:o})),i.url&&Re.createElement(\"li\",{className:\"json-schema-2020-12-property\"},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword\"},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"url\"),Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary\"},Re.createElement(pe,{target:\"_blank\",href:sanitizeUrl(i.url)},i.url))))),ee.length>0&&Re.createElement(de,{openAPISpecObj:i,openAPIExtensions:ee,getSystem:o})))))},keywords_Description=({schema:s,getSystem:o})=>{if(!s?.description)return null;const{getComponent:i}=o(),a=i(\"Markdown\");return Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--description\"},Re.createElement(\"div\",{className:\"json-schema-2020-12-core-keyword__value json-schema-2020-12-core-keyword__value--secondary\"},Re.createElement(a,{source:s.description})))},XI=createOnlyOAS31ComponentWrapper(keywords_Description),QI=createOnlyOAS31ComponentWrapper((({schema:s,getSystem:o,originalComponent:i})=>{const{getComponent:a}=o(),u=a(\"JSONSchema202012KeywordDiscriminator\"),_=a(\"JSONSchema202012KeywordXml\"),w=a(\"JSONSchema202012KeywordExample\"),x=a(\"JSONSchema202012KeywordExternalDocs\");return Re.createElement(Re.Fragment,null,Re.createElement(i,{schema:s}),Re.createElement(u,{schema:s,getSystem:o}),Re.createElement(_,{schema:s,getSystem:o}),Re.createElement(x,{schema:s,getSystem:o}),Re.createElement(w,{schema:s,getSystem:o}))})),ZI=QI,keywords_Properties=({schema:s,getSystem:o})=>{const{fn:i,getComponent:a}=o(),{useComponent:u,usePath:_}=i.jsonSchema202012,{getDependentRequired:w,getProperties:x}=i.jsonSchema202012.useFn(),C=i.jsonSchema202012.useConfig(),j=Array.isArray(s?.required)?s.required:[],{path:L}=_(\"properties\"),B=u(\"JSONSchema\"),$=a(\"JSONSchema202012PathContext\")(),U=x(s,C);return 0===Object.keys(U).length?null:Re.createElement($.Provider,{value:L},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--properties\"},Re.createElement(\"ul\",null,Object.entries(U).map((([o,i])=>{const a=j.includes(o),u=w(o,s);return Re.createElement(\"li\",{key:o,className:Jn()(\"json-schema-2020-12-property\",{\"json-schema-2020-12-property--required\":a})},Re.createElement(B,{name:o,schema:i,dependentRequired:u}))})))))},eT=createOnlyOAS31ComponentWrapper(keywords_Properties);const tT=function oas31_after_load_afterLoad({fn:s,getSystem:o}){if(s.jsonSchema202012){const i=((s,o)=>{const{fn:i}=o();if(\"function\"!=typeof s)return null;const{hasKeyword:a}=i.jsonSchema202012;return o=>s(o)||a(o,\"example\")||o?.xml||o?.discriminator||o?.externalDocs})(s.jsonSchema202012.isExpandable,o);Object.assign(this.fn.jsonSchema202012,{isExpandable:i,getProperties})}if(\"function\"==typeof s.sampleFromSchema&&s.jsonSchema202012){const i=wrapOAS31Fn({sampleFromSchema:s.jsonSchema202012.sampleFromSchema,sampleFromSchemaGeneric:s.jsonSchema202012.sampleFromSchemaGeneric,createXMLExample:s.jsonSchema202012.createXMLExample,memoizedSampleFromSchema:s.jsonSchema202012.memoizedSampleFromSchema,memoizedCreateXMLExample:s.jsonSchema202012.memoizedCreateXMLExample,getJsonSampleSchema:s.jsonSchema202012.getJsonSampleSchema,getYamlSampleSchema:s.jsonSchema202012.getYamlSampleSchema,getXmlSampleSchema:s.jsonSchema202012.getXmlSampleSchema,getSampleSchema:s.jsonSchema202012.getSampleSchema,mergeJsonSchema:s.jsonSchema202012.mergeJsonSchema,getSchemaObjectTypeLabel:o=>s.jsonSchema202012.getType(immutableToJS(o)),getSchemaObjectType:o=>s.jsonSchema202012.foldType(immutableToJS(o)?.type)},o());Object.assign(this.fn,i)}const i=(s=>(o,i=null)=>{const{fn:a}=s();if(a.isFileUploadIntendedOAS30(o,i))return!0;const u=ze.Map.isMap(o);if(!u&&!as()(o))return!1;const _=u?o.get(\"contentMediaType\"):o.contentMediaType,w=u?o.get(\"contentEncoding\"):o.contentEncoding;return\"string\"==typeof _&&\"\"!==_||\"string\"==typeof w&&\"\"!==w})(o),{isFileUploadIntended:a}=wrapOAS31Fn({isFileUploadIntended:i},o());if(this.fn.isFileUploadIntended=a,this.fn.isFileUploadIntendedOAS31=i,s.jsonSchema202012){const{hasSchemaType:i}=wrapOAS31Fn({hasSchemaType:s.jsonSchema202012.hasSchemaType},o());this.fn.hasSchemaType=i}},oas31=({fn:s})=>{const o=s.createSystemSelector||fn_createSystemSelector,i=s.createOnlyOAS31Selector||fn_createOnlyOAS31Selector;return{afterLoad:tT,fn:{isOAS31,createSystemSelector:fn_createSystemSelector,createOnlyOAS31Selector:fn_createOnlyOAS31Selector},components:{Webhooks:webhooks,JsonSchemaDialect:json_schema_dialect,MutualTLSAuth:mutual_tls_auth,OAS31Info:oas31_components_info,OAS31License:oas31_components_license,OAS31Contact:oas31_components_contact,OAS31VersionPragmaFilter:version_pragma_filter,OAS31Model:CI,OAS31Models:models,OAS31Auths:jI,JSONSchema202012KeywordExample:keywords_Example,JSONSchema202012KeywordXml:keywords_Xml,JSONSchema202012KeywordDiscriminator:keywords_Discriminator_Discriminator,JSONSchema202012KeywordExternalDocs:keywords_ExternalDocs,OpenAPI31Extensions:keywords_OpenAPIExtensions},wrapComponents:{InfoContainer:TI,License:PI,Contact:II,VersionPragmaFilter:wrap_components_version_pragma_filter,Model:MI,Models:DI,AuthItem:FI,auths:BI,JSONSchema202012KeywordDescription:XI,JSONSchema202012KeywordExamples:ZI,JSONSchema202012KeywordProperties:eT},statePlugins:{auth:{wrapSelectors:{definitionsToAuthorize:GI}},spec:{selectors:{isOAS31:o(qI),license:selectors_license,selectLicenseNameField,selectLicenseUrlField,selectLicenseIdentifierField:i(selectLicenseIdentifierField),selectLicenseUrl:o(VI),contact:selectors_contact,selectContactNameField,selectContactEmailField,selectContactUrlField,selectContactUrl:o(zI),selectInfoTitleField,selectInfoSummaryField:i(selectInfoSummaryField),selectInfoDescriptionField,selectInfoTermsOfServiceField,selectInfoTermsOfServiceUrl:o(WI),selectExternalDocsDescriptionField,selectExternalDocsUrlField,selectExternalDocsUrl:o(JI),webhooks:i(selectors_webhooks),selectWebhooksOperations:i(o(UI)),selectJsonSchemaDialectField,selectJsonSchemaDialectDefault,selectSchemas:o(HI)},wrapSelectors:{isOAS3:wrap_selectors_isOAS3,selectLicenseUrl:KI}},oas31:{selectors:{selectLicenseUrl:i(o(YI))}}}}},rT=es().object,nT=es().bool,sT=(es().oneOfType([rT,nT]),(0,Re.createContext)(null));sT.displayName=\"JSONSchemaContext\";const oT=(0,Re.createContext)(0);oT.displayName=\"JSONSchemaLevelContext\";const iT=(0,Re.createContext)(new Set),aT=(0,Re.createContext)([]);class JSONSchemaIsExpandedState{static Collapsed=\"collapsed\";static Expanded=\"expanded\";static DeeplyExpanded=\"deeply-expanded\"}const useConfig=()=>{const{config:s}=(0,Re.useContext)(sT);return s},useComponent=s=>{const{components:o}=(0,Re.useContext)(sT);return o[s]||null},useFn=(s=void 0)=>{const{fn:o}=(0,Re.useContext)(sT);return void 0!==s?o[s]:o},useJSONSchemaContextState=()=>{const[,s]=(0,Re.useState)(null),{state:o}=(0,Re.useContext)(sT);return{state:o,setState:i=>{i(o),s({})}}},useLevel=()=>{const s=(0,Re.useContext)(oT);return[s,s+1]},usePath=s=>{const o=(0,Re.useContext)(aT),{setState:i}=useJSONSchemaContextState(),a=\"string\"==typeof s?[...o,s]:o;return{path:a,pathMutator:(s,o={deep:!1})=>{const u=a.toString(),updateFn=o=>{o.paths[u]=s,s===JSONSchemaIsExpandedState.Collapsed&&Object.keys(o.paths).forEach((s=>{s.startsWith(u)&&o.paths[s]===JSONSchemaIsExpandedState.DeeplyExpanded&&(o.paths[s]=JSONSchemaIsExpandedState.Expanded)}))},updateDeepFn=o=>{Object.keys(o.paths).forEach((i=>{i.startsWith(u)&&(o.paths[i]=s)}))};o.deep?i(updateDeepFn):i(updateFn)}}},useIsExpanded=s=>{const[o]=useLevel(),{defaultExpandedLevels:i}=useConfig(),{path:a,pathMutator:u}=usePath(s),{path:_}=usePath(),{state:w}=useJSONSchemaContextState(),x=w.paths[a.toString()],C=w.paths[_.toString()]??w.paths[_.slice(0,-1).toString()],j=x??(i-o>0?JSONSchemaIsExpandedState.Expanded:JSONSchemaIsExpandedState.Collapsed),L=j!==JSONSchemaIsExpandedState.Collapsed;(0,Re.useEffect)((()=>{u(C===JSONSchemaIsExpandedState.DeeplyExpanded?JSONSchemaIsExpandedState.DeeplyExpanded:j)}),[C]);return{isExpanded:L,setExpanded:(0,Re.useCallback)(((s={deep:!1})=>{u(s.deep?JSONSchemaIsExpandedState.DeeplyExpanded:JSONSchemaIsExpandedState.Expanded)}),[]),setCollapsed:(0,Re.useCallback)(((s={deep:!1})=>{u(JSONSchemaIsExpandedState.Collapsed,s)}),[])}},useRenderedSchemas=(s=void 0)=>{if(void 0===s)return(0,Re.useContext)(iT);const o=(0,Re.useContext)(iT);return new Set([...o,s])},cT=(0,Re.forwardRef)((({schema:s,name:o=\"\",dependentRequired:i=[],onExpand:a=()=>{},identifier:u=\"\"},_)=>{const w=useFn(),x=u||s?.$id||o,{path:C}=usePath(x),{isExpanded:j,setExpanded:L,setCollapsed:B}=useIsExpanded(x),[$,U]=useLevel(),V=(()=>{const[s]=useLevel();return s>0})(),z=w.isExpandable(s)||i.length>0,Y=(s=>useRenderedSchemas().has(s))(s),Z=useRenderedSchemas(s),ee=w.stringifyConstraints(s),ie=useComponent(\"Accordion\"),ae=useComponent(\"Keyword$schema\"),ce=useComponent(\"Keyword$vocabulary\"),le=useComponent(\"Keyword$id\"),pe=useComponent(\"Keyword$anchor\"),de=useComponent(\"Keyword$dynamicAnchor\"),fe=useComponent(\"Keyword$ref\"),ye=useComponent(\"Keyword$dynamicRef\"),be=useComponent(\"Keyword$defs\"),_e=useComponent(\"Keyword$comment\"),Se=useComponent(\"KeywordAllOf\"),we=useComponent(\"KeywordAnyOf\"),xe=useComponent(\"KeywordOneOf\"),Pe=useComponent(\"KeywordNot\"),Te=useComponent(\"KeywordIf\"),$e=useComponent(\"KeywordThen\"),qe=useComponent(\"KeywordElse\"),ze=useComponent(\"KeywordDependentSchemas\"),We=useComponent(\"KeywordPrefixItems\"),He=useComponent(\"KeywordItems\"),Ye=useComponent(\"KeywordContains\"),Xe=useComponent(\"KeywordProperties\"),Qe=useComponent(\"KeywordPatternProperties\"),et=useComponent(\"KeywordAdditionalProperties\"),tt=useComponent(\"KeywordPropertyNames\"),rt=useComponent(\"KeywordUnevaluatedItems\"),nt=useComponent(\"KeywordUnevaluatedProperties\"),st=useComponent(\"KeywordType\"),ot=useComponent(\"KeywordEnum\"),it=useComponent(\"KeywordConst\"),at=useComponent(\"KeywordConstraint\"),ct=useComponent(\"KeywordDependentRequired\"),lt=useComponent(\"KeywordContentSchema\"),ut=useComponent(\"KeywordTitle\"),pt=useComponent(\"KeywordDescription\"),ht=useComponent(\"KeywordDefault\"),dt=useComponent(\"KeywordDeprecated\"),mt=useComponent(\"KeywordReadOnly\"),gt=useComponent(\"KeywordWriteOnly\"),yt=useComponent(\"KeywordExamples\"),vt=useComponent(\"ExtensionKeywords\"),bt=useComponent(\"ExpandDeepButton\"),_t=(0,Re.useCallback)(((s,o)=>{o?L():B(),a(s,o,!1)}),[a,L,B]),St=(0,Re.useCallback)(((s,o)=>{o?L({deep:!0}):B({deep:!0}),a(s,o,!0)}),[a,L,B]);return Re.createElement(aT.Provider,{value:C},Re.createElement(oT.Provider,{value:U},Re.createElement(iT.Provider,{value:Z},Re.createElement(\"article\",{ref:_,\"data-json-schema-level\":$,className:Jn()(\"json-schema-2020-12\",{\"json-schema-2020-12--embedded\":V,\"json-schema-2020-12--circular\":Y})},Re.createElement(\"div\",{className:\"json-schema-2020-12-head\"},z&&!Y?Re.createElement(Re.Fragment,null,Re.createElement(ie,{expanded:j,onChange:_t},Re.createElement(ut,{title:o,schema:s})),Re.createElement(bt,{expanded:j,onClick:St})):Re.createElement(ut,{title:o,schema:s}),Re.createElement(dt,{schema:s}),Re.createElement(mt,{schema:s}),Re.createElement(gt,{schema:s}),Re.createElement(st,{schema:s,isCircular:Y}),ee.length>0&&ee.map((s=>Re.createElement(at,{key:`${s.scope}-${s.value}`,constraint:s})))),Re.createElement(\"div\",{className:Jn()(\"json-schema-2020-12-body\",{\"json-schema-2020-12-body--collapsed\":!j})},j&&Re.createElement(Re.Fragment,null,Re.createElement(pt,{schema:s}),!Y&&z&&Re.createElement(Re.Fragment,null,Re.createElement(Xe,{schema:s}),Re.createElement(Qe,{schema:s}),Re.createElement(et,{schema:s}),Re.createElement(nt,{schema:s}),Re.createElement(tt,{schema:s}),Re.createElement(Se,{schema:s}),Re.createElement(we,{schema:s}),Re.createElement(xe,{schema:s}),Re.createElement(Pe,{schema:s}),Re.createElement(Te,{schema:s}),Re.createElement($e,{schema:s}),Re.createElement(qe,{schema:s}),Re.createElement(ze,{schema:s}),Re.createElement(We,{schema:s}),Re.createElement(He,{schema:s}),Re.createElement(rt,{schema:s}),Re.createElement(Ye,{schema:s}),Re.createElement(lt,{schema:s})),Re.createElement(ot,{schema:s}),Re.createElement(it,{schema:s}),Re.createElement(ct,{schema:s,dependentRequired:i}),Re.createElement(ht,{schema:s}),Re.createElement(yt,{schema:s}),Re.createElement(ae,{schema:s}),Re.createElement(ce,{schema:s}),Re.createElement(le,{schema:s}),Re.createElement(pe,{schema:s}),Re.createElement(de,{schema:s}),Re.createElement(fe,{schema:s}),!Y&&z&&Re.createElement(be,{schema:s}),Re.createElement(ye,{schema:s}),Re.createElement(_e,{schema:s}),Re.createElement(vt,{schema:s})))))))})),lT=cT,keywords_$schema=({schema:s})=>s?.$schema?Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--$schema\"},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"$schema\"),Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary\"},s.$schema)):null,$vocabulary_$vocabulary=({schema:s})=>{const o=\"$vocabulary\",{path:i}=usePath(o),{isExpanded:a,setExpanded:u,setCollapsed:_}=useIsExpanded(o),w=useComponent(\"Accordion\"),x=(0,Re.useCallback)((()=>{a?_():u()}),[a,u,_]);return s?.$vocabulary?\"object\"!=typeof s.$vocabulary?null:Re.createElement(aT.Provider,{value:i},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--$vocabulary\"},Re.createElement(w,{expanded:a,onChange:x},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"$vocabulary\")),Re.createElement(\"strong\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary\"},\"object\"),Re.createElement(\"ul\",null,a&&Object.entries(s.$vocabulary).map((([s,o])=>Re.createElement(\"li\",{key:s,className:Jn()(\"json-schema-2020-12-$vocabulary-uri\",{\"json-schema-2020-12-$vocabulary-uri--disabled\":!o})},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary\"},s))))))):null},keywords_$id=({schema:s})=>s?.$id?Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--$id\"},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"$id\"),Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary\"},s.$id)):null,keywords_$anchor=({schema:s})=>s?.$anchor?Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--$anchor\"},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"$anchor\"),Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary\"},s.$anchor)):null,keywords_$dynamicAnchor=({schema:s})=>s?.$dynamicAnchor?Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--$dynamicAnchor\"},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"$dynamicAnchor\"),Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary\"},s.$dynamicAnchor)):null,keywords_$ref=({schema:s})=>s?.$ref?Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--$ref\"},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"$ref\"),Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary\"},s.$ref)):null,keywords_$dynamicRef=({schema:s})=>s?.$dynamicRef?Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--$dynamicRef\"},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"$dynamicRef\"),Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary\"},s.$dynamicRef)):null,keywords_$defs=({schema:s})=>{const o=s?.$defs||{},i=\"$defs\",{path:a}=usePath(i),{isExpanded:u,setExpanded:_,setCollapsed:w}=useIsExpanded(i),[x,C]=useLevel(),j=useComponent(\"Accordion\"),L=useComponent(\"ExpandDeepButton\"),B=useComponent(\"JSONSchema\"),$=(0,Re.useCallback)((()=>{u?w():_()}),[u,_,w]),U=(0,Re.useCallback)(((s,o)=>{o?_({deep:!0}):w({deep:!0})}),[_,w]);return 0===Object.keys(o).length?null:Re.createElement(aT.Provider,{value:a},Re.createElement(oT.Provider,{value:C},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--$defs\",\"data-json-schema-level\":x},Re.createElement(j,{expanded:u,onChange:$},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"$defs\")),Re.createElement(L,{expanded:u,onClick:U}),Re.createElement(\"strong\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary\"},\"object\"),Re.createElement(\"ul\",{className:Jn()(\"json-schema-2020-12-keyword__children\",{\"json-schema-2020-12-keyword__children--collapsed\":!u})},u&&Re.createElement(Re.Fragment,null,Object.entries(o).map((([s,o])=>Re.createElement(\"li\",{key:s,className:\"json-schema-2020-12-property\"},Re.createElement(B,{name:s,schema:o})))))))))},keywords_$comment=({schema:s})=>s?.$comment?Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--$comment\"},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--secondary\"},\"$comment\"),Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--secondary\"},s.$comment)):null,keywords_AllOf=({schema:s})=>{const o=s?.allOf||[],i=useFn(),a=\"allOf\",{path:u}=usePath(a),{isExpanded:_,setExpanded:w,setCollapsed:x}=useIsExpanded(a),[C,j]=useLevel(),L=useComponent(\"Accordion\"),B=useComponent(\"ExpandDeepButton\"),$=useComponent(\"JSONSchema\"),U=useComponent(\"KeywordType\"),V=(0,Re.useCallback)((()=>{_?x():w()}),[_,w,x]),z=(0,Re.useCallback)(((s,o)=>{o?w({deep:!0}):x({deep:!0})}),[w,x]);return Array.isArray(o)&&0!==o.length?Re.createElement(aT.Provider,{value:u},Re.createElement(oT.Provider,{value:j},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--allOf\",\"data-json-schema-level\":C},Re.createElement(L,{expanded:_,onChange:V},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"All of\")),Re.createElement(B,{expanded:_,onClick:z}),Re.createElement(U,{schema:{allOf:o}}),Re.createElement(\"ul\",{className:Jn()(\"json-schema-2020-12-keyword__children\",{\"json-schema-2020-12-keyword__children--collapsed\":!_})},_&&Re.createElement(Re.Fragment,null,o.map(((s,o)=>Re.createElement(\"li\",{key:`#${o}`,className:\"json-schema-2020-12-property\"},Re.createElement($,{name:`#${o} ${i.getTitle(s)}`,schema:s}))))))))):null},keywords_AnyOf=({schema:s})=>{const o=s?.anyOf||[],i=useFn(),a=\"anyOf\",{path:u}=usePath(a),{isExpanded:_,setExpanded:w,setCollapsed:x}=useIsExpanded(a),[C,j]=useLevel(),L=useComponent(\"Accordion\"),B=useComponent(\"ExpandDeepButton\"),$=useComponent(\"JSONSchema\"),U=useComponent(\"KeywordType\"),V=(0,Re.useCallback)((()=>{_?x():w()}),[_,w,x]),z=(0,Re.useCallback)(((s,o)=>{o?w({deep:!0}):x({deep:!0})}),[w,x]);return Array.isArray(o)&&0!==o.length?Re.createElement(aT.Provider,{value:u},Re.createElement(oT.Provider,{value:j},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--anyOf\",\"data-json-schema-level\":C},Re.createElement(L,{expanded:_,onChange:V},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"Any of\")),Re.createElement(B,{expanded:_,onClick:z}),Re.createElement(U,{schema:{anyOf:o}}),Re.createElement(\"ul\",{className:Jn()(\"json-schema-2020-12-keyword__children\",{\"json-schema-2020-12-keyword__children--collapsed\":!_})},_&&Re.createElement(Re.Fragment,null,o.map(((s,o)=>Re.createElement(\"li\",{key:`#${o}`,className:\"json-schema-2020-12-property\"},Re.createElement($,{name:`#${o} ${i.getTitle(s)}`,schema:s}))))))))):null},keywords_OneOf=({schema:s})=>{const o=s?.oneOf||[],i=useFn(),a=\"oneOf\",{path:u}=usePath(a),{isExpanded:_,setExpanded:w,setCollapsed:x}=useIsExpanded(a),[C,j]=useLevel(),L=useComponent(\"Accordion\"),B=useComponent(\"ExpandDeepButton\"),$=useComponent(\"JSONSchema\"),U=useComponent(\"KeywordType\"),V=(0,Re.useCallback)((()=>{_?x():w()}),[_,w,x]),z=(0,Re.useCallback)(((s,o)=>{o?w({deep:!0}):x({deep:!0})}),[w,x]);return Array.isArray(o)&&0!==o.length?Re.createElement(aT.Provider,{value:u},Re.createElement(oT.Provider,{value:j},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--oneOf\",\"data-json-schema-level\":C},Re.createElement(L,{expanded:_,onChange:V},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"One of\")),Re.createElement(B,{expanded:_,onClick:z}),Re.createElement(U,{schema:{oneOf:o}}),Re.createElement(\"ul\",{className:Jn()(\"json-schema-2020-12-keyword__children\",{\"json-schema-2020-12-keyword__children--collapsed\":!_})},_&&Re.createElement(Re.Fragment,null,o.map(((s,o)=>Re.createElement(\"li\",{key:`#${o}`,className:\"json-schema-2020-12-property\"},Re.createElement($,{name:`#${o} ${i.getTitle(s)}`,schema:s}))))))))):null},keywords_Not=({schema:s})=>{const o=useFn(),i=useComponent(\"JSONSchema\");if(!o.hasKeyword(s,\"not\"))return null;const a=Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"Not\");return Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--not\"},Re.createElement(i,{name:a,schema:s.not,identifier:\"not\"}))},keywords_If=({schema:s})=>{const o=useFn(),i=useComponent(\"JSONSchema\");if(!o.hasKeyword(s,\"if\"))return null;const a=Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"If\");return Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--if\"},Re.createElement(i,{name:a,schema:s.if,identifier:\"if\"}))},keywords_Then=({schema:s})=>{const o=useFn(),i=useComponent(\"JSONSchema\");if(!o.hasKeyword(s,\"then\"))return null;const a=Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"Then\");return Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--then\"},Re.createElement(i,{name:a,schema:s.then,identifier:\"then\"}))},keywords_Else=({schema:s})=>{const o=useFn(),i=useComponent(\"JSONSchema\");if(!o.hasKeyword(s,\"else\"))return null;const a=Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"Else\");return Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--if\"},Re.createElement(i,{name:a,schema:s.else,identifier:\"else\"}))},keywords_DependentSchemas=({schema:s})=>{const o=s?.dependentSchemas||[],i=\"dependentSchemas\",{path:a}=usePath(i),{isExpanded:u,setExpanded:_,setCollapsed:w}=useIsExpanded(i),[x,C]=useLevel(),j=useComponent(\"Accordion\"),L=useComponent(\"ExpandDeepButton\"),B=useComponent(\"JSONSchema\"),$=(0,Re.useCallback)((()=>{u?w():_()}),[u,_,w]),U=(0,Re.useCallback)(((s,o)=>{o?_({deep:!0}):w({deep:!0})}),[_,w]);return\"object\"!=typeof o||0===Object.keys(o).length?null:Re.createElement(aT.Provider,{value:a},Re.createElement(oT.Provider,{value:C},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--dependentSchemas\",\"data-json-schema-level\":x},Re.createElement(j,{expanded:u,onChange:$},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"Dependent schemas\")),Re.createElement(L,{expanded:u,onClick:U}),Re.createElement(\"strong\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary\"},\"object\"),Re.createElement(\"ul\",{className:Jn()(\"json-schema-2020-12-keyword__children\",{\"json-schema-2020-12-keyword__children--collapsed\":!u})},u&&Re.createElement(Re.Fragment,null,Object.entries(o).map((([s,o])=>Re.createElement(\"li\",{key:s,className:\"json-schema-2020-12-property\"},Re.createElement(B,{name:s,schema:o})))))))))},keywords_PrefixItems=({schema:s})=>{const o=s?.prefixItems||[],i=useFn(),a=\"prefixItems\",{path:u}=usePath(a),{isExpanded:_,setExpanded:w,setCollapsed:x}=useIsExpanded(a),[C,j]=useLevel(),L=useComponent(\"Accordion\"),B=useComponent(\"ExpandDeepButton\"),$=useComponent(\"JSONSchema\"),U=useComponent(\"KeywordType\"),V=(0,Re.useCallback)((()=>{_?x():w()}),[_,w,x]),z=(0,Re.useCallback)(((s,o)=>{o?w({deep:!0}):x({deep:!0})}),[w,x]);return Array.isArray(o)&&0!==o.length?Re.createElement(aT.Provider,{value:u},Re.createElement(oT.Provider,{value:j},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--prefixItems\",\"data-json-schema-level\":C},Re.createElement(L,{expanded:_,onChange:V},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"Prefix items\")),Re.createElement(B,{expanded:_,onClick:z}),Re.createElement(U,{schema:{prefixItems:o}}),Re.createElement(\"ul\",{className:Jn()(\"json-schema-2020-12-keyword__children\",{\"json-schema-2020-12-keyword__children--collapsed\":!_})},_&&Re.createElement(Re.Fragment,null,o.map(((s,o)=>Re.createElement(\"li\",{key:`#${o}`,className:\"json-schema-2020-12-property\"},Re.createElement($,{name:`#${o} ${i.getTitle(s)}`,schema:s}))))))))):null},keywords_Items=({schema:s})=>{const o=useFn(),i=useComponent(\"JSONSchema\");if(!o.hasKeyword(s,\"items\"))return null;const a=Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"Items\");return Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--items\"},Re.createElement(i,{name:a,schema:s.items,identifier:\"items\"}))},keywords_Contains=({schema:s})=>{const o=useFn(),i=useComponent(\"JSONSchema\");if(!o.hasKeyword(s,\"contains\"))return null;const a=Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"Contains\");return Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--contains\"},Re.createElement(i,{name:a,schema:s.contains,identifier:\"contains\"}))},keywords_Properties_Properties=({schema:s})=>{const o=useFn(),i=s?.properties||{},a=Array.isArray(s?.required)?s.required:[],u=useComponent(\"JSONSchema\"),{path:_}=usePath(\"properties\");return 0===Object.keys(i).length?null:Re.createElement(aT.Provider,{value:_},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--properties\"},Re.createElement(\"ul\",null,Object.entries(i).map((([i,_])=>{const w=a.includes(i),x=o.getDependentRequired(i,s);return Re.createElement(\"li\",{key:i,className:Jn()(\"json-schema-2020-12-property\",{\"json-schema-2020-12-property--required\":w})},Re.createElement(u,{name:i,schema:_,dependentRequired:x}))})))))},PatternProperties_PatternProperties=({schema:s})=>{const o=s?.patternProperties||{},i=useComponent(\"JSONSchema\"),{path:a}=usePath(\"patternProperties\");return 0===Object.keys(o).length?null:Re.createElement(aT.Provider,{value:a},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--patternProperties\"},Re.createElement(\"ul\",null,Object.entries(o).map((([s,o])=>Re.createElement(\"li\",{key:s,className:\"json-schema-2020-12-property\"},Re.createElement(i,{name:s,schema:o})))))))},keywords_AdditionalProperties=({schema:s})=>{const o=useFn(),i=useComponent(\"JSONSchema\");if(!o.hasKeyword(s,\"additionalProperties\"))return null;const a=Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"Additional properties\");return Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--additionalProperties\"},!0===s.additionalProperties?Re.createElement(Re.Fragment,null,a,Re.createElement(\"span\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary\"},\"allowed\")):!1===s.additionalProperties?Re.createElement(Re.Fragment,null,a,Re.createElement(\"span\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary\"},\"forbidden\")):Re.createElement(i,{name:a,schema:s.additionalProperties,identifier:\"additionalProperties\"}))},keywords_PropertyNames=({schema:s})=>{const o=useFn(),i=useComponent(\"JSONSchema\"),a=Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"Property names\");return o.hasKeyword(s,\"propertyNames\")?Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--propertyNames\"},Re.createElement(i,{name:a,schema:s.propertyNames,identifier:\"propertyNames\"})):null},keywords_UnevaluatedItems=({schema:s})=>{const o=useFn(),i=useComponent(\"JSONSchema\");if(!o.hasKeyword(s,\"unevaluatedItems\"))return null;const a=Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"Unevaluated items\");return Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--unevaluatedItems\"},Re.createElement(i,{name:a,schema:s.unevaluatedItems,identifier:\"unevaluatedItems\"}))},keywords_UnevaluatedProperties=({schema:s})=>{const o=useFn(),i=useComponent(\"JSONSchema\");if(!o.hasKeyword(s,\"unevaluatedProperties\"))return null;const a=Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"Unevaluated properties\");return Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--unevaluatedProperties\"},Re.createElement(i,{name:a,schema:s.unevaluatedProperties,identifier:\"unevaluatedProperties\"}))},keywords_Type=({schema:s,isCircular:o=!1})=>{const i=useFn().getType(s),a=o?\" [circular]\":\"\";return Re.createElement(\"strong\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary\"},`${i}${a}`)},Enum_Enum=({schema:s})=>{const o=useComponent(\"JSONViewer\");return Array.isArray(s?.enum)?Re.createElement(o,{name:\"Enum\",value:s.enum,className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--enum\"}):null},Const_Const=({schema:s})=>{const o=useFn(),i=useComponent(\"JSONViewer\");return o.hasKeyword(s,\"const\")?Re.createElement(i,{name:\"Const\",value:s.const,className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--const\"}):null},fn_upperFirst=s=>\"string\"==typeof s?`${s.charAt(0).toUpperCase()}${s.slice(1)}`:s,makeGetTitle=s=>(o,{lookup:i=\"extended\"}={})=>{const a=s();if(null!=o?.title)return a.upperFirst(String(o.title));if(\"extended\"===i){if(null!=o?.$anchor)return a.upperFirst(String(o.$anchor));if(null!=o?.$id)return String(o.$id)}return\"\"},makeGetType=s=>{const getType=(o,i=new WeakSet)=>{const a=s();if(null==o)return\"any\";if(a.isBooleanJSONSchema(o))return o?\"any\":\"never\";if(\"object\"!=typeof o)return\"any\";if(i.has(o))return\"any\";i.add(o);const{type:u,prefixItems:_,items:w}=o,getArrayType=()=>{if(Array.isArray(_)){const s=_.map((s=>getType(s,i))),o=w?getType(w,i):\"any\";return`array<[${s.join(\", \")}], ${o}>`}if(w){return`array<${getType(w,i)}>`}return\"array<any>\"};if(o.not&&\"any\"===getType(o.not))return\"never\";const handleCombiningKeywords=(s,a)=>{if(Array.isArray(o[s])){return`(${o[s].map((s=>getType(s,i))).join(a)})`}return null},x=[Array.isArray(u)?u.map((s=>\"array\"===s?getArrayType():s)).join(\" | \"):\"array\"===u?getArrayType():[\"null\",\"boolean\",\"object\",\"array\",\"number\",\"integer\",\"string\"].includes(u)?u:(()=>{if(Object.hasOwn(o,\"prefixItems\")||Object.hasOwn(o,\"items\")||Object.hasOwn(o,\"contains\"))return getArrayType();if(Object.hasOwn(o,\"properties\")||Object.hasOwn(o,\"additionalProperties\")||Object.hasOwn(o,\"patternProperties\"))return\"object\";if([\"int32\",\"int64\"].includes(o.format))return\"integer\";if([\"float\",\"double\"].includes(o.format))return\"number\";if(Object.hasOwn(o,\"minimum\")||Object.hasOwn(o,\"maximum\")||Object.hasOwn(o,\"exclusiveMinimum\")||Object.hasOwn(o,\"exclusiveMaximum\")||Object.hasOwn(o,\"multipleOf\"))return\"number | integer\";if(Object.hasOwn(o,\"pattern\")||Object.hasOwn(o,\"format\")||Object.hasOwn(o,\"minLength\")||Object.hasOwn(o,\"maxLength\")||Object.hasOwn(o,\"contentEncoding\")||Object.hasOwn(o,\"contentMediaType\"))return\"string\";if(void 0!==o.const){if(null===o.const)return\"null\";if(\"boolean\"==typeof o.const)return\"boolean\";if(\"number\"==typeof o.const)return Number.isInteger(o.const)?\"integer\":\"number\";if(\"string\"==typeof o.const)return\"string\";if(Array.isArray(o.const))return\"array<any>\";if(\"object\"==typeof o.const)return\"object\"}return null})(),handleCombiningKeywords(\"oneOf\",\" | \"),handleCombiningKeywords(\"anyOf\",\" | \"),handleCombiningKeywords(\"allOf\",\" & \")].filter(Boolean).join(\" | \");return i.delete(o),x||\"any\"};return getType},isBooleanJSONSchema=s=>\"boolean\"==typeof s,hasKeyword=(s,o)=>null!==s&&\"object\"==typeof s&&Object.hasOwn(s,o),fn_makeIsExpandable=s=>o=>{const i=s();return o?.$schema||o?.$vocabulary||o?.$id||o?.$anchor||o?.$dynamicAnchor||o?.$ref||o?.$dynamicRef||o?.$defs||o?.$comment||o?.allOf||o?.anyOf||o?.oneOf||i.hasKeyword(o,\"not\")||i.hasKeyword(o,\"if\")||i.hasKeyword(o,\"then\")||i.hasKeyword(o,\"else\")||o?.dependentSchemas||o?.prefixItems||i.hasKeyword(o,\"items\")||i.hasKeyword(o,\"contains\")||o?.properties||o?.patternProperties||i.hasKeyword(o,\"additionalProperties\")||i.hasKeyword(o,\"propertyNames\")||i.hasKeyword(o,\"unevaluatedItems\")||i.hasKeyword(o,\"unevaluatedProperties\")||o?.description||o?.enum||i.hasKeyword(o,\"const\")||i.hasKeyword(o,\"contentSchema\")||i.hasKeyword(o,\"default\")||o?.examples||i.getExtensionKeywords(o).length>0},fn_stringify=s=>null===s||[\"number\",\"bigint\",\"boolean\"].includes(typeof s)?String(s):Array.isArray(s)?`[${s.map(fn_stringify).join(\", \")}]`:JSON.stringify(s),stringifyConstraintRange=(s,o,i)=>{const a=\"number\"==typeof o,u=\"number\"==typeof i;return a&&u?o===i?`${o} ${s}`:`[${o}, ${i}] ${s}`:a?`≥ ${o} ${s}`:u?`≤ ${i} ${s}`:null},stringifyConstraints=s=>{const o=[],i=(s=>{if(\"number\"!=typeof s?.multipleOf)return null;if(s.multipleOf<=0)return null;if(1===s.multipleOf)return null;const{multipleOf:o}=s;if(Number.isInteger(o))return`multiple of ${o}`;const i=10**o.toString().split(\".\")[1].length;return`multiple of ${o*i}/${i}`})(s);null!==i&&o.push({scope:\"number\",value:i});const a=(s=>{const o=s?.minimum,i=s?.maximum,a=s?.exclusiveMinimum,u=s?.exclusiveMaximum,_=\"number\"==typeof o,w=\"number\"==typeof i,x=\"number\"==typeof a,C=\"number\"==typeof u,j=x&&(!_||o<a),L=C&&(!w||i>u);if((_||x)&&(w||C))return`${j?\"(\":\"[\"}${j?a:o}, ${L?u:i}${L?\")\":\"]\"}`;if(_||x)return`${j?\">\":\"≥\"} ${j?a:o}`;if(w||C)return`${L?\"<\":\"≤\"} ${L?u:i}`;return null})(s);null!==a&&o.push({scope:\"number\",value:a}),s?.format&&o.push({scope:\"string\",value:s.format});const u=stringifyConstraintRange(\"characters\",s?.minLength,s?.maxLength);null!==u&&o.push({scope:\"string\",value:u}),s?.pattern&&o.push({scope:\"string\",value:`matches ${s?.pattern}`}),s?.contentMediaType&&o.push({scope:\"string\",value:`media type: ${s.contentMediaType}`}),s?.contentEncoding&&o.push({scope:\"string\",value:`encoding: ${s.contentEncoding}`});const _=stringifyConstraintRange(s?.uniqueItems?\"unique items\":\"items\",s?.minItems,s?.maxItems);null!==_&&o.push({scope:\"array\",value:_}),s?.uniqueItems&&!_&&o.push({scope:\"array\",value:\"unique\"});const w=stringifyConstraintRange(\"contained items\",s?.minContains,s?.maxContains);null!==w&&o.push({scope:\"array\",value:w});const x=stringifyConstraintRange(\"properties\",s?.minProperties,s?.maxProperties);return null!==x&&o.push({scope:\"object\",value:x}),o},getDependentRequired=(s,o)=>o?.dependentRequired?Array.from(Object.entries(o.dependentRequired).reduce(((o,[i,a])=>Array.isArray(a)&&a.includes(s)?(o.add(i),o):o),new Set)):[],fn_isPlainObject=s=>\"object\"==typeof s&&null!==s&&!Array.isArray(s)&&(null===Object.getPrototypeOf(s)||Object.getPrototypeOf(s)===Object.prototype),getSchemaKeywords=()=>[\"$schema\",\"$vocabulary\",\"$id\",\"$anchor\",\"$dynamicAnchor\",\"$dynamicRef\",\"$ref\",\"$defs\",\"$comment\",\"allOf\",\"anyOf\",\"oneOf\",\"not\",\"if\",\"then\",\"else\",\"dependentSchemas\",\"prefixItems\",\"items\",\"contains\",\"properties\",\"patternProperties\",\"additionalProperties\",\"propertyNames\",\"unevaluatedItems\",\"unevaluatedProperties\",\"type\",\"enum\",\"const\",\"multipleOf\",\"maximum\",\"exclusiveMaximum\",\"minimum\",\"exclusiveMinimum\",\"maxLength\",\"minLength\",\"pattern\",\"maxItems\",\"minItems\",\"uniqueItems\",\"maxContains\",\"minContains\",\"maxProperties\",\"minProperties\",\"required\",\"dependentRequired\",\"title\",\"description\",\"default\",\"deprecated\",\"readOnly\",\"writeOnly\",\"examples\",\"format\",\"contentEncoding\",\"contentMediaType\",\"contentSchema\"],makeGetExtensionKeywords=s=>o=>{const i=s().getSchemaKeywords();return fn_isPlainObject(o)?((s,o)=>{const i=new Set(o);return s.filter((s=>!i.has(s)))})(Object.keys(o),i):[]},fn_hasSchemaType=(s,o)=>{const i=ze.Map.isMap(s);if(!i&&!fn_isPlainObject(s))return!1;const hasType=s=>o===s||Array.isArray(o)&&o.includes(s),a=i?s.get(\"type\"):s.type;return ze.List.isList(a)||Array.isArray(a)?a.some((s=>hasType(s))):hasType(a)},Constraint=({constraint:s})=>fn_isPlainObject(s)&&\"string\"==typeof s.scope&&\"string\"==typeof s.value?Re.createElement(\"span\",{className:`json-schema-2020-12__constraint json-schema-2020-12__constraint--${s.scope}`},s.value):null,uT=Re.memo(Constraint),DependentRequired_DependentRequired=({dependentRequired:s})=>Array.isArray(s)&&0!==s.length?Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--dependentRequired\"},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"Required when defined\"),Re.createElement(\"ul\",null,s.map((s=>Re.createElement(\"li\",{key:s},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__value json-schema-2020-12-keyword__value--warning\"},s)))))):null,keywords_ContentSchema=({schema:s})=>{const o=useFn(),i=useComponent(\"JSONSchema\");if(!o.hasKeyword(s,\"contentSchema\"))return null;const a=Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--primary\"},\"Content schema\");return Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--contentSchema\"},Re.createElement(i,{name:a,schema:s.contentSchema,identifier:\"contentSchema\"}))},Title_Title=({title:s=\"\",schema:o})=>{const i=useFn(),a=s||i.getTitle(o);return a?Re.createElement(\"div\",{className:\"json-schema-2020-12__title\"},a):null},keywords_Description_Description=({schema:s})=>s?.description?Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--description\"},Re.createElement(\"div\",{className:\"json-schema-2020-12-core-keyword__value json-schema-2020-12-core-keyword__value--secondary\"},s.description)):null,Default_Default=({schema:s})=>{const o=useFn(),i=useComponent(\"JSONViewer\");return o.hasKeyword(s,\"default\")?Re.createElement(i,{name:\"Default\",value:s.default,className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--default\"}):null},keywords_Deprecated=({schema:s})=>!0!==s?.deprecated?null:Re.createElement(\"span\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--warning\"},\"deprecated\"),keywords_ReadOnly=({schema:s})=>!0!==s?.readOnly?null:Re.createElement(\"span\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--muted\"},\"read-only\"),keywords_WriteOnly=({schema:s})=>!0!==s?.writeOnly?null:Re.createElement(\"span\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--muted\"},\"write-only\"),keywords_Examples_Examples=({schema:s})=>{const o=s?.examples||[],i=useComponent(\"JSONViewer\");return Array.isArray(o)&&0!==o.length?Re.createElement(i,{name:\"Examples\",value:s.examples,className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--examples\"}):null},ExtensionKeywords_ExtensionKeywords=({schema:s})=>{const o=useFn(),i=\"ExtensionKeywords\",{path:a}=usePath(i),{isExpanded:u,setExpanded:_,setCollapsed:w}=useIsExpanded(i),[x,C]=useLevel(),j=useComponent(\"Accordion\"),L=useComponent(\"ExpandDeepButton\"),B=useComponent(\"JSONViewer\"),{showExtensionKeywords:$}=useConfig(),U=o.getExtensionKeywords(s),V=(0,Re.useCallback)((()=>{u?w():_()}),[u,_,w]),z=(0,Re.useCallback)(((s,o)=>{o?_({deep:!0}):w({deep:!0})}),[_,w]);return $&&0!==U.length?Re.createElement(aT.Provider,{value:a},Re.createElement(oT.Provider,{value:C},Re.createElement(\"div\",{className:\"json-schema-2020-12-keyword json-schema-2020-12-keyword--extension-keywords\",\"data-json-schema-level\":x},Re.createElement(j,{expanded:u,onChange:V},Re.createElement(\"span\",{className:\"json-schema-2020-12-keyword__name json-schema-2020-12-keyword__name--extension\"},\"Extension Keywords\")),Re.createElement(L,{expanded:u,onClick:z}),Re.createElement(\"ul\",{className:Jn()(\"json-schema-2020-12-keyword__children\",{\"json-schema-2020-12-keyword__children--collapsed\":!u})},u&&Re.createElement(Re.Fragment,null,U.map((o=>Re.createElement(B,{key:o,name:o,value:s[o],className:\"json-schema-2020-12-json-viewer-extension-keyword\"})))))))):null},JSONViewer=({name:s,value:o,className:i})=>{const a=useFn(),{path:u}=usePath(s),{isExpanded:_,setExpanded:w,setCollapsed:x}=useIsExpanded(s),[C,j]=useLevel(),L=useComponent(\"Accordion\"),B=useComponent(\"ExpandDeepButton\"),$=\"string\"==typeof o||\"number\"==typeof o||\"bigint\"==typeof o||\"boolean\"==typeof o||\"symbol\"==typeof o||null==o,U=(s=>fn_isPlainObject(s)&&0===Object.keys(s).length)(o)||(s=>Array.isArray(s)&&0===s.length)(o),V=(0,Re.useCallback)((()=>{_?x():w()}),[_,w,x]),z=(0,Re.useCallback)(((s,o)=>{o?w({deep:!0}):x({deep:!0})}),[w,x]);return $?Re.createElement(\"div\",{className:Jn()(\"json-schema-2020-12-json-viewer\",i)},Re.createElement(\"span\",{className:\"json-schema-2020-12-json-viewer__name json-schema-2020-12-json-viewer__name--secondary\"},s),Re.createElement(\"span\",{className:\"json-schema-2020-12-json-viewer__value json-schema-2020-12-json-viewer__value--secondary\"},a.stringify(o))):U?Re.createElement(\"div\",{className:Jn()(\"json-schema-2020-12-json-viewer\",i)},Re.createElement(\"span\",{className:\"json-schema-2020-12-json-viewer__name json-schema-2020-12-json-viewer__name--secondary\"},s),Re.createElement(\"strong\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary\"},Array.isArray(o)?\"empty array\":\"empty object\")):Re.createElement(aT.Provider,{value:u},Re.createElement(oT.Provider,{value:j},Re.createElement(\"div\",{className:Jn()(\"json-schema-2020-12-json-viewer\",i),\"data-json-schema-level\":C},Re.createElement(L,{expanded:_,onChange:V},Re.createElement(\"span\",{className:\"json-schema-2020-12-json-viewer__name json-schema-2020-12-json-viewer__name--secondary\"},s)),Re.createElement(B,{expanded:_,onClick:z}),Re.createElement(\"strong\",{className:\"json-schema-2020-12__attribute json-schema-2020-12__attribute--primary\"},Array.isArray(o)?\"array\":\"object\"),Re.createElement(\"ul\",{className:Jn()(\"json-schema-2020-12-json-viewer__children\",{\"json-schema-2020-12-json-viewer__children--collapsed\":!_})},_&&Re.createElement(Re.Fragment,null,Array.isArray(o)?o.map(((s,o)=>Re.createElement(\"li\",{key:`#${o}`,className:\"json-schema-2020-12-property\"},Re.createElement(JSONViewer,{name:`#${o}`,value:s,className:i})))):Object.entries(o).map((([s,o])=>Re.createElement(\"li\",{key:s,className:\"json-schema-2020-12-property\"},Re.createElement(JSONViewer,{name:s,value:o,className:i})))))))))},pT=JSONViewer,Accordion_Accordion=({expanded:s=!1,children:o,onChange:i})=>{const a=useComponent(\"ChevronRightIcon\"),u=(0,Re.useCallback)((o=>{i(o,!s)}),[s,i]);return Re.createElement(\"button\",{type:\"button\",className:\"json-schema-2020-12-accordion\",onClick:u},Re.createElement(\"div\",{className:\"json-schema-2020-12-accordion__children\"},o),Re.createElement(\"span\",{className:Jn()(\"json-schema-2020-12-accordion__icon\",{\"json-schema-2020-12-accordion__icon--expanded\":s,\"json-schema-2020-12-accordion__icon--collapsed\":!s})},Re.createElement(a,null)))},ExpandDeepButton_ExpandDeepButton=({expanded:s,onClick:o})=>{const i=(0,Re.useCallback)((i=>{o(i,!s)}),[s,o]);return Re.createElement(\"button\",{type:\"button\",className:\"json-schema-2020-12-expand-deep-button\",onClick:i},s?\"Collapse all\":\"Expand all\")},icons_ChevronRight=()=>Re.createElement(\"svg\",{xmlns:\"http://www.w3.org/2000/svg\",width:\"24\",height:\"24\",viewBox:\"0 0 24 24\"},Re.createElement(\"path\",{d:\"M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z\"})),withJSONSchemaContext=(s,o={})=>{const i={components:{JSONSchema:lT,Keyword$schema:keywords_$schema,Keyword$vocabulary:$vocabulary_$vocabulary,Keyword$id:keywords_$id,Keyword$anchor:keywords_$anchor,Keyword$dynamicAnchor:keywords_$dynamicAnchor,Keyword$ref:keywords_$ref,Keyword$dynamicRef:keywords_$dynamicRef,Keyword$defs:keywords_$defs,Keyword$comment:keywords_$comment,KeywordAllOf:keywords_AllOf,KeywordAnyOf:keywords_AnyOf,KeywordOneOf:keywords_OneOf,KeywordNot:keywords_Not,KeywordIf:keywords_If,KeywordThen:keywords_Then,KeywordElse:keywords_Else,KeywordDependentSchemas:keywords_DependentSchemas,KeywordPrefixItems:keywords_PrefixItems,KeywordItems:keywords_Items,KeywordContains:keywords_Contains,KeywordProperties:keywords_Properties_Properties,KeywordPatternProperties:PatternProperties_PatternProperties,KeywordAdditionalProperties:keywords_AdditionalProperties,KeywordPropertyNames:keywords_PropertyNames,KeywordUnevaluatedItems:keywords_UnevaluatedItems,KeywordUnevaluatedProperties:keywords_UnevaluatedProperties,KeywordType:keywords_Type,KeywordEnum:Enum_Enum,KeywordConst:Const_Const,KeywordConstraint:uT,KeywordDependentRequired:DependentRequired_DependentRequired,KeywordContentSchema:keywords_ContentSchema,KeywordTitle:Title_Title,KeywordDescription:keywords_Description_Description,KeywordDefault:Default_Default,KeywordDeprecated:keywords_Deprecated,KeywordReadOnly:keywords_ReadOnly,KeywordWriteOnly:keywords_WriteOnly,KeywordExamples:keywords_Examples_Examples,ExtensionKeywords:ExtensionKeywords_ExtensionKeywords,JSONViewer:pT,Accordion:Accordion_Accordion,ExpandDeepButton:ExpandDeepButton_ExpandDeepButton,ChevronRightIcon:icons_ChevronRight,...o.components},config:{default$schema:\"https://json-schema.org/draft/2020-12/schema\",defaultExpandedLevels:0,showExtensionKeywords:!0,...o.config},fn:{upperFirst:fn_upperFirst,getTitle:makeGetTitle(useFn),getType:makeGetType(useFn),isBooleanJSONSchema,hasKeyword,isExpandable:fn_makeIsExpandable(useFn),stringify:fn_stringify,stringifyConstraints,getDependentRequired,getSchemaKeywords,getExtensionKeywords:makeGetExtensionKeywords(useFn),...o.fn},state:{paths:{}}},HOC=o=>Re.createElement(sT.Provider,{value:i},Re.createElement(s,o));return HOC.contexts={JSONSchemaContext:sT},HOC.displayName=s.displayName,HOC},makeWithJSONSchemaSystemContext=({getSystem:s})=>(o,i={})=>{const{getComponent:a,getConfigs:u}=s(),_=u(),w=a(\"JSONSchema202012\"),x=a(\"JSONSchema202012Keyword$schema\"),C=a(\"JSONSchema202012Keyword$vocabulary\"),j=a(\"JSONSchema202012Keyword$id\"),L=a(\"JSONSchema202012Keyword$anchor\"),B=a(\"JSONSchema202012Keyword$dynamicAnchor\"),$=a(\"JSONSchema202012Keyword$ref\"),U=a(\"JSONSchema202012Keyword$dynamicRef\"),V=a(\"JSONSchema202012Keyword$defs\"),z=a(\"JSONSchema202012Keyword$comment\"),Y=a(\"JSONSchema202012KeywordAllOf\"),Z=a(\"JSONSchema202012KeywordAnyOf\"),ee=a(\"JSONSchema202012KeywordOneOf\"),ie=a(\"JSONSchema202012KeywordNot\"),ae=a(\"JSONSchema202012KeywordIf\"),ce=a(\"JSONSchema202012KeywordThen\"),le=a(\"JSONSchema202012KeywordElse\"),pe=a(\"JSONSchema202012KeywordDependentSchemas\"),de=a(\"JSONSchema202012KeywordPrefixItems\"),fe=a(\"JSONSchema202012KeywordItems\"),ye=a(\"JSONSchema202012KeywordContains\"),be=a(\"JSONSchema202012KeywordProperties\"),_e=a(\"JSONSchema202012KeywordPatternProperties\"),Se=a(\"JSONSchema202012KeywordAdditionalProperties\"),we=a(\"JSONSchema202012KeywordPropertyNames\"),xe=a(\"JSONSchema202012KeywordUnevaluatedItems\"),Pe=a(\"JSONSchema202012KeywordUnevaluatedProperties\"),Te=a(\"JSONSchema202012KeywordType\"),Re=a(\"JSONSchema202012KeywordEnum\"),$e=a(\"JSONSchema202012KeywordConst\"),qe=a(\"JSONSchema202012KeywordConstraint\"),ze=a(\"JSONSchema202012KeywordDependentRequired\"),We=a(\"JSONSchema202012KeywordContentSchema\"),He=a(\"JSONSchema202012KeywordTitle\"),Ye=a(\"JSONSchema202012KeywordDescription\"),Xe=a(\"JSONSchema202012KeywordDefault\"),Qe=a(\"JSONSchema202012KeywordDeprecated\"),et=a(\"JSONSchema202012KeywordReadOnly\"),tt=a(\"JSONSchema202012KeywordWriteOnly\"),rt=a(\"JSONSchema202012KeywordExamples\"),nt=a(\"JSONSchema202012ExtensionKeywords\"),st=a(\"JSONSchema202012JSONViewer\"),ot=a(\"JSONSchema202012Accordion\"),it=a(\"JSONSchema202012ExpandDeepButton\"),at=a(\"JSONSchema202012ChevronRightIcon\");return withJSONSchemaContext(o,{components:{JSONSchema:w,Keyword$schema:x,Keyword$vocabulary:C,Keyword$id:j,Keyword$anchor:L,Keyword$dynamicAnchor:B,Keyword$ref:$,Keyword$dynamicRef:U,Keyword$defs:V,Keyword$comment:z,KeywordAllOf:Y,KeywordAnyOf:Z,KeywordOneOf:ee,KeywordNot:ie,KeywordIf:ae,KeywordThen:ce,KeywordElse:le,KeywordDependentSchemas:pe,KeywordPrefixItems:de,KeywordItems:fe,KeywordContains:ye,KeywordProperties:be,KeywordPatternProperties:_e,KeywordAdditionalProperties:Se,KeywordPropertyNames:we,KeywordUnevaluatedItems:xe,KeywordUnevaluatedProperties:Pe,KeywordType:Te,KeywordEnum:Re,KeywordConst:$e,KeywordConstraint:qe,KeywordDependentRequired:ze,KeywordContentSchema:We,KeywordTitle:He,KeywordDescription:Ye,KeywordDefault:Xe,KeywordDeprecated:Qe,KeywordReadOnly:et,KeywordWriteOnly:tt,KeywordExamples:rt,ExtensionKeywords:nt,JSONViewer:st,Accordion:ot,ExpandDeepButton:it,ChevronRightIcon:at,...i.components},config:{showExtensionKeywords:_.showExtensions,...i.config},fn:{...i.fn}})},json_schema_2020_12=({getSystem:s,fn:o})=>{const fnAccessor=()=>({upperFirst:o.upperFirst,...o.jsonSchema202012});return{components:{JSONSchema202012:lT,JSONSchema202012Keyword$schema:keywords_$schema,JSONSchema202012Keyword$vocabulary:$vocabulary_$vocabulary,JSONSchema202012Keyword$id:keywords_$id,JSONSchema202012Keyword$anchor:keywords_$anchor,JSONSchema202012Keyword$dynamicAnchor:keywords_$dynamicAnchor,JSONSchema202012Keyword$ref:keywords_$ref,JSONSchema202012Keyword$dynamicRef:keywords_$dynamicRef,JSONSchema202012Keyword$defs:keywords_$defs,JSONSchema202012Keyword$comment:keywords_$comment,JSONSchema202012KeywordAllOf:keywords_AllOf,JSONSchema202012KeywordAnyOf:keywords_AnyOf,JSONSchema202012KeywordOneOf:keywords_OneOf,JSONSchema202012KeywordNot:keywords_Not,JSONSchema202012KeywordIf:keywords_If,JSONSchema202012KeywordThen:keywords_Then,JSONSchema202012KeywordElse:keywords_Else,JSONSchema202012KeywordDependentSchemas:keywords_DependentSchemas,JSONSchema202012KeywordPrefixItems:keywords_PrefixItems,JSONSchema202012KeywordItems:keywords_Items,JSONSchema202012KeywordContains:keywords_Contains,JSONSchema202012KeywordProperties:keywords_Properties_Properties,JSONSchema202012KeywordPatternProperties:PatternProperties_PatternProperties,JSONSchema202012KeywordAdditionalProperties:keywords_AdditionalProperties,JSONSchema202012KeywordPropertyNames:keywords_PropertyNames,JSONSchema202012KeywordUnevaluatedItems:keywords_UnevaluatedItems,JSONSchema202012KeywordUnevaluatedProperties:keywords_UnevaluatedProperties,JSONSchema202012KeywordType:keywords_Type,JSONSchema202012KeywordEnum:Enum_Enum,JSONSchema202012KeywordConst:Const_Const,JSONSchema202012KeywordConstraint:uT,JSONSchema202012KeywordDependentRequired:DependentRequired_DependentRequired,JSONSchema202012KeywordContentSchema:keywords_ContentSchema,JSONSchema202012KeywordTitle:Title_Title,JSONSchema202012KeywordDescription:keywords_Description_Description,JSONSchema202012KeywordDefault:Default_Default,JSONSchema202012KeywordDeprecated:keywords_Deprecated,JSONSchema202012KeywordReadOnly:keywords_ReadOnly,JSONSchema202012KeywordWriteOnly:keywords_WriteOnly,JSONSchema202012KeywordExamples:keywords_Examples_Examples,JSONSchema202012ExtensionKeywords:ExtensionKeywords_ExtensionKeywords,JSONSchema202012JSONViewer:pT,JSONSchema202012Accordion:Accordion_Accordion,JSONSchema202012ExpandDeepButton:ExpandDeepButton_ExpandDeepButton,JSONSchema202012ChevronRightIcon:icons_ChevronRight,withJSONSchema202012Context:withJSONSchemaContext,withJSONSchema202012SystemContext:makeWithJSONSchemaSystemContext(s()),JSONSchema202012PathContext:()=>aT,JSONSchema202012LevelContext:()=>oT},fn:{upperFirst:fn_upperFirst,jsonSchema202012:{getTitle:makeGetTitle(fnAccessor),getType:makeGetType(fnAccessor),isExpandable:fn_makeIsExpandable(fnAccessor),isBooleanJSONSchema,hasKeyword,useFn,useConfig,useComponent,useIsExpanded,usePath,useLevel,getSchemaKeywords,getExtensionKeywords:makeGetExtensionKeywords(fnAccessor),hasSchemaType:fn_hasSchemaType}}}},array=(s,{sample:o=[]}={})=>((s,o={})=>{const{minItems:i,maxItems:a,uniqueItems:u}=o,{contains:_,minContains:w,maxContains:x}=o;let C=[...s];if(null!=_&&\"object\"==typeof _){if(Number.isInteger(w)&&w>1){const s=C.at(0);for(let o=1;o<w;o+=1)C.unshift(s)}Number.isInteger(x)}if(Number.isInteger(a)&&a>0&&(C=s.slice(0,a)),Number.isInteger(i)&&i>0)for(let s=0;C.length<i;s+=1)C.push(C[s%C.length]);return!0===u&&(C=Array.from(new Set(C))),C})(o,s),object=()=>{throw new Error(\"Not implemented\")},bytes=s=>xt()(s),random_pick=s=>s.at(0),predicates_isBooleanJSONSchema=s=>\"boolean\"==typeof s,isJSONSchemaObject=s=>as()(s),isJSONSchema=s=>predicates_isBooleanJSONSchema(s)||isJSONSchemaObject(s);const hT=class Registry{data={};register(s,o){this.data[s]=o}unregister(s){void 0===s?this.data={}:delete this.data[s]}get(s){return this.data[s]}},int32=()=>0,int64=()=>0,generators_float=()=>.1,generators_double=()=>.1,email=()=>\"user@example.com\",idn_email=()=>\"실례@example.com\",hostname=()=>\"example.com\",idn_hostname=()=>\"실례.com\",ipv4=()=>\"198.51.100.42\",ipv6=()=>\"2001:0db8:5b96:0000:0000:426f:8e17:642a\",uri=()=>\"https://example.com/\",uri_reference=()=>\"path/index.html\",iri=()=>\"https://실례.com/\",iri_reference=()=>\"path/실례.html\",uuid=()=>\"3fa85f64-5717-4562-b3fc-2c963f66afa6\",uri_template=()=>\"https://example.com/dictionary/{term:1}/{term}\",generators_json_pointer=()=>\"/a/b/c\",relative_json_pointer=()=>\"1/0\",date_time=()=>(new Date).toISOString(),date=()=>(new Date).toISOString().substring(0,10),time=()=>(new Date).toISOString().substring(11),duration=()=>\"P3D\",generators_password=()=>\"********\",regex=()=>\"^[a-z]+$\";const dT=new class FormatRegistry extends hT{#s={int32,int64,float:generators_float,double:generators_double,email,\"idn-email\":idn_email,hostname,\"idn-hostname\":idn_hostname,ipv4,ipv6,uri,\"uri-reference\":uri_reference,iri,\"iri-reference\":iri_reference,uuid,\"uri-template\":uri_template,\"json-pointer\":generators_json_pointer,\"relative-json-pointer\":relative_json_pointer,\"date-time\":date_time,date,time,duration,password:generators_password,regex};data={...this.#s};get defaults(){return{...this.#s}}},formatAPI=(s,o)=>\"function\"==typeof o?dT.register(s,o):null===o?dT.unregister(s):dT.get(s);formatAPI.getDefaults=()=>dT.defaults;const fT=formatAPI;var mT=__webpack_require__(48287).Buffer;const _7bit=s=>mT.from(s).toString(\"ascii\");var gT=__webpack_require__(48287).Buffer;const _8bit=s=>gT.from(s).toString(\"utf8\");var yT=__webpack_require__(48287).Buffer;const encoders_binary=s=>yT.from(s).toString(\"binary\"),quoted_printable=s=>{let o=\"\";for(let i=0;i<s.length;i++){const a=s.charCodeAt(i);if(61===a)o+=\"=3D\";else if(a>=33&&a<=60||a>=62&&a<=126||9===a||32===a)o+=s.charAt(i);else if(13===a||10===a)o+=\"\\r\\n\";else if(a>126){const a=unescape(encodeURIComponent(s.charAt(i)));for(let s=0;s<a.length;s++)o+=\"=\"+(\"0\"+a.charCodeAt(s).toString(16)).slice(-2).toUpperCase()}else o+=\"=\"+(\"0\"+a.toString(16)).slice(-2).toUpperCase()}return o};var vT=__webpack_require__(48287).Buffer;const base16=s=>vT.from(s).toString(\"hex\");var bT=__webpack_require__(48287).Buffer;const base32=s=>{const o=bT.from(s).toString(\"utf8\"),i=\"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567\";let a=0,u=\"\",_=0,w=0;for(let s=0;s<o.length;s++)for(_=_<<8|o.charCodeAt(s),w+=8;w>=5;)u+=i.charAt(_>>>w-5&31),w-=5;w>0&&(u+=i.charAt(_<<5-w&31),a=(8-8*o.length%5)%5);for(let s=0;s<a;s++)u+=\"=\";return u};var _T=__webpack_require__(48287).Buffer;const base64=s=>_T.from(s).toString(\"base64\");var ST=__webpack_require__(48287).Buffer;const base64url=s=>ST.from(s).toString(\"base64url\");const ET=new class EncoderRegistry extends hT{#s={\"7bit\":_7bit,\"8bit\":_8bit,binary:encoders_binary,\"quoted-printable\":quoted_printable,base16,base32,base64,base64url};data={...this.#s};get defaults(){return{...this.#s}}},encoderAPI=(s,o)=>\"function\"==typeof o?ET.register(s,o):null===o?ET.unregister(s):ET.get(s);encoderAPI.getDefaults=()=>ET.defaults;const wT=encoderAPI,xT={\"text/plain\":()=>\"string\",\"text/css\":()=>\".selector { border: 1px solid red }\",\"text/csv\":()=>\"value1,value2,value3\",\"text/html\":()=>\"<p>content</p>\",\"text/calendar\":()=>\"BEGIN:VCALENDAR\",\"text/javascript\":()=>\"console.dir('Hello world!');\",\"text/xml\":()=>'<person age=\"30\">John Doe</person>',\"text/*\":()=>\"string\"},kT={\"image/*\":()=>bytes(25).toString(\"binary\")},OT={\"audio/*\":()=>bytes(25).toString(\"binary\")},AT={\"video/*\":()=>bytes(25).toString(\"binary\")},CT={\"application/json\":()=>'{\"key\":\"value\"}',\"application/ld+json\":()=>'{\"name\": \"John Doe\"}',\"application/x-httpd-php\":()=>\"<?php echo '<p>Hello World!</p>'; ?>\",\"application/rtf\":()=>String.raw`{\\rtf1\\adeflang1025\\ansi\\ansicpg1252\\uc1`,\"application/x-sh\":()=>'echo \"Hello World!\"',\"application/xhtml+xml\":()=>\"<p>content</p>\",\"application/*\":()=>bytes(25).toString(\"binary\")};const jT=new class MediaTypeRegistry extends hT{#s={...xT,...kT,...OT,...AT,...CT};data={...this.#s};get defaults(){return{...this.#s}}},mediaTypeAPI=(s,o)=>{if(\"function\"==typeof o)return jT.register(s,o);if(null===o)return jT.unregister(s);const i=s.split(\";\").at(0),a=`${i.split(\"/\").at(0)}/*`;return jT.get(s)||jT.get(i)||jT.get(a)};mediaTypeAPI.getDefaults=()=>jT.defaults;const PT=mediaTypeAPI,applyStringConstraints=(s,o={})=>{const{maxLength:i,minLength:a}=o;let u=s;if(Number.isInteger(i)&&i>0&&(u=u.slice(0,i)),Number.isInteger(a)&&a>0){let s=0;for(;u.length<a;)u+=u[s++%u.length]}return u},types_string=(s,{sample:o}={})=>{const{contentEncoding:i,contentMediaType:a,contentSchema:u}=s,{pattern:_,format:w}=s,x=wT(i)||vO();let C;return C=\"string\"==typeof _?applyStringConstraints((s=>{try{const o=/(?<=(?<!\\\\)\\{)(\\d{3,})(?=\\})|(?<=(?<!\\\\)\\{\\d*,)(\\d{3,})(?=\\})|(?<=(?<!\\\\)\\{)(\\d{3,})(?=,\\d*\\})/g,i=s.replace(o,\"100\"),a=new(ps())(i);return a.max=100,a.gen()}catch{return\"string\"}})(_),s):\"string\"==typeof w?(s=>{const{format:o}=s,i=fT(o);return\"function\"==typeof i?i(s):\"string\"})(s):isJSONSchema(u)&&\"string\"==typeof a&&void 0!==o?Array.isArray(o)||\"object\"==typeof o?JSON.stringify(o):applyStringConstraints(String(o),s):\"string\"==typeof a?(s=>{const{contentMediaType:o}=s,i=PT(o);return\"function\"==typeof i?i(s):\"string\"})(s):applyStringConstraints(\"string\",s),x(C)},applyNumberConstraints=(s,o={})=>{const{minimum:i,maximum:a,exclusiveMinimum:u,exclusiveMaximum:_}=o,{multipleOf:w}=o,x=Number.isInteger(s)?1:Number.EPSILON;let C=\"number\"==typeof i?i:null,j=\"number\"==typeof a?a:null,L=s;if(\"number\"==typeof u&&(C=null!==C?Math.max(C,u+x):u+x),\"number\"==typeof _&&(j=null!==j?Math.min(j,_-x):_-x),L=C>j&&s||C||j||L,\"number\"==typeof w&&w>0){const s=L%w;L=0===s?L:L+w-s}return L},types_number=s=>{const{format:o}=s;let i;return i=\"string\"==typeof o?(s=>{const{format:o}=s,i=fT(o);return\"function\"==typeof i?i(s):0})(s):0,applyNumberConstraints(i,s)},types_integer=s=>{const{format:o}=s;let i;return i=\"string\"==typeof o?(s=>{const{format:o}=s,i=fT(o);if(\"function\"==typeof i)return i(s);switch(o){case\"int32\":return int32();case\"int64\":return int64()}return 0})(s):0,applyNumberConstraints(i,s)},types_boolean=s=>\"boolean\"!=typeof s.default||s.default,IT=new Proxy({array,object,string:types_string,number:types_number,integer:types_integer,boolean:types_boolean,null:()=>null},{get:(s,o)=>\"string\"==typeof o&&Object.hasOwn(s,o)?s[o]:()=>`Unknown Type: ${o}`}),TT=[\"array\",\"object\",\"number\",\"integer\",\"string\",\"boolean\",\"null\"],hasExample=s=>{if(!isJSONSchemaObject(s))return!1;const{examples:o,example:i,default:a}=s;return!!(Array.isArray(o)&&o.length>=1)||(void 0!==a||void 0!==i)},extractExample=s=>{if(!isJSONSchemaObject(s))return null;const{examples:o,example:i,default:a}=s;return Array.isArray(o)&&o.length>=1?o.at(0):void 0!==a?a:void 0!==i?i:void 0},NT={array:[\"items\",\"prefixItems\",\"contains\",\"maxContains\",\"minContains\",\"maxItems\",\"minItems\",\"uniqueItems\",\"unevaluatedItems\"],object:[\"properties\",\"additionalProperties\",\"patternProperties\",\"propertyNames\",\"minProperties\",\"maxProperties\",\"required\",\"dependentSchemas\",\"dependentRequired\",\"unevaluatedProperties\"],string:[\"pattern\",\"format\",\"minLength\",\"maxLength\",\"contentEncoding\",\"contentMediaType\",\"contentSchema\"],integer:[\"minimum\",\"maximum\",\"exclusiveMinimum\",\"exclusiveMaximum\",\"multipleOf\"]};NT.number=NT.integer;const MT=\"string\",inferTypeFromValue=s=>void 0===s?null:null===s?\"null\":Array.isArray(s)?\"array\":Number.isInteger(s)?\"integer\":typeof s,foldType=s=>{if(Array.isArray(s)&&s.length>=1){if(s.includes(\"array\"))return\"array\";if(s.includes(\"object\"))return\"object\";{const o=s.filter((s=>\"null\"!==s)),i=random_pick(o.length>0?o:s);if(TT.includes(i))return i}}return TT.includes(s)?s:null},inferType=(s,o=new WeakSet)=>{if(!isJSONSchemaObject(s))return MT;if(o.has(s))return MT;o.add(s);let{type:i,const:a}=s;if(i=foldType(i),\"string\"!=typeof i){const o=Object.keys(NT);e:for(let a=0;a<o.length;a+=1){const u=o[a],_=NT[u];for(let o=0;o<_.length;o+=1){const a=_[o];if(Object.hasOwn(s,a)){i=u;break e}}}}if(\"string\"!=typeof i&&void 0!==a){const s=inferTypeFromValue(a);i=\"string\"==typeof s?s:i}if(\"string\"!=typeof i){const combineTypes=i=>{if(Array.isArray(s[i])){const a=s[i].map((s=>inferType(s,o)));return foldType(a)}return null},a=combineTypes(\"allOf\"),u=combineTypes(\"anyOf\"),_=combineTypes(\"oneOf\"),w=s.not?inferType(s.not,o):null;(a||u||_||w)&&(i=foldType([a,u,_,w].filter(Boolean)))}if(\"string\"!=typeof i&&hasExample(s)){const o=extractExample(s),a=inferTypeFromValue(o);i=\"string\"==typeof a?a:i}return o.delete(s),i||MT},type_getType=s=>inferType(s),typeCast=s=>predicates_isBooleanJSONSchema(s)?(s=>!1===s?{not:{}}:{})(s):isJSONSchemaObject(s)?s:{},merge_merge=(s,o,i={})=>{if(predicates_isBooleanJSONSchema(s)&&!0===s)return!0;if(predicates_isBooleanJSONSchema(s)&&!1===s)return!1;if(predicates_isBooleanJSONSchema(o)&&!0===o)return!0;if(predicates_isBooleanJSONSchema(o)&&!1===o)return!1;if(!isJSONSchema(s))return o;if(!isJSONSchema(o))return s;const a={...o,...s};if(o.type&&s.type&&Array.isArray(o.type)&&\"string\"==typeof o.type){const i=normalizeArray(o.type).concat(s.type);a.type=Array.from(new Set(i))}if(Array.isArray(o.required)&&Array.isArray(s.required)&&(a.required=[...new Set([...s.required,...o.required])]),o.properties&&s.properties){const u=new Set([...Object.keys(o.properties),...Object.keys(s.properties)]);a.properties={};for(const _ of u){const u=o.properties[_]||{},w=s.properties[_]||{};u.readOnly&&!i.includeReadOnly||u.writeOnly&&!i.includeWriteOnly?a.required=(a.required||[]).filter((s=>s!==_)):a.properties[_]=merge_merge(w,u,i)}}return isJSONSchema(o.items)&&isJSONSchema(s.items)&&(a.items=merge_merge(s.items,o.items,i)),isJSONSchema(o.contains)&&isJSONSchema(s.contains)&&(a.contains=merge_merge(s.contains,o.contains,i)),isJSONSchema(o.contentSchema)&&isJSONSchema(s.contentSchema)&&(a.contentSchema=merge_merge(s.contentSchema,o.contentSchema,i)),a},RT=merge_merge,main_sampleFromSchemaGeneric=(s,o={},i=void 0,a=!1)=>{if(null==s&&void 0===i)return;\"function\"==typeof s?.toJS&&(s=s.toJS()),s=typeCast(s);let u=void 0!==i||hasExample(s);const _=!u&&Array.isArray(s.oneOf)&&s.oneOf.length>0,w=!u&&Array.isArray(s.anyOf)&&s.anyOf.length>0;if(!u&&(_||w)){const i=typeCast(random_pick(_?s.oneOf:s.anyOf));!(s=RT(s,i,o)).xml&&i.xml&&(s.xml=i.xml),hasExample(s)&&hasExample(i)&&(u=!0)}const x={};let{xml:C,properties:j,additionalProperties:L,items:B,contains:$}=s||{},U=type_getType(s),{includeReadOnly:V,includeWriteOnly:z}=o;C=C||{};let Y,{name:Z,prefix:ee,namespace:ie}=C,ae={};if(Object.hasOwn(s,\"type\")||(s.type=U),a&&(Z=Z||\"notagname\",Y=(ee?`${ee}:`:\"\")+Z,ie)){x[ee?`xmlns:${ee}`:\"xmlns\"]=ie}a&&(ae[Y]=[]);const ce=objectify(j);let le,pe=0;const hasExceededMaxProperties=()=>Number.isInteger(s.maxProperties)&&s.maxProperties>0&&pe>=s.maxProperties,canAddProperty=o=>!(Number.isInteger(s.maxProperties)&&s.maxProperties>0)||!hasExceededMaxProperties()&&(!(o=>!Array.isArray(s.required)||0===s.required.length||!s.required.includes(o))(o)||s.maxProperties-pe-(()=>{if(!Array.isArray(s.required)||0===s.required.length)return 0;let o=0;return a?s.required.forEach((s=>o+=void 0===ae[s]?0:1)):s.required.forEach((s=>{o+=void 0===ae[Y]?.find((o=>void 0!==o[s]))?0:1})),s.required.length-o})()>0);if(le=a?(i,u=void 0)=>{if(s&&ce[i]){if(ce[i].xml=ce[i].xml||{},ce[i].xml.attribute){const s=Array.isArray(ce[i].enum)?random_pick(ce[i].enum):void 0;if(hasExample(ce[i]))x[ce[i].xml.name||i]=extractExample(ce[i]);else if(void 0!==s)x[ce[i].xml.name||i]=s;else{const s=typeCast(ce[i]),a=type_getType(s),_=ce[i].xml.name||i;if(\"array\"===a){const s=main_sampleFromSchemaGeneric(ce[i],o,u,!1);x[_]=s.map((s=>as()(s)?\"UnknownTypeObject\":Array.isArray(s)?\"UnknownTypeArray\":s)).join(\" \")}else x[_]=\"object\"===a?\"UnknownTypeObject\":IT[a](s)}return}ce[i].xml.name=ce[i].xml.name||i}else ce[i]||!1===L||(ce[i]={xml:{name:i}});let _=main_sampleFromSchemaGeneric(ce[i],o,u,a);canAddProperty(i)&&(pe++,Array.isArray(_)?ae[Y]=ae[Y].concat(_):ae[Y].push(_))}:(i,u)=>{if(canAddProperty(i)){if(as()(s.discriminator?.mapping)&&s.discriminator.propertyName===i&&\"string\"==typeof s.$$ref){for(const o in s.discriminator.mapping)if(-1!==s.$$ref.search(s.discriminator.mapping[o])){ae[i]=o;break}}else ae[i]=main_sampleFromSchemaGeneric(ce[i],o,u,a);pe++}},u){let u;if(u=void 0!==i?i:extractExample(s),!a){if(\"number\"==typeof u&&\"string\"===U)return`${u}`;if(\"string\"!=typeof u||\"string\"===U)return u;try{return JSON.parse(u)}catch{return u}}if(\"array\"===U){if(!Array.isArray(u)){if(\"string\"==typeof u)return u;u=[u]}let i=[];return isJSONSchemaObject(B)&&(B.xml=B.xml||C||{},B.xml.name=B.xml.name||C.name,i=u.map((s=>main_sampleFromSchemaGeneric(B,o,s,a)))),isJSONSchemaObject($)&&($.xml=$.xml||C||{},$.xml.name=$.xml.name||C.name,i=[main_sampleFromSchemaGeneric($,o,void 0,a),...i]),i=IT.array(s,{sample:i}),C.wrapped?(ae[Y]=i,ds()(x)||ae[Y].push({_attr:x})):ae=i,ae}if(\"object\"===U){if(\"string\"==typeof u)return u;for(const s in u)Object.hasOwn(u,s)&&(ce[s]?.readOnly&&!V||ce[s]?.writeOnly&&!z||(ce[s]?.xml?.attribute?x[ce[s].xml.name||s]=u[s]:le(s,u[s])));return ds()(x)||ae[Y].push({_attr:x}),ae}return ae[Y]=ds()(x)?u:[{_attr:x},u],ae}if(\"array\"===U){let i=[];if(isJSONSchemaObject($))if(a&&($.xml=$.xml||s.xml||{},$.xml.name=$.xml.name||C.name),Array.isArray($.anyOf)){const{anyOf:s,...u}=B;i.push(...$.anyOf.map((s=>main_sampleFromSchemaGeneric(RT(s,u,o),o,void 0,a))))}else if(Array.isArray($.oneOf)){const{oneOf:s,...u}=B;i.push(...$.oneOf.map((s=>main_sampleFromSchemaGeneric(RT(s,u,o),o,void 0,a))))}else{if(!(!a||a&&C.wrapped))return main_sampleFromSchemaGeneric($,o,void 0,a);i.push(main_sampleFromSchemaGeneric($,o,void 0,a))}if(isJSONSchemaObject(B))if(a&&(B.xml=B.xml||s.xml||{},B.xml.name=B.xml.name||C.name),Array.isArray(B.anyOf)){const{anyOf:s,...u}=B;i.push(...B.anyOf.map((s=>main_sampleFromSchemaGeneric(RT(s,u,o),o,void 0,a))))}else if(Array.isArray(B.oneOf)){const{oneOf:s,...u}=B;i.push(...B.oneOf.map((s=>main_sampleFromSchemaGeneric(RT(s,u,o),o,void 0,a))))}else{if(!(!a||a&&C.wrapped))return main_sampleFromSchemaGeneric(B,o,void 0,a);i.push(main_sampleFromSchemaGeneric(B,o,void 0,a))}return i=IT.array(s,{sample:i}),a&&C.wrapped?(ae[Y]=i,ds()(x)||ae[Y].push({_attr:x}),ae):i}if(\"object\"===U){for(let s in ce)Object.hasOwn(ce,s)&&(ce[s]?.deprecated||ce[s]?.readOnly&&!V||ce[s]?.writeOnly&&!z||le(s));if(a&&x&&ae[Y].push({_attr:x}),hasExceededMaxProperties())return ae;if(predicates_isBooleanJSONSchema(L)&&L)a?ae[Y].push({additionalProp:\"Anything can be here\"}):ae.additionalProp1={},pe++;else if(isJSONSchemaObject(L)){const i=L,u=main_sampleFromSchemaGeneric(i,o,void 0,a);if(a&&\"string\"==typeof i?.xml?.name&&\"notagname\"!==i?.xml?.name)ae[Y].push(u);else{const o=i?.[\"x-additionalPropertiesName\"]||\"additionalProp\",_=Number.isInteger(s.minProperties)&&s.minProperties>0&&pe<s.minProperties?s.minProperties-pe:3;for(let s=1;s<=_;s++){if(hasExceededMaxProperties())return ae;if(a){const i={};i[o+s]=u.notagname,ae[Y].push(i)}else ae[o+s]=u;pe++}}}return ae}let de;if(void 0!==s.const)de=s.const;else if(s&&Array.isArray(s.enum))de=random_pick(normalizeArray(s.enum));else{const i=isJSONSchemaObject(s.contentSchema)?main_sampleFromSchemaGeneric(s.contentSchema,o,void 0,a):void 0;de=IT[U](s,{sample:i})}return a?(ae[Y]=ds()(x)?de:[{_attr:x},de],ae):de},main_createXMLExample=(s,o,i)=>{const a=main_sampleFromSchemaGeneric(s,o,i,!0);if(a)return\"string\"==typeof a?a:ls()(a,{declaration:!0,indent:\"\\t\"})},main_sampleFromSchema=(s,o,i)=>main_sampleFromSchemaGeneric(s,o,i,!1),main_resolver=(s,o,i)=>[s,JSON.stringify(o),JSON.stringify(i)],DT=utils_memoizeN(main_createXMLExample,main_resolver),LT=utils_memoizeN(main_sampleFromSchema,main_resolver);const FT=new class OptionRegistry extends hT{#s={};data={...this.#s};get defaults(){return{...this.#s}}},api_optionAPI=(s,o)=>(void 0!==o&&FT.register(s,o),FT.get(s)),BT=[{when:/json/,shouldStringifyTypes:[\"string\"]}],$T=[\"object\"],fn_get_json_sample_schema=s=>(o,i,a,u)=>{const{fn:_}=s(),w=_.jsonSchema202012.memoizedSampleFromSchema(o,i,u),x=typeof w,C=BT.reduce(((s,o)=>o.when.test(a)?[...s,...o.shouldStringifyTypes]:s),$T);return gt()(C,(s=>s===x))?JSON.stringify(w,null,2):w},fn_get_yaml_sample_schema=s=>(o,i,a,u)=>{const{fn:_}=s(),w=_.jsonSchema202012.getJsonSampleSchema(o,i,a,u);let x;try{x=fn.dump(fn.load(w),{lineWidth:-1},{schema:rn}),\"\\n\"===x[x.length-1]&&(x=x.slice(0,x.length-1))}catch(s){return console.error(s),\"error: could not generate yaml example\"}return x.replace(/\\t/g,\"  \")},fn_get_xml_sample_schema=s=>(o,i,a)=>{const{fn:u}=s();if(o&&!o.xml&&(o.xml={}),o&&!o.xml.name){if(!o.$$ref&&(o.type||o.items||o.properties||o.additionalProperties))return'<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n\\x3c!-- XML example cannot be generated; root element name is undefined --\\x3e';if(o.$$ref){let s=o.$$ref.match(/\\S*\\/(\\S+)$/);o.xml.name=s[1]}}return u.jsonSchema202012.memoizedCreateXMLExample(o,i,a)},fn_get_sample_schema=s=>(o,i=\"\",a={},u=void 0)=>{const{fn:_}=s();return\"function\"==typeof o?.toJS&&(o=o.toJS()),\"function\"==typeof u?.toJS&&(u=u.toJS()),/xml/.test(i)?_.jsonSchema202012.getXmlSampleSchema(o,a,u):/(yaml|yml)/.test(i)?_.jsonSchema202012.getYamlSampleSchema(o,a,i,u):_.jsonSchema202012.getJsonSampleSchema(o,a,i,u)},json_schema_2020_12_samples=({getSystem:s})=>{const o=fn_get_json_sample_schema(s),i=fn_get_yaml_sample_schema(s),a=fn_get_xml_sample_schema(s),u=fn_get_sample_schema(s);return{fn:{jsonSchema202012:{sampleFromSchema:main_sampleFromSchema,sampleFromSchemaGeneric:main_sampleFromSchemaGeneric,sampleOptionAPI:api_optionAPI,sampleEncoderAPI:wT,sampleFormatAPI:fT,sampleMediaTypeAPI:PT,createXMLExample:main_createXMLExample,memoizedSampleFromSchema:LT,memoizedCreateXMLExample:DT,getJsonSampleSchema:o,getYamlSampleSchema:i,getXmlSampleSchema:a,getSampleSchema:u,mergeJsonSchema:RT,foldType}}}};function PresetApis(){return[base,oas3,json_schema_2020_12,json_schema_2020_12_samples,oas31]}const inline_plugin=s=>()=>({fn:s.fn,components:s.components}),factorization_system=s=>{const o=Ye()({layout:{layout:s.layout,filter:s.filter},spec:{spec:\"\",url:s.url},requestSnippets:s.requestSnippets},s.initialState);if(s.initialState)for(const[i,a]of Object.entries(s.initialState))void 0===a&&delete o[i];return{system:{configs:s.configs},plugins:s.presets,state:o}},sources_query=()=>s=>{const o=s.queryConfigEnabled?(()=>{const s=new URLSearchParams(lt.location.search);return Object.fromEntries(s)})():{};return Object.entries(o).reduce(((s,[o,i])=>(\"config\"===o?s.configUrl=i:\"urls.primaryName\"===o?s[o]=i:s=co()(s,o,i),s)),{})},sources_url=({url:s,system:o})=>async i=>{if(!s)return{};if(\"function\"!=typeof o.configsActions?.getConfigByUrl)return{};const a=(()=>{const s={};return s.promise=new Promise(((o,i)=>{s.resolve=o,s.reject=i})),s})();return o.configsActions.getConfigByUrl({url:s,loadRemoteConfig:!0,requestInterceptor:i.requestInterceptor,responseInterceptor:i.responseInterceptor},(s=>{a.resolve(s)})),a.promise},runtime=()=>()=>{const s={};return globalThis.location&&(s.oauth2RedirectUrl=`${globalThis.location.protocol}//${globalThis.location.host}${globalThis.location.pathname.substring(0,globalThis.location.pathname.lastIndexOf(\"/\"))}/oauth2-redirect.html`),s},qT=Object.freeze({dom_id:null,domNode:null,spec:{},url:\"\",urls:null,configUrl:null,layout:\"BaseLayout\",docExpansion:\"list\",maxDisplayedTags:-1,filter:!1,validatorUrl:\"https://validator.swagger.io/validator\",oauth2RedirectUrl:void 0,persistAuthorization:!1,configs:{},displayOperationId:!1,displayRequestDuration:!1,deepLinking:!1,tryItOutEnabled:!1,requestInterceptor:s=>(s.curlOptions=[],s),responseInterceptor:s=>s,showMutatedRequest:!0,defaultModelRendering:\"example\",defaultModelExpandDepth:1,defaultModelsExpandDepth:1,showExtensions:!1,showCommonExtensions:!1,withCredentials:!1,requestSnippetsEnabled:!1,requestSnippets:{generators:{curl_bash:{title:\"cURL (bash)\",syntax:\"bash\"},curl_powershell:{title:\"cURL (PowerShell)\",syntax:\"powershell\"},curl_cmd:{title:\"cURL (CMD)\",syntax:\"bash\"}},defaultExpanded:!0,languages:null},supportedSubmitMethods:[\"get\",\"put\",\"post\",\"delete\",\"options\",\"head\",\"patch\",\"trace\"],queryConfigEnabled:!1,presets:[PresetApis],plugins:[],initialState:{},fn:{},components:{},syntaxHighlight:{activated:!0,theme:\"agate\"},operationsSorter:null,tagsSorter:null,onComplete:null,modelPropertyMacro:null,parameterMacro:null,fileUploadMediaTypes:[\"application/octet-stream\",\"image/\",\"audio/\",\"video/\"],uncaughtExceptionHandler:null});var UT=__webpack_require__(61448),VT=__webpack_require__.n(UT),zT=__webpack_require__(77731),WT=__webpack_require__.n(zT);const type_casters_array=(s,o=[])=>Array.isArray(s)?s:o,type_casters_boolean=(s,o=!1)=>!0===s||\"true\"===s||1===s||\"1\"===s||!1!==s&&\"false\"!==s&&0!==s&&\"0\"!==s&&o,dom_node=s=>null===s||\"null\"===s?null:s,type_casters_filter=s=>{const o=String(s);return type_casters_boolean(s,o)},type_casters_function=(s,o)=>\"function\"==typeof s?s:o,nullable_array=s=>Array.isArray(s)?s:null,nullable_function=s=>\"function\"==typeof s?s:null,nullable_string=s=>null===s||\"null\"===s?null:String(s),type_casters_number=(s,o=-1)=>{const i=parseInt(s,10);return Number.isNaN(i)?o:i},type_casters_object=(s,o={})=>as()(s)?s:o,sorter=s=>\"function\"==typeof s||\"string\"==typeof s?s:null,type_casters_string=s=>String(s),syntax_highlight=(s,o)=>as()(s)?s:!1===s||\"false\"===s||0===s||\"0\"===s?{activated:!1}:o,undefined_string=s=>void 0===s||\"undefined\"===s?void 0:String(s),JT={components:{typeCaster:type_casters_object},configs:{typeCaster:type_casters_object},configUrl:{typeCaster:nullable_string},deepLinking:{typeCaster:type_casters_boolean,defaultValue:qT.deepLinking},defaultModelExpandDepth:{typeCaster:type_casters_number,defaultValue:qT.defaultModelExpandDepth},defaultModelRendering:{typeCaster:type_casters_string},defaultModelsExpandDepth:{typeCaster:type_casters_number,defaultValue:qT.defaultModelsExpandDepth},displayOperationId:{typeCaster:type_casters_boolean,defaultValue:qT.displayOperationId},displayRequestDuration:{typeCaster:type_casters_boolean,defaultValue:qT.displayRequestDuration},docExpansion:{typeCaster:type_casters_string},dom_id:{typeCaster:nullable_string},domNode:{typeCaster:dom_node},fileUploadMediaTypes:{typeCaster:type_casters_array,defaultValue:qT.fileUploadMediaTypes},filter:{typeCaster:type_casters_filter},fn:{typeCaster:type_casters_object},initialState:{typeCaster:type_casters_object},layout:{typeCaster:type_casters_string},maxDisplayedTags:{typeCaster:type_casters_number,defaultValue:qT.maxDisplayedTags},modelPropertyMacro:{typeCaster:nullable_function},oauth2RedirectUrl:{typeCaster:undefined_string},onComplete:{typeCaster:nullable_function},operationsSorter:{typeCaster:sorter},paramaterMacro:{typeCaster:nullable_function},persistAuthorization:{typeCaster:type_casters_boolean,defaultValue:qT.persistAuthorization},plugins:{typeCaster:type_casters_array,defaultValue:qT.plugins},presets:{typeCaster:type_casters_array,defaultValue:qT.presets},requestInterceptor:{typeCaster:type_casters_function,defaultValue:qT.requestInterceptor},requestSnippets:{typeCaster:type_casters_object,defaultValue:qT.requestSnippets},requestSnippetsEnabled:{typeCaster:type_casters_boolean,defaultValue:qT.requestSnippetsEnabled},responseInterceptor:{typeCaster:type_casters_function,defaultValue:qT.responseInterceptor},showCommonExtensions:{typeCaster:type_casters_boolean,defaultValue:qT.showCommonExtensions},showExtensions:{typeCaster:type_casters_boolean,defaultValue:qT.showExtensions},showMutatedRequest:{typeCaster:type_casters_boolean,defaultValue:qT.showMutatedRequest},spec:{typeCaster:type_casters_object,defaultValue:qT.spec},supportedSubmitMethods:{typeCaster:type_casters_array,defaultValue:qT.supportedSubmitMethods},syntaxHighlight:{typeCaster:syntax_highlight,defaultValue:qT.syntaxHighlight},\"syntaxHighlight.activated\":{typeCaster:type_casters_boolean,defaultValue:qT.syntaxHighlight.activated},\"syntaxHighlight.theme\":{typeCaster:type_casters_string},tagsSorter:{typeCaster:sorter},tryItOutEnabled:{typeCaster:type_casters_boolean,defaultValue:qT.tryItOutEnabled},url:{typeCaster:type_casters_string},urls:{typeCaster:nullable_array},\"urls.primaryName\":{typeCaster:type_casters_string},validatorUrl:{typeCaster:nullable_string},withCredentials:{typeCaster:type_casters_boolean,defaultValue:qT.withCredentials},uncaughtExceptionHandler:{typeCaster:nullable_function}},type_cast=s=>Object.entries(JT).reduce(((s,[o,{typeCaster:i,defaultValue:a}])=>{if(VT()(s,o)){const u=i(Cn()(s,o),a);s=WT()(o,u,s)}return s}),{...s}),config_merge=(s,...o)=>{let i=Symbol.for(\"domNode\"),a=Symbol.for(\"primaryName\");const u=[];for(const s of o){const o={...s};Object.hasOwn(o,\"domNode\")&&(i=o.domNode,delete o.domNode),Object.hasOwn(o,\"urls.primaryName\")?(a=o[\"urls.primaryName\"],delete o[\"urls.primaryName\"]):Array.isArray(o.urls)&&Object.hasOwn(o.urls,\"primaryName\")&&(a=o.urls.primaryName,delete o.urls.primaryName),u.push(o)}const _=Ye()(s,...u);return i!==Symbol.for(\"domNode\")&&(_.domNode=i),a!==Symbol.for(\"primaryName\")&&Array.isArray(_.urls)&&(_.urls.primaryName=a),type_cast(_)};function SwaggerUI(s){const o=sources_query()(s),i=runtime()(),a=SwaggerUI.config.merge({},SwaggerUI.config.defaults,i,s,o),u=factorization_system(a),_=inline_plugin(a),w=new Store(u);w.register([a.plugins,_]);const x=w.getSystem(),persistConfigs=s=>{w.setConfigs(s),x.configsActions.loaded()},updateSpec=s=>{!o.url&&\"object\"==typeof s.spec&&Object.keys(s.spec).length>0?(x.specActions.updateUrl(\"\"),x.specActions.updateLoadingStatus(\"success\"),x.specActions.updateSpec(JSON.stringify(s.spec))):\"function\"==typeof x.specActions.download&&s.url&&!s.urls&&(x.specActions.updateUrl(s.url),x.specActions.download(s.url))},render=s=>{if(s.domNode)x.render(s.domNode,\"App\");else if(s.dom_id){const o=document.querySelector(s.dom_id);x.render(o,\"App\")}else null===s.dom_id||null===s.domNode||console.error(\"Skipped rendering: no `dom_id` or `domNode` was specified\")};return a.configUrl?((async()=>{const{configUrl:s}=a,i=await sources_url({url:s,system:x})(a),u=SwaggerUI.config.merge({},a,i,o);persistConfigs(u),null!==i&&updateSpec(u),render(u)})(),x):(persistConfigs(a),updateSpec(a),render(a),x)}SwaggerUI.System=Store,SwaggerUI.config={defaults:qT,merge:config_merge,typeCast:type_cast,typeCastMappings:JT},SwaggerUI.presets={base,apis:PresetApis},SwaggerUI.plugins={Auth:auth,Configs:configsPlugin,DeepLining:deep_linking,Err:err,Filter:filter,Icons:icons,JSONSchema5:json_schema_5,JSONSchema5Samples:json_schema_5_samples,JSONSchema202012:json_schema_2020_12,JSONSchema202012Samples:json_schema_2020_12_samples,Layout:plugins_layout,Logs:logs,OpenAPI30:oas3,OpenAPI31:oas3,OnComplete:on_complete,RequestSnippets:plugins_request_snippets,Spec:plugins_spec,SwaggerClient:swagger_client,Util:util,View:view,ViewLegacy:view_legacy,DownloadUrl:downloadUrlPlugin,SyntaxHighlighting:syntax_highlighting,Versions:versions,SafeRender:safe_render};const HT=SwaggerUI})(),i=i.default})()));\n"
  },
  {
    "path": "lightrag/api/static/swagger-ui/swagger-ui.css",
    "content": ".swagger-ui{color:#3b4151;font-family:sans-serif}.swagger-ui html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}.swagger-ui body{margin:0}.swagger-ui article,.swagger-ui aside,.swagger-ui footer,.swagger-ui header,.swagger-ui nav,.swagger-ui section{display:block}.swagger-ui h1{font-size:2em;margin:.67em 0}.swagger-ui figcaption,.swagger-ui figure,.swagger-ui main{display:block}.swagger-ui figure{margin:1em 40px}.swagger-ui hr{box-sizing:content-box;height:0;overflow:visible}.swagger-ui pre{font-family:monospace,monospace;font-size:1em}.swagger-ui a{background-color:transparent;-webkit-text-decoration-skip:objects}.swagger-ui abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.swagger-ui b,.swagger-ui strong{font-weight:inherit;font-weight:bolder}.swagger-ui code,.swagger-ui kbd,.swagger-ui samp{font-family:monospace,monospace;font-size:1em}.swagger-ui dfn{font-style:italic}.swagger-ui mark{background-color:#ff0;color:#000}.swagger-ui small{font-size:80%}.swagger-ui sub,.swagger-ui sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}.swagger-ui sub{bottom:-.25em}.swagger-ui sup{top:-.5em}.swagger-ui audio,.swagger-ui video{display:inline-block}.swagger-ui audio:not([controls]){display:none;height:0}.swagger-ui img{border-style:none}.swagger-ui svg:not(:root){overflow:hidden}.swagger-ui button,.swagger-ui input,.swagger-ui optgroup,.swagger-ui select,.swagger-ui textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}.swagger-ui button,.swagger-ui input{overflow:visible}.swagger-ui button,.swagger-ui select{text-transform:none}.swagger-ui [type=reset],.swagger-ui [type=submit],.swagger-ui button,.swagger-ui html [type=button]{-webkit-appearance:button}.swagger-ui [type=button]::-moz-focus-inner,.swagger-ui [type=reset]::-moz-focus-inner,.swagger-ui [type=submit]::-moz-focus-inner,.swagger-ui button::-moz-focus-inner{border-style:none;padding:0}.swagger-ui [type=button]:-moz-focusring,.swagger-ui [type=reset]:-moz-focusring,.swagger-ui [type=submit]:-moz-focusring,.swagger-ui button:-moz-focusring{outline:1px dotted ButtonText}.swagger-ui fieldset{padding:.35em .75em .625em}.swagger-ui legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}.swagger-ui progress{display:inline-block;vertical-align:baseline}.swagger-ui textarea{overflow:auto}.swagger-ui [type=checkbox],.swagger-ui [type=radio]{box-sizing:border-box;padding:0}.swagger-ui [type=number]::-webkit-inner-spin-button,.swagger-ui [type=number]::-webkit-outer-spin-button{height:auto}.swagger-ui [type=search]{-webkit-appearance:textfield;outline-offset:-2px}.swagger-ui [type=search]::-webkit-search-cancel-button,.swagger-ui [type=search]::-webkit-search-decoration{-webkit-appearance:none}.swagger-ui ::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}.swagger-ui details,.swagger-ui menu{display:block}.swagger-ui summary{display:list-item}.swagger-ui canvas{display:inline-block}.swagger-ui [hidden],.swagger-ui template{display:none}.swagger-ui .debug *{outline:1px solid gold}.swagger-ui .debug-white *{outline:1px solid #fff}.swagger-ui .debug-black *{outline:1px solid #000}.swagger-ui .debug-grid{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) repeat 0 0}.swagger-ui .debug-grid-16{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6ODYyRjhERDU2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ODYyRjhERDQ2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QTY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3QjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvCS01IAAABMSURBVHjaYmR4/5+BFPBfAMFm/MBgx8RAGWCn1AAmSg34Q6kBDKMGMDCwICeMIemF/5QawEipAWwUhwEjMDvbAWlWkvVBwu8vQIABAEwBCph8U6c0AAAAAElFTkSuQmCC) repeat 0 0}.swagger-ui .debug-grid-8-solid{background:#fff url(data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAAAAAD/4QMxaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzExMSA3OS4xNTgzMjUsIDIwMTUvMDkvMTAtMDE6MTA6MjAgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE1IChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkIxMjI0OTczNjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkIxMjI0OTc0NjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QjEyMjQ5NzE2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QjEyMjQ5NzI2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAbGhopHSlBJiZBQi8vL0JHPz4+P0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHAR0pKTQmND8oKD9HPzU/R0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0f/wAARCAAIAAgDASIAAhEBAxEB/8QAWQABAQAAAAAAAAAAAAAAAAAAAAYBAQEAAAAAAAAAAAAAAAAAAAIEEAEBAAMBAAAAAAAAAAAAAAABADECA0ERAAEDBQAAAAAAAAAAAAAAAAARITFBUWESIv/aAAwDAQACEQMRAD8AoOnTV1QTD7JJshP3vSM3P//Z) repeat 0 0}.swagger-ui .debug-grid-16-solid{background:#fff url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NzY3MkJEN0U2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NzY3MkJEN0Y2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3RDY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pve6J3kAAAAzSURBVHjaYvz//z8D0UDsMwMjSRoYP5Gq4SPNbRjVMEQ1fCRDg+in/6+J1AJUxsgAEGAA31BAJMS0GYEAAAAASUVORK5CYII=) repeat 0 0}.swagger-ui .border-box,.swagger-ui a,.swagger-ui article,.swagger-ui body,.swagger-ui code,.swagger-ui dd,.swagger-ui div,.swagger-ui dl,.swagger-ui dt,.swagger-ui fieldset,.swagger-ui footer,.swagger-ui form,.swagger-ui h1,.swagger-ui h2,.swagger-ui h3,.swagger-ui h4,.swagger-ui h5,.swagger-ui h6,.swagger-ui header,.swagger-ui html,.swagger-ui input[type=email],.swagger-ui input[type=number],.swagger-ui input[type=password],.swagger-ui input[type=tel],.swagger-ui input[type=text],.swagger-ui input[type=url],.swagger-ui legend,.swagger-ui li,.swagger-ui main,.swagger-ui ol,.swagger-ui p,.swagger-ui pre,.swagger-ui section,.swagger-ui table,.swagger-ui td,.swagger-ui textarea,.swagger-ui th,.swagger-ui tr,.swagger-ui ul{box-sizing:border-box}.swagger-ui .aspect-ratio{height:0;position:relative}.swagger-ui .aspect-ratio--16x9{padding-bottom:56.25%}.swagger-ui .aspect-ratio--9x16{padding-bottom:177.77%}.swagger-ui .aspect-ratio--4x3{padding-bottom:75%}.swagger-ui .aspect-ratio--3x4{padding-bottom:133.33%}.swagger-ui .aspect-ratio--6x4{padding-bottom:66.6%}.swagger-ui .aspect-ratio--4x6{padding-bottom:150%}.swagger-ui .aspect-ratio--8x5{padding-bottom:62.5%}.swagger-ui .aspect-ratio--5x8{padding-bottom:160%}.swagger-ui .aspect-ratio--7x5{padding-bottom:71.42%}.swagger-ui .aspect-ratio--5x7{padding-bottom:140%}.swagger-ui .aspect-ratio--1x1{padding-bottom:100%}.swagger-ui .aspect-ratio--object{bottom:0;height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:100}@media screen and (min-width:30em){.swagger-ui .aspect-ratio-ns{height:0;position:relative}.swagger-ui .aspect-ratio--16x9-ns{padding-bottom:56.25%}.swagger-ui .aspect-ratio--9x16-ns{padding-bottom:177.77%}.swagger-ui .aspect-ratio--4x3-ns{padding-bottom:75%}.swagger-ui .aspect-ratio--3x4-ns{padding-bottom:133.33%}.swagger-ui .aspect-ratio--6x4-ns{padding-bottom:66.6%}.swagger-ui .aspect-ratio--4x6-ns{padding-bottom:150%}.swagger-ui .aspect-ratio--8x5-ns{padding-bottom:62.5%}.swagger-ui .aspect-ratio--5x8-ns{padding-bottom:160%}.swagger-ui .aspect-ratio--7x5-ns{padding-bottom:71.42%}.swagger-ui .aspect-ratio--5x7-ns{padding-bottom:140%}.swagger-ui .aspect-ratio--1x1-ns{padding-bottom:100%}.swagger-ui .aspect-ratio--object-ns{bottom:0;height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:100}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .aspect-ratio-m{height:0;position:relative}.swagger-ui .aspect-ratio--16x9-m{padding-bottom:56.25%}.swagger-ui .aspect-ratio--9x16-m{padding-bottom:177.77%}.swagger-ui .aspect-ratio--4x3-m{padding-bottom:75%}.swagger-ui .aspect-ratio--3x4-m{padding-bottom:133.33%}.swagger-ui .aspect-ratio--6x4-m{padding-bottom:66.6%}.swagger-ui .aspect-ratio--4x6-m{padding-bottom:150%}.swagger-ui .aspect-ratio--8x5-m{padding-bottom:62.5%}.swagger-ui .aspect-ratio--5x8-m{padding-bottom:160%}.swagger-ui .aspect-ratio--7x5-m{padding-bottom:71.42%}.swagger-ui .aspect-ratio--5x7-m{padding-bottom:140%}.swagger-ui .aspect-ratio--1x1-m{padding-bottom:100%}.swagger-ui .aspect-ratio--object-m{bottom:0;height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:100}}@media screen and (min-width:60em){.swagger-ui .aspect-ratio-l{height:0;position:relative}.swagger-ui .aspect-ratio--16x9-l{padding-bottom:56.25%}.swagger-ui .aspect-ratio--9x16-l{padding-bottom:177.77%}.swagger-ui .aspect-ratio--4x3-l{padding-bottom:75%}.swagger-ui .aspect-ratio--3x4-l{padding-bottom:133.33%}.swagger-ui .aspect-ratio--6x4-l{padding-bottom:66.6%}.swagger-ui .aspect-ratio--4x6-l{padding-bottom:150%}.swagger-ui .aspect-ratio--8x5-l{padding-bottom:62.5%}.swagger-ui .aspect-ratio--5x8-l{padding-bottom:160%}.swagger-ui .aspect-ratio--7x5-l{padding-bottom:71.42%}.swagger-ui .aspect-ratio--5x7-l{padding-bottom:140%}.swagger-ui .aspect-ratio--1x1-l{padding-bottom:100%}.swagger-ui .aspect-ratio--object-l{bottom:0;height:100%;left:0;position:absolute;right:0;top:0;width:100%;z-index:100}}.swagger-ui img{max-width:100%}.swagger-ui .cover{background-size:cover!important}.swagger-ui .contain{background-size:contain!important}@media screen and (min-width:30em){.swagger-ui .cover-ns{background-size:cover!important}.swagger-ui .contain-ns{background-size:contain!important}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .cover-m{background-size:cover!important}.swagger-ui .contain-m{background-size:contain!important}}@media screen and (min-width:60em){.swagger-ui .cover-l{background-size:cover!important}.swagger-ui .contain-l{background-size:contain!important}}.swagger-ui .bg-center{background-position:50%;background-repeat:no-repeat}.swagger-ui .bg-top{background-position:top;background-repeat:no-repeat}.swagger-ui .bg-right{background-position:100%;background-repeat:no-repeat}.swagger-ui .bg-bottom{background-position:bottom;background-repeat:no-repeat}.swagger-ui .bg-left{background-position:0;background-repeat:no-repeat}@media screen and (min-width:30em){.swagger-ui .bg-center-ns{background-position:50%;background-repeat:no-repeat}.swagger-ui .bg-top-ns{background-position:top;background-repeat:no-repeat}.swagger-ui .bg-right-ns{background-position:100%;background-repeat:no-repeat}.swagger-ui .bg-bottom-ns{background-position:bottom;background-repeat:no-repeat}.swagger-ui .bg-left-ns{background-position:0;background-repeat:no-repeat}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .bg-center-m{background-position:50%;background-repeat:no-repeat}.swagger-ui .bg-top-m{background-position:top;background-repeat:no-repeat}.swagger-ui .bg-right-m{background-position:100%;background-repeat:no-repeat}.swagger-ui .bg-bottom-m{background-position:bottom;background-repeat:no-repeat}.swagger-ui .bg-left-m{background-position:0;background-repeat:no-repeat}}@media screen and (min-width:60em){.swagger-ui .bg-center-l{background-position:50%;background-repeat:no-repeat}.swagger-ui .bg-top-l{background-position:top;background-repeat:no-repeat}.swagger-ui .bg-right-l{background-position:100%;background-repeat:no-repeat}.swagger-ui .bg-bottom-l{background-position:bottom;background-repeat:no-repeat}.swagger-ui .bg-left-l{background-position:0;background-repeat:no-repeat}}.swagger-ui .outline{outline:1px solid}.swagger-ui .outline-transparent{outline:1px solid transparent}.swagger-ui .outline-0{outline:0}@media screen and (min-width:30em){.swagger-ui .outline-ns{outline:1px solid}.swagger-ui .outline-transparent-ns{outline:1px solid transparent}.swagger-ui .outline-0-ns{outline:0}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .outline-m{outline:1px solid}.swagger-ui .outline-transparent-m{outline:1px solid transparent}.swagger-ui .outline-0-m{outline:0}}@media screen and (min-width:60em){.swagger-ui .outline-l{outline:1px solid}.swagger-ui .outline-transparent-l{outline:1px solid transparent}.swagger-ui .outline-0-l{outline:0}}.swagger-ui .ba{border-style:solid;border-width:1px}.swagger-ui .bt{border-top-style:solid;border-top-width:1px}.swagger-ui .br{border-right-style:solid;border-right-width:1px}.swagger-ui .bb{border-bottom-style:solid;border-bottom-width:1px}.swagger-ui .bl{border-left-style:solid;border-left-width:1px}.swagger-ui .bn{border-style:none;border-width:0}@media screen and (min-width:30em){.swagger-ui .ba-ns{border-style:solid;border-width:1px}.swagger-ui .bt-ns{border-top-style:solid;border-top-width:1px}.swagger-ui .br-ns{border-right-style:solid;border-right-width:1px}.swagger-ui .bb-ns{border-bottom-style:solid;border-bottom-width:1px}.swagger-ui .bl-ns{border-left-style:solid;border-left-width:1px}.swagger-ui .bn-ns{border-style:none;border-width:0}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .ba-m{border-style:solid;border-width:1px}.swagger-ui .bt-m{border-top-style:solid;border-top-width:1px}.swagger-ui .br-m{border-right-style:solid;border-right-width:1px}.swagger-ui .bb-m{border-bottom-style:solid;border-bottom-width:1px}.swagger-ui .bl-m{border-left-style:solid;border-left-width:1px}.swagger-ui .bn-m{border-style:none;border-width:0}}@media screen and (min-width:60em){.swagger-ui .ba-l{border-style:solid;border-width:1px}.swagger-ui .bt-l{border-top-style:solid;border-top-width:1px}.swagger-ui .br-l{border-right-style:solid;border-right-width:1px}.swagger-ui .bb-l{border-bottom-style:solid;border-bottom-width:1px}.swagger-ui .bl-l{border-left-style:solid;border-left-width:1px}.swagger-ui .bn-l{border-style:none;border-width:0}}.swagger-ui .b--black{border-color:#000}.swagger-ui .b--near-black{border-color:#111}.swagger-ui .b--dark-gray{border-color:#333}.swagger-ui .b--mid-gray{border-color:#555}.swagger-ui .b--gray{border-color:#777}.swagger-ui .b--silver{border-color:#999}.swagger-ui .b--light-silver{border-color:#aaa}.swagger-ui .b--moon-gray{border-color:#ccc}.swagger-ui .b--light-gray{border-color:#eee}.swagger-ui .b--near-white{border-color:#f4f4f4}.swagger-ui .b--white{border-color:#fff}.swagger-ui .b--white-90{border-color:hsla(0,0%,100%,.9)}.swagger-ui .b--white-80{border-color:hsla(0,0%,100%,.8)}.swagger-ui .b--white-70{border-color:hsla(0,0%,100%,.7)}.swagger-ui .b--white-60{border-color:hsla(0,0%,100%,.6)}.swagger-ui .b--white-50{border-color:hsla(0,0%,100%,.5)}.swagger-ui .b--white-40{border-color:hsla(0,0%,100%,.4)}.swagger-ui .b--white-30{border-color:hsla(0,0%,100%,.3)}.swagger-ui .b--white-20{border-color:hsla(0,0%,100%,.2)}.swagger-ui .b--white-10{border-color:hsla(0,0%,100%,.1)}.swagger-ui .b--white-05{border-color:hsla(0,0%,100%,.05)}.swagger-ui .b--white-025{border-color:hsla(0,0%,100%,.025)}.swagger-ui .b--white-0125{border-color:hsla(0,0%,100%,.013)}.swagger-ui .b--black-90{border-color:rgba(0,0,0,.9)}.swagger-ui .b--black-80{border-color:rgba(0,0,0,.8)}.swagger-ui .b--black-70{border-color:rgba(0,0,0,.7)}.swagger-ui .b--black-60{border-color:rgba(0,0,0,.6)}.swagger-ui .b--black-50{border-color:rgba(0,0,0,.5)}.swagger-ui .b--black-40{border-color:rgba(0,0,0,.4)}.swagger-ui .b--black-30{border-color:rgba(0,0,0,.3)}.swagger-ui .b--black-20{border-color:rgba(0,0,0,.2)}.swagger-ui .b--black-10{border-color:rgba(0,0,0,.1)}.swagger-ui .b--black-05{border-color:rgba(0,0,0,.05)}.swagger-ui .b--black-025{border-color:rgba(0,0,0,.025)}.swagger-ui .b--black-0125{border-color:rgba(0,0,0,.013)}.swagger-ui .b--dark-red{border-color:#e7040f}.swagger-ui .b--red{border-color:#ff4136}.swagger-ui .b--light-red{border-color:#ff725c}.swagger-ui .b--orange{border-color:#ff6300}.swagger-ui .b--gold{border-color:#ffb700}.swagger-ui .b--yellow{border-color:gold}.swagger-ui .b--light-yellow{border-color:#fbf1a9}.swagger-ui .b--purple{border-color:#5e2ca5}.swagger-ui .b--light-purple{border-color:#a463f2}.swagger-ui .b--dark-pink{border-color:#d5008f}.swagger-ui .b--hot-pink{border-color:#ff41b4}.swagger-ui .b--pink{border-color:#ff80cc}.swagger-ui .b--light-pink{border-color:#ffa3d7}.swagger-ui .b--dark-green{border-color:#137752}.swagger-ui .b--green{border-color:#19a974}.swagger-ui .b--light-green{border-color:#9eebcf}.swagger-ui .b--navy{border-color:#001b44}.swagger-ui .b--dark-blue{border-color:#00449e}.swagger-ui .b--blue{border-color:#357edd}.swagger-ui .b--light-blue{border-color:#96ccff}.swagger-ui .b--lightest-blue{border-color:#cdecff}.swagger-ui .b--washed-blue{border-color:#f6fffe}.swagger-ui .b--washed-green{border-color:#e8fdf5}.swagger-ui .b--washed-yellow{border-color:#fffceb}.swagger-ui .b--washed-red{border-color:#ffdfdf}.swagger-ui .b--transparent{border-color:transparent}.swagger-ui .b--inherit{border-color:inherit}.swagger-ui .br0{border-radius:0}.swagger-ui .br1{border-radius:.125rem}.swagger-ui .br2{border-radius:.25rem}.swagger-ui .br3{border-radius:.5rem}.swagger-ui .br4{border-radius:1rem}.swagger-ui .br-100{border-radius:100%}.swagger-ui .br-pill{border-radius:9999px}.swagger-ui .br--bottom{border-top-left-radius:0;border-top-right-radius:0}.swagger-ui .br--top{border-bottom-left-radius:0;border-bottom-right-radius:0}.swagger-ui .br--right{border-bottom-left-radius:0;border-top-left-radius:0}.swagger-ui .br--left{border-bottom-right-radius:0;border-top-right-radius:0}@media screen and (min-width:30em){.swagger-ui .br0-ns{border-radius:0}.swagger-ui .br1-ns{border-radius:.125rem}.swagger-ui .br2-ns{border-radius:.25rem}.swagger-ui .br3-ns{border-radius:.5rem}.swagger-ui .br4-ns{border-radius:1rem}.swagger-ui .br-100-ns{border-radius:100%}.swagger-ui .br-pill-ns{border-radius:9999px}.swagger-ui .br--bottom-ns{border-top-left-radius:0;border-top-right-radius:0}.swagger-ui .br--top-ns{border-bottom-left-radius:0;border-bottom-right-radius:0}.swagger-ui .br--right-ns{border-bottom-left-radius:0;border-top-left-radius:0}.swagger-ui .br--left-ns{border-bottom-right-radius:0;border-top-right-radius:0}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .br0-m{border-radius:0}.swagger-ui .br1-m{border-radius:.125rem}.swagger-ui .br2-m{border-radius:.25rem}.swagger-ui .br3-m{border-radius:.5rem}.swagger-ui .br4-m{border-radius:1rem}.swagger-ui .br-100-m{border-radius:100%}.swagger-ui .br-pill-m{border-radius:9999px}.swagger-ui .br--bottom-m{border-top-left-radius:0;border-top-right-radius:0}.swagger-ui .br--top-m{border-bottom-left-radius:0;border-bottom-right-radius:0}.swagger-ui .br--right-m{border-bottom-left-radius:0;border-top-left-radius:0}.swagger-ui .br--left-m{border-bottom-right-radius:0;border-top-right-radius:0}}@media screen and (min-width:60em){.swagger-ui .br0-l{border-radius:0}.swagger-ui .br1-l{border-radius:.125rem}.swagger-ui .br2-l{border-radius:.25rem}.swagger-ui .br3-l{border-radius:.5rem}.swagger-ui .br4-l{border-radius:1rem}.swagger-ui .br-100-l{border-radius:100%}.swagger-ui .br-pill-l{border-radius:9999px}.swagger-ui .br--bottom-l{border-top-left-radius:0;border-top-right-radius:0}.swagger-ui .br--top-l{border-bottom-left-radius:0;border-bottom-right-radius:0}.swagger-ui .br--right-l{border-bottom-left-radius:0;border-top-left-radius:0}.swagger-ui .br--left-l{border-bottom-right-radius:0;border-top-right-radius:0}}.swagger-ui .b--dotted{border-style:dotted}.swagger-ui .b--dashed{border-style:dashed}.swagger-ui .b--solid{border-style:solid}.swagger-ui .b--none{border-style:none}@media screen and (min-width:30em){.swagger-ui .b--dotted-ns{border-style:dotted}.swagger-ui .b--dashed-ns{border-style:dashed}.swagger-ui .b--solid-ns{border-style:solid}.swagger-ui .b--none-ns{border-style:none}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .b--dotted-m{border-style:dotted}.swagger-ui .b--dashed-m{border-style:dashed}.swagger-ui .b--solid-m{border-style:solid}.swagger-ui .b--none-m{border-style:none}}@media screen and (min-width:60em){.swagger-ui .b--dotted-l{border-style:dotted}.swagger-ui .b--dashed-l{border-style:dashed}.swagger-ui .b--solid-l{border-style:solid}.swagger-ui .b--none-l{border-style:none}}.swagger-ui .bw0{border-width:0}.swagger-ui .bw1{border-width:.125rem}.swagger-ui .bw2{border-width:.25rem}.swagger-ui .bw3{border-width:.5rem}.swagger-ui .bw4{border-width:1rem}.swagger-ui .bw5{border-width:2rem}.swagger-ui .bt-0{border-top-width:0}.swagger-ui .br-0{border-right-width:0}.swagger-ui .bb-0{border-bottom-width:0}.swagger-ui .bl-0{border-left-width:0}@media screen and (min-width:30em){.swagger-ui .bw0-ns{border-width:0}.swagger-ui .bw1-ns{border-width:.125rem}.swagger-ui .bw2-ns{border-width:.25rem}.swagger-ui .bw3-ns{border-width:.5rem}.swagger-ui .bw4-ns{border-width:1rem}.swagger-ui .bw5-ns{border-width:2rem}.swagger-ui .bt-0-ns{border-top-width:0}.swagger-ui .br-0-ns{border-right-width:0}.swagger-ui .bb-0-ns{border-bottom-width:0}.swagger-ui .bl-0-ns{border-left-width:0}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .bw0-m{border-width:0}.swagger-ui .bw1-m{border-width:.125rem}.swagger-ui .bw2-m{border-width:.25rem}.swagger-ui .bw3-m{border-width:.5rem}.swagger-ui .bw4-m{border-width:1rem}.swagger-ui .bw5-m{border-width:2rem}.swagger-ui .bt-0-m{border-top-width:0}.swagger-ui .br-0-m{border-right-width:0}.swagger-ui .bb-0-m{border-bottom-width:0}.swagger-ui .bl-0-m{border-left-width:0}}@media screen and (min-width:60em){.swagger-ui .bw0-l{border-width:0}.swagger-ui .bw1-l{border-width:.125rem}.swagger-ui .bw2-l{border-width:.25rem}.swagger-ui .bw3-l{border-width:.5rem}.swagger-ui .bw4-l{border-width:1rem}.swagger-ui .bw5-l{border-width:2rem}.swagger-ui .bt-0-l{border-top-width:0}.swagger-ui .br-0-l{border-right-width:0}.swagger-ui .bb-0-l{border-bottom-width:0}.swagger-ui .bl-0-l{border-left-width:0}}.swagger-ui .shadow-1{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-2{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-3{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-4{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.swagger-ui .shadow-5{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}@media screen and (min-width:30em){.swagger-ui .shadow-1-ns{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-2-ns{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-3-ns{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-4-ns{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.swagger-ui .shadow-5-ns{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .shadow-1-m{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-2-m{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-3-m{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-4-m{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.swagger-ui .shadow-5-m{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}}@media screen and (min-width:60em){.swagger-ui .shadow-1-l{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-2-l{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-3-l{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.swagger-ui .shadow-4-l{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.swagger-ui .shadow-5-l{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}}.swagger-ui .pre{overflow-x:auto;overflow-y:hidden;overflow:scroll}.swagger-ui .top-0{top:0}.swagger-ui .right-0{right:0}.swagger-ui .bottom-0{bottom:0}.swagger-ui .left-0{left:0}.swagger-ui .top-1{top:1rem}.swagger-ui .right-1{right:1rem}.swagger-ui .bottom-1{bottom:1rem}.swagger-ui .left-1{left:1rem}.swagger-ui .top-2{top:2rem}.swagger-ui .right-2{right:2rem}.swagger-ui .bottom-2{bottom:2rem}.swagger-ui .left-2{left:2rem}.swagger-ui .top--1{top:-1rem}.swagger-ui .right--1{right:-1rem}.swagger-ui .bottom--1{bottom:-1rem}.swagger-ui .left--1{left:-1rem}.swagger-ui .top--2{top:-2rem}.swagger-ui .right--2{right:-2rem}.swagger-ui .bottom--2{bottom:-2rem}.swagger-ui .left--2{left:-2rem}.swagger-ui .absolute--fill{bottom:0;left:0;right:0;top:0}@media screen and (min-width:30em){.swagger-ui .top-0-ns{top:0}.swagger-ui .left-0-ns{left:0}.swagger-ui .right-0-ns{right:0}.swagger-ui .bottom-0-ns{bottom:0}.swagger-ui .top-1-ns{top:1rem}.swagger-ui .left-1-ns{left:1rem}.swagger-ui .right-1-ns{right:1rem}.swagger-ui .bottom-1-ns{bottom:1rem}.swagger-ui .top-2-ns{top:2rem}.swagger-ui .left-2-ns{left:2rem}.swagger-ui .right-2-ns{right:2rem}.swagger-ui .bottom-2-ns{bottom:2rem}.swagger-ui .top--1-ns{top:-1rem}.swagger-ui .right--1-ns{right:-1rem}.swagger-ui .bottom--1-ns{bottom:-1rem}.swagger-ui .left--1-ns{left:-1rem}.swagger-ui .top--2-ns{top:-2rem}.swagger-ui .right--2-ns{right:-2rem}.swagger-ui .bottom--2-ns{bottom:-2rem}.swagger-ui .left--2-ns{left:-2rem}.swagger-ui .absolute--fill-ns{bottom:0;left:0;right:0;top:0}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .top-0-m{top:0}.swagger-ui .left-0-m{left:0}.swagger-ui .right-0-m{right:0}.swagger-ui .bottom-0-m{bottom:0}.swagger-ui .top-1-m{top:1rem}.swagger-ui .left-1-m{left:1rem}.swagger-ui .right-1-m{right:1rem}.swagger-ui .bottom-1-m{bottom:1rem}.swagger-ui .top-2-m{top:2rem}.swagger-ui .left-2-m{left:2rem}.swagger-ui .right-2-m{right:2rem}.swagger-ui .bottom-2-m{bottom:2rem}.swagger-ui .top--1-m{top:-1rem}.swagger-ui .right--1-m{right:-1rem}.swagger-ui .bottom--1-m{bottom:-1rem}.swagger-ui .left--1-m{left:-1rem}.swagger-ui .top--2-m{top:-2rem}.swagger-ui .right--2-m{right:-2rem}.swagger-ui .bottom--2-m{bottom:-2rem}.swagger-ui .left--2-m{left:-2rem}.swagger-ui .absolute--fill-m{bottom:0;left:0;right:0;top:0}}@media screen and (min-width:60em){.swagger-ui .top-0-l{top:0}.swagger-ui .left-0-l{left:0}.swagger-ui .right-0-l{right:0}.swagger-ui .bottom-0-l{bottom:0}.swagger-ui .top-1-l{top:1rem}.swagger-ui .left-1-l{left:1rem}.swagger-ui .right-1-l{right:1rem}.swagger-ui .bottom-1-l{bottom:1rem}.swagger-ui .top-2-l{top:2rem}.swagger-ui .left-2-l{left:2rem}.swagger-ui .right-2-l{right:2rem}.swagger-ui .bottom-2-l{bottom:2rem}.swagger-ui .top--1-l{top:-1rem}.swagger-ui .right--1-l{right:-1rem}.swagger-ui .bottom--1-l{bottom:-1rem}.swagger-ui .left--1-l{left:-1rem}.swagger-ui .top--2-l{top:-2rem}.swagger-ui .right--2-l{right:-2rem}.swagger-ui .bottom--2-l{bottom:-2rem}.swagger-ui .left--2-l{left:-2rem}.swagger-ui .absolute--fill-l{bottom:0;left:0;right:0;top:0}}.swagger-ui .cf:after,.swagger-ui .cf:before{content:\" \";display:table}.swagger-ui .cf:after{clear:both}.swagger-ui .cf{zoom:1}.swagger-ui .cl{clear:left}.swagger-ui .cr{clear:right}.swagger-ui .cb{clear:both}.swagger-ui .cn{clear:none}@media screen and (min-width:30em){.swagger-ui .cl-ns{clear:left}.swagger-ui .cr-ns{clear:right}.swagger-ui .cb-ns{clear:both}.swagger-ui .cn-ns{clear:none}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .cl-m{clear:left}.swagger-ui .cr-m{clear:right}.swagger-ui .cb-m{clear:both}.swagger-ui .cn-m{clear:none}}@media screen and (min-width:60em){.swagger-ui .cl-l{clear:left}.swagger-ui .cr-l{clear:right}.swagger-ui .cb-l{clear:both}.swagger-ui .cn-l{clear:none}}.swagger-ui .flex{display:flex}.swagger-ui .inline-flex{display:inline-flex}.swagger-ui .flex-auto{flex:1 1 auto;min-height:0;min-width:0}.swagger-ui .flex-none{flex:none}.swagger-ui .flex-column{flex-direction:column}.swagger-ui .flex-row{flex-direction:row}.swagger-ui .flex-wrap{flex-wrap:wrap}.swagger-ui .flex-nowrap{flex-wrap:nowrap}.swagger-ui .flex-wrap-reverse{flex-wrap:wrap-reverse}.swagger-ui .flex-column-reverse{flex-direction:column-reverse}.swagger-ui .flex-row-reverse{flex-direction:row-reverse}.swagger-ui .items-start{align-items:flex-start}.swagger-ui .items-end{align-items:flex-end}.swagger-ui .items-center{align-items:center}.swagger-ui .items-baseline{align-items:baseline}.swagger-ui .items-stretch{align-items:stretch}.swagger-ui .self-start{align-self:flex-start}.swagger-ui .self-end{align-self:flex-end}.swagger-ui .self-center{align-self:center}.swagger-ui .self-baseline{align-self:baseline}.swagger-ui .self-stretch{align-self:stretch}.swagger-ui .justify-start{justify-content:flex-start}.swagger-ui .justify-end{justify-content:flex-end}.swagger-ui .justify-center{justify-content:center}.swagger-ui .justify-between{justify-content:space-between}.swagger-ui .justify-around{justify-content:space-around}.swagger-ui .content-start{align-content:flex-start}.swagger-ui .content-end{align-content:flex-end}.swagger-ui .content-center{align-content:center}.swagger-ui .content-between{align-content:space-between}.swagger-ui .content-around{align-content:space-around}.swagger-ui .content-stretch{align-content:stretch}.swagger-ui .order-0{order:0}.swagger-ui .order-1{order:1}.swagger-ui .order-2{order:2}.swagger-ui .order-3{order:3}.swagger-ui .order-4{order:4}.swagger-ui .order-5{order:5}.swagger-ui .order-6{order:6}.swagger-ui .order-7{order:7}.swagger-ui .order-8{order:8}.swagger-ui .order-last{order:99999}.swagger-ui .flex-grow-0{flex-grow:0}.swagger-ui .flex-grow-1{flex-grow:1}.swagger-ui .flex-shrink-0{flex-shrink:0}.swagger-ui .flex-shrink-1{flex-shrink:1}@media screen and (min-width:30em){.swagger-ui .flex-ns{display:flex}.swagger-ui .inline-flex-ns{display:inline-flex}.swagger-ui .flex-auto-ns{flex:1 1 auto;min-height:0;min-width:0}.swagger-ui .flex-none-ns{flex:none}.swagger-ui .flex-column-ns{flex-direction:column}.swagger-ui .flex-row-ns{flex-direction:row}.swagger-ui .flex-wrap-ns{flex-wrap:wrap}.swagger-ui .flex-nowrap-ns{flex-wrap:nowrap}.swagger-ui .flex-wrap-reverse-ns{flex-wrap:wrap-reverse}.swagger-ui .flex-column-reverse-ns{flex-direction:column-reverse}.swagger-ui .flex-row-reverse-ns{flex-direction:row-reverse}.swagger-ui .items-start-ns{align-items:flex-start}.swagger-ui .items-end-ns{align-items:flex-end}.swagger-ui .items-center-ns{align-items:center}.swagger-ui .items-baseline-ns{align-items:baseline}.swagger-ui .items-stretch-ns{align-items:stretch}.swagger-ui .self-start-ns{align-self:flex-start}.swagger-ui .self-end-ns{align-self:flex-end}.swagger-ui .self-center-ns{align-self:center}.swagger-ui .self-baseline-ns{align-self:baseline}.swagger-ui .self-stretch-ns{align-self:stretch}.swagger-ui .justify-start-ns{justify-content:flex-start}.swagger-ui .justify-end-ns{justify-content:flex-end}.swagger-ui .justify-center-ns{justify-content:center}.swagger-ui .justify-between-ns{justify-content:space-between}.swagger-ui .justify-around-ns{justify-content:space-around}.swagger-ui .content-start-ns{align-content:flex-start}.swagger-ui .content-end-ns{align-content:flex-end}.swagger-ui .content-center-ns{align-content:center}.swagger-ui .content-between-ns{align-content:space-between}.swagger-ui .content-around-ns{align-content:space-around}.swagger-ui .content-stretch-ns{align-content:stretch}.swagger-ui .order-0-ns{order:0}.swagger-ui .order-1-ns{order:1}.swagger-ui .order-2-ns{order:2}.swagger-ui .order-3-ns{order:3}.swagger-ui .order-4-ns{order:4}.swagger-ui .order-5-ns{order:5}.swagger-ui .order-6-ns{order:6}.swagger-ui .order-7-ns{order:7}.swagger-ui .order-8-ns{order:8}.swagger-ui .order-last-ns{order:99999}.swagger-ui .flex-grow-0-ns{flex-grow:0}.swagger-ui .flex-grow-1-ns{flex-grow:1}.swagger-ui .flex-shrink-0-ns{flex-shrink:0}.swagger-ui .flex-shrink-1-ns{flex-shrink:1}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .flex-m{display:flex}.swagger-ui .inline-flex-m{display:inline-flex}.swagger-ui .flex-auto-m{flex:1 1 auto;min-height:0;min-width:0}.swagger-ui .flex-none-m{flex:none}.swagger-ui .flex-column-m{flex-direction:column}.swagger-ui .flex-row-m{flex-direction:row}.swagger-ui .flex-wrap-m{flex-wrap:wrap}.swagger-ui .flex-nowrap-m{flex-wrap:nowrap}.swagger-ui .flex-wrap-reverse-m{flex-wrap:wrap-reverse}.swagger-ui .flex-column-reverse-m{flex-direction:column-reverse}.swagger-ui .flex-row-reverse-m{flex-direction:row-reverse}.swagger-ui .items-start-m{align-items:flex-start}.swagger-ui .items-end-m{align-items:flex-end}.swagger-ui .items-center-m{align-items:center}.swagger-ui .items-baseline-m{align-items:baseline}.swagger-ui .items-stretch-m{align-items:stretch}.swagger-ui .self-start-m{align-self:flex-start}.swagger-ui .self-end-m{align-self:flex-end}.swagger-ui .self-center-m{align-self:center}.swagger-ui .self-baseline-m{align-self:baseline}.swagger-ui .self-stretch-m{align-self:stretch}.swagger-ui .justify-start-m{justify-content:flex-start}.swagger-ui .justify-end-m{justify-content:flex-end}.swagger-ui .justify-center-m{justify-content:center}.swagger-ui .justify-between-m{justify-content:space-between}.swagger-ui .justify-around-m{justify-content:space-around}.swagger-ui .content-start-m{align-content:flex-start}.swagger-ui .content-end-m{align-content:flex-end}.swagger-ui .content-center-m{align-content:center}.swagger-ui .content-between-m{align-content:space-between}.swagger-ui .content-around-m{align-content:space-around}.swagger-ui .content-stretch-m{align-content:stretch}.swagger-ui .order-0-m{order:0}.swagger-ui .order-1-m{order:1}.swagger-ui .order-2-m{order:2}.swagger-ui .order-3-m{order:3}.swagger-ui .order-4-m{order:4}.swagger-ui .order-5-m{order:5}.swagger-ui .order-6-m{order:6}.swagger-ui .order-7-m{order:7}.swagger-ui .order-8-m{order:8}.swagger-ui .order-last-m{order:99999}.swagger-ui .flex-grow-0-m{flex-grow:0}.swagger-ui .flex-grow-1-m{flex-grow:1}.swagger-ui .flex-shrink-0-m{flex-shrink:0}.swagger-ui .flex-shrink-1-m{flex-shrink:1}}@media screen and (min-width:60em){.swagger-ui .flex-l{display:flex}.swagger-ui .inline-flex-l{display:inline-flex}.swagger-ui .flex-auto-l{flex:1 1 auto;min-height:0;min-width:0}.swagger-ui .flex-none-l{flex:none}.swagger-ui .flex-column-l{flex-direction:column}.swagger-ui .flex-row-l{flex-direction:row}.swagger-ui .flex-wrap-l{flex-wrap:wrap}.swagger-ui .flex-nowrap-l{flex-wrap:nowrap}.swagger-ui .flex-wrap-reverse-l{flex-wrap:wrap-reverse}.swagger-ui .flex-column-reverse-l{flex-direction:column-reverse}.swagger-ui .flex-row-reverse-l{flex-direction:row-reverse}.swagger-ui .items-start-l{align-items:flex-start}.swagger-ui .items-end-l{align-items:flex-end}.swagger-ui .items-center-l{align-items:center}.swagger-ui .items-baseline-l{align-items:baseline}.swagger-ui .items-stretch-l{align-items:stretch}.swagger-ui .self-start-l{align-self:flex-start}.swagger-ui .self-end-l{align-self:flex-end}.swagger-ui .self-center-l{align-self:center}.swagger-ui .self-baseline-l{align-self:baseline}.swagger-ui .self-stretch-l{align-self:stretch}.swagger-ui .justify-start-l{justify-content:flex-start}.swagger-ui .justify-end-l{justify-content:flex-end}.swagger-ui .justify-center-l{justify-content:center}.swagger-ui .justify-between-l{justify-content:space-between}.swagger-ui .justify-around-l{justify-content:space-around}.swagger-ui .content-start-l{align-content:flex-start}.swagger-ui .content-end-l{align-content:flex-end}.swagger-ui .content-center-l{align-content:center}.swagger-ui .content-between-l{align-content:space-between}.swagger-ui .content-around-l{align-content:space-around}.swagger-ui .content-stretch-l{align-content:stretch}.swagger-ui .order-0-l{order:0}.swagger-ui .order-1-l{order:1}.swagger-ui .order-2-l{order:2}.swagger-ui .order-3-l{order:3}.swagger-ui .order-4-l{order:4}.swagger-ui .order-5-l{order:5}.swagger-ui .order-6-l{order:6}.swagger-ui .order-7-l{order:7}.swagger-ui .order-8-l{order:8}.swagger-ui .order-last-l{order:99999}.swagger-ui .flex-grow-0-l{flex-grow:0}.swagger-ui .flex-grow-1-l{flex-grow:1}.swagger-ui .flex-shrink-0-l{flex-shrink:0}.swagger-ui .flex-shrink-1-l{flex-shrink:1}}.swagger-ui .dn{display:none}.swagger-ui .di{display:inline}.swagger-ui .db{display:block}.swagger-ui .dib{display:inline-block}.swagger-ui .dit{display:inline-table}.swagger-ui .dt{display:table}.swagger-ui .dtc{display:table-cell}.swagger-ui .dt-row{display:table-row}.swagger-ui .dt-row-group{display:table-row-group}.swagger-ui .dt-column{display:table-column}.swagger-ui .dt-column-group{display:table-column-group}.swagger-ui .dt--fixed{table-layout:fixed;width:100%}@media screen and (min-width:30em){.swagger-ui .dn-ns{display:none}.swagger-ui .di-ns{display:inline}.swagger-ui .db-ns{display:block}.swagger-ui .dib-ns{display:inline-block}.swagger-ui .dit-ns{display:inline-table}.swagger-ui .dt-ns{display:table}.swagger-ui .dtc-ns{display:table-cell}.swagger-ui .dt-row-ns{display:table-row}.swagger-ui .dt-row-group-ns{display:table-row-group}.swagger-ui .dt-column-ns{display:table-column}.swagger-ui .dt-column-group-ns{display:table-column-group}.swagger-ui .dt--fixed-ns{table-layout:fixed;width:100%}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .dn-m{display:none}.swagger-ui .di-m{display:inline}.swagger-ui .db-m{display:block}.swagger-ui .dib-m{display:inline-block}.swagger-ui .dit-m{display:inline-table}.swagger-ui .dt-m{display:table}.swagger-ui .dtc-m{display:table-cell}.swagger-ui .dt-row-m{display:table-row}.swagger-ui .dt-row-group-m{display:table-row-group}.swagger-ui .dt-column-m{display:table-column}.swagger-ui .dt-column-group-m{display:table-column-group}.swagger-ui .dt--fixed-m{table-layout:fixed;width:100%}}@media screen and (min-width:60em){.swagger-ui .dn-l{display:none}.swagger-ui .di-l{display:inline}.swagger-ui .db-l{display:block}.swagger-ui .dib-l{display:inline-block}.swagger-ui .dit-l{display:inline-table}.swagger-ui .dt-l{display:table}.swagger-ui .dtc-l{display:table-cell}.swagger-ui .dt-row-l{display:table-row}.swagger-ui .dt-row-group-l{display:table-row-group}.swagger-ui .dt-column-l{display:table-column}.swagger-ui .dt-column-group-l{display:table-column-group}.swagger-ui .dt--fixed-l{table-layout:fixed;width:100%}}.swagger-ui .fl{_display:inline;float:left}.swagger-ui .fr{_display:inline;float:right}.swagger-ui .fn{float:none}@media screen and (min-width:30em){.swagger-ui .fl-ns{_display:inline;float:left}.swagger-ui .fr-ns{_display:inline;float:right}.swagger-ui .fn-ns{float:none}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .fl-m{_display:inline;float:left}.swagger-ui .fr-m{_display:inline;float:right}.swagger-ui .fn-m{float:none}}@media screen and (min-width:60em){.swagger-ui .fl-l{_display:inline;float:left}.swagger-ui .fr-l{_display:inline;float:right}.swagger-ui .fn-l{float:none}}.swagger-ui .sans-serif{font-family:-apple-system,BlinkMacSystemFont,avenir next,avenir,helvetica,helvetica neue,ubuntu,roboto,noto,segoe ui,arial,sans-serif}.swagger-ui .serif{font-family:georgia,serif}.swagger-ui .system-sans-serif{font-family:sans-serif}.swagger-ui .system-serif{font-family:serif}.swagger-ui .code,.swagger-ui code{font-family:Consolas,monaco,monospace}.swagger-ui .courier{font-family:Courier Next,courier,monospace}.swagger-ui .helvetica{font-family:helvetica neue,helvetica,sans-serif}.swagger-ui .avenir{font-family:avenir next,avenir,sans-serif}.swagger-ui .athelas{font-family:athelas,georgia,serif}.swagger-ui .georgia{font-family:georgia,serif}.swagger-ui .times{font-family:times,serif}.swagger-ui .bodoni{font-family:Bodoni MT,serif}.swagger-ui .calisto{font-family:Calisto MT,serif}.swagger-ui .garamond{font-family:garamond,serif}.swagger-ui .baskerville{font-family:baskerville,serif}.swagger-ui .i{font-style:italic}.swagger-ui .fs-normal{font-style:normal}@media screen and (min-width:30em){.swagger-ui .i-ns{font-style:italic}.swagger-ui .fs-normal-ns{font-style:normal}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .i-m{font-style:italic}.swagger-ui .fs-normal-m{font-style:normal}}@media screen and (min-width:60em){.swagger-ui .i-l{font-style:italic}.swagger-ui .fs-normal-l{font-style:normal}}.swagger-ui .normal{font-weight:400}.swagger-ui .b{font-weight:700}.swagger-ui .fw1{font-weight:100}.swagger-ui .fw2{font-weight:200}.swagger-ui .fw3{font-weight:300}.swagger-ui .fw4{font-weight:400}.swagger-ui .fw5{font-weight:500}.swagger-ui .fw6{font-weight:600}.swagger-ui .fw7{font-weight:700}.swagger-ui .fw8{font-weight:800}.swagger-ui .fw9{font-weight:900}@media screen and (min-width:30em){.swagger-ui .normal-ns{font-weight:400}.swagger-ui .b-ns{font-weight:700}.swagger-ui .fw1-ns{font-weight:100}.swagger-ui .fw2-ns{font-weight:200}.swagger-ui .fw3-ns{font-weight:300}.swagger-ui .fw4-ns{font-weight:400}.swagger-ui .fw5-ns{font-weight:500}.swagger-ui .fw6-ns{font-weight:600}.swagger-ui .fw7-ns{font-weight:700}.swagger-ui .fw8-ns{font-weight:800}.swagger-ui .fw9-ns{font-weight:900}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .normal-m{font-weight:400}.swagger-ui .b-m{font-weight:700}.swagger-ui .fw1-m{font-weight:100}.swagger-ui .fw2-m{font-weight:200}.swagger-ui .fw3-m{font-weight:300}.swagger-ui .fw4-m{font-weight:400}.swagger-ui .fw5-m{font-weight:500}.swagger-ui .fw6-m{font-weight:600}.swagger-ui .fw7-m{font-weight:700}.swagger-ui .fw8-m{font-weight:800}.swagger-ui .fw9-m{font-weight:900}}@media screen and (min-width:60em){.swagger-ui .normal-l{font-weight:400}.swagger-ui .b-l{font-weight:700}.swagger-ui .fw1-l{font-weight:100}.swagger-ui .fw2-l{font-weight:200}.swagger-ui .fw3-l{font-weight:300}.swagger-ui .fw4-l{font-weight:400}.swagger-ui .fw5-l{font-weight:500}.swagger-ui .fw6-l{font-weight:600}.swagger-ui .fw7-l{font-weight:700}.swagger-ui .fw8-l{font-weight:800}.swagger-ui .fw9-l{font-weight:900}}.swagger-ui .input-reset{-webkit-appearance:none;-moz-appearance:none}.swagger-ui .button-reset::-moz-focus-inner,.swagger-ui .input-reset::-moz-focus-inner{border:0;padding:0}.swagger-ui .h1{height:1rem}.swagger-ui .h2{height:2rem}.swagger-ui .h3{height:4rem}.swagger-ui .h4{height:8rem}.swagger-ui .h5{height:16rem}.swagger-ui .h-25{height:25%}.swagger-ui .h-50{height:50%}.swagger-ui .h-75{height:75%}.swagger-ui .h-100{height:100%}.swagger-ui .min-h-100{min-height:100%}.swagger-ui .vh-25{height:25vh}.swagger-ui .vh-50{height:50vh}.swagger-ui .vh-75{height:75vh}.swagger-ui .vh-100{height:100vh}.swagger-ui .min-vh-100{min-height:100vh}.swagger-ui .h-auto{height:auto}.swagger-ui .h-inherit{height:inherit}@media screen and (min-width:30em){.swagger-ui .h1-ns{height:1rem}.swagger-ui .h2-ns{height:2rem}.swagger-ui .h3-ns{height:4rem}.swagger-ui .h4-ns{height:8rem}.swagger-ui .h5-ns{height:16rem}.swagger-ui .h-25-ns{height:25%}.swagger-ui .h-50-ns{height:50%}.swagger-ui .h-75-ns{height:75%}.swagger-ui .h-100-ns{height:100%}.swagger-ui .min-h-100-ns{min-height:100%}.swagger-ui .vh-25-ns{height:25vh}.swagger-ui .vh-50-ns{height:50vh}.swagger-ui .vh-75-ns{height:75vh}.swagger-ui .vh-100-ns{height:100vh}.swagger-ui .min-vh-100-ns{min-height:100vh}.swagger-ui .h-auto-ns{height:auto}.swagger-ui .h-inherit-ns{height:inherit}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .h1-m{height:1rem}.swagger-ui .h2-m{height:2rem}.swagger-ui .h3-m{height:4rem}.swagger-ui .h4-m{height:8rem}.swagger-ui .h5-m{height:16rem}.swagger-ui .h-25-m{height:25%}.swagger-ui .h-50-m{height:50%}.swagger-ui .h-75-m{height:75%}.swagger-ui .h-100-m{height:100%}.swagger-ui .min-h-100-m{min-height:100%}.swagger-ui .vh-25-m{height:25vh}.swagger-ui .vh-50-m{height:50vh}.swagger-ui .vh-75-m{height:75vh}.swagger-ui .vh-100-m{height:100vh}.swagger-ui .min-vh-100-m{min-height:100vh}.swagger-ui .h-auto-m{height:auto}.swagger-ui .h-inherit-m{height:inherit}}@media screen and (min-width:60em){.swagger-ui .h1-l{height:1rem}.swagger-ui .h2-l{height:2rem}.swagger-ui .h3-l{height:4rem}.swagger-ui .h4-l{height:8rem}.swagger-ui .h5-l{height:16rem}.swagger-ui .h-25-l{height:25%}.swagger-ui .h-50-l{height:50%}.swagger-ui .h-75-l{height:75%}.swagger-ui .h-100-l{height:100%}.swagger-ui .min-h-100-l{min-height:100%}.swagger-ui .vh-25-l{height:25vh}.swagger-ui .vh-50-l{height:50vh}.swagger-ui .vh-75-l{height:75vh}.swagger-ui .vh-100-l{height:100vh}.swagger-ui .min-vh-100-l{min-height:100vh}.swagger-ui .h-auto-l{height:auto}.swagger-ui .h-inherit-l{height:inherit}}.swagger-ui .tracked{letter-spacing:.1em}.swagger-ui .tracked-tight{letter-spacing:-.05em}.swagger-ui .tracked-mega{letter-spacing:.25em}@media screen and (min-width:30em){.swagger-ui .tracked-ns{letter-spacing:.1em}.swagger-ui .tracked-tight-ns{letter-spacing:-.05em}.swagger-ui .tracked-mega-ns{letter-spacing:.25em}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .tracked-m{letter-spacing:.1em}.swagger-ui .tracked-tight-m{letter-spacing:-.05em}.swagger-ui .tracked-mega-m{letter-spacing:.25em}}@media screen and (min-width:60em){.swagger-ui .tracked-l{letter-spacing:.1em}.swagger-ui .tracked-tight-l{letter-spacing:-.05em}.swagger-ui .tracked-mega-l{letter-spacing:.25em}}.swagger-ui .lh-solid{line-height:1}.swagger-ui .lh-title{line-height:1.25}.swagger-ui .lh-copy{line-height:1.5}@media screen and (min-width:30em){.swagger-ui .lh-solid-ns{line-height:1}.swagger-ui .lh-title-ns{line-height:1.25}.swagger-ui .lh-copy-ns{line-height:1.5}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .lh-solid-m{line-height:1}.swagger-ui .lh-title-m{line-height:1.25}.swagger-ui .lh-copy-m{line-height:1.5}}@media screen and (min-width:60em){.swagger-ui .lh-solid-l{line-height:1}.swagger-ui .lh-title-l{line-height:1.25}.swagger-ui .lh-copy-l{line-height:1.5}}.swagger-ui .link{-webkit-text-decoration:none;text-decoration:none}.swagger-ui .link,.swagger-ui .link:active,.swagger-ui .link:focus,.swagger-ui .link:hover,.swagger-ui .link:link,.swagger-ui .link:visited{transition:color .15s ease-in}.swagger-ui .link:focus{outline:1px dotted currentColor}.swagger-ui .list{list-style-type:none}.swagger-ui .mw-100{max-width:100%}.swagger-ui .mw1{max-width:1rem}.swagger-ui .mw2{max-width:2rem}.swagger-ui .mw3{max-width:4rem}.swagger-ui .mw4{max-width:8rem}.swagger-ui .mw5{max-width:16rem}.swagger-ui .mw6{max-width:32rem}.swagger-ui .mw7{max-width:48rem}.swagger-ui .mw8{max-width:64rem}.swagger-ui .mw9{max-width:96rem}.swagger-ui .mw-none{max-width:none}@media screen and (min-width:30em){.swagger-ui .mw-100-ns{max-width:100%}.swagger-ui .mw1-ns{max-width:1rem}.swagger-ui .mw2-ns{max-width:2rem}.swagger-ui .mw3-ns{max-width:4rem}.swagger-ui .mw4-ns{max-width:8rem}.swagger-ui .mw5-ns{max-width:16rem}.swagger-ui .mw6-ns{max-width:32rem}.swagger-ui .mw7-ns{max-width:48rem}.swagger-ui .mw8-ns{max-width:64rem}.swagger-ui .mw9-ns{max-width:96rem}.swagger-ui .mw-none-ns{max-width:none}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .mw-100-m{max-width:100%}.swagger-ui .mw1-m{max-width:1rem}.swagger-ui .mw2-m{max-width:2rem}.swagger-ui .mw3-m{max-width:4rem}.swagger-ui .mw4-m{max-width:8rem}.swagger-ui .mw5-m{max-width:16rem}.swagger-ui .mw6-m{max-width:32rem}.swagger-ui .mw7-m{max-width:48rem}.swagger-ui .mw8-m{max-width:64rem}.swagger-ui .mw9-m{max-width:96rem}.swagger-ui .mw-none-m{max-width:none}}@media screen and (min-width:60em){.swagger-ui .mw-100-l{max-width:100%}.swagger-ui .mw1-l{max-width:1rem}.swagger-ui .mw2-l{max-width:2rem}.swagger-ui .mw3-l{max-width:4rem}.swagger-ui .mw4-l{max-width:8rem}.swagger-ui .mw5-l{max-width:16rem}.swagger-ui .mw6-l{max-width:32rem}.swagger-ui .mw7-l{max-width:48rem}.swagger-ui .mw8-l{max-width:64rem}.swagger-ui .mw9-l{max-width:96rem}.swagger-ui .mw-none-l{max-width:none}}.swagger-ui .w1{width:1rem}.swagger-ui .w2{width:2rem}.swagger-ui .w3{width:4rem}.swagger-ui .w4{width:8rem}.swagger-ui .w5{width:16rem}.swagger-ui .w-10{width:10%}.swagger-ui .w-20{width:20%}.swagger-ui .w-25{width:25%}.swagger-ui .w-30{width:30%}.swagger-ui .w-33{width:33%}.swagger-ui .w-34{width:34%}.swagger-ui .w-40{width:40%}.swagger-ui .w-50{width:50%}.swagger-ui .w-60{width:60%}.swagger-ui .w-70{width:70%}.swagger-ui .w-75{width:75%}.swagger-ui .w-80{width:80%}.swagger-ui .w-90{width:90%}.swagger-ui .w-100{width:100%}.swagger-ui .w-third{width:33.3333333333%}.swagger-ui .w-two-thirds{width:66.6666666667%}.swagger-ui .w-auto{width:auto}@media screen and (min-width:30em){.swagger-ui .w1-ns{width:1rem}.swagger-ui .w2-ns{width:2rem}.swagger-ui .w3-ns{width:4rem}.swagger-ui .w4-ns{width:8rem}.swagger-ui .w5-ns{width:16rem}.swagger-ui .w-10-ns{width:10%}.swagger-ui .w-20-ns{width:20%}.swagger-ui .w-25-ns{width:25%}.swagger-ui .w-30-ns{width:30%}.swagger-ui .w-33-ns{width:33%}.swagger-ui .w-34-ns{width:34%}.swagger-ui .w-40-ns{width:40%}.swagger-ui .w-50-ns{width:50%}.swagger-ui .w-60-ns{width:60%}.swagger-ui .w-70-ns{width:70%}.swagger-ui .w-75-ns{width:75%}.swagger-ui .w-80-ns{width:80%}.swagger-ui .w-90-ns{width:90%}.swagger-ui .w-100-ns{width:100%}.swagger-ui .w-third-ns{width:33.3333333333%}.swagger-ui .w-two-thirds-ns{width:66.6666666667%}.swagger-ui .w-auto-ns{width:auto}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .w1-m{width:1rem}.swagger-ui .w2-m{width:2rem}.swagger-ui .w3-m{width:4rem}.swagger-ui .w4-m{width:8rem}.swagger-ui .w5-m{width:16rem}.swagger-ui .w-10-m{width:10%}.swagger-ui .w-20-m{width:20%}.swagger-ui .w-25-m{width:25%}.swagger-ui .w-30-m{width:30%}.swagger-ui .w-33-m{width:33%}.swagger-ui .w-34-m{width:34%}.swagger-ui .w-40-m{width:40%}.swagger-ui .w-50-m{width:50%}.swagger-ui .w-60-m{width:60%}.swagger-ui .w-70-m{width:70%}.swagger-ui .w-75-m{width:75%}.swagger-ui .w-80-m{width:80%}.swagger-ui .w-90-m{width:90%}.swagger-ui .w-100-m{width:100%}.swagger-ui .w-third-m{width:33.3333333333%}.swagger-ui .w-two-thirds-m{width:66.6666666667%}.swagger-ui .w-auto-m{width:auto}}@media screen and (min-width:60em){.swagger-ui .w1-l{width:1rem}.swagger-ui .w2-l{width:2rem}.swagger-ui .w3-l{width:4rem}.swagger-ui .w4-l{width:8rem}.swagger-ui .w5-l{width:16rem}.swagger-ui .w-10-l{width:10%}.swagger-ui .w-20-l{width:20%}.swagger-ui .w-25-l{width:25%}.swagger-ui .w-30-l{width:30%}.swagger-ui .w-33-l{width:33%}.swagger-ui .w-34-l{width:34%}.swagger-ui .w-40-l{width:40%}.swagger-ui .w-50-l{width:50%}.swagger-ui .w-60-l{width:60%}.swagger-ui .w-70-l{width:70%}.swagger-ui .w-75-l{width:75%}.swagger-ui .w-80-l{width:80%}.swagger-ui .w-90-l{width:90%}.swagger-ui .w-100-l{width:100%}.swagger-ui .w-third-l{width:33.3333333333%}.swagger-ui .w-two-thirds-l{width:66.6666666667%}.swagger-ui .w-auto-l{width:auto}}.swagger-ui .overflow-visible{overflow:visible}.swagger-ui .overflow-hidden{overflow:hidden}.swagger-ui .overflow-scroll{overflow:scroll}.swagger-ui .overflow-auto{overflow:auto}.swagger-ui .overflow-x-visible{overflow-x:visible}.swagger-ui .overflow-x-hidden{overflow-x:hidden}.swagger-ui .overflow-x-scroll{overflow-x:scroll}.swagger-ui .overflow-x-auto{overflow-x:auto}.swagger-ui .overflow-y-visible{overflow-y:visible}.swagger-ui .overflow-y-hidden{overflow-y:hidden}.swagger-ui .overflow-y-scroll{overflow-y:scroll}.swagger-ui .overflow-y-auto{overflow-y:auto}@media screen and (min-width:30em){.swagger-ui .overflow-visible-ns{overflow:visible}.swagger-ui .overflow-hidden-ns{overflow:hidden}.swagger-ui .overflow-scroll-ns{overflow:scroll}.swagger-ui .overflow-auto-ns{overflow:auto}.swagger-ui .overflow-x-visible-ns{overflow-x:visible}.swagger-ui .overflow-x-hidden-ns{overflow-x:hidden}.swagger-ui .overflow-x-scroll-ns{overflow-x:scroll}.swagger-ui .overflow-x-auto-ns{overflow-x:auto}.swagger-ui .overflow-y-visible-ns{overflow-y:visible}.swagger-ui .overflow-y-hidden-ns{overflow-y:hidden}.swagger-ui .overflow-y-scroll-ns{overflow-y:scroll}.swagger-ui .overflow-y-auto-ns{overflow-y:auto}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .overflow-visible-m{overflow:visible}.swagger-ui .overflow-hidden-m{overflow:hidden}.swagger-ui .overflow-scroll-m{overflow:scroll}.swagger-ui .overflow-auto-m{overflow:auto}.swagger-ui .overflow-x-visible-m{overflow-x:visible}.swagger-ui .overflow-x-hidden-m{overflow-x:hidden}.swagger-ui .overflow-x-scroll-m{overflow-x:scroll}.swagger-ui .overflow-x-auto-m{overflow-x:auto}.swagger-ui .overflow-y-visible-m{overflow-y:visible}.swagger-ui .overflow-y-hidden-m{overflow-y:hidden}.swagger-ui .overflow-y-scroll-m{overflow-y:scroll}.swagger-ui .overflow-y-auto-m{overflow-y:auto}}@media screen and (min-width:60em){.swagger-ui .overflow-visible-l{overflow:visible}.swagger-ui .overflow-hidden-l{overflow:hidden}.swagger-ui .overflow-scroll-l{overflow:scroll}.swagger-ui .overflow-auto-l{overflow:auto}.swagger-ui .overflow-x-visible-l{overflow-x:visible}.swagger-ui .overflow-x-hidden-l{overflow-x:hidden}.swagger-ui .overflow-x-scroll-l{overflow-x:scroll}.swagger-ui .overflow-x-auto-l{overflow-x:auto}.swagger-ui .overflow-y-visible-l{overflow-y:visible}.swagger-ui .overflow-y-hidden-l{overflow-y:hidden}.swagger-ui .overflow-y-scroll-l{overflow-y:scroll}.swagger-ui .overflow-y-auto-l{overflow-y:auto}}.swagger-ui .static{position:static}.swagger-ui .relative{position:relative}.swagger-ui .absolute{position:absolute}.swagger-ui .fixed{position:fixed}@media screen and (min-width:30em){.swagger-ui .static-ns{position:static}.swagger-ui .relative-ns{position:relative}.swagger-ui .absolute-ns{position:absolute}.swagger-ui .fixed-ns{position:fixed}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .static-m{position:static}.swagger-ui .relative-m{position:relative}.swagger-ui .absolute-m{position:absolute}.swagger-ui .fixed-m{position:fixed}}@media screen and (min-width:60em){.swagger-ui .static-l{position:static}.swagger-ui .relative-l{position:relative}.swagger-ui .absolute-l{position:absolute}.swagger-ui .fixed-l{position:fixed}}.swagger-ui .o-100{opacity:1}.swagger-ui .o-90{opacity:.9}.swagger-ui .o-80{opacity:.8}.swagger-ui .o-70{opacity:.7}.swagger-ui .o-60{opacity:.6}.swagger-ui .o-50{opacity:.5}.swagger-ui .o-40{opacity:.4}.swagger-ui .o-30{opacity:.3}.swagger-ui .o-20{opacity:.2}.swagger-ui .o-10{opacity:.1}.swagger-ui .o-05{opacity:.05}.swagger-ui .o-025{opacity:.025}.swagger-ui .o-0{opacity:0}.swagger-ui .rotate-45{transform:rotate(45deg)}.swagger-ui .rotate-90{transform:rotate(90deg)}.swagger-ui .rotate-135{transform:rotate(135deg)}.swagger-ui .rotate-180{transform:rotate(180deg)}.swagger-ui .rotate-225{transform:rotate(225deg)}.swagger-ui .rotate-270{transform:rotate(270deg)}.swagger-ui .rotate-315{transform:rotate(315deg)}@media screen and (min-width:30em){.swagger-ui .rotate-45-ns{transform:rotate(45deg)}.swagger-ui .rotate-90-ns{transform:rotate(90deg)}.swagger-ui .rotate-135-ns{transform:rotate(135deg)}.swagger-ui .rotate-180-ns{transform:rotate(180deg)}.swagger-ui .rotate-225-ns{transform:rotate(225deg)}.swagger-ui .rotate-270-ns{transform:rotate(270deg)}.swagger-ui .rotate-315-ns{transform:rotate(315deg)}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .rotate-45-m{transform:rotate(45deg)}.swagger-ui .rotate-90-m{transform:rotate(90deg)}.swagger-ui .rotate-135-m{transform:rotate(135deg)}.swagger-ui .rotate-180-m{transform:rotate(180deg)}.swagger-ui .rotate-225-m{transform:rotate(225deg)}.swagger-ui .rotate-270-m{transform:rotate(270deg)}.swagger-ui .rotate-315-m{transform:rotate(315deg)}}@media screen and (min-width:60em){.swagger-ui .rotate-45-l{transform:rotate(45deg)}.swagger-ui .rotate-90-l{transform:rotate(90deg)}.swagger-ui .rotate-135-l{transform:rotate(135deg)}.swagger-ui .rotate-180-l{transform:rotate(180deg)}.swagger-ui .rotate-225-l{transform:rotate(225deg)}.swagger-ui .rotate-270-l{transform:rotate(270deg)}.swagger-ui .rotate-315-l{transform:rotate(315deg)}}.swagger-ui .black-90{color:rgba(0,0,0,.9)}.swagger-ui .black-80{color:rgba(0,0,0,.8)}.swagger-ui .black-70{color:rgba(0,0,0,.7)}.swagger-ui .black-60{color:rgba(0,0,0,.6)}.swagger-ui .black-50{color:rgba(0,0,0,.5)}.swagger-ui .black-40{color:rgba(0,0,0,.4)}.swagger-ui .black-30{color:rgba(0,0,0,.3)}.swagger-ui .black-20{color:rgba(0,0,0,.2)}.swagger-ui .black-10{color:rgba(0,0,0,.1)}.swagger-ui .black-05{color:rgba(0,0,0,.05)}.swagger-ui .white-90{color:hsla(0,0%,100%,.9)}.swagger-ui .white-80{color:hsla(0,0%,100%,.8)}.swagger-ui .white-70{color:hsla(0,0%,100%,.7)}.swagger-ui .white-60{color:hsla(0,0%,100%,.6)}.swagger-ui .white-50{color:hsla(0,0%,100%,.5)}.swagger-ui .white-40{color:hsla(0,0%,100%,.4)}.swagger-ui .white-30{color:hsla(0,0%,100%,.3)}.swagger-ui .white-20{color:hsla(0,0%,100%,.2)}.swagger-ui .white-10{color:hsla(0,0%,100%,.1)}.swagger-ui .black{color:#000}.swagger-ui .near-black{color:#111}.swagger-ui .dark-gray{color:#333}.swagger-ui .mid-gray{color:#555}.swagger-ui .gray{color:#777}.swagger-ui .silver{color:#999}.swagger-ui .light-silver{color:#aaa}.swagger-ui .moon-gray{color:#ccc}.swagger-ui .light-gray{color:#eee}.swagger-ui .near-white{color:#f4f4f4}.swagger-ui .white{color:#fff}.swagger-ui .dark-red{color:#e7040f}.swagger-ui .red{color:#ff4136}.swagger-ui .light-red{color:#ff725c}.swagger-ui .orange{color:#ff6300}.swagger-ui .gold{color:#ffb700}.swagger-ui .yellow{color:gold}.swagger-ui .light-yellow{color:#fbf1a9}.swagger-ui .purple{color:#5e2ca5}.swagger-ui .light-purple{color:#a463f2}.swagger-ui .dark-pink{color:#d5008f}.swagger-ui .hot-pink{color:#ff41b4}.swagger-ui .pink{color:#ff80cc}.swagger-ui .light-pink{color:#ffa3d7}.swagger-ui .dark-green{color:#137752}.swagger-ui .green{color:#19a974}.swagger-ui .light-green{color:#9eebcf}.swagger-ui .navy{color:#001b44}.swagger-ui .dark-blue{color:#00449e}.swagger-ui .blue{color:#357edd}.swagger-ui .light-blue{color:#96ccff}.swagger-ui .lightest-blue{color:#cdecff}.swagger-ui .washed-blue{color:#f6fffe}.swagger-ui .washed-green{color:#e8fdf5}.swagger-ui .washed-yellow{color:#fffceb}.swagger-ui .washed-red{color:#ffdfdf}.swagger-ui .color-inherit{color:inherit}.swagger-ui .bg-black-90{background-color:rgba(0,0,0,.9)}.swagger-ui .bg-black-80{background-color:rgba(0,0,0,.8)}.swagger-ui .bg-black-70{background-color:rgba(0,0,0,.7)}.swagger-ui .bg-black-60{background-color:rgba(0,0,0,.6)}.swagger-ui .bg-black-50{background-color:rgba(0,0,0,.5)}.swagger-ui .bg-black-40{background-color:rgba(0,0,0,.4)}.swagger-ui .bg-black-30{background-color:rgba(0,0,0,.3)}.swagger-ui .bg-black-20{background-color:rgba(0,0,0,.2)}.swagger-ui .bg-black-10{background-color:rgba(0,0,0,.1)}.swagger-ui .bg-black-05{background-color:rgba(0,0,0,.05)}.swagger-ui .bg-white-90{background-color:hsla(0,0%,100%,.9)}.swagger-ui .bg-white-80{background-color:hsla(0,0%,100%,.8)}.swagger-ui .bg-white-70{background-color:hsla(0,0%,100%,.7)}.swagger-ui .bg-white-60{background-color:hsla(0,0%,100%,.6)}.swagger-ui .bg-white-50{background-color:hsla(0,0%,100%,.5)}.swagger-ui .bg-white-40{background-color:hsla(0,0%,100%,.4)}.swagger-ui .bg-white-30{background-color:hsla(0,0%,100%,.3)}.swagger-ui .bg-white-20{background-color:hsla(0,0%,100%,.2)}.swagger-ui .bg-white-10{background-color:hsla(0,0%,100%,.1)}.swagger-ui .bg-black{background-color:#000}.swagger-ui .bg-near-black{background-color:#111}.swagger-ui .bg-dark-gray{background-color:#333}.swagger-ui .bg-mid-gray{background-color:#555}.swagger-ui .bg-gray{background-color:#777}.swagger-ui .bg-silver{background-color:#999}.swagger-ui .bg-light-silver{background-color:#aaa}.swagger-ui .bg-moon-gray{background-color:#ccc}.swagger-ui .bg-light-gray{background-color:#eee}.swagger-ui .bg-near-white{background-color:#f4f4f4}.swagger-ui .bg-white{background-color:#fff}.swagger-ui .bg-transparent{background-color:transparent}.swagger-ui .bg-dark-red{background-color:#e7040f}.swagger-ui .bg-red{background-color:#ff4136}.swagger-ui .bg-light-red{background-color:#ff725c}.swagger-ui .bg-orange{background-color:#ff6300}.swagger-ui .bg-gold{background-color:#ffb700}.swagger-ui .bg-yellow{background-color:gold}.swagger-ui .bg-light-yellow{background-color:#fbf1a9}.swagger-ui .bg-purple{background-color:#5e2ca5}.swagger-ui .bg-light-purple{background-color:#a463f2}.swagger-ui .bg-dark-pink{background-color:#d5008f}.swagger-ui .bg-hot-pink{background-color:#ff41b4}.swagger-ui .bg-pink{background-color:#ff80cc}.swagger-ui .bg-light-pink{background-color:#ffa3d7}.swagger-ui .bg-dark-green{background-color:#137752}.swagger-ui .bg-green{background-color:#19a974}.swagger-ui .bg-light-green{background-color:#9eebcf}.swagger-ui .bg-navy{background-color:#001b44}.swagger-ui .bg-dark-blue{background-color:#00449e}.swagger-ui .bg-blue{background-color:#357edd}.swagger-ui .bg-light-blue{background-color:#96ccff}.swagger-ui .bg-lightest-blue{background-color:#cdecff}.swagger-ui .bg-washed-blue{background-color:#f6fffe}.swagger-ui .bg-washed-green{background-color:#e8fdf5}.swagger-ui .bg-washed-yellow{background-color:#fffceb}.swagger-ui .bg-washed-red{background-color:#ffdfdf}.swagger-ui .bg-inherit{background-color:inherit}.swagger-ui .hover-black:focus,.swagger-ui .hover-black:hover{color:#000}.swagger-ui .hover-near-black:focus,.swagger-ui .hover-near-black:hover{color:#111}.swagger-ui .hover-dark-gray:focus,.swagger-ui .hover-dark-gray:hover{color:#333}.swagger-ui .hover-mid-gray:focus,.swagger-ui .hover-mid-gray:hover{color:#555}.swagger-ui .hover-gray:focus,.swagger-ui .hover-gray:hover{color:#777}.swagger-ui .hover-silver:focus,.swagger-ui .hover-silver:hover{color:#999}.swagger-ui .hover-light-silver:focus,.swagger-ui .hover-light-silver:hover{color:#aaa}.swagger-ui .hover-moon-gray:focus,.swagger-ui .hover-moon-gray:hover{color:#ccc}.swagger-ui .hover-light-gray:focus,.swagger-ui .hover-light-gray:hover{color:#eee}.swagger-ui .hover-near-white:focus,.swagger-ui .hover-near-white:hover{color:#f4f4f4}.swagger-ui .hover-white:focus,.swagger-ui .hover-white:hover{color:#fff}.swagger-ui .hover-black-90:focus,.swagger-ui .hover-black-90:hover{color:rgba(0,0,0,.9)}.swagger-ui .hover-black-80:focus,.swagger-ui .hover-black-80:hover{color:rgba(0,0,0,.8)}.swagger-ui .hover-black-70:focus,.swagger-ui .hover-black-70:hover{color:rgba(0,0,0,.7)}.swagger-ui .hover-black-60:focus,.swagger-ui .hover-black-60:hover{color:rgba(0,0,0,.6)}.swagger-ui .hover-black-50:focus,.swagger-ui .hover-black-50:hover{color:rgba(0,0,0,.5)}.swagger-ui .hover-black-40:focus,.swagger-ui .hover-black-40:hover{color:rgba(0,0,0,.4)}.swagger-ui .hover-black-30:focus,.swagger-ui .hover-black-30:hover{color:rgba(0,0,0,.3)}.swagger-ui .hover-black-20:focus,.swagger-ui .hover-black-20:hover{color:rgba(0,0,0,.2)}.swagger-ui .hover-black-10:focus,.swagger-ui .hover-black-10:hover{color:rgba(0,0,0,.1)}.swagger-ui .hover-white-90:focus,.swagger-ui .hover-white-90:hover{color:hsla(0,0%,100%,.9)}.swagger-ui .hover-white-80:focus,.swagger-ui .hover-white-80:hover{color:hsla(0,0%,100%,.8)}.swagger-ui .hover-white-70:focus,.swagger-ui .hover-white-70:hover{color:hsla(0,0%,100%,.7)}.swagger-ui .hover-white-60:focus,.swagger-ui .hover-white-60:hover{color:hsla(0,0%,100%,.6)}.swagger-ui .hover-white-50:focus,.swagger-ui .hover-white-50:hover{color:hsla(0,0%,100%,.5)}.swagger-ui .hover-white-40:focus,.swagger-ui .hover-white-40:hover{color:hsla(0,0%,100%,.4)}.swagger-ui .hover-white-30:focus,.swagger-ui .hover-white-30:hover{color:hsla(0,0%,100%,.3)}.swagger-ui .hover-white-20:focus,.swagger-ui .hover-white-20:hover{color:hsla(0,0%,100%,.2)}.swagger-ui .hover-white-10:focus,.swagger-ui .hover-white-10:hover{color:hsla(0,0%,100%,.1)}.swagger-ui .hover-inherit:focus,.swagger-ui .hover-inherit:hover{color:inherit}.swagger-ui .hover-bg-black:focus,.swagger-ui .hover-bg-black:hover{background-color:#000}.swagger-ui .hover-bg-near-black:focus,.swagger-ui .hover-bg-near-black:hover{background-color:#111}.swagger-ui .hover-bg-dark-gray:focus,.swagger-ui .hover-bg-dark-gray:hover{background-color:#333}.swagger-ui .hover-bg-mid-gray:focus,.swagger-ui .hover-bg-mid-gray:hover{background-color:#555}.swagger-ui .hover-bg-gray:focus,.swagger-ui .hover-bg-gray:hover{background-color:#777}.swagger-ui .hover-bg-silver:focus,.swagger-ui .hover-bg-silver:hover{background-color:#999}.swagger-ui .hover-bg-light-silver:focus,.swagger-ui .hover-bg-light-silver:hover{background-color:#aaa}.swagger-ui .hover-bg-moon-gray:focus,.swagger-ui .hover-bg-moon-gray:hover{background-color:#ccc}.swagger-ui .hover-bg-light-gray:focus,.swagger-ui .hover-bg-light-gray:hover{background-color:#eee}.swagger-ui .hover-bg-near-white:focus,.swagger-ui .hover-bg-near-white:hover{background-color:#f4f4f4}.swagger-ui .hover-bg-white:focus,.swagger-ui .hover-bg-white:hover{background-color:#fff}.swagger-ui .hover-bg-transparent:focus,.swagger-ui .hover-bg-transparent:hover{background-color:transparent}.swagger-ui .hover-bg-black-90:focus,.swagger-ui .hover-bg-black-90:hover{background-color:rgba(0,0,0,.9)}.swagger-ui .hover-bg-black-80:focus,.swagger-ui .hover-bg-black-80:hover{background-color:rgba(0,0,0,.8)}.swagger-ui .hover-bg-black-70:focus,.swagger-ui .hover-bg-black-70:hover{background-color:rgba(0,0,0,.7)}.swagger-ui .hover-bg-black-60:focus,.swagger-ui .hover-bg-black-60:hover{background-color:rgba(0,0,0,.6)}.swagger-ui .hover-bg-black-50:focus,.swagger-ui .hover-bg-black-50:hover{background-color:rgba(0,0,0,.5)}.swagger-ui .hover-bg-black-40:focus,.swagger-ui .hover-bg-black-40:hover{background-color:rgba(0,0,0,.4)}.swagger-ui .hover-bg-black-30:focus,.swagger-ui .hover-bg-black-30:hover{background-color:rgba(0,0,0,.3)}.swagger-ui .hover-bg-black-20:focus,.swagger-ui .hover-bg-black-20:hover{background-color:rgba(0,0,0,.2)}.swagger-ui .hover-bg-black-10:focus,.swagger-ui .hover-bg-black-10:hover{background-color:rgba(0,0,0,.1)}.swagger-ui .hover-bg-white-90:focus,.swagger-ui .hover-bg-white-90:hover{background-color:hsla(0,0%,100%,.9)}.swagger-ui .hover-bg-white-80:focus,.swagger-ui .hover-bg-white-80:hover{background-color:hsla(0,0%,100%,.8)}.swagger-ui .hover-bg-white-70:focus,.swagger-ui .hover-bg-white-70:hover{background-color:hsla(0,0%,100%,.7)}.swagger-ui .hover-bg-white-60:focus,.swagger-ui .hover-bg-white-60:hover{background-color:hsla(0,0%,100%,.6)}.swagger-ui .hover-bg-white-50:focus,.swagger-ui .hover-bg-white-50:hover{background-color:hsla(0,0%,100%,.5)}.swagger-ui .hover-bg-white-40:focus,.swagger-ui .hover-bg-white-40:hover{background-color:hsla(0,0%,100%,.4)}.swagger-ui .hover-bg-white-30:focus,.swagger-ui .hover-bg-white-30:hover{background-color:hsla(0,0%,100%,.3)}.swagger-ui .hover-bg-white-20:focus,.swagger-ui .hover-bg-white-20:hover{background-color:hsla(0,0%,100%,.2)}.swagger-ui .hover-bg-white-10:focus,.swagger-ui .hover-bg-white-10:hover{background-color:hsla(0,0%,100%,.1)}.swagger-ui .hover-dark-red:focus,.swagger-ui .hover-dark-red:hover{color:#e7040f}.swagger-ui .hover-red:focus,.swagger-ui .hover-red:hover{color:#ff4136}.swagger-ui .hover-light-red:focus,.swagger-ui .hover-light-red:hover{color:#ff725c}.swagger-ui .hover-orange:focus,.swagger-ui .hover-orange:hover{color:#ff6300}.swagger-ui .hover-gold:focus,.swagger-ui .hover-gold:hover{color:#ffb700}.swagger-ui .hover-yellow:focus,.swagger-ui .hover-yellow:hover{color:gold}.swagger-ui .hover-light-yellow:focus,.swagger-ui .hover-light-yellow:hover{color:#fbf1a9}.swagger-ui .hover-purple:focus,.swagger-ui .hover-purple:hover{color:#5e2ca5}.swagger-ui .hover-light-purple:focus,.swagger-ui .hover-light-purple:hover{color:#a463f2}.swagger-ui .hover-dark-pink:focus,.swagger-ui .hover-dark-pink:hover{color:#d5008f}.swagger-ui .hover-hot-pink:focus,.swagger-ui .hover-hot-pink:hover{color:#ff41b4}.swagger-ui .hover-pink:focus,.swagger-ui .hover-pink:hover{color:#ff80cc}.swagger-ui .hover-light-pink:focus,.swagger-ui .hover-light-pink:hover{color:#ffa3d7}.swagger-ui .hover-dark-green:focus,.swagger-ui .hover-dark-green:hover{color:#137752}.swagger-ui .hover-green:focus,.swagger-ui .hover-green:hover{color:#19a974}.swagger-ui .hover-light-green:focus,.swagger-ui .hover-light-green:hover{color:#9eebcf}.swagger-ui .hover-navy:focus,.swagger-ui .hover-navy:hover{color:#001b44}.swagger-ui .hover-dark-blue:focus,.swagger-ui .hover-dark-blue:hover{color:#00449e}.swagger-ui .hover-blue:focus,.swagger-ui .hover-blue:hover{color:#357edd}.swagger-ui .hover-light-blue:focus,.swagger-ui .hover-light-blue:hover{color:#96ccff}.swagger-ui .hover-lightest-blue:focus,.swagger-ui .hover-lightest-blue:hover{color:#cdecff}.swagger-ui .hover-washed-blue:focus,.swagger-ui .hover-washed-blue:hover{color:#f6fffe}.swagger-ui .hover-washed-green:focus,.swagger-ui .hover-washed-green:hover{color:#e8fdf5}.swagger-ui .hover-washed-yellow:focus,.swagger-ui .hover-washed-yellow:hover{color:#fffceb}.swagger-ui .hover-washed-red:focus,.swagger-ui .hover-washed-red:hover{color:#ffdfdf}.swagger-ui .hover-bg-dark-red:focus,.swagger-ui .hover-bg-dark-red:hover{background-color:#e7040f}.swagger-ui .hover-bg-red:focus,.swagger-ui .hover-bg-red:hover{background-color:#ff4136}.swagger-ui .hover-bg-light-red:focus,.swagger-ui .hover-bg-light-red:hover{background-color:#ff725c}.swagger-ui .hover-bg-orange:focus,.swagger-ui .hover-bg-orange:hover{background-color:#ff6300}.swagger-ui .hover-bg-gold:focus,.swagger-ui .hover-bg-gold:hover{background-color:#ffb700}.swagger-ui .hover-bg-yellow:focus,.swagger-ui .hover-bg-yellow:hover{background-color:gold}.swagger-ui .hover-bg-light-yellow:focus,.swagger-ui .hover-bg-light-yellow:hover{background-color:#fbf1a9}.swagger-ui .hover-bg-purple:focus,.swagger-ui .hover-bg-purple:hover{background-color:#5e2ca5}.swagger-ui .hover-bg-light-purple:focus,.swagger-ui .hover-bg-light-purple:hover{background-color:#a463f2}.swagger-ui .hover-bg-dark-pink:focus,.swagger-ui .hover-bg-dark-pink:hover{background-color:#d5008f}.swagger-ui .hover-bg-hot-pink:focus,.swagger-ui .hover-bg-hot-pink:hover{background-color:#ff41b4}.swagger-ui .hover-bg-pink:focus,.swagger-ui .hover-bg-pink:hover{background-color:#ff80cc}.swagger-ui .hover-bg-light-pink:focus,.swagger-ui .hover-bg-light-pink:hover{background-color:#ffa3d7}.swagger-ui .hover-bg-dark-green:focus,.swagger-ui .hover-bg-dark-green:hover{background-color:#137752}.swagger-ui .hover-bg-green:focus,.swagger-ui .hover-bg-green:hover{background-color:#19a974}.swagger-ui .hover-bg-light-green:focus,.swagger-ui .hover-bg-light-green:hover{background-color:#9eebcf}.swagger-ui .hover-bg-navy:focus,.swagger-ui .hover-bg-navy:hover{background-color:#001b44}.swagger-ui .hover-bg-dark-blue:focus,.swagger-ui .hover-bg-dark-blue:hover{background-color:#00449e}.swagger-ui .hover-bg-blue:focus,.swagger-ui .hover-bg-blue:hover{background-color:#357edd}.swagger-ui .hover-bg-light-blue:focus,.swagger-ui .hover-bg-light-blue:hover{background-color:#96ccff}.swagger-ui .hover-bg-lightest-blue:focus,.swagger-ui .hover-bg-lightest-blue:hover{background-color:#cdecff}.swagger-ui .hover-bg-washed-blue:focus,.swagger-ui .hover-bg-washed-blue:hover{background-color:#f6fffe}.swagger-ui .hover-bg-washed-green:focus,.swagger-ui .hover-bg-washed-green:hover{background-color:#e8fdf5}.swagger-ui .hover-bg-washed-yellow:focus,.swagger-ui .hover-bg-washed-yellow:hover{background-color:#fffceb}.swagger-ui .hover-bg-washed-red:focus,.swagger-ui .hover-bg-washed-red:hover{background-color:#ffdfdf}.swagger-ui .hover-bg-inherit:focus,.swagger-ui .hover-bg-inherit:hover{background-color:inherit}.swagger-ui .pa0{padding:0}.swagger-ui .pa1{padding:.25rem}.swagger-ui .pa2{padding:.5rem}.swagger-ui .pa3{padding:1rem}.swagger-ui .pa4{padding:2rem}.swagger-ui .pa5{padding:4rem}.swagger-ui .pa6{padding:8rem}.swagger-ui .pa7{padding:16rem}.swagger-ui .pl0{padding-left:0}.swagger-ui .pl1{padding-left:.25rem}.swagger-ui .pl2{padding-left:.5rem}.swagger-ui .pl3{padding-left:1rem}.swagger-ui .pl4{padding-left:2rem}.swagger-ui .pl5{padding-left:4rem}.swagger-ui .pl6{padding-left:8rem}.swagger-ui .pl7{padding-left:16rem}.swagger-ui .pr0{padding-right:0}.swagger-ui .pr1{padding-right:.25rem}.swagger-ui .pr2{padding-right:.5rem}.swagger-ui .pr3{padding-right:1rem}.swagger-ui .pr4{padding-right:2rem}.swagger-ui .pr5{padding-right:4rem}.swagger-ui .pr6{padding-right:8rem}.swagger-ui .pr7{padding-right:16rem}.swagger-ui .pb0{padding-bottom:0}.swagger-ui .pb1{padding-bottom:.25rem}.swagger-ui .pb2{padding-bottom:.5rem}.swagger-ui .pb3{padding-bottom:1rem}.swagger-ui .pb4{padding-bottom:2rem}.swagger-ui .pb5{padding-bottom:4rem}.swagger-ui .pb6{padding-bottom:8rem}.swagger-ui .pb7{padding-bottom:16rem}.swagger-ui .pt0{padding-top:0}.swagger-ui .pt1{padding-top:.25rem}.swagger-ui .pt2{padding-top:.5rem}.swagger-ui .pt3{padding-top:1rem}.swagger-ui .pt4{padding-top:2rem}.swagger-ui .pt5{padding-top:4rem}.swagger-ui .pt6{padding-top:8rem}.swagger-ui .pt7{padding-top:16rem}.swagger-ui .pv0{padding-bottom:0;padding-top:0}.swagger-ui .pv1{padding-bottom:.25rem;padding-top:.25rem}.swagger-ui .pv2{padding-bottom:.5rem;padding-top:.5rem}.swagger-ui .pv3{padding-bottom:1rem;padding-top:1rem}.swagger-ui .pv4{padding-bottom:2rem;padding-top:2rem}.swagger-ui .pv5{padding-bottom:4rem;padding-top:4rem}.swagger-ui .pv6{padding-bottom:8rem;padding-top:8rem}.swagger-ui .pv7{padding-bottom:16rem;padding-top:16rem}.swagger-ui .ph0{padding-left:0;padding-right:0}.swagger-ui .ph1{padding-left:.25rem;padding-right:.25rem}.swagger-ui .ph2{padding-left:.5rem;padding-right:.5rem}.swagger-ui .ph3{padding-left:1rem;padding-right:1rem}.swagger-ui .ph4{padding-left:2rem;padding-right:2rem}.swagger-ui .ph5{padding-left:4rem;padding-right:4rem}.swagger-ui .ph6{padding-left:8rem;padding-right:8rem}.swagger-ui .ph7{padding-left:16rem;padding-right:16rem}.swagger-ui .ma0{margin:0}.swagger-ui .ma1{margin:.25rem}.swagger-ui .ma2{margin:.5rem}.swagger-ui .ma3{margin:1rem}.swagger-ui .ma4{margin:2rem}.swagger-ui .ma5{margin:4rem}.swagger-ui .ma6{margin:8rem}.swagger-ui .ma7{margin:16rem}.swagger-ui .ml0{margin-left:0}.swagger-ui .ml1{margin-left:.25rem}.swagger-ui .ml2{margin-left:.5rem}.swagger-ui .ml3{margin-left:1rem}.swagger-ui .ml4{margin-left:2rem}.swagger-ui .ml5{margin-left:4rem}.swagger-ui .ml6{margin-left:8rem}.swagger-ui .ml7{margin-left:16rem}.swagger-ui .mr0{margin-right:0}.swagger-ui .mr1{margin-right:.25rem}.swagger-ui .mr2{margin-right:.5rem}.swagger-ui .mr3{margin-right:1rem}.swagger-ui .mr4{margin-right:2rem}.swagger-ui .mr5{margin-right:4rem}.swagger-ui .mr6{margin-right:8rem}.swagger-ui .mr7{margin-right:16rem}.swagger-ui .mb0{margin-bottom:0}.swagger-ui .mb1{margin-bottom:.25rem}.swagger-ui .mb2{margin-bottom:.5rem}.swagger-ui .mb3{margin-bottom:1rem}.swagger-ui .mb4{margin-bottom:2rem}.swagger-ui .mb5{margin-bottom:4rem}.swagger-ui .mb6{margin-bottom:8rem}.swagger-ui .mb7{margin-bottom:16rem}.swagger-ui .mt0{margin-top:0}.swagger-ui .mt1{margin-top:.25rem}.swagger-ui .mt2{margin-top:.5rem}.swagger-ui .mt3{margin-top:1rem}.swagger-ui .mt4{margin-top:2rem}.swagger-ui .mt5{margin-top:4rem}.swagger-ui .mt6{margin-top:8rem}.swagger-ui .mt7{margin-top:16rem}.swagger-ui .mv0{margin-bottom:0;margin-top:0}.swagger-ui .mv1{margin-bottom:.25rem;margin-top:.25rem}.swagger-ui .mv2{margin-bottom:.5rem;margin-top:.5rem}.swagger-ui .mv3{margin-bottom:1rem;margin-top:1rem}.swagger-ui .mv4{margin-bottom:2rem;margin-top:2rem}.swagger-ui .mv5{margin-bottom:4rem;margin-top:4rem}.swagger-ui .mv6{margin-bottom:8rem;margin-top:8rem}.swagger-ui .mv7{margin-bottom:16rem;margin-top:16rem}.swagger-ui .mh0{margin-left:0;margin-right:0}.swagger-ui .mh1{margin-left:.25rem;margin-right:.25rem}.swagger-ui .mh2{margin-left:.5rem;margin-right:.5rem}.swagger-ui .mh3{margin-left:1rem;margin-right:1rem}.swagger-ui .mh4{margin-left:2rem;margin-right:2rem}.swagger-ui .mh5{margin-left:4rem;margin-right:4rem}.swagger-ui .mh6{margin-left:8rem;margin-right:8rem}.swagger-ui .mh7{margin-left:16rem;margin-right:16rem}@media screen and (min-width:30em){.swagger-ui .pa0-ns{padding:0}.swagger-ui .pa1-ns{padding:.25rem}.swagger-ui .pa2-ns{padding:.5rem}.swagger-ui .pa3-ns{padding:1rem}.swagger-ui .pa4-ns{padding:2rem}.swagger-ui .pa5-ns{padding:4rem}.swagger-ui .pa6-ns{padding:8rem}.swagger-ui .pa7-ns{padding:16rem}.swagger-ui .pl0-ns{padding-left:0}.swagger-ui .pl1-ns{padding-left:.25rem}.swagger-ui .pl2-ns{padding-left:.5rem}.swagger-ui .pl3-ns{padding-left:1rem}.swagger-ui .pl4-ns{padding-left:2rem}.swagger-ui .pl5-ns{padding-left:4rem}.swagger-ui .pl6-ns{padding-left:8rem}.swagger-ui .pl7-ns{padding-left:16rem}.swagger-ui .pr0-ns{padding-right:0}.swagger-ui .pr1-ns{padding-right:.25rem}.swagger-ui .pr2-ns{padding-right:.5rem}.swagger-ui .pr3-ns{padding-right:1rem}.swagger-ui .pr4-ns{padding-right:2rem}.swagger-ui .pr5-ns{padding-right:4rem}.swagger-ui .pr6-ns{padding-right:8rem}.swagger-ui .pr7-ns{padding-right:16rem}.swagger-ui .pb0-ns{padding-bottom:0}.swagger-ui .pb1-ns{padding-bottom:.25rem}.swagger-ui .pb2-ns{padding-bottom:.5rem}.swagger-ui .pb3-ns{padding-bottom:1rem}.swagger-ui .pb4-ns{padding-bottom:2rem}.swagger-ui .pb5-ns{padding-bottom:4rem}.swagger-ui .pb6-ns{padding-bottom:8rem}.swagger-ui .pb7-ns{padding-bottom:16rem}.swagger-ui .pt0-ns{padding-top:0}.swagger-ui .pt1-ns{padding-top:.25rem}.swagger-ui .pt2-ns{padding-top:.5rem}.swagger-ui .pt3-ns{padding-top:1rem}.swagger-ui .pt4-ns{padding-top:2rem}.swagger-ui .pt5-ns{padding-top:4rem}.swagger-ui .pt6-ns{padding-top:8rem}.swagger-ui .pt7-ns{padding-top:16rem}.swagger-ui .pv0-ns{padding-bottom:0;padding-top:0}.swagger-ui .pv1-ns{padding-bottom:.25rem;padding-top:.25rem}.swagger-ui .pv2-ns{padding-bottom:.5rem;padding-top:.5rem}.swagger-ui .pv3-ns{padding-bottom:1rem;padding-top:1rem}.swagger-ui .pv4-ns{padding-bottom:2rem;padding-top:2rem}.swagger-ui .pv5-ns{padding-bottom:4rem;padding-top:4rem}.swagger-ui .pv6-ns{padding-bottom:8rem;padding-top:8rem}.swagger-ui .pv7-ns{padding-bottom:16rem;padding-top:16rem}.swagger-ui .ph0-ns{padding-left:0;padding-right:0}.swagger-ui .ph1-ns{padding-left:.25rem;padding-right:.25rem}.swagger-ui .ph2-ns{padding-left:.5rem;padding-right:.5rem}.swagger-ui .ph3-ns{padding-left:1rem;padding-right:1rem}.swagger-ui .ph4-ns{padding-left:2rem;padding-right:2rem}.swagger-ui .ph5-ns{padding-left:4rem;padding-right:4rem}.swagger-ui .ph6-ns{padding-left:8rem;padding-right:8rem}.swagger-ui .ph7-ns{padding-left:16rem;padding-right:16rem}.swagger-ui .ma0-ns{margin:0}.swagger-ui .ma1-ns{margin:.25rem}.swagger-ui .ma2-ns{margin:.5rem}.swagger-ui .ma3-ns{margin:1rem}.swagger-ui .ma4-ns{margin:2rem}.swagger-ui .ma5-ns{margin:4rem}.swagger-ui .ma6-ns{margin:8rem}.swagger-ui .ma7-ns{margin:16rem}.swagger-ui .ml0-ns{margin-left:0}.swagger-ui .ml1-ns{margin-left:.25rem}.swagger-ui .ml2-ns{margin-left:.5rem}.swagger-ui .ml3-ns{margin-left:1rem}.swagger-ui .ml4-ns{margin-left:2rem}.swagger-ui .ml5-ns{margin-left:4rem}.swagger-ui .ml6-ns{margin-left:8rem}.swagger-ui .ml7-ns{margin-left:16rem}.swagger-ui .mr0-ns{margin-right:0}.swagger-ui .mr1-ns{margin-right:.25rem}.swagger-ui .mr2-ns{margin-right:.5rem}.swagger-ui .mr3-ns{margin-right:1rem}.swagger-ui .mr4-ns{margin-right:2rem}.swagger-ui .mr5-ns{margin-right:4rem}.swagger-ui .mr6-ns{margin-right:8rem}.swagger-ui .mr7-ns{margin-right:16rem}.swagger-ui .mb0-ns{margin-bottom:0}.swagger-ui .mb1-ns{margin-bottom:.25rem}.swagger-ui .mb2-ns{margin-bottom:.5rem}.swagger-ui .mb3-ns{margin-bottom:1rem}.swagger-ui .mb4-ns{margin-bottom:2rem}.swagger-ui .mb5-ns{margin-bottom:4rem}.swagger-ui .mb6-ns{margin-bottom:8rem}.swagger-ui .mb7-ns{margin-bottom:16rem}.swagger-ui .mt0-ns{margin-top:0}.swagger-ui .mt1-ns{margin-top:.25rem}.swagger-ui .mt2-ns{margin-top:.5rem}.swagger-ui .mt3-ns{margin-top:1rem}.swagger-ui .mt4-ns{margin-top:2rem}.swagger-ui .mt5-ns{margin-top:4rem}.swagger-ui .mt6-ns{margin-top:8rem}.swagger-ui .mt7-ns{margin-top:16rem}.swagger-ui .mv0-ns{margin-bottom:0;margin-top:0}.swagger-ui .mv1-ns{margin-bottom:.25rem;margin-top:.25rem}.swagger-ui .mv2-ns{margin-bottom:.5rem;margin-top:.5rem}.swagger-ui .mv3-ns{margin-bottom:1rem;margin-top:1rem}.swagger-ui .mv4-ns{margin-bottom:2rem;margin-top:2rem}.swagger-ui .mv5-ns{margin-bottom:4rem;margin-top:4rem}.swagger-ui .mv6-ns{margin-bottom:8rem;margin-top:8rem}.swagger-ui .mv7-ns{margin-bottom:16rem;margin-top:16rem}.swagger-ui .mh0-ns{margin-left:0;margin-right:0}.swagger-ui .mh1-ns{margin-left:.25rem;margin-right:.25rem}.swagger-ui .mh2-ns{margin-left:.5rem;margin-right:.5rem}.swagger-ui .mh3-ns{margin-left:1rem;margin-right:1rem}.swagger-ui .mh4-ns{margin-left:2rem;margin-right:2rem}.swagger-ui .mh5-ns{margin-left:4rem;margin-right:4rem}.swagger-ui .mh6-ns{margin-left:8rem;margin-right:8rem}.swagger-ui .mh7-ns{margin-left:16rem;margin-right:16rem}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .pa0-m{padding:0}.swagger-ui .pa1-m{padding:.25rem}.swagger-ui .pa2-m{padding:.5rem}.swagger-ui .pa3-m{padding:1rem}.swagger-ui .pa4-m{padding:2rem}.swagger-ui .pa5-m{padding:4rem}.swagger-ui .pa6-m{padding:8rem}.swagger-ui .pa7-m{padding:16rem}.swagger-ui .pl0-m{padding-left:0}.swagger-ui .pl1-m{padding-left:.25rem}.swagger-ui .pl2-m{padding-left:.5rem}.swagger-ui .pl3-m{padding-left:1rem}.swagger-ui .pl4-m{padding-left:2rem}.swagger-ui .pl5-m{padding-left:4rem}.swagger-ui .pl6-m{padding-left:8rem}.swagger-ui .pl7-m{padding-left:16rem}.swagger-ui .pr0-m{padding-right:0}.swagger-ui .pr1-m{padding-right:.25rem}.swagger-ui .pr2-m{padding-right:.5rem}.swagger-ui .pr3-m{padding-right:1rem}.swagger-ui .pr4-m{padding-right:2rem}.swagger-ui .pr5-m{padding-right:4rem}.swagger-ui .pr6-m{padding-right:8rem}.swagger-ui .pr7-m{padding-right:16rem}.swagger-ui .pb0-m{padding-bottom:0}.swagger-ui .pb1-m{padding-bottom:.25rem}.swagger-ui .pb2-m{padding-bottom:.5rem}.swagger-ui .pb3-m{padding-bottom:1rem}.swagger-ui .pb4-m{padding-bottom:2rem}.swagger-ui .pb5-m{padding-bottom:4rem}.swagger-ui .pb6-m{padding-bottom:8rem}.swagger-ui .pb7-m{padding-bottom:16rem}.swagger-ui .pt0-m{padding-top:0}.swagger-ui .pt1-m{padding-top:.25rem}.swagger-ui .pt2-m{padding-top:.5rem}.swagger-ui .pt3-m{padding-top:1rem}.swagger-ui .pt4-m{padding-top:2rem}.swagger-ui .pt5-m{padding-top:4rem}.swagger-ui .pt6-m{padding-top:8rem}.swagger-ui .pt7-m{padding-top:16rem}.swagger-ui .pv0-m{padding-bottom:0;padding-top:0}.swagger-ui .pv1-m{padding-bottom:.25rem;padding-top:.25rem}.swagger-ui .pv2-m{padding-bottom:.5rem;padding-top:.5rem}.swagger-ui .pv3-m{padding-bottom:1rem;padding-top:1rem}.swagger-ui .pv4-m{padding-bottom:2rem;padding-top:2rem}.swagger-ui .pv5-m{padding-bottom:4rem;padding-top:4rem}.swagger-ui .pv6-m{padding-bottom:8rem;padding-top:8rem}.swagger-ui .pv7-m{padding-bottom:16rem;padding-top:16rem}.swagger-ui .ph0-m{padding-left:0;padding-right:0}.swagger-ui .ph1-m{padding-left:.25rem;padding-right:.25rem}.swagger-ui .ph2-m{padding-left:.5rem;padding-right:.5rem}.swagger-ui .ph3-m{padding-left:1rem;padding-right:1rem}.swagger-ui .ph4-m{padding-left:2rem;padding-right:2rem}.swagger-ui .ph5-m{padding-left:4rem;padding-right:4rem}.swagger-ui .ph6-m{padding-left:8rem;padding-right:8rem}.swagger-ui .ph7-m{padding-left:16rem;padding-right:16rem}.swagger-ui .ma0-m{margin:0}.swagger-ui .ma1-m{margin:.25rem}.swagger-ui .ma2-m{margin:.5rem}.swagger-ui .ma3-m{margin:1rem}.swagger-ui .ma4-m{margin:2rem}.swagger-ui .ma5-m{margin:4rem}.swagger-ui .ma6-m{margin:8rem}.swagger-ui .ma7-m{margin:16rem}.swagger-ui .ml0-m{margin-left:0}.swagger-ui .ml1-m{margin-left:.25rem}.swagger-ui .ml2-m{margin-left:.5rem}.swagger-ui .ml3-m{margin-left:1rem}.swagger-ui .ml4-m{margin-left:2rem}.swagger-ui .ml5-m{margin-left:4rem}.swagger-ui .ml6-m{margin-left:8rem}.swagger-ui .ml7-m{margin-left:16rem}.swagger-ui .mr0-m{margin-right:0}.swagger-ui .mr1-m{margin-right:.25rem}.swagger-ui .mr2-m{margin-right:.5rem}.swagger-ui .mr3-m{margin-right:1rem}.swagger-ui .mr4-m{margin-right:2rem}.swagger-ui .mr5-m{margin-right:4rem}.swagger-ui .mr6-m{margin-right:8rem}.swagger-ui .mr7-m{margin-right:16rem}.swagger-ui .mb0-m{margin-bottom:0}.swagger-ui .mb1-m{margin-bottom:.25rem}.swagger-ui .mb2-m{margin-bottom:.5rem}.swagger-ui .mb3-m{margin-bottom:1rem}.swagger-ui .mb4-m{margin-bottom:2rem}.swagger-ui .mb5-m{margin-bottom:4rem}.swagger-ui .mb6-m{margin-bottom:8rem}.swagger-ui .mb7-m{margin-bottom:16rem}.swagger-ui .mt0-m{margin-top:0}.swagger-ui .mt1-m{margin-top:.25rem}.swagger-ui .mt2-m{margin-top:.5rem}.swagger-ui .mt3-m{margin-top:1rem}.swagger-ui .mt4-m{margin-top:2rem}.swagger-ui .mt5-m{margin-top:4rem}.swagger-ui .mt6-m{margin-top:8rem}.swagger-ui .mt7-m{margin-top:16rem}.swagger-ui .mv0-m{margin-bottom:0;margin-top:0}.swagger-ui .mv1-m{margin-bottom:.25rem;margin-top:.25rem}.swagger-ui .mv2-m{margin-bottom:.5rem;margin-top:.5rem}.swagger-ui .mv3-m{margin-bottom:1rem;margin-top:1rem}.swagger-ui .mv4-m{margin-bottom:2rem;margin-top:2rem}.swagger-ui .mv5-m{margin-bottom:4rem;margin-top:4rem}.swagger-ui .mv6-m{margin-bottom:8rem;margin-top:8rem}.swagger-ui .mv7-m{margin-bottom:16rem;margin-top:16rem}.swagger-ui .mh0-m{margin-left:0;margin-right:0}.swagger-ui .mh1-m{margin-left:.25rem;margin-right:.25rem}.swagger-ui .mh2-m{margin-left:.5rem;margin-right:.5rem}.swagger-ui .mh3-m{margin-left:1rem;margin-right:1rem}.swagger-ui .mh4-m{margin-left:2rem;margin-right:2rem}.swagger-ui .mh5-m{margin-left:4rem;margin-right:4rem}.swagger-ui .mh6-m{margin-left:8rem;margin-right:8rem}.swagger-ui .mh7-m{margin-left:16rem;margin-right:16rem}}@media screen and (min-width:60em){.swagger-ui .pa0-l{padding:0}.swagger-ui .pa1-l{padding:.25rem}.swagger-ui .pa2-l{padding:.5rem}.swagger-ui .pa3-l{padding:1rem}.swagger-ui .pa4-l{padding:2rem}.swagger-ui .pa5-l{padding:4rem}.swagger-ui .pa6-l{padding:8rem}.swagger-ui .pa7-l{padding:16rem}.swagger-ui .pl0-l{padding-left:0}.swagger-ui .pl1-l{padding-left:.25rem}.swagger-ui .pl2-l{padding-left:.5rem}.swagger-ui .pl3-l{padding-left:1rem}.swagger-ui .pl4-l{padding-left:2rem}.swagger-ui .pl5-l{padding-left:4rem}.swagger-ui .pl6-l{padding-left:8rem}.swagger-ui .pl7-l{padding-left:16rem}.swagger-ui .pr0-l{padding-right:0}.swagger-ui .pr1-l{padding-right:.25rem}.swagger-ui .pr2-l{padding-right:.5rem}.swagger-ui .pr3-l{padding-right:1rem}.swagger-ui .pr4-l{padding-right:2rem}.swagger-ui .pr5-l{padding-right:4rem}.swagger-ui .pr6-l{padding-right:8rem}.swagger-ui .pr7-l{padding-right:16rem}.swagger-ui .pb0-l{padding-bottom:0}.swagger-ui .pb1-l{padding-bottom:.25rem}.swagger-ui .pb2-l{padding-bottom:.5rem}.swagger-ui .pb3-l{padding-bottom:1rem}.swagger-ui .pb4-l{padding-bottom:2rem}.swagger-ui .pb5-l{padding-bottom:4rem}.swagger-ui .pb6-l{padding-bottom:8rem}.swagger-ui .pb7-l{padding-bottom:16rem}.swagger-ui .pt0-l{padding-top:0}.swagger-ui .pt1-l{padding-top:.25rem}.swagger-ui .pt2-l{padding-top:.5rem}.swagger-ui .pt3-l{padding-top:1rem}.swagger-ui .pt4-l{padding-top:2rem}.swagger-ui .pt5-l{padding-top:4rem}.swagger-ui .pt6-l{padding-top:8rem}.swagger-ui .pt7-l{padding-top:16rem}.swagger-ui .pv0-l{padding-bottom:0;padding-top:0}.swagger-ui .pv1-l{padding-bottom:.25rem;padding-top:.25rem}.swagger-ui .pv2-l{padding-bottom:.5rem;padding-top:.5rem}.swagger-ui .pv3-l{padding-bottom:1rem;padding-top:1rem}.swagger-ui .pv4-l{padding-bottom:2rem;padding-top:2rem}.swagger-ui .pv5-l{padding-bottom:4rem;padding-top:4rem}.swagger-ui .pv6-l{padding-bottom:8rem;padding-top:8rem}.swagger-ui .pv7-l{padding-bottom:16rem;padding-top:16rem}.swagger-ui .ph0-l{padding-left:0;padding-right:0}.swagger-ui .ph1-l{padding-left:.25rem;padding-right:.25rem}.swagger-ui .ph2-l{padding-left:.5rem;padding-right:.5rem}.swagger-ui .ph3-l{padding-left:1rem;padding-right:1rem}.swagger-ui .ph4-l{padding-left:2rem;padding-right:2rem}.swagger-ui .ph5-l{padding-left:4rem;padding-right:4rem}.swagger-ui .ph6-l{padding-left:8rem;padding-right:8rem}.swagger-ui .ph7-l{padding-left:16rem;padding-right:16rem}.swagger-ui .ma0-l{margin:0}.swagger-ui .ma1-l{margin:.25rem}.swagger-ui .ma2-l{margin:.5rem}.swagger-ui .ma3-l{margin:1rem}.swagger-ui .ma4-l{margin:2rem}.swagger-ui .ma5-l{margin:4rem}.swagger-ui .ma6-l{margin:8rem}.swagger-ui .ma7-l{margin:16rem}.swagger-ui .ml0-l{margin-left:0}.swagger-ui .ml1-l{margin-left:.25rem}.swagger-ui .ml2-l{margin-left:.5rem}.swagger-ui .ml3-l{margin-left:1rem}.swagger-ui .ml4-l{margin-left:2rem}.swagger-ui .ml5-l{margin-left:4rem}.swagger-ui .ml6-l{margin-left:8rem}.swagger-ui .ml7-l{margin-left:16rem}.swagger-ui .mr0-l{margin-right:0}.swagger-ui .mr1-l{margin-right:.25rem}.swagger-ui .mr2-l{margin-right:.5rem}.swagger-ui .mr3-l{margin-right:1rem}.swagger-ui .mr4-l{margin-right:2rem}.swagger-ui .mr5-l{margin-right:4rem}.swagger-ui .mr6-l{margin-right:8rem}.swagger-ui .mr7-l{margin-right:16rem}.swagger-ui .mb0-l{margin-bottom:0}.swagger-ui .mb1-l{margin-bottom:.25rem}.swagger-ui .mb2-l{margin-bottom:.5rem}.swagger-ui .mb3-l{margin-bottom:1rem}.swagger-ui .mb4-l{margin-bottom:2rem}.swagger-ui .mb5-l{margin-bottom:4rem}.swagger-ui .mb6-l{margin-bottom:8rem}.swagger-ui .mb7-l{margin-bottom:16rem}.swagger-ui .mt0-l{margin-top:0}.swagger-ui .mt1-l{margin-top:.25rem}.swagger-ui .mt2-l{margin-top:.5rem}.swagger-ui .mt3-l{margin-top:1rem}.swagger-ui .mt4-l{margin-top:2rem}.swagger-ui .mt5-l{margin-top:4rem}.swagger-ui .mt6-l{margin-top:8rem}.swagger-ui .mt7-l{margin-top:16rem}.swagger-ui .mv0-l{margin-bottom:0;margin-top:0}.swagger-ui .mv1-l{margin-bottom:.25rem;margin-top:.25rem}.swagger-ui .mv2-l{margin-bottom:.5rem;margin-top:.5rem}.swagger-ui .mv3-l{margin-bottom:1rem;margin-top:1rem}.swagger-ui .mv4-l{margin-bottom:2rem;margin-top:2rem}.swagger-ui .mv5-l{margin-bottom:4rem;margin-top:4rem}.swagger-ui .mv6-l{margin-bottom:8rem;margin-top:8rem}.swagger-ui .mv7-l{margin-bottom:16rem;margin-top:16rem}.swagger-ui .mh0-l{margin-left:0;margin-right:0}.swagger-ui .mh1-l{margin-left:.25rem;margin-right:.25rem}.swagger-ui .mh2-l{margin-left:.5rem;margin-right:.5rem}.swagger-ui .mh3-l{margin-left:1rem;margin-right:1rem}.swagger-ui .mh4-l{margin-left:2rem;margin-right:2rem}.swagger-ui .mh5-l{margin-left:4rem;margin-right:4rem}.swagger-ui .mh6-l{margin-left:8rem;margin-right:8rem}.swagger-ui .mh7-l{margin-left:16rem;margin-right:16rem}}.swagger-ui .na1{margin:-.25rem}.swagger-ui .na2{margin:-.5rem}.swagger-ui .na3{margin:-1rem}.swagger-ui .na4{margin:-2rem}.swagger-ui .na5{margin:-4rem}.swagger-ui .na6{margin:-8rem}.swagger-ui .na7{margin:-16rem}.swagger-ui .nl1{margin-left:-.25rem}.swagger-ui .nl2{margin-left:-.5rem}.swagger-ui .nl3{margin-left:-1rem}.swagger-ui .nl4{margin-left:-2rem}.swagger-ui .nl5{margin-left:-4rem}.swagger-ui .nl6{margin-left:-8rem}.swagger-ui .nl7{margin-left:-16rem}.swagger-ui .nr1{margin-right:-.25rem}.swagger-ui .nr2{margin-right:-.5rem}.swagger-ui .nr3{margin-right:-1rem}.swagger-ui .nr4{margin-right:-2rem}.swagger-ui .nr5{margin-right:-4rem}.swagger-ui .nr6{margin-right:-8rem}.swagger-ui .nr7{margin-right:-16rem}.swagger-ui .nb1{margin-bottom:-.25rem}.swagger-ui .nb2{margin-bottom:-.5rem}.swagger-ui .nb3{margin-bottom:-1rem}.swagger-ui .nb4{margin-bottom:-2rem}.swagger-ui .nb5{margin-bottom:-4rem}.swagger-ui .nb6{margin-bottom:-8rem}.swagger-ui .nb7{margin-bottom:-16rem}.swagger-ui .nt1{margin-top:-.25rem}.swagger-ui .nt2{margin-top:-.5rem}.swagger-ui .nt3{margin-top:-1rem}.swagger-ui .nt4{margin-top:-2rem}.swagger-ui .nt5{margin-top:-4rem}.swagger-ui .nt6{margin-top:-8rem}.swagger-ui .nt7{margin-top:-16rem}@media screen and (min-width:30em){.swagger-ui .na1-ns{margin:-.25rem}.swagger-ui .na2-ns{margin:-.5rem}.swagger-ui .na3-ns{margin:-1rem}.swagger-ui .na4-ns{margin:-2rem}.swagger-ui .na5-ns{margin:-4rem}.swagger-ui .na6-ns{margin:-8rem}.swagger-ui .na7-ns{margin:-16rem}.swagger-ui .nl1-ns{margin-left:-.25rem}.swagger-ui .nl2-ns{margin-left:-.5rem}.swagger-ui .nl3-ns{margin-left:-1rem}.swagger-ui .nl4-ns{margin-left:-2rem}.swagger-ui .nl5-ns{margin-left:-4rem}.swagger-ui .nl6-ns{margin-left:-8rem}.swagger-ui .nl7-ns{margin-left:-16rem}.swagger-ui .nr1-ns{margin-right:-.25rem}.swagger-ui .nr2-ns{margin-right:-.5rem}.swagger-ui .nr3-ns{margin-right:-1rem}.swagger-ui .nr4-ns{margin-right:-2rem}.swagger-ui .nr5-ns{margin-right:-4rem}.swagger-ui .nr6-ns{margin-right:-8rem}.swagger-ui .nr7-ns{margin-right:-16rem}.swagger-ui .nb1-ns{margin-bottom:-.25rem}.swagger-ui .nb2-ns{margin-bottom:-.5rem}.swagger-ui .nb3-ns{margin-bottom:-1rem}.swagger-ui .nb4-ns{margin-bottom:-2rem}.swagger-ui .nb5-ns{margin-bottom:-4rem}.swagger-ui .nb6-ns{margin-bottom:-8rem}.swagger-ui .nb7-ns{margin-bottom:-16rem}.swagger-ui .nt1-ns{margin-top:-.25rem}.swagger-ui .nt2-ns{margin-top:-.5rem}.swagger-ui .nt3-ns{margin-top:-1rem}.swagger-ui .nt4-ns{margin-top:-2rem}.swagger-ui .nt5-ns{margin-top:-4rem}.swagger-ui .nt6-ns{margin-top:-8rem}.swagger-ui .nt7-ns{margin-top:-16rem}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .na1-m{margin:-.25rem}.swagger-ui .na2-m{margin:-.5rem}.swagger-ui .na3-m{margin:-1rem}.swagger-ui .na4-m{margin:-2rem}.swagger-ui .na5-m{margin:-4rem}.swagger-ui .na6-m{margin:-8rem}.swagger-ui .na7-m{margin:-16rem}.swagger-ui .nl1-m{margin-left:-.25rem}.swagger-ui .nl2-m{margin-left:-.5rem}.swagger-ui .nl3-m{margin-left:-1rem}.swagger-ui .nl4-m{margin-left:-2rem}.swagger-ui .nl5-m{margin-left:-4rem}.swagger-ui .nl6-m{margin-left:-8rem}.swagger-ui .nl7-m{margin-left:-16rem}.swagger-ui .nr1-m{margin-right:-.25rem}.swagger-ui .nr2-m{margin-right:-.5rem}.swagger-ui .nr3-m{margin-right:-1rem}.swagger-ui .nr4-m{margin-right:-2rem}.swagger-ui .nr5-m{margin-right:-4rem}.swagger-ui .nr6-m{margin-right:-8rem}.swagger-ui .nr7-m{margin-right:-16rem}.swagger-ui .nb1-m{margin-bottom:-.25rem}.swagger-ui .nb2-m{margin-bottom:-.5rem}.swagger-ui .nb3-m{margin-bottom:-1rem}.swagger-ui .nb4-m{margin-bottom:-2rem}.swagger-ui .nb5-m{margin-bottom:-4rem}.swagger-ui .nb6-m{margin-bottom:-8rem}.swagger-ui .nb7-m{margin-bottom:-16rem}.swagger-ui .nt1-m{margin-top:-.25rem}.swagger-ui .nt2-m{margin-top:-.5rem}.swagger-ui .nt3-m{margin-top:-1rem}.swagger-ui .nt4-m{margin-top:-2rem}.swagger-ui .nt5-m{margin-top:-4rem}.swagger-ui .nt6-m{margin-top:-8rem}.swagger-ui .nt7-m{margin-top:-16rem}}@media screen and (min-width:60em){.swagger-ui .na1-l{margin:-.25rem}.swagger-ui .na2-l{margin:-.5rem}.swagger-ui .na3-l{margin:-1rem}.swagger-ui .na4-l{margin:-2rem}.swagger-ui .na5-l{margin:-4rem}.swagger-ui .na6-l{margin:-8rem}.swagger-ui .na7-l{margin:-16rem}.swagger-ui .nl1-l{margin-left:-.25rem}.swagger-ui .nl2-l{margin-left:-.5rem}.swagger-ui .nl3-l{margin-left:-1rem}.swagger-ui .nl4-l{margin-left:-2rem}.swagger-ui .nl5-l{margin-left:-4rem}.swagger-ui .nl6-l{margin-left:-8rem}.swagger-ui .nl7-l{margin-left:-16rem}.swagger-ui .nr1-l{margin-right:-.25rem}.swagger-ui .nr2-l{margin-right:-.5rem}.swagger-ui .nr3-l{margin-right:-1rem}.swagger-ui .nr4-l{margin-right:-2rem}.swagger-ui .nr5-l{margin-right:-4rem}.swagger-ui .nr6-l{margin-right:-8rem}.swagger-ui .nr7-l{margin-right:-16rem}.swagger-ui .nb1-l{margin-bottom:-.25rem}.swagger-ui .nb2-l{margin-bottom:-.5rem}.swagger-ui .nb3-l{margin-bottom:-1rem}.swagger-ui .nb4-l{margin-bottom:-2rem}.swagger-ui .nb5-l{margin-bottom:-4rem}.swagger-ui .nb6-l{margin-bottom:-8rem}.swagger-ui .nb7-l{margin-bottom:-16rem}.swagger-ui .nt1-l{margin-top:-.25rem}.swagger-ui .nt2-l{margin-top:-.5rem}.swagger-ui .nt3-l{margin-top:-1rem}.swagger-ui .nt4-l{margin-top:-2rem}.swagger-ui .nt5-l{margin-top:-4rem}.swagger-ui .nt6-l{margin-top:-8rem}.swagger-ui .nt7-l{margin-top:-16rem}}.swagger-ui .collapse{border-collapse:collapse;border-spacing:0}.swagger-ui .striped--light-silver:nth-child(odd){background-color:#aaa}.swagger-ui .striped--moon-gray:nth-child(odd){background-color:#ccc}.swagger-ui .striped--light-gray:nth-child(odd){background-color:#eee}.swagger-ui .striped--near-white:nth-child(odd){background-color:#f4f4f4}.swagger-ui .stripe-light:nth-child(odd){background-color:hsla(0,0%,100%,.1)}.swagger-ui .stripe-dark:nth-child(odd){background-color:rgba(0,0,0,.1)}.swagger-ui .strike{-webkit-text-decoration:line-through;text-decoration:line-through}.swagger-ui .underline{-webkit-text-decoration:underline;text-decoration:underline}.swagger-ui .no-underline{-webkit-text-decoration:none;text-decoration:none}@media screen and (min-width:30em){.swagger-ui .strike-ns{-webkit-text-decoration:line-through;text-decoration:line-through}.swagger-ui .underline-ns{-webkit-text-decoration:underline;text-decoration:underline}.swagger-ui .no-underline-ns{-webkit-text-decoration:none;text-decoration:none}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .strike-m{-webkit-text-decoration:line-through;text-decoration:line-through}.swagger-ui .underline-m{-webkit-text-decoration:underline;text-decoration:underline}.swagger-ui .no-underline-m{-webkit-text-decoration:none;text-decoration:none}}@media screen and (min-width:60em){.swagger-ui .strike-l{-webkit-text-decoration:line-through;text-decoration:line-through}.swagger-ui .underline-l{-webkit-text-decoration:underline;text-decoration:underline}.swagger-ui .no-underline-l{-webkit-text-decoration:none;text-decoration:none}}.swagger-ui .tl{text-align:left}.swagger-ui .tr{text-align:right}.swagger-ui .tc{text-align:center}.swagger-ui .tj{text-align:justify}@media screen and (min-width:30em){.swagger-ui .tl-ns{text-align:left}.swagger-ui .tr-ns{text-align:right}.swagger-ui .tc-ns{text-align:center}.swagger-ui .tj-ns{text-align:justify}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .tl-m{text-align:left}.swagger-ui .tr-m{text-align:right}.swagger-ui .tc-m{text-align:center}.swagger-ui .tj-m{text-align:justify}}@media screen and (min-width:60em){.swagger-ui .tl-l{text-align:left}.swagger-ui .tr-l{text-align:right}.swagger-ui .tc-l{text-align:center}.swagger-ui .tj-l{text-align:justify}}.swagger-ui .ttc{text-transform:capitalize}.swagger-ui .ttl{text-transform:lowercase}.swagger-ui .ttu{text-transform:uppercase}.swagger-ui .ttn{text-transform:none}@media screen and (min-width:30em){.swagger-ui .ttc-ns{text-transform:capitalize}.swagger-ui .ttl-ns{text-transform:lowercase}.swagger-ui .ttu-ns{text-transform:uppercase}.swagger-ui .ttn-ns{text-transform:none}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .ttc-m{text-transform:capitalize}.swagger-ui .ttl-m{text-transform:lowercase}.swagger-ui .ttu-m{text-transform:uppercase}.swagger-ui .ttn-m{text-transform:none}}@media screen and (min-width:60em){.swagger-ui .ttc-l{text-transform:capitalize}.swagger-ui .ttl-l{text-transform:lowercase}.swagger-ui .ttu-l{text-transform:uppercase}.swagger-ui .ttn-l{text-transform:none}}.swagger-ui .f-6,.swagger-ui .f-headline{font-size:6rem}.swagger-ui .f-5,.swagger-ui .f-subheadline{font-size:5rem}.swagger-ui .f1{font-size:3rem}.swagger-ui .f2{font-size:2.25rem}.swagger-ui .f3{font-size:1.5rem}.swagger-ui .f4{font-size:1.25rem}.swagger-ui .f5{font-size:1rem}.swagger-ui .f6{font-size:.875rem}.swagger-ui .f7{font-size:.75rem}@media screen and (min-width:30em){.swagger-ui .f-6-ns,.swagger-ui .f-headline-ns{font-size:6rem}.swagger-ui .f-5-ns,.swagger-ui .f-subheadline-ns{font-size:5rem}.swagger-ui .f1-ns{font-size:3rem}.swagger-ui .f2-ns{font-size:2.25rem}.swagger-ui .f3-ns{font-size:1.5rem}.swagger-ui .f4-ns{font-size:1.25rem}.swagger-ui .f5-ns{font-size:1rem}.swagger-ui .f6-ns{font-size:.875rem}.swagger-ui .f7-ns{font-size:.75rem}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .f-6-m,.swagger-ui .f-headline-m{font-size:6rem}.swagger-ui .f-5-m,.swagger-ui .f-subheadline-m{font-size:5rem}.swagger-ui .f1-m{font-size:3rem}.swagger-ui .f2-m{font-size:2.25rem}.swagger-ui .f3-m{font-size:1.5rem}.swagger-ui .f4-m{font-size:1.25rem}.swagger-ui .f5-m{font-size:1rem}.swagger-ui .f6-m{font-size:.875rem}.swagger-ui .f7-m{font-size:.75rem}}@media screen and (min-width:60em){.swagger-ui .f-6-l,.swagger-ui .f-headline-l{font-size:6rem}.swagger-ui .f-5-l,.swagger-ui .f-subheadline-l{font-size:5rem}.swagger-ui .f1-l{font-size:3rem}.swagger-ui .f2-l{font-size:2.25rem}.swagger-ui .f3-l{font-size:1.5rem}.swagger-ui .f4-l{font-size:1.25rem}.swagger-ui .f5-l{font-size:1rem}.swagger-ui .f6-l{font-size:.875rem}.swagger-ui .f7-l{font-size:.75rem}}.swagger-ui .measure{max-width:30em}.swagger-ui .measure-wide{max-width:34em}.swagger-ui .measure-narrow{max-width:20em}.swagger-ui .indent{margin-bottom:0;margin-top:0;text-indent:1em}.swagger-ui .small-caps{font-feature-settings:\"smcp\";font-variant:small-caps}.swagger-ui .truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media screen and (min-width:30em){.swagger-ui .measure-ns{max-width:30em}.swagger-ui .measure-wide-ns{max-width:34em}.swagger-ui .measure-narrow-ns{max-width:20em}.swagger-ui .indent-ns{margin-bottom:0;margin-top:0;text-indent:1em}.swagger-ui .small-caps-ns{font-feature-settings:\"smcp\";font-variant:small-caps}.swagger-ui .truncate-ns{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .measure-m{max-width:30em}.swagger-ui .measure-wide-m{max-width:34em}.swagger-ui .measure-narrow-m{max-width:20em}.swagger-ui .indent-m{margin-bottom:0;margin-top:0;text-indent:1em}.swagger-ui .small-caps-m{font-feature-settings:\"smcp\";font-variant:small-caps}.swagger-ui .truncate-m{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}@media screen and (min-width:60em){.swagger-ui .measure-l{max-width:30em}.swagger-ui .measure-wide-l{max-width:34em}.swagger-ui .measure-narrow-l{max-width:20em}.swagger-ui .indent-l{margin-bottom:0;margin-top:0;text-indent:1em}.swagger-ui .small-caps-l{font-feature-settings:\"smcp\";font-variant:small-caps}.swagger-ui .truncate-l{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}.swagger-ui .overflow-container{overflow-y:scroll}.swagger-ui .center{margin-left:auto;margin-right:auto}.swagger-ui .mr-auto{margin-right:auto}.swagger-ui .ml-auto{margin-left:auto}@media screen and (min-width:30em){.swagger-ui .center-ns{margin-left:auto;margin-right:auto}.swagger-ui .mr-auto-ns{margin-right:auto}.swagger-ui .ml-auto-ns{margin-left:auto}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .center-m{margin-left:auto;margin-right:auto}.swagger-ui .mr-auto-m{margin-right:auto}.swagger-ui .ml-auto-m{margin-left:auto}}@media screen and (min-width:60em){.swagger-ui .center-l{margin-left:auto;margin-right:auto}.swagger-ui .mr-auto-l{margin-right:auto}.swagger-ui .ml-auto-l{margin-left:auto}}.swagger-ui .clip{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}@media screen and (min-width:30em){.swagger-ui .clip-ns{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .clip-m{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}}@media screen and (min-width:60em){.swagger-ui .clip-l{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}}.swagger-ui .ws-normal{white-space:normal}.swagger-ui .nowrap{white-space:nowrap}.swagger-ui .pre{white-space:pre}@media screen and (min-width:30em){.swagger-ui .ws-normal-ns{white-space:normal}.swagger-ui .nowrap-ns{white-space:nowrap}.swagger-ui .pre-ns{white-space:pre}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .ws-normal-m{white-space:normal}.swagger-ui .nowrap-m{white-space:nowrap}.swagger-ui .pre-m{white-space:pre}}@media screen and (min-width:60em){.swagger-ui .ws-normal-l{white-space:normal}.swagger-ui .nowrap-l{white-space:nowrap}.swagger-ui .pre-l{white-space:pre}}.swagger-ui .v-base{vertical-align:baseline}.swagger-ui .v-mid{vertical-align:middle}.swagger-ui .v-top{vertical-align:top}.swagger-ui .v-btm{vertical-align:bottom}@media screen and (min-width:30em){.swagger-ui .v-base-ns{vertical-align:baseline}.swagger-ui .v-mid-ns{vertical-align:middle}.swagger-ui .v-top-ns{vertical-align:top}.swagger-ui .v-btm-ns{vertical-align:bottom}}@media screen and (min-width:30em)and (max-width:60em){.swagger-ui .v-base-m{vertical-align:baseline}.swagger-ui .v-mid-m{vertical-align:middle}.swagger-ui .v-top-m{vertical-align:top}.swagger-ui .v-btm-m{vertical-align:bottom}}@media screen and (min-width:60em){.swagger-ui .v-base-l{vertical-align:baseline}.swagger-ui .v-mid-l{vertical-align:middle}.swagger-ui .v-top-l{vertical-align:top}.swagger-ui .v-btm-l{vertical-align:bottom}}.swagger-ui .dim{opacity:1;transition:opacity .15s ease-in}.swagger-ui .dim:focus,.swagger-ui .dim:hover{opacity:.5;transition:opacity .15s ease-in}.swagger-ui .dim:active{opacity:.8;transition:opacity .15s ease-out}.swagger-ui .glow{transition:opacity .15s ease-in}.swagger-ui .glow:focus,.swagger-ui .glow:hover{opacity:1;transition:opacity .15s ease-in}.swagger-ui .hide-child .child{opacity:0;transition:opacity .15s ease-in}.swagger-ui .hide-child:active .child,.swagger-ui .hide-child:focus .child,.swagger-ui .hide-child:hover .child{opacity:1;transition:opacity .15s ease-in}.swagger-ui .underline-hover:focus,.swagger-ui .underline-hover:hover{-webkit-text-decoration:underline;text-decoration:underline}.swagger-ui .grow{-moz-osx-font-smoothing:grayscale;backface-visibility:hidden;transform:translateZ(0);transition:transform .25s ease-out}.swagger-ui .grow:focus,.swagger-ui .grow:hover{transform:scale(1.05)}.swagger-ui .grow:active{transform:scale(.9)}.swagger-ui .grow-large{-moz-osx-font-smoothing:grayscale;backface-visibility:hidden;transform:translateZ(0);transition:transform .25s ease-in-out}.swagger-ui .grow-large:focus,.swagger-ui .grow-large:hover{transform:scale(1.2)}.swagger-ui .grow-large:active{transform:scale(.95)}.swagger-ui .pointer:hover{cursor:pointer}.swagger-ui .shadow-hover{cursor:pointer;position:relative;transition:all .5s cubic-bezier(.165,.84,.44,1)}.swagger-ui .shadow-hover:after{border-radius:inherit;box-shadow:0 0 16px 2px rgba(0,0,0,.2);content:\"\";height:100%;left:0;opacity:0;position:absolute;top:0;transition:opacity .5s cubic-bezier(.165,.84,.44,1);width:100%;z-index:-1}.swagger-ui .shadow-hover:focus:after,.swagger-ui .shadow-hover:hover:after{opacity:1}.swagger-ui .bg-animate,.swagger-ui .bg-animate:focus,.swagger-ui .bg-animate:hover{transition:background-color .15s ease-in-out}.swagger-ui .z-0{z-index:0}.swagger-ui .z-1{z-index:1}.swagger-ui .z-2{z-index:2}.swagger-ui .z-3{z-index:3}.swagger-ui .z-4{z-index:4}.swagger-ui .z-5{z-index:5}.swagger-ui .z-999{z-index:999}.swagger-ui .z-9999{z-index:9999}.swagger-ui .z-max{z-index:2147483647}.swagger-ui .z-inherit{z-index:inherit}.swagger-ui .z-initial,.swagger-ui .z-unset{z-index:auto}.swagger-ui .nested-copy-line-height ol,.swagger-ui .nested-copy-line-height p,.swagger-ui .nested-copy-line-height ul{line-height:1.5}.swagger-ui .nested-headline-line-height h1,.swagger-ui .nested-headline-line-height h2,.swagger-ui .nested-headline-line-height h3,.swagger-ui .nested-headline-line-height h4,.swagger-ui .nested-headline-line-height h5,.swagger-ui .nested-headline-line-height h6{line-height:1.25}.swagger-ui .nested-list-reset ol,.swagger-ui .nested-list-reset ul{list-style-type:none;margin-left:0;padding-left:0}.swagger-ui .nested-copy-indent p+p{margin-bottom:0;margin-top:0;text-indent:.1em}.swagger-ui .nested-copy-seperator p+p{margin-top:1.5em}.swagger-ui .nested-img img{display:block;max-width:100%;width:100%}.swagger-ui .nested-links a{color:#357edd;transition:color .15s ease-in}.swagger-ui .nested-links a:focus,.swagger-ui .nested-links a:hover{color:#96ccff;transition:color .15s ease-in}.swagger-ui .wrapper{box-sizing:border-box;margin:0 auto;max-width:1460px;padding:0 20px;width:100%}.swagger-ui .opblock-tag-section{display:flex;flex-direction:column}.swagger-ui .try-out.btn-group{display:flex;flex:.1 2 auto;padding:0}.swagger-ui .try-out__btn{margin-left:1.25rem}.swagger-ui .opblock-tag{align-items:center;border-bottom:1px solid rgba(59,65,81,.3);cursor:pointer;display:flex;padding:10px 20px 10px 10px;transition:all .2s}.swagger-ui .opblock-tag:hover{background:rgba(0,0,0,.02)}.swagger-ui .opblock-tag{color:#3b4151;font-family:sans-serif;font-size:24px;margin:0 0 5px}.swagger-ui .opblock-tag.no-desc span{flex:1}.swagger-ui .opblock-tag svg{transition:all .4s}.swagger-ui .opblock-tag small{color:#3b4151;flex:2;font-family:sans-serif;font-size:14px;font-weight:400;padding:0 10px}.swagger-ui .opblock-tag>div{flex:1 1 150px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(max-width:640px){.swagger-ui .opblock-tag small,.swagger-ui .opblock-tag>div{flex:1}}.swagger-ui .opblock-tag .info__externaldocs{text-align:right}.swagger-ui .parameter__type{color:#3b4151;font-family:monospace;font-size:12px;font-weight:600;padding:5px 0}.swagger-ui .parameter-controls{margin-top:.75em}.swagger-ui .examples__title{display:block;font-size:1.1em;font-weight:700;margin-bottom:.75em}.swagger-ui .examples__section{margin-top:1.5em}.swagger-ui .examples__section-header{font-size:.9rem;font-weight:700;margin-bottom:.5rem}.swagger-ui .examples-select{display:inline-block;margin-bottom:.75em}.swagger-ui .examples-select .examples-select-element{width:100%}.swagger-ui .examples-select__section-label{font-size:.9rem;font-weight:700;margin-right:.5rem}.swagger-ui .example__section{margin-top:1.5em}.swagger-ui .example__section-header{font-size:.9rem;font-weight:700;margin-bottom:.5rem}.swagger-ui .view-line-link{cursor:pointer;margin:0 5px;position:relative;top:3px;transition:all .5s;width:20px}.swagger-ui .opblock{border:1px solid #000;border-radius:4px;box-shadow:0 0 3px rgba(0,0,0,.19);margin:0 0 15px}.swagger-ui .opblock .tab-header{display:flex;flex:1}.swagger-ui .opblock .tab-header .tab-item{cursor:pointer;padding:0 40px}.swagger-ui .opblock .tab-header .tab-item:first-of-type{padding:0 40px 0 0}.swagger-ui .opblock .tab-header .tab-item.active h4 span{position:relative}.swagger-ui .opblock .tab-header .tab-item.active h4 span:after{background:grey;bottom:-15px;content:\"\";height:4px;left:50%;position:absolute;transform:translateX(-50%);width:120%}.swagger-ui .opblock.is-open .opblock-summary{border-bottom:1px solid #000}.swagger-ui .opblock .opblock-section-header{align-items:center;background:hsla(0,0%,100%,.8);box-shadow:0 1px 2px rgba(0,0,0,.1);display:flex;min-height:50px;padding:8px 20px}.swagger-ui .opblock .opblock-section-header>label{align-items:center;color:#3b4151;display:flex;font-family:sans-serif;font-size:12px;font-weight:700;margin:0 0 0 auto}.swagger-ui .opblock .opblock-section-header>label>span{padding:0 10px 0 0}.swagger-ui .opblock .opblock-section-header h4{color:#3b4151;flex:1;font-family:sans-serif;font-size:14px;margin:0}.swagger-ui .opblock .opblock-summary-method{background:#000;border-radius:3px;color:#fff;font-family:sans-serif;font-size:14px;font-weight:700;min-width:80px;padding:6px 0;text-align:center;text-shadow:0 1px 0 rgba(0,0,0,.1)}@media(max-width:768px){.swagger-ui .opblock .opblock-summary-method{font-size:12px}}.swagger-ui .opblock .opblock-summary-operation-id,.swagger-ui .opblock .opblock-summary-path,.swagger-ui .opblock .opblock-summary-path__deprecated{align-items:center;color:#3b4151;display:flex;font-family:monospace;font-size:16px;font-weight:600;word-break:break-word}@media(max-width:768px){.swagger-ui .opblock .opblock-summary-operation-id,.swagger-ui .opblock .opblock-summary-path,.swagger-ui .opblock .opblock-summary-path__deprecated{font-size:12px}}.swagger-ui .opblock .opblock-summary-path{flex-shrink:1}@media(max-width:640px){.swagger-ui .opblock .opblock-summary-path{max-width:100%}}.swagger-ui .opblock .opblock-summary-path__deprecated{-webkit-text-decoration:line-through;text-decoration:line-through}.swagger-ui .opblock .opblock-summary-operation-id{font-size:14px}.swagger-ui .opblock .opblock-summary-description{color:#3b4151;font-family:sans-serif;font-size:13px;word-break:break-word}.swagger-ui .opblock .opblock-summary-path-description-wrapper{align-items:center;display:flex;flex-direction:row;flex-grow:1;flex-wrap:wrap;gap:0 10px;padding:0 10px}@media(max-width:550px){.swagger-ui .opblock .opblock-summary-path-description-wrapper{align-items:flex-start;flex-direction:column}}.swagger-ui .opblock .opblock-summary{align-items:center;cursor:pointer;display:flex;padding:5px}.swagger-ui .opblock .opblock-summary .view-line-link{cursor:pointer;margin:0;position:relative;top:2px;transition:all .5s;width:0}.swagger-ui .opblock .opblock-summary:hover .view-line-link{margin:0 5px;width:18px}.swagger-ui .opblock .opblock-summary:hover .view-line-link.copy-to-clipboard{width:24px}.swagger-ui .opblock.opblock-post{background:rgba(73,204,144,.1);border-color:#49cc90}.swagger-ui .opblock.opblock-post .opblock-summary-method{background:#49cc90}.swagger-ui .opblock.opblock-post .opblock-summary{border-color:#49cc90}.swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span:after{background:#49cc90}.swagger-ui .opblock.opblock-put{background:rgba(252,161,48,.1);border-color:#fca130}.swagger-ui .opblock.opblock-put .opblock-summary-method{background:#fca130}.swagger-ui .opblock.opblock-put .opblock-summary{border-color:#fca130}.swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span:after{background:#fca130}.swagger-ui .opblock.opblock-delete{background:rgba(249,62,62,.1);border-color:#f93e3e}.swagger-ui .opblock.opblock-delete .opblock-summary-method{background:#f93e3e}.swagger-ui .opblock.opblock-delete .opblock-summary{border-color:#f93e3e}.swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span:after{background:#f93e3e}.swagger-ui .opblock.opblock-get{background:rgba(97,175,254,.1);border-color:#61affe}.swagger-ui .opblock.opblock-get .opblock-summary-method{background:#61affe}.swagger-ui .opblock.opblock-get .opblock-summary{border-color:#61affe}.swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span:after{background:#61affe}.swagger-ui .opblock.opblock-patch{background:rgba(80,227,194,.1);border-color:#50e3c2}.swagger-ui .opblock.opblock-patch .opblock-summary-method{background:#50e3c2}.swagger-ui .opblock.opblock-patch .opblock-summary{border-color:#50e3c2}.swagger-ui .opblock.opblock-patch .tab-header .tab-item.active h4 span:after{background:#50e3c2}.swagger-ui .opblock.opblock-head{background:rgba(144,18,254,.1);border-color:#9012fe}.swagger-ui .opblock.opblock-head .opblock-summary-method{background:#9012fe}.swagger-ui .opblock.opblock-head .opblock-summary{border-color:#9012fe}.swagger-ui .opblock.opblock-head .tab-header .tab-item.active h4 span:after{background:#9012fe}.swagger-ui .opblock.opblock-options{background:rgba(13,90,167,.1);border-color:#0d5aa7}.swagger-ui .opblock.opblock-options .opblock-summary-method{background:#0d5aa7}.swagger-ui .opblock.opblock-options .opblock-summary{border-color:#0d5aa7}.swagger-ui .opblock.opblock-options .tab-header .tab-item.active h4 span:after{background:#0d5aa7}.swagger-ui .opblock.opblock-deprecated{background:hsla(0,0%,92%,.1);border-color:#ebebeb;opacity:.6}.swagger-ui .opblock.opblock-deprecated .opblock-summary-method{background:#ebebeb}.swagger-ui .opblock.opblock-deprecated .opblock-summary{border-color:#ebebeb}.swagger-ui .opblock.opblock-deprecated .tab-header .tab-item.active h4 span:after{background:#ebebeb}.swagger-ui .opblock .opblock-schemes{padding:8px 20px}.swagger-ui .opblock .opblock-schemes .schemes-title{padding:0 10px 0 0}.swagger-ui .filter .operation-filter-input{border:2px solid #d8dde7;margin:20px 0;padding:10px;width:100%}.swagger-ui .download-url-wrapper .failed,.swagger-ui .filter .failed{color:red}.swagger-ui .download-url-wrapper .loading,.swagger-ui .filter .loading{color:#aaa}.swagger-ui .model-example{margin-top:1em}.swagger-ui .model-example .model-container{overflow-x:auto;width:100%}.swagger-ui .model-example .model-container .model-hint:not(.model-hint--embedded){top:-1.15em}.swagger-ui .tab{display:flex;list-style:none;padding:0}.swagger-ui .tab li{color:#3b4151;cursor:pointer;font-family:sans-serif;font-size:12px;min-width:60px;padding:0}.swagger-ui .tab li:first-of-type{padding-left:0;padding-right:12px;position:relative}.swagger-ui .tab li:first-of-type:after{background:rgba(0,0,0,.2);content:\"\";height:100%;position:absolute;right:6px;top:0;width:1px}.swagger-ui .tab li.active{font-weight:700}.swagger-ui .tab li button.tablinks{background:none;border:0;color:inherit;font-family:inherit;font-weight:inherit;padding:0}.swagger-ui .opblock-description-wrapper,.swagger-ui .opblock-external-docs-wrapper,.swagger-ui .opblock-title_normal{color:#3b4151;font-family:sans-serif;font-size:12px;margin:0 0 5px;padding:15px 20px}.swagger-ui .opblock-description-wrapper h4,.swagger-ui .opblock-external-docs-wrapper h4,.swagger-ui .opblock-title_normal h4{color:#3b4151;font-family:sans-serif;font-size:12px;margin:0 0 5px}.swagger-ui .opblock-description-wrapper p,.swagger-ui .opblock-external-docs-wrapper p,.swagger-ui .opblock-title_normal p{color:#3b4151;font-family:sans-serif;font-size:14px;margin:0}.swagger-ui .opblock-external-docs-wrapper h4{padding-left:0}.swagger-ui .execute-wrapper{padding:20px;text-align:right}.swagger-ui .execute-wrapper .btn{padding:8px 40px;width:100%}.swagger-ui .body-param-options{display:flex;flex-direction:column}.swagger-ui .body-param-options .body-param-edit{padding:10px 0}.swagger-ui .body-param-options label{padding:8px 0}.swagger-ui .body-param-options label select{margin:3px 0 0}.swagger-ui .responses-inner{padding:20px}.swagger-ui .responses-inner h4,.swagger-ui .responses-inner h5{color:#3b4151;font-family:sans-serif;font-size:12px;margin:10px 0 5px}.swagger-ui .responses-inner .curl{max-height:400px;min-height:6em;overflow-y:auto}.swagger-ui .response-col_status{color:#3b4151;font-family:sans-serif;font-size:14px}.swagger-ui .response-col_status .response-undocumented{color:#909090;font-family:monospace;font-size:11px;font-weight:600}.swagger-ui .response-col_links{color:#3b4151;font-family:sans-serif;font-size:14px;max-width:40em;padding-left:2em}.swagger-ui .response-col_links .response-undocumented{color:#909090;font-family:monospace;font-size:11px;font-weight:600}.swagger-ui .response-col_links .operation-link{margin-bottom:1.5em}.swagger-ui .response-col_links .operation-link .description{margin-bottom:.5em}.swagger-ui .opblock-body .opblock-loading-animation{display:block;margin:3em auto}.swagger-ui .opblock-body pre.microlight{background:#333;border-radius:4px;font-size:12px;hyphens:auto;margin:0;padding:10px;white-space:pre-wrap;word-break:break-all;word-break:break-word;word-wrap:break-word;color:#fff;font-family:monospace;font-weight:600}.swagger-ui .opblock-body pre.microlight .headerline{display:block}.swagger-ui .highlight-code{position:relative}.swagger-ui .highlight-code>.microlight{max-height:400px;min-height:6em;overflow-y:auto}.swagger-ui .highlight-code>.microlight code{white-space:pre-wrap!important;word-break:break-all}.swagger-ui .curl-command{position:relative}.swagger-ui .download-contents{align-items:center;background:#7d8293;border:none;border-radius:4px;bottom:10px;color:#fff;display:flex;font-family:sans-serif;font-size:14px;font-weight:600;height:30px;justify-content:center;padding:5px;position:absolute;right:10px;text-align:center}.swagger-ui .scheme-container{background:#fff;box-shadow:0 1px 2px 0 rgba(0,0,0,.15);margin:0 0 20px;padding:30px 0}.swagger-ui .scheme-container .schemes{align-items:flex-end;display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between}.swagger-ui .scheme-container .schemes>.schemes-server-container{display:flex;flex-wrap:wrap;gap:10px}.swagger-ui .scheme-container .schemes>.schemes-server-container>label{color:#3b4151;display:flex;flex-direction:column;font-family:sans-serif;font-size:12px;font-weight:700;margin:-20px 15px 0 0}.swagger-ui .scheme-container .schemes>.schemes-server-container>label select{min-width:130px;text-transform:uppercase}.swagger-ui .scheme-container .schemes:not(:has(.schemes-server-container)){justify-content:flex-end}.swagger-ui .scheme-container .schemes .auth-wrapper{flex:none;justify-content:start}.swagger-ui .scheme-container .schemes .auth-wrapper .authorize{display:flex;flex-wrap:nowrap;margin:0;padding-right:20px}.swagger-ui .loading-container{align-items:center;display:flex;flex-direction:column;justify-content:center;margin-top:1em;min-height:1px;padding:40px 0 60px}.swagger-ui .loading-container .loading{position:relative}.swagger-ui .loading-container .loading:after{color:#3b4151;content:\"loading\";font-family:sans-serif;font-size:10px;font-weight:700;left:50%;position:absolute;text-transform:uppercase;top:50%;transform:translate(-50%,-50%)}.swagger-ui .loading-container .loading:before{animation:rotation 1s linear infinite,opacity .5s;backface-visibility:hidden;border:2px solid rgba(85,85,85,.1);border-radius:100%;border-top-color:rgba(0,0,0,.6);content:\"\";display:block;height:60px;left:50%;margin:-30px;opacity:1;position:absolute;top:50%;width:60px}@keyframes rotation{to{transform:rotate(1turn)}}.swagger-ui .response-controls{display:flex;padding-top:1em}.swagger-ui .response-control-media-type{margin-right:1em}.swagger-ui .response-control-media-type--accept-controller select{border-color:green}.swagger-ui .response-control-media-type__accept-message{color:green;font-size:.7em}.swagger-ui .response-control-examples__title,.swagger-ui .response-control-media-type__title{display:block;font-size:.7em;margin-bottom:.2em}@keyframes blinker{50%{opacity:0}}.swagger-ui .hidden{display:none}.swagger-ui .no-margin{border:none;height:auto;margin:0;padding:0}.swagger-ui .float-right{float:right}.swagger-ui .svg-assets{height:0;position:absolute;width:0}.swagger-ui section h3{color:#3b4151;font-family:sans-serif}.swagger-ui a.nostyle{display:inline}.swagger-ui a.nostyle,.swagger-ui a.nostyle:visited{color:inherit;cursor:pointer;text-decoration:inherit}.swagger-ui .fallback{color:#aaa;padding:1em}.swagger-ui .version-pragma{height:100%;padding:5em 0}.swagger-ui .version-pragma__message{display:flex;font-size:1.2em;height:100%;justify-content:center;line-height:1.5em;padding:0 .6em;text-align:center}.swagger-ui .version-pragma__message>div{flex:1;max-width:55ch}.swagger-ui .version-pragma__message code{background-color:#dedede;padding:4px 4px 2px;white-space:pre}.swagger-ui .opblock-link{font-weight:400}.swagger-ui .opblock-link.shown{font-weight:700}.swagger-ui span.token-string{color:#555}.swagger-ui span.token-not-formatted{color:#555;font-weight:700}.swagger-ui .btn{background:transparent;border:2px solid grey;border-radius:4px;box-shadow:0 1px 2px rgba(0,0,0,.1);color:#3b4151;font-family:sans-serif;font-size:14px;font-weight:700;padding:5px 23px;transition:all .3s}.swagger-ui .btn.btn-sm{font-size:12px;padding:4px 23px}.swagger-ui .btn[disabled]{cursor:not-allowed;opacity:.3}.swagger-ui .btn:hover{box-shadow:0 0 5px rgba(0,0,0,.3)}.swagger-ui .btn.cancel{background-color:transparent;border-color:#ff6060;color:#ff6060;font-family:sans-serif}.swagger-ui .btn.authorize{background-color:transparent;border-color:#49cc90;color:#49cc90;display:inline;line-height:1}.swagger-ui .btn.authorize span{float:left;padding:4px 20px 0 0}.swagger-ui .btn.authorize svg{fill:#49cc90}.swagger-ui .btn.execute{background-color:#4990e2;border-color:#4990e2;color:#fff}.swagger-ui .btn-group{display:flex;padding:30px}.swagger-ui .btn-group .btn{flex:1}.swagger-ui .btn-group .btn:first-child{border-radius:4px 0 0 4px}.swagger-ui .btn-group .btn:last-child{border-radius:0 4px 4px 0}.swagger-ui .authorization__btn{background:none;border:none;padding:0 0 0 10px}.swagger-ui .authorization__btn .locked{opacity:1}.swagger-ui .authorization__btn .unlocked{opacity:.4}.swagger-ui .model-box-control,.swagger-ui .models-control,.swagger-ui .opblock-summary-control{all:inherit;border-bottom:0;cursor:pointer;flex:1;padding:0}.swagger-ui .model-box-control:focus,.swagger-ui .models-control:focus,.swagger-ui .opblock-summary-control:focus{outline:auto}.swagger-ui .expand-methods,.swagger-ui .expand-operation{background:none;border:none}.swagger-ui .expand-methods svg,.swagger-ui .expand-operation svg{height:20px;width:20px}.swagger-ui .expand-methods{padding:0 10px}.swagger-ui .expand-methods:hover svg{fill:#404040}.swagger-ui .expand-methods svg{transition:all .3s;fill:#707070}.swagger-ui button{cursor:pointer}.swagger-ui button.invalid{animation:shake .4s 1;background:#feebeb;border-color:#f93e3e}.swagger-ui .copy-to-clipboard{align-items:center;background:#7d8293;border:none;border-radius:4px;bottom:10px;display:flex;height:30px;justify-content:center;position:absolute;right:100px;width:30px}.swagger-ui .copy-to-clipboard button{background:url(\"data:image/svg+xml;charset=utf-8,<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" width=\\\"16\\\" height=\\\"15\\\" aria-hidden=\\\"true\\\"><path fill=\\\"%23fff\\\" fill-rule=\\\"evenodd\\\" d=\\\"M4 12h4v1H4zm5-6H4v1h5zm2 3V7l-3 3 3 3v-2h5V9zM6.5 8H4v1h2.5zM4 11h2.5v-1H4zm9 1h1v2c-.02.28-.11.52-.3.7s-.42.28-.7.3H3c-.55 0-1-.45-1-1V3c0-.55.45-1 1-1h3c0-1.11.89-2 2-2s2 .89 2 2h3c.55 0 1 .45 1 1v5h-1V5H3v9h10zM4 4h8c0-.55-.45-1-1-1h-1c-.55 0-1-.45-1-1s-.45-1-1-1-1 .45-1 1-.45 1-1 1H5c-.55 0-1 .45-1 1\\\"/></svg>\") 50% no-repeat;border:none;flex-grow:1;flex-shrink:1;height:25px}.swagger-ui .copy-to-clipboard:active{background:#5e626f}.swagger-ui .opblock-control-arrow{background:none;border:none;text-align:center}.swagger-ui .curl-command .copy-to-clipboard{bottom:5px;height:20px;right:10px;width:20px}.swagger-ui .curl-command .copy-to-clipboard button{height:18px}.swagger-ui .opblock .opblock-summary .view-line-link.copy-to-clipboard{height:26px;position:static}.swagger-ui select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#f7f7f7 url(\"data:image/svg+xml;charset=utf-8,<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" viewBox=\\\"0 0 20 20\\\"><path d=\\\"M13.418 7.859a.695.695 0 0 1 .978 0 .68.68 0 0 1 0 .969l-3.908 3.83a.697.697 0 0 1-.979 0l-3.908-3.83a.68.68 0 0 1 0-.969.695.695 0 0 1 .978 0L10 11z\\\"/></svg>\") right 10px center no-repeat;background-size:20px;border:2px solid #41444e;border-radius:4px;box-shadow:0 1px 2px 0 rgba(0,0,0,.25);color:#3b4151;font-family:sans-serif;font-size:14px;font-weight:700;padding:5px 40px 5px 10px}.swagger-ui select[multiple]{background:#f7f7f7;margin:5px 0;padding:5px}.swagger-ui select.invalid{animation:shake .4s 1;background:#feebeb;border-color:#f93e3e}.swagger-ui .opblock-body select{min-width:230px}@media(max-width:768px){.swagger-ui .opblock-body select{min-width:180px}}@media(max-width:640px){.swagger-ui .opblock-body select{min-width:100%;width:100%}}.swagger-ui label{color:#3b4151;font-family:sans-serif;font-size:12px;font-weight:700;margin:0 0 5px}.swagger-ui input[type=email],.swagger-ui input[type=file],.swagger-ui input[type=password],.swagger-ui input[type=search],.swagger-ui input[type=text]{line-height:1}@media(max-width:768px){.swagger-ui input[type=email],.swagger-ui input[type=file],.swagger-ui input[type=password],.swagger-ui input[type=search],.swagger-ui input[type=text]{max-width:175px}}.swagger-ui input[type=email],.swagger-ui input[type=file],.swagger-ui input[type=password],.swagger-ui input[type=search],.swagger-ui input[type=text],.swagger-ui textarea{background:#fff;border:1px solid #d9d9d9;border-radius:4px;margin:5px 0;min-width:100px;padding:8px 10px}.swagger-ui input[type=email].invalid,.swagger-ui input[type=file].invalid,.swagger-ui input[type=password].invalid,.swagger-ui input[type=search].invalid,.swagger-ui input[type=text].invalid,.swagger-ui textarea.invalid{animation:shake .4s 1;background:#feebeb;border-color:#f93e3e}.swagger-ui input[disabled],.swagger-ui select[disabled],.swagger-ui textarea[disabled]{background-color:#fafafa;color:#888;cursor:not-allowed}.swagger-ui select[disabled]{border-color:#888}.swagger-ui textarea[disabled]{background-color:#41444e;color:#fff}@keyframes shake{10%,90%{transform:translate3d(-1px,0,0)}20%,80%{transform:translate3d(2px,0,0)}30%,50%,70%{transform:translate3d(-4px,0,0)}40%,60%{transform:translate3d(4px,0,0)}}.swagger-ui textarea{background:hsla(0,0%,100%,.8);border:none;border-radius:4px;color:#3b4151;font-family:monospace;font-size:12px;font-weight:600;min-height:280px;outline:none;padding:10px;width:100%}.swagger-ui textarea:focus{border:2px solid #61affe}.swagger-ui textarea.curl{background:#41444e;border-radius:4px;color:#fff;font-family:monospace;font-size:12px;font-weight:600;margin:0;min-height:100px;padding:10px;resize:none}.swagger-ui .checkbox{color:#303030;padding:5px 0 10px;transition:opacity .5s}.swagger-ui .checkbox label{display:flex}.swagger-ui .checkbox p{color:#3b4151;font-family:monospace;font-style:italic;font-weight:400!important;font-weight:600;margin:0!important}.swagger-ui .checkbox input[type=checkbox]{display:none}.swagger-ui .checkbox input[type=checkbox]+label>.item{background:#e8e8e8;border-radius:1px;box-shadow:0 0 0 2px #e8e8e8;cursor:pointer;display:inline-block;flex:none;height:16px;margin:0 8px 0 0;padding:5px;position:relative;top:3px;width:16px}.swagger-ui .checkbox input[type=checkbox]+label>.item:active{transform:scale(.9)}.swagger-ui .checkbox input[type=checkbox]:checked+label>.item{background:#e8e8e8 url(\"data:image/svg+xml;charset=utf-8,<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" width=\\\"10\\\" height=\\\"8\\\" viewBox=\\\"3 7 10 8\\\"><path fill=\\\"%2341474E\\\" fill-rule=\\\"evenodd\\\" d=\\\"M6.333 15 3 11.667l1.333-1.334 2 2L11.667 7 13 8.333z\\\"/></svg>\") 50% no-repeat}.swagger-ui .dialog-ux{bottom:0;left:0;position:fixed;right:0;top:0;z-index:9999}.swagger-ui .dialog-ux .backdrop-ux{background:rgba(0,0,0,.8);bottom:0;left:0;position:fixed;right:0;top:0}.swagger-ui .dialog-ux .modal-ux{background:#fff;border:1px solid #ebebeb;border-radius:4px;box-shadow:0 10px 30px 0 rgba(0,0,0,.2);left:50%;max-width:650px;min-width:300px;position:absolute;top:50%;transform:translate(-50%,-50%);width:100%;z-index:9999}.swagger-ui .dialog-ux .modal-ux-content{max-height:540px;overflow-y:auto;padding:20px}.swagger-ui .dialog-ux .modal-ux-content p{color:#41444e;color:#3b4151;font-family:sans-serif;font-size:12px;margin:0 0 5px}.swagger-ui .dialog-ux .modal-ux-content h4{color:#3b4151;font-family:sans-serif;font-size:18px;font-weight:600;margin:15px 0 0}.swagger-ui .dialog-ux .modal-ux-header{align-items:center;border-bottom:1px solid #ebebeb;display:flex;padding:12px 0}.swagger-ui .dialog-ux .modal-ux-header .close-modal{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:none;border:none;padding:0 10px}.swagger-ui .dialog-ux .modal-ux-header h3{color:#3b4151;flex:1;font-family:sans-serif;font-size:20px;font-weight:600;margin:0;padding:0 20px}.swagger-ui .model{color:#3b4151;font-family:monospace;font-size:12px;font-weight:300;font-weight:600}.swagger-ui .model .deprecated span,.swagger-ui .model .deprecated td{color:#a0a0a0!important}.swagger-ui .model .deprecated>td:first-of-type{-webkit-text-decoration:line-through;text-decoration:line-through}.swagger-ui .model-toggle{cursor:pointer;display:inline-block;font-size:10px;margin:auto .3em;position:relative;top:6px;transform:rotate(90deg);transform-origin:50% 50%;transition:transform .15s ease-in}.swagger-ui .model-toggle.collapsed{transform:rotate(0deg)}.swagger-ui .model-toggle:after{background:url(\"data:image/svg+xml;charset=utf-8,<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" width=\\\"24\\\" height=\\\"24\\\" viewBox=\\\"0 0 24 24\\\"><path d=\\\"M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z\\\"/></svg>\") 50% no-repeat;background-size:100%;content:\"\";display:block;height:20px;width:20px}.swagger-ui .model-jump-to-path{cursor:pointer;position:relative}.swagger-ui .model-jump-to-path .view-line-link{cursor:pointer;position:absolute;top:-.4em}.swagger-ui .model-title{position:relative}.swagger-ui .model-title:hover .model-hint{display:block}.swagger-ui .model-hint{background:rgba(0,0,0,.7);border-radius:4px;color:#ebebeb;display:none;padding:.1em .5em;position:absolute;top:-1.8em;white-space:nowrap}.swagger-ui .model p{margin:0 0 1em}.swagger-ui .model .property{color:#999;font-style:italic}.swagger-ui .model .property.primitive{color:#6b6b6b}.swagger-ui .model .property.primitive.extension{display:block}.swagger-ui .model .property.primitive.extension>td:first-child{padding-left:0;padding-right:0;width:auto}.swagger-ui .model .property.primitive.extension>td:first-child:after{content:\": \"}.swagger-ui .model .external-docs,.swagger-ui table.model tr.description{color:#666;font-weight:400}.swagger-ui table.model tr.description td:first-child,.swagger-ui table.model tr.property-row.required td:first-child{font-weight:700}.swagger-ui table.model tr.property-row td{vertical-align:top}.swagger-ui table.model tr.property-row td:first-child{padding-right:.2em}.swagger-ui table.model tr.property-row .star{color:red}.swagger-ui table.model tr.extension{color:#777}.swagger-ui table.model tr.extension td:last-child{vertical-align:top}.swagger-ui table.model tr.external-docs td:first-child{font-weight:700}.swagger-ui table.model tr .renderedMarkdown p:first-child{margin-top:0}.swagger-ui section.models{border:1px solid rgba(59,65,81,.3);border-radius:4px;margin:30px 0}.swagger-ui section.models .pointer{cursor:pointer}.swagger-ui section.models.is-open{padding:0 0 20px}.swagger-ui section.models.is-open h4{border-bottom:1px solid rgba(59,65,81,.3);margin:0 0 5px}.swagger-ui section.models h4{align-items:center;color:#606060;cursor:pointer;display:flex;font-family:sans-serif;font-size:16px;margin:0;padding:10px 20px 10px 10px;transition:all .2s}.swagger-ui section.models h4 svg{transition:all .4s}.swagger-ui section.models h4 span{flex:1}.swagger-ui section.models h4:hover{background:rgba(0,0,0,.02)}.swagger-ui section.models h5{color:#707070;font-family:sans-serif;font-size:16px;margin:0 0 10px}.swagger-ui section.models .model-jump-to-path{position:relative;top:5px}.swagger-ui section.models .model-container{background:rgba(0,0,0,.05);border-radius:4px;margin:0 20px 15px;position:relative;transition:all .5s}.swagger-ui section.models .model-container:hover{background:rgba(0,0,0,.07)}.swagger-ui section.models .model-container:first-of-type{margin:20px}.swagger-ui section.models .model-container:last-of-type{margin:0 20px}.swagger-ui section.models .model-container .models-jump-to-path{opacity:.65;position:absolute;right:5px;top:8px}.swagger-ui section.models .model-box{background:none}.swagger-ui section.models .model-box:has(.model-box){overflow-x:auto;width:100%}.swagger-ui .model-box{background:rgba(0,0,0,.1);border-radius:4px;display:inline-block;padding:10px}.swagger-ui .model-box .model-jump-to-path{position:relative;top:4px}.swagger-ui .model-box.deprecated{opacity:.5}.swagger-ui .model-title{color:#505050;font-family:sans-serif;font-size:16px}.swagger-ui .model-title img{bottom:0;margin-left:1em;position:relative}.swagger-ui .model-deprecated-warning{color:#f93e3e;font-family:sans-serif;font-size:16px;font-weight:600;margin-right:1em}.swagger-ui span>span.model .brace-close{padding:0 0 0 10px}.swagger-ui .prop-name{display:inline-block;margin-right:1em}.swagger-ui .prop-type{color:#55a}.swagger-ui .prop-enum{display:block}.swagger-ui .prop-format{color:#606060}.swagger-ui .servers>label{color:#3b4151;font-family:sans-serif;font-size:12px;margin:-20px 15px 0 0}.swagger-ui .servers>label select{max-width:100%;min-width:130px;width:100%}.swagger-ui .servers h4.message{padding-bottom:2em}.swagger-ui .servers table tr{width:30em}.swagger-ui .servers table td{display:inline-block;max-width:15em;padding-bottom:10px;padding-top:10px;vertical-align:middle}.swagger-ui .servers table td:first-of-type{padding-right:1em}.swagger-ui .servers table td input{height:100%;width:100%}.swagger-ui .servers .computed-url{margin:2em 0}.swagger-ui .servers .computed-url code{display:inline-block;font-size:16px;margin:0 1em;padding:4px}.swagger-ui .servers-title{font-size:12px;font-weight:700}.swagger-ui .operation-servers h4.message{margin-bottom:2em}.swagger-ui table{border-collapse:collapse;padding:0 10px;width:100%}.swagger-ui table.model tbody tr td{padding:0 0 0 1em;vertical-align:top}.swagger-ui table.model tbody tr td:first-of-type{padding:0 0 0 2em;width:174px}.swagger-ui table.headers td{color:#3b4151;font-family:monospace;font-size:12px;font-weight:300;font-weight:600;vertical-align:middle}.swagger-ui table.headers .header-example{color:#999;font-style:italic}.swagger-ui table tbody tr td{padding:10px 0 0;vertical-align:top}.swagger-ui table tbody tr td:first-of-type{min-width:6em;padding:10px 0}.swagger-ui table tbody tr td:has(.model-box){max-width:1px}.swagger-ui table thead tr td,.swagger-ui table thead tr th{border-bottom:1px solid rgba(59,65,81,.2);color:#3b4151;font-family:sans-serif;font-size:12px;font-weight:700;padding:12px 0;text-align:left}.swagger-ui .parameters-col_description{margin-bottom:2em;width:99%}.swagger-ui .parameters-col_description input{max-width:340px;width:100%}.swagger-ui .parameters-col_description select{border-width:1px}.swagger-ui .parameters-col_description .markdown:first-child p:first-child,.swagger-ui .parameters-col_description .renderedMarkdown:first-child p:first-child{margin:0}.swagger-ui .parameter__name{color:#3b4151;font-family:sans-serif;font-size:16px;font-weight:400;margin-right:.75em}.swagger-ui .parameter__name.required{font-weight:700}.swagger-ui .parameter__name.required span{color:red}.swagger-ui .parameter__name.required:after{color:rgba(255,0,0,.6);content:\"required\";font-size:10px;padding:5px;position:relative;top:-6px}.swagger-ui .parameter__extension,.swagger-ui .parameter__in{color:grey;font-family:monospace;font-size:12px;font-style:italic;font-weight:600}.swagger-ui .parameter__deprecated{color:red;font-family:monospace;font-size:12px;font-style:italic;font-weight:600}.swagger-ui .parameter__empty_value_toggle{display:block;font-size:13px;padding-bottom:12px;padding-top:5px}.swagger-ui .parameter__empty_value_toggle input{margin-right:7px;width:auto}.swagger-ui .parameter__empty_value_toggle.disabled{opacity:.7}.swagger-ui .table-container{padding:20px}.swagger-ui .response-col_description{width:99%}.swagger-ui .response-col_description .markdown p:first-child,.swagger-ui .response-col_description .renderedMarkdown p:first-child{margin:0}.swagger-ui .response-col_description .markdown p:last-child,.swagger-ui .response-col_description .renderedMarkdown p:last-child{margin-bottom:0}.swagger-ui .response-col_links{min-width:6em}.swagger-ui .response__extension{color:grey;font-family:monospace;font-size:12px;font-style:italic;font-weight:600}.swagger-ui .topbar{background-color:#1b1b1b;padding:10px 0}.swagger-ui .topbar .topbar-wrapper{align-items:center;display:flex;flex-wrap:wrap;gap:10px}@media(max-width:550px){.swagger-ui .topbar .topbar-wrapper{align-items:start;flex-direction:column}}.swagger-ui .topbar a{align-items:center;color:#fff;display:flex;flex:1;font-family:sans-serif;font-size:1.5em;font-weight:700;max-width:300px;-webkit-text-decoration:none;text-decoration:none}.swagger-ui .topbar a span{margin:0;padding:0 10px}.swagger-ui .topbar .download-url-wrapper{display:flex;flex:3;justify-content:flex-end}.swagger-ui .topbar .download-url-wrapper input[type=text]{border:2px solid #62a03f;border-radius:4px 0 0 4px;margin:0;max-width:100%;outline:none;width:100%}.swagger-ui .topbar .download-url-wrapper .select-label{align-items:center;color:#f0f0f0;display:flex;margin:0;max-width:600px;width:100%}.swagger-ui .topbar .download-url-wrapper .select-label span{flex:1;font-size:16px;padding:0 10px 0 0;text-align:right}.swagger-ui .topbar .download-url-wrapper .select-label select{border:2px solid #62a03f;box-shadow:none;flex:2;outline:none;width:100%}.swagger-ui .topbar .download-url-wrapper .download-url-button{background:#62a03f;border:none;border-radius:0 4px 4px 0;color:#fff;font-family:sans-serif;font-size:16px;font-weight:700;padding:4px 30px}@media(max-width:550px){.swagger-ui .topbar .download-url-wrapper{width:100%}}.swagger-ui .info{margin:50px 0}.swagger-ui .info.failed-config{margin-left:auto;margin-right:auto;max-width:880px;text-align:center}.swagger-ui .info hgroup.main{margin:0 0 20px}.swagger-ui .info hgroup.main a{font-size:12px}.swagger-ui .info li,.swagger-ui .info p,.swagger-ui .info pre,.swagger-ui .info table{font-size:14px}.swagger-ui .info h1,.swagger-ui .info h2,.swagger-ui .info h3,.swagger-ui .info h4,.swagger-ui .info h5,.swagger-ui .info li,.swagger-ui .info p,.swagger-ui .info table{color:#3b4151;font-family:sans-serif}.swagger-ui .info a{color:#4990e2;font-family:sans-serif;font-size:14px;transition:all .4s}.swagger-ui .info a:hover{color:#1f69c0}.swagger-ui .info>div{margin:0 0 5px}.swagger-ui .info .base-url{color:#3b4151;font-family:monospace;font-size:12px;font-weight:300!important;font-weight:600;margin:0}.swagger-ui .info .title{color:#3b4151;font-family:sans-serif;font-size:36px;margin:0}.swagger-ui .info .title small{background:#7d8492;border-radius:57px;display:inline-block;font-size:10px;margin:0 0 0 5px;padding:2px 4px;position:relative;top:-5px;vertical-align:super}.swagger-ui .info .title small.version-stamp{background-color:#89bf04}.swagger-ui .info .title small pre{color:#fff;font-family:sans-serif;margin:0;padding:0}.swagger-ui .auth-btn-wrapper{display:flex;justify-content:center;padding:10px 0}.swagger-ui .auth-btn-wrapper .btn-done{margin-right:1em}.swagger-ui .auth-wrapper{display:flex;flex:1;justify-content:flex-end}.swagger-ui .auth-wrapper .authorize{margin-left:10px;margin-right:10px;padding-right:20px}.swagger-ui .auth-container{border-bottom:1px solid #ebebeb;margin:0 0 10px;padding:10px 20px}.swagger-ui .auth-container:last-of-type{border:0;margin:0;padding:10px 20px}.swagger-ui .auth-container h4{margin:5px 0 15px!important}.swagger-ui .auth-container .wrapper{margin:0;padding:0}.swagger-ui .auth-container input[type=password],.swagger-ui .auth-container input[type=text]{min-width:230px}.swagger-ui .auth-container .errors{background-color:#fee;border-radius:4px;color:red;color:#3b4151;font-family:monospace;font-size:12px;font-weight:600;margin:1em;padding:10px}.swagger-ui .auth-container .errors b{margin-right:1em;text-transform:capitalize}.swagger-ui .scopes h2{color:#3b4151;font-family:sans-serif;font-size:14px}.swagger-ui .scopes h2 a{color:#4990e2;cursor:pointer;font-size:12px;padding-left:10px;-webkit-text-decoration:underline;text-decoration:underline}.swagger-ui .scope-def{padding:0 0 20px}.swagger-ui .errors-wrapper{animation:scaleUp .5s;background:rgba(249,62,62,.1);border:2px solid #f93e3e;border-radius:4px;margin:20px;padding:10px 20px}.swagger-ui .errors-wrapper .error-wrapper{margin:0 0 10px}.swagger-ui .errors-wrapper .errors h4{color:#3b4151;font-family:monospace;font-size:14px;font-weight:600;margin:0}.swagger-ui .errors-wrapper .errors small{color:#606060}.swagger-ui .errors-wrapper .errors .message{white-space:pre-line}.swagger-ui .errors-wrapper .errors .message.thrown{max-width:100%}.swagger-ui .errors-wrapper .errors .error-line{cursor:pointer;-webkit-text-decoration:underline;text-decoration:underline}.swagger-ui .errors-wrapper hgroup{align-items:center;display:flex}.swagger-ui .errors-wrapper hgroup h4{color:#3b4151;flex:1;font-family:sans-serif;font-size:20px;margin:0}@keyframes scaleUp{0%{opacity:0;transform:scale(.8)}to{opacity:1;transform:scale(1)}}.swagger-ui .Resizer.vertical.disabled{display:none}.swagger-ui .markdown p,.swagger-ui .markdown pre,.swagger-ui .renderedMarkdown p,.swagger-ui .renderedMarkdown pre{margin:1em auto;word-break:break-all;word-break:break-word}.swagger-ui .markdown pre,.swagger-ui .renderedMarkdown pre{background:none;color:#000;font-weight:400;padding:0;white-space:pre-wrap}.swagger-ui .markdown code,.swagger-ui .renderedMarkdown code{background:rgba(0,0,0,.05);border-radius:4px;color:#9012fe;font-family:monospace;font-size:14px;font-weight:600;padding:5px 7px}.swagger-ui .markdown pre>code,.swagger-ui .renderedMarkdown pre>code{display:block}.swagger-ui .json-schema-2020-12-keyword--\\$vocabulary ul{border-left:1px dashed rgba(0,0,0,.1);margin:0 0 0 20px}.swagger-ui .json-schema-2020-12-\\$vocabulary-uri{margin-left:35px}.swagger-ui .json-schema-2020-12-\\$vocabulary-uri--disabled{-webkit-text-decoration:line-through;text-decoration:line-through}.swagger-ui .json-schema-2020-12-keyword--const .json-schema-2020-12-json-viewer__name,.swagger-ui .json-schema-2020-12-keyword--const .json-schema-2020-12-json-viewer__value{color:#3b4151;font-style:normal}.swagger-ui .json-schema-2020-12__constraint{background-color:#805ad5;border-radius:4px;color:#3b4151;color:#fff;font-family:monospace;font-weight:600;line-height:1.5;margin-left:10px;padding:1px 3px}.swagger-ui .json-schema-2020-12__constraint--string{background-color:#d69e2e;color:#fff}.swagger-ui .json-schema-2020-12-keyword--default .json-schema-2020-12-json-viewer__name,.swagger-ui .json-schema-2020-12-keyword--default .json-schema-2020-12-json-viewer__value{color:#3b4151;font-style:normal}.swagger-ui .json-schema-2020-12-keyword--dependentRequired>ul{display:inline-block;margin:0;padding:0}.swagger-ui .json-schema-2020-12-keyword--dependentRequired>ul li{display:inline;list-style-type:none}.swagger-ui .json-schema-2020-12-keyword--description{color:#6b6b6b;font-size:12px;margin-left:20px}.swagger-ui .json-schema-2020-12-keyword--description p{margin:0}.swagger-ui .json-schema-2020-12-keyword--enum .json-schema-2020-12-json-viewer__name,.swagger-ui .json-schema-2020-12-keyword--enum .json-schema-2020-12-json-viewer__value,.swagger-ui .json-schema-2020-12-keyword--examples .json-schema-2020-12-json-viewer__name,.swagger-ui .json-schema-2020-12-keyword--examples .json-schema-2020-12-json-viewer__value{color:#3b4151;font-style:normal}.swagger-ui .json-schema-2020-12-json-viewer-extension-keyword .json-schema-2020-12-json-viewer__name,.swagger-ui .json-schema-2020-12-json-viewer-extension-keyword .json-schema-2020-12-json-viewer__value{color:#929292;font-style:italic}.swagger-ui .json-schema-2020-12-keyword--patternProperties ul{border:none;margin:0;padding:0}.swagger-ui .json-schema-2020-12-keyword--patternProperties .json-schema-2020-12__title:first-of-type:after,.swagger-ui .json-schema-2020-12-keyword--patternProperties .json-schema-2020-12__title:first-of-type:before{color:#55a;content:\"/\"}.swagger-ui .json-schema-2020-12-keyword--properties>ul{border:none;margin:0;padding:0}.swagger-ui .json-schema-2020-12-property{list-style-type:none}.swagger-ui .json-schema-2020-12-property--required>.json-schema-2020-12:first-of-type>.json-schema-2020-12-head .json-schema-2020-12__title:after{color:red;content:\"*\";font-weight:700}.swagger-ui .json-schema-2020-12__title{color:#505050;display:inline-block;font-family:sans-serif;font-size:12px;font-weight:700;line-height:normal}.swagger-ui .json-schema-2020-12__title .json-schema-2020-12-keyword__name{margin:0}.swagger-ui .json-schema-2020-12-property{margin:7px 0}.swagger-ui .json-schema-2020-12-property .json-schema-2020-12__title{color:#3b4151;font-family:monospace;font-size:12px;font-weight:600;vertical-align:middle}.swagger-ui .json-schema-2020-12-keyword{margin:5px 0}.swagger-ui .json-schema-2020-12-keyword__children{border-left:1px dashed rgba(0,0,0,.1);margin:0 0 0 20px;padding:0}.swagger-ui .json-schema-2020-12-keyword__children--collapsed{display:none}.swagger-ui .json-schema-2020-12-keyword__name{font-size:12px;font-weight:700;margin-left:20px}.swagger-ui .json-schema-2020-12-keyword__name--primary{color:#3b4151;font-style:normal}.swagger-ui .json-schema-2020-12-keyword__name--secondary{color:#6b6b6b;font-style:italic}.swagger-ui .json-schema-2020-12-keyword__name--extension{color:#929292;font-style:italic}.swagger-ui .json-schema-2020-12-keyword__value{color:#6b6b6b;font-size:12px;font-style:italic;font-weight:400}.swagger-ui .json-schema-2020-12-keyword__value--primary{color:#3b4151;font-style:normal}.swagger-ui .json-schema-2020-12-keyword__value--secondary{color:#6b6b6b;font-style:italic}.swagger-ui .json-schema-2020-12-keyword__value--extension{color:#929292;font-style:italic}.swagger-ui .json-schema-2020-12-keyword__value--warning{border:1px dashed red;border-radius:4px;color:#3b4151;color:red;display:inline-block;font-family:monospace;font-style:normal;font-weight:600;line-height:1.5;margin-left:10px;padding:1px 4px}.swagger-ui .json-schema-2020-12-keyword__name--secondary+.json-schema-2020-12-keyword__value--secondary:before{content:\"=\"}.swagger-ui .json-schema-2020-12__attribute{color:#3b4151;font-family:monospace;font-size:12px;padding-left:10px;text-transform:lowercase}.swagger-ui .json-schema-2020-12__attribute--primary{color:#55a}.swagger-ui .json-schema-2020-12__attribute--muted{color:gray}.swagger-ui .json-schema-2020-12__attribute--warning{color:red}.swagger-ui .json-schema-2020-12-json-viewer{margin:5px 0}.swagger-ui .json-schema-2020-12-json-viewer__children{border-left:1px dashed rgba(0,0,0,.1);margin:0 0 0 20px;padding:0}.swagger-ui .json-schema-2020-12-json-viewer__children--collapsed{display:none}.swagger-ui .json-schema-2020-12-json-viewer__name{font-size:12px;font-weight:700;margin-left:20px}.swagger-ui .json-schema-2020-12-json-viewer__name--primary{color:#3b4151;font-style:normal}.swagger-ui .json-schema-2020-12-json-viewer__name--secondary{color:#6b6b6b;font-style:italic}.swagger-ui .json-schema-2020-12-json-viewer__name--extension{color:#929292;font-style:italic}.swagger-ui .json-schema-2020-12-json-viewer__value{color:#6b6b6b;font-size:12px;font-style:italic;font-weight:400}.swagger-ui .json-schema-2020-12-json-viewer__value--primary{color:#3b4151;font-style:normal}.swagger-ui .json-schema-2020-12-json-viewer__value--secondary{color:#6b6b6b;font-style:italic}.swagger-ui .json-schema-2020-12-json-viewer__value--extension{color:#929292;font-style:italic}.swagger-ui .json-schema-2020-12-json-viewer__value--warning{border:1px dashed red;border-radius:4px;color:#3b4151;color:red;display:inline-block;font-family:monospace;font-style:normal;font-weight:600;line-height:1.5;margin-left:10px;padding:1px 4px}.swagger-ui .json-schema-2020-12-json-viewer__name--secondary+.json-schema-2020-12-json-viewer__value--secondary:before{content:\"=\"}.swagger-ui .json-schema-2020-12{background-color:rgba(0,0,0,.05);border-radius:4px;margin:0 20px 15px;padding:12px 0 12px 20px}.swagger-ui .json-schema-2020-12:first-of-type{margin:20px}.swagger-ui .json-schema-2020-12:last-of-type{margin:0 20px}.swagger-ui .json-schema-2020-12--embedded{background-color:inherit;padding-bottom:0;padding-left:inherit;padding-right:inherit;padding-top:0}.swagger-ui .json-schema-2020-12-body{border-left:1px dashed rgba(0,0,0,.1);margin:2px 0}.swagger-ui .json-schema-2020-12-body--collapsed{display:none}.swagger-ui .json-schema-2020-12-accordion{border:none;outline:none;padding-left:0}.swagger-ui .json-schema-2020-12-accordion__children{display:inline-block}.swagger-ui .json-schema-2020-12-accordion__icon{display:inline-block;height:18px;vertical-align:bottom;width:18px}.swagger-ui .json-schema-2020-12-accordion__icon--expanded{transform:rotate(-90deg);transform-origin:50% 50%;transition:transform .15s ease-in}.swagger-ui .json-schema-2020-12-accordion__icon--collapsed{transform:rotate(0deg);transform-origin:50% 50%;transition:transform .15s ease-in}.swagger-ui .json-schema-2020-12-accordion__icon svg{height:20px;width:20px}.swagger-ui .json-schema-2020-12-expand-deep-button{border:none;color:#505050;color:#afaeae;font-family:sans-serif;font-size:12px;padding-right:0}.swagger-ui .model-box .json-schema-2020-12:not(.json-schema-2020-12--embedded)>.json-schema-2020-12-head .json-schema-2020-12__title:first-of-type{font-size:16px}.swagger-ui .model-box>.json-schema-2020-12{margin:0}.swagger-ui .model-box .json-schema-2020-12{background-color:transparent;padding:0}.swagger-ui .model-box .json-schema-2020-12-accordion,.swagger-ui .model-box .json-schema-2020-12-expand-deep-button{background-color:transparent}.swagger-ui .models .json-schema-2020-12:not(.json-schema-2020-12--embedded)>.json-schema-2020-12-head .json-schema-2020-12__title:first-of-type{font-size:16px}.swagger-ui .models .json-schema-2020-12:not(.json-schema-2020-12--embedded){overflow-x:auto;width:calc(100% - 40px)}\n\n/*# sourceMappingURL=swagger-ui.css.map*/\n"
  },
  {
    "path": "lightrag/api/utils_api.py",
    "content": "\"\"\"\nUtility functions for the LightRAG API.\n\"\"\"\n\nimport os\nimport argparse\nfrom typing import Optional, List, Tuple\nimport sys\nimport time\nimport logging\nfrom ascii_colors import ASCIIColors\nfrom lightrag.api import __api_version__ as api_version\nfrom lightrag import __version__ as core_version\nfrom lightrag.constants import (\n    DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,\n)\nfrom lightrag.api.runtime_validation import validate_runtime_target_from_env_file\nfrom fastapi import HTTPException, Security, Request, Response, status\nfrom fastapi.security import APIKeyHeader, OAuth2PasswordBearer\nfrom starlette.status import HTTP_403_FORBIDDEN\nfrom .auth import auth_handler\nfrom .config import ollama_server_infos, global_args, get_env_value\n\nlogger = logging.getLogger(\"lightrag\")\n\n# ========== Token Renewal Rate Limiting ==========\n# Cache to track last renewal time per user (username as key)\n# Format: {username: last_renewal_timestamp}\n_token_renewal_cache: dict[str, float] = {}\n_RENEWAL_MIN_INTERVAL = 60  # Minimum 60 seconds between renewals for same user\n\n# ========== Token Renewal Path Exclusions ==========\n# Paths that should NOT trigger token auto-renewal\n# - /health: Health check endpoint, no login required\n# - /documents/paginated: Client polls this frequently (5-30s), renewal not needed\n# - /documents/pipeline_status: Client polls this very frequently (2s), renewal not needed\n_TOKEN_RENEWAL_SKIP_PATHS = [\n    \"/health\",\n    \"/documents/paginated\",\n    \"/documents/pipeline_status\",\n]\n\n\ndef check_env_file():\n    \"\"\"\n    Check if .env file exists and handle user confirmation if needed.\n    Returns True if should continue, False if should exit.\n    \"\"\"\n    env_path = \".env\"\n\n    if not os.path.exists(env_path):\n        warning_msg = \"Warning: Startup directory must contain .env file for multi-instance support.\"\n        ASCIIColors.yellow(warning_msg)\n\n        # Check if running in interactive terminal\n        if sys.stdin.isatty():\n            response = input(\"Do you want to continue? (yes/no): \")\n            if response.lower() != \"yes\":\n                ASCIIColors.red(\"Server startup cancelled\")\n                return False\n        return True\n\n    is_valid, error_message = validate_runtime_target_from_env_file(env_path)\n    if not is_valid:\n        for line in error_message.splitlines():\n            ASCIIColors.red(line)\n        return False\n\n    return True\n\n\n# Get whitelist paths from global_args, only once during initialization\nwhitelist_paths = global_args.whitelist_paths.split(\",\")\n\n# Pre-compile path matching patterns\nwhitelist_patterns: List[Tuple[str, bool]] = []\nfor path in whitelist_paths:\n    path = path.strip()\n    if path:\n        # If path ends with /*, match all paths with that prefix\n        if path.endswith(\"/*\"):\n            prefix = path[:-2]\n            whitelist_patterns.append((prefix, True))  # (prefix, is_prefix_match)\n        else:\n            whitelist_patterns.append((path, False))  # (exact_path, is_prefix_match)\n\n# Global authentication configuration\nauth_configured = bool(auth_handler.accounts)\n\n\ndef get_combined_auth_dependency(api_key: Optional[str] = None):\n    \"\"\"\n    Create a combined authentication dependency that implements authentication logic\n    based on API key, OAuth2 token, and whitelist paths.\n\n    Args:\n        api_key (Optional[str]): API key for validation\n\n    Returns:\n        Callable: A dependency function that implements the authentication logic\n    \"\"\"\n    # Use global whitelist_patterns and auth_configured variables\n    # whitelist_patterns and auth_configured are already initialized at module level\n\n    # Only calculate api_key_configured as it depends on the function parameter\n    api_key_configured = bool(api_key)\n\n    # Create security dependencies with proper descriptions for Swagger UI\n    oauth2_scheme = OAuth2PasswordBearer(\n        tokenUrl=\"login\", auto_error=False, description=\"OAuth2 Password Authentication\"\n    )\n\n    # If API key is configured, create an API key header security\n    api_key_header = None\n    if api_key_configured:\n        api_key_header = APIKeyHeader(\n            name=\"X-API-Key\", auto_error=False, description=\"API Key Authentication\"\n        )\n\n    async def combined_dependency(\n        request: Request,\n        response: Response,  # Added: needed to return new token via response header\n        token: str = Security(oauth2_scheme),\n        api_key_header_value: Optional[str] = None\n        if api_key_header is None\n        else Security(api_key_header),\n    ):\n        # 1. Check if path is in whitelist\n        path = request.url.path\n        for pattern, is_prefix in whitelist_patterns:\n            if (is_prefix and path.startswith(pattern)) or (\n                not is_prefix and path == pattern\n            ):\n                return  # Whitelist path, allow access\n\n        # 2. Validate token first if provided in the request (Ensure 401 error if token is invalid)\n        if token:\n            try:\n                token_info = auth_handler.validate_token(token)\n\n                # ========== Token Auto-Renewal Logic ==========\n                from lightrag.api.config import global_args\n                from datetime import datetime\n\n                if global_args.token_auto_renew:\n                    # Check if current path should skip token renewal\n                    skip_renewal = any(\n                        path == skip_path or path.startswith(skip_path + \"/\")\n                        for skip_path in _TOKEN_RENEWAL_SKIP_PATHS\n                    )\n\n                    if skip_renewal:\n                        logger.debug(f\"Token auto-renewal skipped for path: {path}\")\n                    else:\n                        try:\n                            expire_time = token_info.get(\"exp\")\n                            if expire_time:\n                                # Calculate remaining time ratio\n                                now = datetime.utcnow()\n                                remaining_seconds = (expire_time - now).total_seconds()\n\n                                # Get original token expiration duration\n                                role = token_info.get(\"role\", \"user\")\n                                total_hours = (\n                                    auth_handler.guest_expire_hours\n                                    if role == \"guest\"\n                                    else auth_handler.expire_hours\n                                )\n                                total_seconds = total_hours * 3600\n\n                                # Issue new token if remaining time < threshold\n                                if (\n                                    remaining_seconds\n                                    < total_seconds * global_args.token_renew_threshold\n                                ):\n                                    # ========== Rate Limiting Check ==========\n                                    username = token_info[\"username\"]\n                                    current_time = time.time()\n                                    last_renewal = _token_renewal_cache.get(username, 0)\n                                    time_since_last_renewal = (\n                                        current_time - last_renewal\n                                    )\n\n                                    # Only renew if enough time has passed since last renewal\n                                    if time_since_last_renewal >= _RENEWAL_MIN_INTERVAL:\n                                        new_token = auth_handler.create_token(\n                                            username=username,\n                                            role=role,\n                                            metadata=token_info.get(\"metadata\", {}),\n                                        )\n                                        # Return new token via response header\n                                        response.headers[\"X-New-Token\"] = new_token\n\n                                        # Update renewal cache\n                                        _token_renewal_cache[username] = current_time\n\n                                        # Optional: log renewal\n                                        logger.info(\n                                            f\"Token auto-renewed for user {username} \"\n                                            f\"(role: {role}, remaining: {remaining_seconds:.0f}s)\"\n                                        )\n                                    else:\n                                        # Log skip due to rate limit\n                                        logger.debug(\n                                            f\"Token renewal skipped for {username} \"\n                                            f\"(rate limit: last renewal {time_since_last_renewal:.0f}s ago)\"\n                                        )\n                                    # ========== End of Rate Limiting Check ==========\n                        except Exception as e:\n                            # Renewal failure should not affect normal request, just log\n                            logger.warning(f\"Token auto-renew failed: {e}\")\n                # ========== End of Token Auto-Renewal Logic ==========\n\n                # Accept guest token if no auth is configured\n                if not auth_configured and token_info.get(\"role\") == \"guest\":\n                    return\n                # Accept non-guest token if auth is configured\n                if auth_configured and token_info.get(\"role\") != \"guest\":\n                    return\n\n                # Token validation failed, immediately return 401 error\n                raise HTTPException(\n                    status_code=status.HTTP_401_UNAUTHORIZED,\n                    detail=\"Invalid token. Please login again.\",\n                )\n            except HTTPException as e:\n                # If already a 401 error, re-raise it\n                if e.status_code == status.HTTP_401_UNAUTHORIZED:\n                    raise\n                # For other exceptions, continue processing\n\n        # 3. Acept all request if no API protection needed\n        if not auth_configured and not api_key_configured:\n            return\n\n        # 4. Validate API key if provided and API-Key authentication is configured\n        if (\n            api_key_configured\n            and api_key_header_value\n            and api_key_header_value == api_key\n        ):\n            return  # API key validation successful\n\n        ### Authentication failed ####\n\n        # if password authentication is configured but not provided, ensure 401 error if auth_configured\n        if auth_configured and not token:\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                detail=\"No credentials provided. Please login.\",\n            )\n\n        # if api key is provided but validation failed\n        if api_key_header_value:\n            raise HTTPException(\n                status_code=HTTP_403_FORBIDDEN,\n                detail=\"Invalid API Key\",\n            )\n\n        # if api_key_configured but not provided\n        if api_key_configured and not api_key_header_value:\n            raise HTTPException(\n                status_code=HTTP_403_FORBIDDEN,\n                detail=\"API Key required\",\n            )\n\n        # Otherwise: refuse access and return 403 error\n        raise HTTPException(\n            status_code=HTTP_403_FORBIDDEN,\n            detail=\"API Key required or login authentication required.\",\n        )\n\n    return combined_dependency\n\n\ndef display_splash_screen(args: argparse.Namespace) -> None:\n    \"\"\"\n    Display a colorful splash screen showing LightRAG server configuration\n\n    Args:\n        args: Parsed command line arguments\n    \"\"\"\n    # Banner\n    # Banner\n    top_border = \"╔══════════════════════════════════════════════════════════════╗\"\n    bottom_border = \"╚══════════════════════════════════════════════════════════════╝\"\n    width = len(top_border) - 4  # width inside the borders\n\n    line1_text = f\"LightRAG Server v{core_version}/{api_version}\"\n    line2_text = \"Fast, Lightweight RAG Server Implementation\"\n\n    line1 = f\"║ {line1_text.center(width)} ║\"\n    line2 = f\"║ {line2_text.center(width)} ║\"\n\n    banner = f\"\"\"\n    {top_border}\n    {line1}\n    {line2}\n    {bottom_border}\n    \"\"\"\n    ASCIIColors.cyan(banner)\n\n    # Server Configuration\n    ASCIIColors.magenta(\"\\n📡 Server Configuration:\")\n    ASCIIColors.white(\"    ├─ Host: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.host}\")\n    ASCIIColors.white(\"    ├─ Port: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.port}\")\n    ASCIIColors.white(\"    ├─ Workers: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.workers}\")\n    ASCIIColors.white(\"    ├─ Timeout: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.timeout}\")\n    ASCIIColors.white(\"    ├─ CORS Origins: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.cors_origins}\")\n    ASCIIColors.white(\"    ├─ SSL Enabled: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.ssl}\")\n    if args.ssl:\n        ASCIIColors.white(\"    ├─ SSL Cert: \", end=\"\")\n        ASCIIColors.yellow(f\"{args.ssl_certfile}\")\n        ASCIIColors.white(\"    ├─ SSL Key: \", end=\"\")\n        ASCIIColors.yellow(f\"{args.ssl_keyfile}\")\n    ASCIIColors.white(\"    ├─ Ollama Emulating Model: \", end=\"\")\n    ASCIIColors.yellow(f\"{ollama_server_infos.LIGHTRAG_MODEL}\")\n    ASCIIColors.white(\"    ├─ Log Level: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.log_level}\")\n    ASCIIColors.white(\"    ├─ Verbose Debug: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.verbose}\")\n    ASCIIColors.white(\"    ├─ API Key: \", end=\"\")\n    ASCIIColors.yellow(\"Set\" if args.key else \"Not Set\")\n    ASCIIColors.white(\"    └─ JWT Auth: \", end=\"\")\n    ASCIIColors.yellow(\"Enabled\" if args.auth_accounts else \"Disabled\")\n\n    # Directory Configuration\n    ASCIIColors.magenta(\"\\n📂 Directory Configuration:\")\n    ASCIIColors.white(\"    ├─ Working Directory: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.working_dir}\")\n    ASCIIColors.white(\"    └─ Input Directory: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.input_dir}\")\n\n    # LLM Configuration\n    ASCIIColors.magenta(\"\\n🤖 LLM Configuration:\")\n    ASCIIColors.white(\"    ├─ Binding: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.llm_binding}\")\n    ASCIIColors.white(\"    ├─ Host: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.llm_binding_host}\")\n    ASCIIColors.white(\"    ├─ Model: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.llm_model}\")\n    ASCIIColors.white(\"    ├─ Max Async for LLM: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.max_async}\")\n    ASCIIColors.white(\"    ├─ Summary Context Size: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.summary_context_size}\")\n    ASCIIColors.white(\"    ├─ LLM Cache Enabled: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.enable_llm_cache}\")\n    ASCIIColors.white(\"    └─ LLM Cache for Extraction Enabled: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.enable_llm_cache_for_extract}\")\n\n    # Embedding Configuration\n    ASCIIColors.magenta(\"\\n📊 Embedding Configuration:\")\n    ASCIIColors.white(\"    ├─ Binding: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.embedding_binding}\")\n    ASCIIColors.white(\"    ├─ Host: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.embedding_binding_host}\")\n    ASCIIColors.white(\"    ├─ Model: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.embedding_model}\")\n    ASCIIColors.white(\"    └─ Dimensions: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.embedding_dim}\")\n\n    # RAG Configuration\n    ASCIIColors.magenta(\"\\n⚙️ RAG Configuration:\")\n    ASCIIColors.white(\"    ├─ Summary Language: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.summary_language}\")\n    ASCIIColors.white(\"    ├─ Entity Types: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.entity_types}\")\n    ASCIIColors.white(\"    ├─ Max Parallel Insert: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.max_parallel_insert}\")\n    ASCIIColors.white(\"    ├─ Chunk Size: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.chunk_size}\")\n    ASCIIColors.white(\"    ├─ Chunk Overlap Size: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.chunk_overlap_size}\")\n    ASCIIColors.white(\"    ├─ Cosine Threshold: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.cosine_threshold}\")\n    ASCIIColors.white(\"    ├─ Top-K: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.top_k}\")\n    ASCIIColors.white(\"    └─ Force LLM Summary on Merge: \", end=\"\")\n    ASCIIColors.yellow(\n        f\"{get_env_value('FORCE_LLM_SUMMARY_ON_MERGE', DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE, int)}\"\n    )\n\n    # System Configuration\n    ASCIIColors.magenta(\"\\n💾 Storage Configuration:\")\n    ASCIIColors.white(\"    ├─ KV Storage: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.kv_storage}\")\n    ASCIIColors.white(\"    ├─ Vector Storage: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.vector_storage}\")\n    ASCIIColors.white(\"    ├─ Graph Storage: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.graph_storage}\")\n    ASCIIColors.white(\"    ├─ Document Status Storage: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.doc_status_storage}\")\n    ASCIIColors.white(\"    └─ Workspace: \", end=\"\")\n    ASCIIColors.yellow(f\"{args.workspace if args.workspace else '-'}\")\n\n    # Server Status\n    ASCIIColors.green(\"\\n✨ Server starting up...\\n\")\n\n    # Server Access Information\n    protocol = \"https\" if args.ssl else \"http\"\n    if args.host == \"0.0.0.0\":\n        ASCIIColors.magenta(\"\\n🌐 Server Access Information:\")\n        ASCIIColors.white(\"    ├─ WebUI (local): \", end=\"\")\n        ASCIIColors.yellow(f\"{protocol}://localhost:{args.port}\")\n        ASCIIColors.white(\"    ├─ Remote Access: \", end=\"\")\n        ASCIIColors.yellow(f\"{protocol}://<your-ip-address>:{args.port}\")\n        ASCIIColors.white(\"    ├─ API Documentation (local): \", end=\"\")\n        ASCIIColors.yellow(f\"{protocol}://localhost:{args.port}/docs\")\n        ASCIIColors.white(\"    └─ Alternative Documentation (local): \", end=\"\")\n        ASCIIColors.yellow(f\"{protocol}://localhost:{args.port}/redoc\")\n\n        ASCIIColors.magenta(\"\\n📝 Note:\")\n        ASCIIColors.cyan(\"\"\"    Since the server is running on 0.0.0.0:\n    - Use 'localhost' or '127.0.0.1' for local access\n    - Use your machine's IP address for remote access\n    - To find your IP address:\n      • Windows: Run 'ipconfig' in terminal\n      • Linux/Mac: Run 'ifconfig' or 'ip addr' in terminal\n    \"\"\")\n    else:\n        base_url = f\"{protocol}://{args.host}:{args.port}\"\n        ASCIIColors.magenta(\"\\n🌐 Server Access Information:\")\n        ASCIIColors.white(\"    ├─ WebUI (local): \", end=\"\")\n        ASCIIColors.yellow(f\"{base_url}\")\n        ASCIIColors.white(\"    ├─ API Documentation: \", end=\"\")\n        ASCIIColors.yellow(f\"{base_url}/docs\")\n        ASCIIColors.white(\"    └─ Alternative Documentation: \", end=\"\")\n        ASCIIColors.yellow(f\"{base_url}/redoc\")\n\n    # Security Notice\n    if args.key:\n        ASCIIColors.yellow(\"\\n⚠️  Security Notice:\")\n        ASCIIColors.white(\"\"\"    API Key authentication is enabled.\n    Make sure to include the X-API-Key header in all your requests.\n    \"\"\")\n    if args.auth_accounts:\n        ASCIIColors.yellow(\"\\n⚠️  Security Notice:\")\n        ASCIIColors.white(\"\"\"    JWT authentication is enabled.\n    Make sure to login before making the request, and include the 'Authorization' in the header.\n    \"\"\")\n\n    # Ensure splash output flush to system log\n    sys.stdout.flush()\n"
  },
  {
    "path": "lightrag/base.py",
    "content": "from __future__ import annotations\n\nfrom abc import ABC, abstractmethod\nfrom enum import Enum\nimport os\nfrom dotenv import load_dotenv\nfrom dataclasses import dataclass, field\nfrom typing import (\n    Any,\n    Literal,\n    TypedDict,\n    TypeVar,\n    Callable,\n    Optional,\n    Dict,\n    List,\n    AsyncIterator,\n)\nfrom .utils import EmbeddingFunc\nfrom .types import KnowledgeGraph\nfrom .constants import (\n    DEFAULT_TOP_K,\n    DEFAULT_CHUNK_TOP_K,\n    DEFAULT_MAX_ENTITY_TOKENS,\n    DEFAULT_MAX_RELATION_TOKENS,\n    DEFAULT_MAX_TOTAL_TOKENS,\n    DEFAULT_HISTORY_TURNS,\n    DEFAULT_OLLAMA_MODEL_NAME,\n    DEFAULT_OLLAMA_MODEL_TAG,\n    DEFAULT_OLLAMA_MODEL_SIZE,\n    DEFAULT_OLLAMA_CREATED_AT,\n    DEFAULT_OLLAMA_DIGEST,\n)\n\n# use the .env that is inside the current folder\n# allows to use different .env file for each lightrag instance\n# the OS environment variables take precedence over the .env file\nload_dotenv(dotenv_path=\".env\", override=False)\n\n\nclass OllamaServerInfos:\n    def __init__(self, name=None, tag=None):\n        self._lightrag_name = name or os.getenv(\n            \"OLLAMA_EMULATING_MODEL_NAME\", DEFAULT_OLLAMA_MODEL_NAME\n        )\n        self._lightrag_tag = tag or os.getenv(\n            \"OLLAMA_EMULATING_MODEL_TAG\", DEFAULT_OLLAMA_MODEL_TAG\n        )\n        self.LIGHTRAG_SIZE = DEFAULT_OLLAMA_MODEL_SIZE\n        self.LIGHTRAG_CREATED_AT = DEFAULT_OLLAMA_CREATED_AT\n        self.LIGHTRAG_DIGEST = DEFAULT_OLLAMA_DIGEST\n\n    @property\n    def LIGHTRAG_NAME(self):\n        return self._lightrag_name\n\n    @LIGHTRAG_NAME.setter\n    def LIGHTRAG_NAME(self, value):\n        self._lightrag_name = value\n\n    @property\n    def LIGHTRAG_TAG(self):\n        return self._lightrag_tag\n\n    @LIGHTRAG_TAG.setter\n    def LIGHTRAG_TAG(self, value):\n        self._lightrag_tag = value\n\n    @property\n    def LIGHTRAG_MODEL(self):\n        return f\"{self._lightrag_name}:{self._lightrag_tag}\"\n\n\nclass TextChunkSchema(TypedDict):\n    tokens: int\n    content: str\n    full_doc_id: str\n    chunk_order_index: int\n\n\nT = TypeVar(\"T\")\n\n\n@dataclass\nclass QueryParam:\n    \"\"\"Configuration parameters for query execution in LightRAG.\"\"\"\n\n    mode: Literal[\"local\", \"global\", \"hybrid\", \"naive\", \"mix\", \"bypass\"] = \"mix\"\n    \"\"\"Specifies the retrieval mode:\n    - \"local\": Focuses on context-dependent information.\n    - \"global\": Utilizes global knowledge.\n    - \"hybrid\": Combines local and global retrieval methods.\n    - \"naive\": Performs a basic search without advanced techniques.\n    - \"mix\": Integrates knowledge graph and vector retrieval.\n    \"\"\"\n\n    only_need_context: bool = False\n    \"\"\"If True, only returns the retrieved context without generating a response.\"\"\"\n\n    only_need_prompt: bool = False\n    \"\"\"If True, only returns the generated prompt without producing a response.\"\"\"\n\n    response_type: str = \"Multiple Paragraphs\"\n    \"\"\"Defines the response format. Examples: 'Multiple Paragraphs', 'Single Paragraph', 'Bullet Points'.\"\"\"\n\n    stream: bool = False\n    \"\"\"If True, enables streaming output for real-time responses.\"\"\"\n\n    top_k: int = int(os.getenv(\"TOP_K\", str(DEFAULT_TOP_K)))\n    \"\"\"Number of top items to retrieve. Represents entities in 'local' mode and relationships in 'global' mode.\"\"\"\n\n    chunk_top_k: int = int(os.getenv(\"CHUNK_TOP_K\", str(DEFAULT_CHUNK_TOP_K)))\n    \"\"\"Number of text chunks to retrieve initially from vector search and keep after reranking.\n    If None, defaults to top_k value.\n    \"\"\"\n\n    max_entity_tokens: int = int(\n        os.getenv(\"MAX_ENTITY_TOKENS\", str(DEFAULT_MAX_ENTITY_TOKENS))\n    )\n    \"\"\"Maximum number of tokens allocated for entity context in unified token control system.\"\"\"\n\n    max_relation_tokens: int = int(\n        os.getenv(\"MAX_RELATION_TOKENS\", str(DEFAULT_MAX_RELATION_TOKENS))\n    )\n    \"\"\"Maximum number of tokens allocated for relationship context in unified token control system.\"\"\"\n\n    max_total_tokens: int = int(\n        os.getenv(\"MAX_TOTAL_TOKENS\", str(DEFAULT_MAX_TOTAL_TOKENS))\n    )\n    \"\"\"Maximum total tokens budget for the entire query context (entities + relations + chunks + system prompt).\"\"\"\n\n    hl_keywords: list[str] = field(default_factory=list)\n    \"\"\"List of high-level keywords to prioritize in retrieval.\"\"\"\n\n    ll_keywords: list[str] = field(default_factory=list)\n    \"\"\"List of low-level keywords to refine retrieval focus.\"\"\"\n\n    # History mesages is only send to LLM for context, not used for retrieval\n    conversation_history: list[dict[str, str]] = field(default_factory=list)\n    \"\"\"Stores past conversation history to maintain context.\n    Format: [{\"role\": \"user/assistant\", \"content\": \"message\"}].\n    \"\"\"\n\n    # TODO: deprecated. No longer used in the codebase, all conversation_history messages is send to LLM\n    history_turns: int = int(os.getenv(\"HISTORY_TURNS\", str(DEFAULT_HISTORY_TURNS)))\n    \"\"\"Number of complete conversation turns (user-assistant pairs) to consider in the response context.\"\"\"\n\n    model_func: Callable[..., object] | None = None\n    \"\"\"Optional override for the LLM model function to use for this specific query.\n    If provided, this will be used instead of the global model function.\n    This allows using different models for different query modes.\n    \"\"\"\n\n    user_prompt: str | None = None\n    \"\"\"User-provided prompt for the query.\n    Addition instructions for LLM. If provided, this will be inject into the prompt template.\n    It's purpose is the let user customize the way LLM generate the response.\n    \"\"\"\n\n    enable_rerank: bool = os.getenv(\"RERANK_BY_DEFAULT\", \"true\").lower() == \"true\"\n    \"\"\"Enable reranking for retrieved text chunks. If True but no rerank model is configured, a warning will be issued.\n    Default is True to enable reranking when rerank model is available.\n    \"\"\"\n\n    include_references: bool = False\n    \"\"\"If True, includes reference list in the response for supported endpoints.\n    This parameter controls whether the API response includes a references field\n    containing citation information for the retrieved content.\n    \"\"\"\n\n\n@dataclass\nclass StorageNameSpace(ABC):\n    namespace: str\n    workspace: str\n    global_config: dict[str, Any]\n\n    async def initialize(self):\n        \"\"\"Initialize the storage\"\"\"\n        pass\n\n    async def finalize(self):\n        \"\"\"Finalize the storage\"\"\"\n        pass\n\n    @abstractmethod\n    async def index_done_callback(self) -> None:\n        \"\"\"Commit the storage operations after indexing\"\"\"\n\n    @abstractmethod\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop all data from storage and clean up resources\n\n        This abstract method defines the contract for dropping all data from a storage implementation.\n        Each storage type must implement this method to:\n        1. Clear all data from memory and/or external storage\n        2. Remove any associated storage files if applicable\n        3. Reset the storage to its initial state\n        4. Handle cleanup of any resources\n        5. Notify other processes if necessary\n        6. This action should persistent the data to disk immediately.\n\n        Returns:\n            dict[str, str]: Operation status and message with the following format:\n                {\n                    \"status\": str,  # \"success\" or \"error\"\n                    \"message\": str  # \"data dropped\" on success, error details on failure\n                }\n\n        Implementation specific:\n        - On success: return {\"status\": \"success\", \"message\": \"data dropped\"}\n        - On failure: return {\"status\": \"error\", \"message\": \"<error details>\"}\n        - If not supported: return {\"status\": \"error\", \"message\": \"unsupported\"}\n        \"\"\"\n\n\n@dataclass\nclass BaseVectorStorage(StorageNameSpace, ABC):\n    embedding_func: EmbeddingFunc\n    cosine_better_than_threshold: float = field(default=0.2)\n    meta_fields: set[str] = field(default_factory=set)\n\n    def _validate_embedding_func(self):\n        \"\"\"Validate that embedding_func is provided.\n\n        This method should be called at the beginning of __post_init__\n        in all vector storage implementations.\n\n        Raises:\n            ValueError: If embedding_func is None\n        \"\"\"\n        if self.embedding_func is None:\n            raise ValueError(\n                \"embedding_func is required for vector storage. \"\n                \"Please provide a valid EmbeddingFunc instance.\"\n            )\n\n    def _generate_collection_suffix(self) -> str | None:\n        \"\"\"Generates collection/table suffix from embedding_func.\n\n        Return suffix if model_name exists in embedding_func, otherwise return None.\n        Note: embedding_func is guaranteed to exist (validated in __post_init__).\n\n        Returns:\n            str | None: Suffix string e.g. \"text_embedding_3_large_3072d\", or None if model_name not available\n        \"\"\"\n        import re\n\n        # Check if model_name exists (model_name is optional in EmbeddingFunc)\n        model_name = getattr(self.embedding_func, \"model_name\", None)\n        if not model_name:\n            return None\n\n        # embedding_dim is required in EmbeddingFunc\n        embedding_dim = self.embedding_func.embedding_dim\n\n        # Generate suffix: clean model name and append dimension\n        safe_model_name = re.sub(r\"[^a-zA-Z0-9_]\", \"_\", model_name.lower())\n        return f\"{safe_model_name}_{embedding_dim}d\"\n\n    @abstractmethod\n    async def query(\n        self, query: str, top_k: int, query_embedding: list[float] = None\n    ) -> list[dict[str, Any]]:\n        \"\"\"Query the vector storage and retrieve top_k results.\n\n        Args:\n            query: The query string to search for\n            top_k: Number of top results to return\n            query_embedding: Optional pre-computed embedding for the query.\n                           If provided, skips embedding computation for better performance.\n        \"\"\"\n\n    @abstractmethod\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        \"\"\"Insert or update vectors in the storage.\n\n        Importance notes for in-memory storage:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n        \"\"\"\n\n    @abstractmethod\n    async def delete_entity(self, entity_name: str) -> None:\n        \"\"\"Delete a single entity by its name.\n\n        Importance notes for in-memory storage:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n        \"\"\"\n\n    @abstractmethod\n    async def delete_entity_relation(self, entity_name: str) -> None:\n        \"\"\"Delete relations for a given entity.\n\n        Importance notes for in-memory storage:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n        \"\"\"\n\n    @abstractmethod\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        \"\"\"Get vector data by its ID\n\n        Args:\n            id: The unique identifier of the vector\n\n        Returns:\n            The vector data if found, or None if not found\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        \"\"\"Get multiple vector data by their IDs\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            List of vector data objects that were found\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def delete(self, ids: list[str]):\n        \"\"\"Delete vectors with specified IDs\n\n        Importance notes for in-memory storage:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n\n        Args:\n            ids: List of vector IDs to be deleted\n        \"\"\"\n\n    @abstractmethod\n    async def get_vectors_by_ids(self, ids: list[str]) -> dict[str, list[float]]:\n        \"\"\"Get vectors by their IDs, returning only ID and vector data for efficiency\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            Dictionary mapping IDs to their vector embeddings\n            Format: {id: [vector_values], ...}\n        \"\"\"\n        pass\n\n\n@dataclass\nclass BaseKVStorage(StorageNameSpace, ABC):\n    embedding_func: EmbeddingFunc\n\n    @abstractmethod\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        \"\"\"Get value by id\"\"\"\n\n    @abstractmethod\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        \"\"\"Get values by ids\"\"\"\n\n    @abstractmethod\n    async def filter_keys(self, keys: set[str]) -> set[str]:\n        \"\"\"Return un-exist keys\"\"\"\n\n    @abstractmethod\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        \"\"\"Upsert data\n\n        Importance notes for in-memory storage:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. update flags to notify other processes that data persistence is needed\n        \"\"\"\n\n    @abstractmethod\n    async def delete(self, ids: list[str]) -> None:\n        \"\"\"Delete specific records from storage by their IDs\n\n        Importance notes for in-memory storage:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. update flags to notify other processes that data persistence is needed\n\n        Args:\n            ids (list[str]): List of document IDs to be deleted from storage\n\n        Returns:\n            None\n        \"\"\"\n\n    @abstractmethod\n    async def is_empty(self) -> bool:\n        \"\"\"Check if the storage is empty\n\n        Returns:\n            bool: True if storage contains no data, False otherwise\n        \"\"\"\n\n\n@dataclass\nclass BaseGraphStorage(StorageNameSpace, ABC):\n    \"\"\"All operations related to edges in graph should be undirected.\"\"\"\n\n    embedding_func: EmbeddingFunc\n\n    @abstractmethod\n    async def has_node(self, node_id: str) -> bool:\n        \"\"\"Check if a node exists in the graph.\n\n        Args:\n            node_id: The ID of the node to check\n\n        Returns:\n            True if the node exists, False otherwise\n        \"\"\"\n\n    @abstractmethod\n    async def has_edge(self, source_node_id: str, target_node_id: str) -> bool:\n        \"\"\"Check if an edge exists between two nodes.\n\n        Args:\n            source_node_id: The ID of the source node\n            target_node_id: The ID of the target node\n\n        Returns:\n            True if the edge exists, False otherwise\n        \"\"\"\n\n    @abstractmethod\n    async def node_degree(self, node_id: str) -> int:\n        \"\"\"Get the degree (number of connected edges) of a node.\n\n        Args:\n            node_id: The ID of the node\n\n        Returns:\n            The number of edges connected to the node\n        \"\"\"\n\n    @abstractmethod\n    async def edge_degree(self, src_id: str, tgt_id: str) -> int:\n        \"\"\"Get the total degree of an edge (sum of degrees of its source and target nodes).\n\n        Args:\n            src_id: The ID of the source node\n            tgt_id: The ID of the target node\n\n        Returns:\n            The sum of the degrees of the source and target nodes\n        \"\"\"\n\n    @abstractmethod\n    async def get_node(self, node_id: str) -> dict[str, str] | None:\n        \"\"\"Get node by its ID, returning only node properties.\n\n        Args:\n            node_id: The ID of the node to retrieve\n\n        Returns:\n            A dictionary of node properties if found, None otherwise\n        \"\"\"\n\n    @abstractmethod\n    async def get_edge(\n        self, source_node_id: str, target_node_id: str\n    ) -> dict[str, str] | None:\n        \"\"\"Get edge properties between two nodes.\n\n        Args:\n            source_node_id: The ID of the source node\n            target_node_id: The ID of the target node\n\n        Returns:\n            A dictionary of edge properties if found, None otherwise\n        \"\"\"\n\n    @abstractmethod\n    async def get_node_edges(self, source_node_id: str) -> list[tuple[str, str]] | None:\n        \"\"\"Get all edges connected to a node.\n\n        Args:\n            source_node_id: The ID of the node to get edges for\n\n        Returns:\n            A list of (source_id, target_id) tuples representing edges,\n            or None if the node doesn't exist\n        \"\"\"\n\n    async def get_nodes_batch(self, node_ids: list[str]) -> dict[str, dict]:\n        \"\"\"Get nodes as a batch using UNWIND\n\n        Default implementation fetches nodes one by one.\n        Override this method for better performance in storage backends\n        that support batch operations.\n        \"\"\"\n        result = {}\n        for node_id in node_ids:\n            node = await self.get_node(node_id)\n            if node is not None:\n                result[node_id] = node\n        return result\n\n    async def node_degrees_batch(self, node_ids: list[str]) -> dict[str, int]:\n        \"\"\"Node degrees as a batch using UNWIND\n\n        Default implementation fetches node degrees one by one.\n        Override this method for better performance in storage backends\n        that support batch operations.\n        \"\"\"\n        result = {}\n        for node_id in node_ids:\n            degree = await self.node_degree(node_id)\n            result[node_id] = degree\n        return result\n\n    async def edge_degrees_batch(\n        self, edge_pairs: list[tuple[str, str]]\n    ) -> dict[tuple[str, str], int]:\n        \"\"\"Edge degrees as a batch using UNWIND also uses node_degrees_batch\n\n        Default implementation calculates edge degrees one by one.\n        Override this method for better performance in storage backends\n        that support batch operations.\n        \"\"\"\n        result = {}\n        for src_id, tgt_id in edge_pairs:\n            degree = await self.edge_degree(src_id, tgt_id)\n            result[(src_id, tgt_id)] = degree\n        return result\n\n    async def get_edges_batch(\n        self, pairs: list[dict[str, str]]\n    ) -> dict[tuple[str, str], dict]:\n        \"\"\"Get edges as a batch using UNWIND\n\n        Default implementation fetches edges one by one.\n        Override this method for better performance in storage backends\n        that support batch operations.\n        \"\"\"\n        result = {}\n        for pair in pairs:\n            src_id = pair[\"src\"]\n            tgt_id = pair[\"tgt\"]\n            edge = await self.get_edge(src_id, tgt_id)\n            if edge is not None:\n                result[(src_id, tgt_id)] = edge\n        return result\n\n    async def get_nodes_edges_batch(\n        self, node_ids: list[str]\n    ) -> dict[str, list[tuple[str, str]]]:\n        \"\"\"Get nodes edges as a batch using UNWIND\n\n        Default implementation fetches node edges one by one.\n        Override this method for better performance in storage backends\n        that support batch operations.\n        \"\"\"\n        result = {}\n        for node_id in node_ids:\n            edges = await self.get_node_edges(node_id)\n            result[node_id] = edges if edges is not None else []\n        return result\n\n    @abstractmethod\n    async def upsert_node(self, node_id: str, node_data: dict[str, str]) -> None:\n        \"\"\"Insert a new node or update an existing node in the graph.\n\n        Importance notes for in-memory storage:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n\n        Args:\n            node_id: The ID of the node to insert or update\n            node_data: A dictionary of node properties\n        \"\"\"\n\n    @abstractmethod\n    async def upsert_edge(\n        self, source_node_id: str, target_node_id: str, edge_data: dict[str, str]\n    ) -> None:\n        \"\"\"Insert a new edge or update an existing edge in the graph.\n\n        Importance notes for in-memory storage:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n\n        Args:\n            source_node_id: The ID of the source node\n            target_node_id: The ID of the target node\n            edge_data: A dictionary of edge properties\n        \"\"\"\n\n    @abstractmethod\n    async def delete_node(self, node_id: str) -> None:\n        \"\"\"Delete a node from the graph.\n\n        Importance notes for in-memory storage:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n\n        Args:\n            node_id: The ID of the node to delete\n        \"\"\"\n\n    @abstractmethod\n    async def remove_nodes(self, nodes: list[str]):\n        \"\"\"Delete multiple nodes\n\n        Importance notes:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n\n        Args:\n            nodes: List of node IDs to be deleted\n        \"\"\"\n\n    @abstractmethod\n    async def remove_edges(self, edges: list[tuple[str, str]]):\n        \"\"\"Delete multiple edges\n\n        Importance notes:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n\n        Args:\n            edges: List of edges to be deleted, each edge is a (source, target) tuple\n        \"\"\"\n\n    @abstractmethod\n    async def get_all_labels(self) -> list[str]:\n        \"\"\"Get all labels(entity names) in the graph.\n        Do not use this method for large graph, use get_popular_labels or search_labels instead.\n\n        Returns:\n            A list of all node labels in the graph, sorted alphabetically\n        \"\"\"\n\n    @abstractmethod\n    async def get_knowledge_graph(\n        self, node_label: str, max_depth: int = 3, max_nodes: int = 1000\n    ) -> KnowledgeGraph:\n        \"\"\"\n        Retrieve a connected subgraph of nodes where the label includes the specified `node_label`.\n\n        Args:\n            node_label: Label(entity name) of the starting node，* means all nodes\n            max_depth: Maximum depth of the subgraph, Defaults to 3\n            max_nodes: Maxiumu nodes to return, Defaults to 1000（BFS if possible)\n\n        Returns:\n            KnowledgeGraph object containing nodes and edges, with an is_truncated flag\n            indicating whether the graph was truncated due to max_nodes limit\n        \"\"\"\n\n    @abstractmethod\n    async def get_all_nodes(self) -> list[dict]:\n        \"\"\"Get all nodes in the graph.\n\n        Returns:\n            A list of all nodes, where each node is a dictionary of its properties\n            (Edge is bidirectional for some storage implementation; deduplication must be handled by the caller)\n        \"\"\"\n\n    @abstractmethod\n    async def get_all_edges(self) -> list[dict]:\n        \"\"\"Get all edges in the graph.\n\n        Returns:\n            A list of all edges, where each edge is a dictionary of its properties\n        \"\"\"\n\n    @abstractmethod\n    async def get_popular_labels(self, limit: int = 300) -> list[str]:\n        \"\"\"Get popular labels(entity names) by node degree (most connected entities)\n\n        Args:\n            limit: Maximum number of labels to return\n\n        Returns:\n            List of labels sorted by degree (highest first)\n        \"\"\"\n\n    @abstractmethod\n    async def search_labels(self, query: str, limit: int = 50) -> list[str]:\n        \"\"\"Search labels(entity names) with fuzzy matching\n\n        Args:\n            query: Search query string\n            limit: Maximum number of results to return\n\n        Returns:\n            List of matching labels sorted by relevance\n        \"\"\"\n\n\nclass DocStatus(str, Enum):\n    \"\"\"Document processing status\"\"\"\n\n    PENDING = \"pending\"\n    PROCESSING = \"processing\"\n    PREPROCESSED = \"preprocessed\"\n    PROCESSED = \"processed\"\n    FAILED = \"failed\"\n\n\n@dataclass\nclass DocProcessingStatus:\n    \"\"\"Document processing status data structure\"\"\"\n\n    content_summary: str\n    \"\"\"First 100 chars of document content, used for preview\"\"\"\n    content_length: int\n    \"\"\"Total length of document\"\"\"\n    file_path: str\n    \"\"\"File path of the document\"\"\"\n    status: DocStatus\n    \"\"\"Current processing status\"\"\"\n    created_at: str\n    \"\"\"ISO format timestamp when document was created\"\"\"\n    updated_at: str\n    \"\"\"ISO format timestamp when document was last updated\"\"\"\n    track_id: str | None = None\n    \"\"\"Tracking ID for monitoring progress\"\"\"\n    chunks_count: int | None = None\n    \"\"\"Number of chunks after splitting, used for processing\"\"\"\n    chunks_list: list[str] | None = field(default_factory=list)\n    \"\"\"List of chunk IDs associated with this document, used for deletion\"\"\"\n    error_msg: str | None = None\n    \"\"\"Error message if failed\"\"\"\n    metadata: dict[str, Any] = field(default_factory=dict)\n    \"\"\"Additional metadata\"\"\"\n    multimodal_processed: bool | None = field(default=None, repr=False)\n    \"\"\"Internal field: indicates if multimodal processing is complete. Not shown in repr() but accessible for debugging.\"\"\"\n\n    def __post_init__(self):\n        \"\"\"\n        Handle status conversion based on multimodal_processed field.\n\n        Business rules:\n        - If multimodal_processed is False and status is PROCESSED,\n          then change status to PREPROCESSED\n        - The multimodal_processed field is kept (with repr=False) for internal use and debugging\n        \"\"\"\n        # Apply status conversion logic\n        if self.multimodal_processed is not None:\n            if (\n                self.multimodal_processed is False\n                and self.status == DocStatus.PROCESSED\n            ):\n                self.status = DocStatus.PREPROCESSED\n\n\n@dataclass\nclass DocStatusStorage(BaseKVStorage, ABC):\n    \"\"\"Base class for document status storage\"\"\"\n\n    @abstractmethod\n    async def get_status_counts(self) -> dict[str, int]:\n        \"\"\"Get counts of documents in each status\"\"\"\n\n    @abstractmethod\n    async def get_docs_by_status(\n        self, status: DocStatus\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Get all documents with a specific status\"\"\"\n\n    @abstractmethod\n    async def get_docs_by_track_id(\n        self, track_id: str\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Get all documents with a specific track_id\"\"\"\n\n    @abstractmethod\n    async def get_docs_paginated(\n        self,\n        status_filter: DocStatus | None = None,\n        page: int = 1,\n        page_size: int = 50,\n        sort_field: str = \"updated_at\",\n        sort_direction: str = \"desc\",\n    ) -> tuple[list[tuple[str, DocProcessingStatus]], int]:\n        \"\"\"Get documents with pagination support\n\n        Args:\n            status_filter: Filter by document status, None for all statuses\n            page: Page number (1-based)\n            page_size: Number of documents per page (10-200)\n            sort_field: Field to sort by ('created_at', 'updated_at', 'id')\n            sort_direction: Sort direction ('asc' or 'desc')\n\n        Returns:\n            Tuple of (list of (doc_id, DocProcessingStatus) tuples, total_count)\n        \"\"\"\n\n    @abstractmethod\n    async def get_all_status_counts(self) -> dict[str, int]:\n        \"\"\"Get counts of documents in each status for all documents\n\n        Returns:\n            Dictionary mapping status names to counts\n        \"\"\"\n\n    @abstractmethod\n    async def get_doc_by_file_path(self, file_path: str) -> dict[str, Any] | None:\n        \"\"\"Get document by file path\n\n        Args:\n            file_path: The file path to search for\n\n        Returns:\n            dict[str, Any] | None: Document data if found, None otherwise\n            Returns the same format as get_by_ids method\n        \"\"\"\n\n\nclass StoragesStatus(str, Enum):\n    \"\"\"Storages status\"\"\"\n\n    NOT_CREATED = \"not_created\"\n    CREATED = \"created\"\n    INITIALIZED = \"initialized\"\n    FINALIZED = \"finalized\"\n\n\n@dataclass\nclass DeletionResult:\n    \"\"\"Represents the result of a deletion operation.\"\"\"\n\n    status: Literal[\"success\", \"not_found\", \"fail\"]\n    doc_id: str\n    message: str\n    status_code: int = 200\n    file_path: str | None = None\n\n\n# Unified Query Result Data Structures for Reference List Support\n\n\n@dataclass\nclass QueryResult:\n    \"\"\"\n    Unified query result data structure for all query modes.\n\n    Attributes:\n        content: Text content for non-streaming responses\n        response_iterator: Streaming response iterator for streaming responses\n        raw_data: Complete structured data including references and metadata\n        is_streaming: Whether this is a streaming result\n    \"\"\"\n\n    content: Optional[str] = None\n    response_iterator: Optional[AsyncIterator[str]] = None\n    raw_data: Optional[Dict[str, Any]] = None\n    is_streaming: bool = False\n\n    @property\n    def reference_list(self) -> List[Dict[str, str]]:\n        \"\"\"\n        Convenient property to extract reference list from raw_data.\n\n        Returns:\n            List[Dict[str, str]]: Reference list in format:\n            [{\"reference_id\": \"1\", \"file_path\": \"/path/to/file.pdf\"}, ...]\n        \"\"\"\n        if self.raw_data:\n            return self.raw_data.get(\"data\", {}).get(\"references\", [])\n        return []\n\n    @property\n    def metadata(self) -> Dict[str, Any]:\n        \"\"\"\n        Convenient property to extract metadata from raw_data.\n\n        Returns:\n            Dict[str, Any]: Query metadata including query_mode, keywords, etc.\n        \"\"\"\n        if self.raw_data:\n            return self.raw_data.get(\"metadata\", {})\n        return {}\n\n\n@dataclass\nclass QueryContextResult:\n    \"\"\"\n    Unified query context result data structure.\n\n    Attributes:\n        context: LLM context string\n        raw_data: Complete structured data including reference_list\n    \"\"\"\n\n    context: str\n    raw_data: Dict[str, Any]\n\n    @property\n    def reference_list(self) -> List[Dict[str, str]]:\n        \"\"\"Convenient property to extract reference list from raw_data.\"\"\"\n        return self.raw_data.get(\"data\", {}).get(\"references\", [])\n"
  },
  {
    "path": "lightrag/constants.py",
    "content": "\"\"\"\nCentralized configuration constants for LightRAG.\n\nThis module defines default values for configuration constants used across\ndifferent parts of the LightRAG system. Centralizing these values ensures\nconsistency and makes maintenance easier.\n\"\"\"\n\n# Default values for server settings\nDEFAULT_WOKERS = 2\nDEFAULT_MAX_GRAPH_NODES = 1000\n\n# Default values for extraction settings\nDEFAULT_SUMMARY_LANGUAGE = \"English\"  # Default language for document processing\nDEFAULT_MAX_GLEANING = 1\nDEFAULT_ENTITY_NAME_MAX_LENGTH = 256\n\n# Number of description fragments to trigger LLM summary\nDEFAULT_FORCE_LLM_SUMMARY_ON_MERGE = 8\n# Max description token size to trigger LLM summary\nDEFAULT_SUMMARY_MAX_TOKENS = 1200\n# Recommended LLM summary output length in tokens\nDEFAULT_SUMMARY_LENGTH_RECOMMENDED = 600\n# Maximum token size sent to LLM for summary\nDEFAULT_SUMMARY_CONTEXT_SIZE = 12000\n# Maximum token size allowed for entity extraction input context\nDEFAULT_MAX_EXTRACT_INPUT_TOKENS = 20480\n# Default entities to extract if ENTITY_TYPES is not specified in .env\nDEFAULT_ENTITY_TYPES = [\n    \"Person\",\n    \"Creature\",\n    \"Organization\",\n    \"Location\",\n    \"Event\",\n    \"Concept\",\n    \"Method\",\n    \"Content\",\n    \"Data\",\n    \"Artifact\",\n    \"NaturalObject\",\n]\n\n# Separator for: description, source_id and relation-key fields(Can not be changed after data inserted)\nGRAPH_FIELD_SEP = \"<SEP>\"\n\n# Query and retrieval configuration defaults\nDEFAULT_TOP_K = 40\nDEFAULT_CHUNK_TOP_K = 20\nDEFAULT_MAX_ENTITY_TOKENS = 6000\nDEFAULT_MAX_RELATION_TOKENS = 8000\nDEFAULT_MAX_TOTAL_TOKENS = 30000\nDEFAULT_COSINE_THRESHOLD = 0.2\nDEFAULT_RELATED_CHUNK_NUMBER = 5\nDEFAULT_KG_CHUNK_PICK_METHOD = \"VECTOR\"\n\n# TODO: Deprated. All conversation_history messages is send to LLM.\nDEFAULT_HISTORY_TURNS = 0\n\n# Rerank configuration defaults\nDEFAULT_MIN_RERANK_SCORE = 0.0\nDEFAULT_RERANK_BINDING = \"null\"\n\n# Default source ids limit in meta data for entity and relation\nDEFAULT_MAX_SOURCE_IDS_PER_ENTITY = 300\nDEFAULT_MAX_SOURCE_IDS_PER_RELATION = 300\n### control chunk_ids limitation method: FIFO, FIFO\n###    FIFO: First in first out\n###    KEEP: Keep oldest (less merge action and faster)\nSOURCE_IDS_LIMIT_METHOD_KEEP = \"KEEP\"\nSOURCE_IDS_LIMIT_METHOD_FIFO = \"FIFO\"\nDEFAULT_SOURCE_IDS_LIMIT_METHOD = SOURCE_IDS_LIMIT_METHOD_FIFO\nVALID_SOURCE_IDS_LIMIT_METHODS = {\n    SOURCE_IDS_LIMIT_METHOD_KEEP,\n    SOURCE_IDS_LIMIT_METHOD_FIFO,\n}\n# Maximum number of file paths stored in entity/relation file_path field (For displayed only, does not affect query performance)\nDEFAULT_MAX_FILE_PATHS = 100\n\n# Field length of file_path in Milvus Schema for entity and relation (Should not be changed)\n# file_path must store all file paths up to the DEFAULT_MAX_FILE_PATHS limit within the metadata.\nDEFAULT_MAX_FILE_PATH_LENGTH = 32768\n# Placeholder for more file paths in meta data for entity and relation (Should not be changed)\nDEFAULT_FILE_PATH_MORE_PLACEHOLDER = \"truncated\"\n\n# Default temperature for LLM\nDEFAULT_TEMPERATURE = 1.0\n\n# Async configuration defaults\nDEFAULT_MAX_ASYNC = 4  # Default maximum async operations\nDEFAULT_MAX_PARALLEL_INSERT = 2  # Default maximum parallel insert operations\n\n# Embedding configuration defaults\nDEFAULT_EMBEDDING_FUNC_MAX_ASYNC = 8  # Default max async for embedding functions\nDEFAULT_EMBEDDING_BATCH_NUM = 10  # Default batch size for embedding computations\n\n# Gunicorn worker timeout\nDEFAULT_TIMEOUT = 300\n\n# Default llm and embedding timeout\nDEFAULT_LLM_TIMEOUT = 180\nDEFAULT_EMBEDDING_TIMEOUT = 30\n\n# Logging configuration defaults\nDEFAULT_LOG_MAX_BYTES = 10485760  # Default 10MB\nDEFAULT_LOG_BACKUP_COUNT = 5  # Default 5 backups\nDEFAULT_LOG_FILENAME = \"lightrag.log\"  # Default log filename\n\n# Ollama server configuration defaults\nDEFAULT_OLLAMA_MODEL_NAME = \"lightrag\"\nDEFAULT_OLLAMA_MODEL_TAG = \"latest\"\nDEFAULT_OLLAMA_MODEL_SIZE = 7365960935\nDEFAULT_OLLAMA_CREATED_AT = \"2024-01-15T00:00:00Z\"\nDEFAULT_OLLAMA_DIGEST = \"sha256:lightrag\"\n"
  },
  {
    "path": "lightrag/evaluation/README_EVALUASTION_RAGAS.md",
    "content": "# 📊 RAGAS-based Evaluation Framework\n\n## What is RAGAS?\n\n**RAGAS** (Retrieval Augmented Generation Assessment) is a framework for reference-free evaluation of RAG systems using LLMs. RAGAS uses state-of-the-art evaluation metrics:\n\n### Core Metrics\n\n| Metric | What It Measures | Good Score |\n| ------ | ---------------- | ---------- |\n| **Faithfulness** | Is the answer factually accurate based on retrieved context? | > 0.80 |\n| **Answer Relevance** | Is the answer relevant to the user's question? | > 0.80 |\n| **Context Recall** | Was all relevant information retrieved from documents? | > 0.80 |\n| **Context Precision** | Is retrieved context clean without irrelevant noise? | > 0.80 |\n| **RAGAS Score** | Overall quality metric (average of above) | > 0.80 |\n\n### 📁 LightRAG Evalua'tion Framework Directory Structure\n\n```\nlightrag/evaluation/\n├── eval_rag_quality.py      # Main evaluation script\n├── sample_dataset.json        # 3 test questions about LightRAG\n├── sample_documents/          # Matching markdown files for testing\n│   ├── 01_lightrag_overview.md\n│   ├── 02_rag_architecture.md\n│   ├── 03_lightrag_improvements.md\n│   ├── 04_supported_databases.md\n│   ├── 05_evaluation_and_deployment.md\n│   └── README.md\n├── __init__.py              # Package init\n├── results/                 # Output directory\n│   ├── results_YYYYMMDD_HHMMSS.json    # Raw metrics in JSON\n│   └── results_YYYYMMDD_HHMMSS.csv     # Metrics in CSV format\n└── README.md                # This file\n```\n\n**Quick Test:** Index files from `sample_documents/` into LightRAG, then run the evaluator to reproduce results (~89-100% RAGAS score per question).\n\n\n\n## 🚀 Quick Start\n\n### 1. Install Dependencies\n\n```bash\npip install ragas datasets langfuse\n```\n\nOr use your project dependencies (already included in pyproject.toml):\n\n```bash\npip install -e \".[evaluation]\"\n```\n\n### 2. Run Evaluation\n\n**Basic usage (uses defaults):**\n```bash\ncd /path/to/LightRAG\npython lightrag/evaluation/eval_rag_quality.py\n```\n\n**Specify custom dataset:**\n```bash\npython lightrag/evaluation/eval_rag_quality.py --dataset my_test.json\n```\n\n**Specify custom RAG endpoint:**\n```bash\npython lightrag/evaluation/eval_rag_quality.py --ragendpoint http://my-server.com:9621\n```\n\n**Specify both (short form):**\n```bash\npython lightrag/evaluation/eval_rag_quality.py -d my_test.json -r http://localhost:9621\n```\n\n**Get help:**\n```bash\npython lightrag/evaluation/eval_rag_quality.py --help\n```\n\n### 3. View Results\n\nResults are saved automatically in `lightrag/evaluation/results/`:\n\n```\nresults/\n├── results_20241023_143022.json     ← Raw metrics in JSON format\n└── results_20241023_143022.csv      ← Metrics in CSV format (for spreadsheets)\n```\n\n**Results include:**\n- ✅ Overall RAGAS score\n- 📊 Per-metric averages (Faithfulness, Answer Relevance, Context Recall, Context Precision)\n- 📋 Individual test case results\n- 📈 Performance breakdown by question\n\n\n\n## 📋 Command-Line Arguments\n\nThe evaluation script supports command-line arguments for easy configuration:\n\n| Argument | Short | Default | Description |\n| -------- | ----- | ------- | ----------- |\n| `--dataset` | `-d` | `sample_dataset.json` | Path to test dataset JSON file |\n| `--ragendpoint` | `-r` | `http://localhost:9621` or `$LIGHTRAG_API_URL` | LightRAG API endpoint URL |\n\n### Usage Examples\n\n**Use default dataset and endpoint:**\n```bash\npython lightrag/evaluation/eval_rag_quality.py\n```\n\n**Custom dataset with default endpoint:**\n```bash\npython lightrag/evaluation/eval_rag_quality.py --dataset path/to/my_dataset.json\n```\n\n**Default dataset with custom endpoint:**\n```bash\npython lightrag/evaluation/eval_rag_quality.py --ragendpoint http://my-server.com:9621\n```\n\n**Custom dataset and endpoint:**\n```bash\npython lightrag/evaluation/eval_rag_quality.py -d my_dataset.json -r http://localhost:9621\n```\n\n**Absolute path to dataset:**\n```bash\npython lightrag/evaluation/eval_rag_quality.py -d /path/to/custom_dataset.json\n```\n\n**Show help message:**\n```bash\npython lightrag/evaluation/eval_rag_quality.py --help\n```\n\n\n\n## ⚙️ Configuration\n\n### Environment Variables\n\nThe evaluation framework supports customization through environment variables:\n\n**⚠️ IMPORTANT: Both LLM and Embedding endpoints MUST be OpenAI-compatible**\n- The RAGAS framework requires OpenAI-compatible API interfaces\n- Custom endpoints must implement the OpenAI API format (e.g., vLLM, SGLang, LocalAI)\n- Non-compatible endpoints will cause evaluation failures\n\n| Variable | Default | Description |\n| -------- | ------- | ----------- |\n| **LLM Configuration** | | |\n| `EVAL_LLM_MODEL` | `gpt-4o-mini` | LLM model used for RAGAS evaluation |\n| `EVAL_LLM_BINDING_API_KEY` | falls back to `OPENAI_API_KEY` | API key for LLM evaluation |\n| `EVAL_LLM_BINDING_HOST` | (optional) | Custom OpenAI-compatible endpoint URL for LLM |\n| **Embedding Configuration** | | |\n| `EVAL_EMBEDDING_MODEL` | `text-embedding-3-large` | Embedding model for evaluation |\n| `EVAL_EMBEDDING_BINDING_API_KEY` | falls back to `EVAL_LLM_BINDING_API_KEY` → `OPENAI_API_KEY` | API key for embeddings |\n| `EVAL_EMBEDDING_BINDING_HOST` | falls back to `EVAL_LLM_BINDING_HOST` | Custom OpenAI-compatible endpoint URL for embeddings |\n| **Performance Tuning** | | |\n| `EVAL_MAX_CONCURRENT` | 2 | Number of concurrent test case evaluations (1=serial) |\n| `EVAL_QUERY_TOP_K` | 10 | Number of documents to retrieve per query |\n| `EVAL_LLM_MAX_RETRIES` | 5 | Maximum LLM request retries |\n| `EVAL_LLM_TIMEOUT` | 180 | LLM request timeout in seconds |\n\n### Usage Examples\n\n**Example 1: Default Configuration (OpenAI Official API)**\n```bash\nexport OPENAI_API_KEY=sk-xxx\npython lightrag/evaluation/eval_rag_quality.py\n```\nBoth LLM and embeddings use OpenAI's official API with default models.\n\n**Example 2: Custom Models on OpenAI**\n```bash\nexport OPENAI_API_KEY=sk-xxx\nexport EVAL_LLM_MODEL=gpt-4o-mini\nexport EVAL_EMBEDDING_MODEL=text-embedding-3-large\npython lightrag/evaluation/eval_rag_quality.py\n```\n\n**Example 3: Same Custom OpenAI-Compatible Endpoint for Both**\n```bash\n# Both LLM and embeddings use the same custom endpoint\nexport EVAL_LLM_BINDING_API_KEY=your-custom-key\nexport EVAL_LLM_BINDING_HOST=http://localhost:8000/v1\nexport EVAL_LLM_MODEL=qwen-plus\nexport EVAL_EMBEDDING_MODEL=BAAI/bge-m3\npython lightrag/evaluation/eval_rag_quality.py\n```\nEmbeddings automatically inherit LLM endpoint configuration.\n\n**Example 4: Separate Endpoints (Cost Optimization)**\n```bash\n# Use OpenAI for LLM (high quality)\nexport EVAL_LLM_BINDING_API_KEY=sk-openai-key\nexport EVAL_LLM_MODEL=gpt-4o-mini\n# No EVAL_LLM_BINDING_HOST means use OpenAI official API\n\n# Use local vLLM for embeddings (cost-effective)\nexport EVAL_EMBEDDING_BINDING_API_KEY=local-key\nexport EVAL_EMBEDDING_BINDING_HOST=http://localhost:8001/v1\nexport EVAL_EMBEDDING_MODEL=BAAI/bge-m3\n\npython lightrag/evaluation/eval_rag_quality.py\n```\nLLM uses OpenAI official API, embeddings use local custom endpoint.\n\n**Example 5: Different Custom Endpoints for LLM and Embeddings**\n```bash\n# LLM on one OpenAI-compatible server\nexport EVAL_LLM_BINDING_API_KEY=key1\nexport EVAL_LLM_BINDING_HOST=http://llm-server:8000/v1\nexport EVAL_LLM_MODEL=custom-llm\n\n# Embeddings on another OpenAI-compatible server\nexport EVAL_EMBEDDING_BINDING_API_KEY=key2\nexport EVAL_EMBEDDING_BINDING_HOST=http://embedding-server:8001/v1\nexport EVAL_EMBEDDING_MODEL=custom-embedding\n\npython lightrag/evaluation/eval_rag_quality.py\n```\nBoth use different custom OpenAI-compatible endpoints.\n\n**Example 6: Using Environment Variables from .env File**\n```bash\n# Create .env file in project root\ncat > .env << EOF\nEVAL_LLM_BINDING_API_KEY=your-key\nEVAL_LLM_BINDING_HOST=http://localhost:8000/v1\nEVAL_LLM_MODEL=qwen-plus\nEVAL_EMBEDDING_MODEL=BAAI/bge-m3\nEOF\n\n# Run evaluation (automatically loads .env)\npython lightrag/evaluation/eval_rag_quality.py\n```\n\n### Concurrency Control & Rate Limiting\n\nThe evaluation framework includes built-in concurrency control to prevent API rate limiting issues:\n\n**Why Concurrency Control Matters:**\n- RAGAS internally makes many concurrent LLM calls for each test case\n- Context Precision metric calls LLM once per retrieved document\n- Without control, this can easily exceed API rate limits\n\n**Default Configuration (Conservative):**\n```bash\nEVAL_MAX_CONCURRENT=2    # Serial evaluation (one test at a time)\nEVAL_QUERY_TOP_K=10      # OP_K query parameter of LightRAG\nEVAL_LLM_MAX_RETRIES=5   # Retry failed requests 5 times\nEVAL_LLM_TIMEOUT=180     # 3-minute timeout per request\n```\n\n**Common Issues and Solutions:**\n\n| Issue | Solution |\n| ----- | -------- |\n| **Warning: \"LM returned 1 generations instead of 3\"** | Reduce `EVAL_MAX_CONCURRENT` to 1 or decrease `EVAL_QUERY_TOP_K` |\n| **Context Precision returns NaN** | Lower `EVAL_QUERY_TOP_K` to reduce LLM calls per test case |\n| **Rate limit errors (429)** | Increase `EVAL_LLM_MAX_RETRIES` and decrease `EVAL_MAX_CONCURRENT` |\n| **Request timeouts** | Increase `EVAL_LLM_TIMEOUT` to 180 or higher |\n\n\n\n## 📝 Test Dataset\n\n`sample_dataset.json` contains 3 generic questions about LightRAG. Replace with questions matching YOUR indexed documents.\n\n**Custom Test Cases:**\n\n```json\n{\n  \"test_cases\": [\n    {\n      \"question\": \"Your question here\",\n      \"ground_truth\": \"Expected answer from your data\",\n      \"project\": \"evaluation_project_name\"\n    }\n  ]\n}\n```\n\n---\n\n## 📊 Interpreting Results\n\n### Score Ranges\n\n- **0.80-1.00**: ✅ Excellent (Production-ready)\n- **0.60-0.80**: ⚠️ Good (Room for improvement)\n- **0.40-0.60**: ❌ Poor (Needs optimization)\n- **0.00-0.40**: 🔴 Critical (Major issues)\n\n### What Low Scores Mean\n\n| Metric | Low Score Indicates |\n| ------ | ------------------- |\n| **Faithfulness** | Responses contain hallucinations or incorrect information |\n| **Answer Relevance** | Answers don't match what users asked |\n| **Context Recall** | Missing important information in retrieval |\n| **Context Precision** | Retrieved documents contain irrelevant noise |\n\n### Optimization Tips\n\n1. **Low Faithfulness**:\n   - Improve entity extraction quality\n   - Better document chunking\n   - Tune retrieval temperature\n\n2. **Low Answer Relevance**:\n   - Improve prompt engineering\n   - Better query understanding\n   - Check semantic similarity threshold\n\n3. **Low Context Recall**:\n   - Increase retrieval `top_k` results\n   - Improve embedding model\n   - Better document preprocessing\n\n4. **Low Context Precision**:\n   - Smaller, focused chunks\n   - Better filtering\n   - Improve chunking strategy\n\n---\n\n## 📚 Resources\n\n- [RAGAS Documentation](https://docs.ragas.io/)\n- [RAGAS GitHub](https://github.com/explodinggradients/ragas)\n\n---\n\n## 🐛 Troubleshooting\n\n### \"ModuleNotFoundError: No module named 'ragas'\"\n\n```bash\npip install ragas datasets\n```\n\n### \"Warning: LM returned 1 generations instead of requested 3\" or Context Precision NaN\n\n**Cause**: This warning indicates API rate limiting or concurrent request overload:\n- RAGAS makes multiple LLM calls per test case (faithfulness, relevancy, recall, precision)\n- Context Precision calls LLM once per retrieved document (with `EVAL_QUERY_TOP_K=10`, that's 10 calls)\n- Concurrent evaluation multiplies these calls: `EVAL_MAX_CONCURRENT × LLM calls per test`\n\n**Solutions** (in order of effectiveness):\n\n1. **Serial Evaluation** (Default):\n   ```bash\n   export EVAL_MAX_CONCURRENT=1\n   python lightrag/evaluation/eval_rag_quality.py\n   ```\n\n2. **Reduce Retrieved Documents**:\n   ```bash\n   export EVAL_QUERY_TOP_K=5  # Halves Context Precision LLM calls\n   python lightrag/evaluation/eval_rag_quality.py\n   ```\n\n3. **Increase Retry & Timeout**:\n   ```bash\n   export EVAL_LLM_MAX_RETRIES=10\n   export EVAL_LLM_TIMEOUT=180\n   python lightrag/evaluation/eval_rag_quality.py\n   ```\n\n4. **Use Higher Quota API** (if available):\n   - Upgrade to OpenAI Tier 2+ for higher RPM limits\n   - Use self-hosted OpenAI-compatible service with no rate limits\n\n### \"AttributeError: 'InstructorLLM' object has no attribute 'agenerate_prompt'\" or NaN results\n\nThis error occurs with RAGAS 0.3.x when LLM and Embeddings are not explicitly configured. The evaluation framework now handles this automatically by:\n- Using environment variables to configure evaluation models\n- Creating proper LLM and Embeddings instances for RAGAS\n\n**Solution**: Ensure you have set one of the following:\n- `OPENAI_API_KEY` environment variable (default)\n- `EVAL_LLM_BINDING_API_KEY` for custom API key\n\nThe framework will automatically configure the evaluation models.\n\n### \"No sample_dataset.json found\"\n\nMake sure you're running from the project root:\n\n```bash\ncd /path/to/LightRAG\npython lightrag/evaluation/eval_rag_quality.py\n```\n\n### \"LightRAG query API errors during evaluation\"\n\nThe evaluation uses your configured LLM (OpenAI by default). Ensure:\n- API keys are set in `.env`\n- Network connection is stable\n\n### Evaluation requires running LightRAG API\n\nThe evaluator queries a running LightRAG API server at `http://localhost:9621`. Make sure:\n1. LightRAG API server is running (`python lightrag/api/lightrag_server.py`)\n2. Documents are indexed in your LightRAG instance\n3. API is accessible at the configured URL\n\n\n\n## 📝 Next Steps\n\n1. Start LightRAG API server\n2. Upload sample documents into LightRAG  throught  WebUI\n3. Run `python lightrag/evaluation/eval_rag_quality.py`\n4. Review results (JSON/CSV) in `results/` folder\n\nEvaluation Result Sample:\n\n```\nINFO: ======================================================================\nINFO: 🔍 RAGAS Evaluation - Using Real LightRAG API\nINFO: ======================================================================\nINFO: Evaluation Models:\nINFO:   • LLM Model:            gpt-4.1\nINFO:   • Embedding Model:      text-embedding-3-large\nINFO:   • Endpoint:             OpenAI Official API\nINFO: Concurrency & Rate Limiting:\nINFO:   • Query Top-K:          10 Entities/Relations\nINFO:   • LLM Max Retries:      5\nINFO:   • LLM Timeout:          180 seconds\nINFO: Test Configuration:\nINFO:   • Total Test Cases:     6\nINFO:   • Test Dataset:         sample_dataset.json\nINFO:   • LightRAG API:         http://localhost:9621\nINFO:   • Results Directory:    results\nINFO: ======================================================================\nINFO: 🚀 Starting RAGAS Evaluation of LightRAG System\nINFO: 🔧 RAGAS Evaluation (Stage 2): 2 concurrent\nINFO: ======================================================================\nINFO:\nINFO: ===================================================================================================================\nINFO: 📊 EVALUATION RESULTS SUMMARY\nINFO: ===================================================================================================================\nINFO: #    | Question                                           |  Faith | AnswRel | CtxRec | CtxPrec |  RAGAS | Status\nINFO: -------------------------------------------------------------------------------------------------------------------\nINFO: 1    | How does LightRAG solve the hallucination probl... | 1.0000 |  1.0000 | 1.0000 |  1.0000 | 1.0000 |      ✓\nINFO: 2    | What are the three main components required in ... | 0.8500 |  0.5790 | 1.0000 |  1.0000 | 0.8573 |      ✓\nINFO: 3    | How does LightRAG's retrieval performance compa... | 0.8056 |  1.0000 | 1.0000 |  1.0000 | 0.9514 |      ✓\nINFO: 4    | What vector databases does LightRAG support and... | 0.8182 |  0.9807 | 1.0000 |  1.0000 | 0.9497 |      ✓\nINFO: 5    | What are the four key metrics for evaluating RA... | 1.0000 |  0.7452 | 1.0000 |  1.0000 | 0.9363 |      ✓\nINFO: 6    | What are the core benefits of LightRAG and how ... | 0.9583 |  0.8829 | 1.0000 |  1.0000 | 0.9603 |      ✓\nINFO: ===================================================================================================================\nINFO:\nINFO: ======================================================================\nINFO: 📊 EVALUATION COMPLETE\nINFO: ======================================================================\nINFO: Total Tests:    6\nINFO: Successful:     6\nINFO: Failed:         0\nINFO: Success Rate:   100.00%\nINFO: Elapsed Time:   161.10 seconds\nINFO: Avg Time/Test:  26.85 seconds\nINFO:\nINFO: ======================================================================\nINFO: 📈 BENCHMARK RESULTS (Average)\nINFO: ======================================================================\nINFO: Average Faithfulness:      0.9053\nINFO: Average Answer Relevance:  0.8646\nINFO: Average Context Recall:    1.0000\nINFO: Average Context Precision: 1.0000\nINFO: Average RAGAS Score:       0.9425\nINFO: ----------------------------------------------------------------------\nINFO: Min RAGAS Score:           0.8573\nINFO: Max RAGAS Score:           1.0000\n```\n\n---\n\n**Happy Evaluating! 🚀**\n"
  },
  {
    "path": "lightrag/evaluation/__init__.py",
    "content": "\"\"\"\nLightRAG Evaluation Module\n\nRAGAS-based evaluation framework for assessing RAG system quality.\n\nUsage:\n    from lightrag.evaluation import RAGEvaluator\n\n    evaluator = RAGEvaluator()\n    results = await evaluator.run()\n\nNote: RAGEvaluator is imported lazily to avoid import errors\nwhen ragas/datasets are not installed.\n\"\"\"\n\n__all__ = [\"RAGEvaluator\"]\n\n\ndef __getattr__(name):\n    \"\"\"Lazy import to avoid dependency errors when ragas is not installed.\"\"\"\n    if name == \"RAGEvaluator\":\n        from .eval_rag_quality import RAGEvaluator\n\n        return RAGEvaluator\n    raise AttributeError(f\"module {__name__!r} has no attribute {name!r}\")\n"
  },
  {
    "path": "lightrag/evaluation/eval_rag_quality.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nRAGAS Evaluation Script for LightRAG System\n\nEvaluates RAG response quality using RAGAS metrics:\n- Faithfulness: Is the answer factually accurate based on context?\n- Answer Relevance: Is the answer relevant to the question?\n- Context Recall: Is all relevant information retrieved?\n- Context Precision: Is retrieved context clean without noise?\n\nUsage:\n    # Use defaults (sample_dataset.json, http://localhost:9621)\n    python lightrag/evaluation/eval_rag_quality.py\n\n    # Specify custom dataset\n    python lightrag/evaluation/eval_rag_quality.py --dataset my_test.json\n    python lightrag/evaluation/eval_rag_quality.py -d my_test.json\n\n    # Specify custom RAG endpoint\n    python lightrag/evaluation/eval_rag_quality.py --ragendpoint http://my-server.com:9621\n    python lightrag/evaluation/eval_rag_quality.py -r http://my-server.com:9621\n\n    # Specify both\n    python lightrag/evaluation/eval_rag_quality.py -d my_test.json -r http://localhost:9621\n\n    # Get help\n    python lightrag/evaluation/eval_rag_quality.py --help\n\nResults are saved to: lightrag/evaluation/results/\n    - results_YYYYMMDD_HHMMSS.csv   (CSV export for analysis)\n    - results_YYYYMMDD_HHMMSS.json  (Full results with details)\n\nTechnical Notes:\n    - Uses stable RAGAS API (LangchainLLMWrapper) for maximum compatibility\n    - Supports custom OpenAI-compatible endpoints via EVAL_LLM_BINDING_HOST\n    - Enables bypass_n mode for endpoints that don't support 'n' parameter\n    - Deprecation warnings are suppressed for cleaner output\n\"\"\"\n\nimport argparse\nimport asyncio\nimport csv\nimport json\nimport math\nimport os\nimport sys\nimport time\nimport warnings\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Any, Dict, List\n\nimport httpx\nfrom dotenv import load_dotenv\nfrom lightrag.utils import logger\n\n# Suppress LangchainLLMWrapper deprecation warning\n# We use LangchainLLMWrapper for stability and compatibility with all RAGAS versions\nwarnings.filterwarnings(\n    \"ignore\",\n    message=\".*LangchainLLMWrapper is deprecated.*\",\n    category=DeprecationWarning,\n)\n\n# Suppress token usage warning for custom OpenAI-compatible endpoints\n# Custom endpoints (vLLM, SGLang, etc.) often don't return usage information\n# This is non-critical as token tracking is not required for RAGAS evaluation\nwarnings.filterwarnings(\n    \"ignore\",\n    message=\".*Unexpected type for token usage.*\",\n    category=UserWarning,\n)\n\n# Add parent directory to path\nsys.path.insert(0, str(Path(__file__).parent.parent.parent))\n\n# use the .env that is inside the current folder\n# allows to use different .env file for each lightrag instance\n# the OS environment variables take precedence over the .env file\nload_dotenv(dotenv_path=\".env\", override=False)\n\n# Conditional imports - will raise ImportError if dependencies not installed\ntry:\n    from datasets import Dataset\n    from ragas import evaluate\n    from ragas.metrics import (\n        AnswerRelevancy,\n        ContextPrecision,\n        ContextRecall,\n        Faithfulness,\n    )\n    from ragas.llms import LangchainLLMWrapper\n    from langchain_openai import ChatOpenAI, OpenAIEmbeddings\n    from tqdm.auto import tqdm\n\n    RAGAS_AVAILABLE = True\n\nexcept ImportError:\n    RAGAS_AVAILABLE = False\n    Dataset = None\n    evaluate = None\n    LangchainLLMWrapper = None\n\n\nCONNECT_TIMEOUT_SECONDS = 180.0\nREAD_TIMEOUT_SECONDS = 300.0\nTOTAL_TIMEOUT_SECONDS = 180.0\n\n\ndef _is_nan(value: Any) -> bool:\n    \"\"\"Return True when value is a float NaN.\"\"\"\n    return isinstance(value, float) and math.isnan(value)\n\n\nclass RAGEvaluator:\n    \"\"\"Evaluate RAG system quality using RAGAS metrics\"\"\"\n\n    def __init__(self, test_dataset_path: str = None, rag_api_url: str = None):\n        \"\"\"\n        Initialize evaluator with test dataset\n\n        Args:\n            test_dataset_path: Path to test dataset JSON file\n            rag_api_url: Base URL of LightRAG API (e.g., http://localhost:9621)\n                        If None, will try to read from environment or use default\n\n        Environment Variables:\n            EVAL_LLM_MODEL: LLM model for evaluation (default: gpt-4o-mini)\n            EVAL_EMBEDDING_MODEL: Embedding model for evaluation (default: text-embedding-3-small)\n            EVAL_LLM_BINDING_API_KEY: API key for LLM (fallback to OPENAI_API_KEY)\n            EVAL_LLM_BINDING_HOST: Custom endpoint URL for LLM (optional)\n            EVAL_EMBEDDING_BINDING_API_KEY: API key for embeddings (fallback: EVAL_LLM_BINDING_API_KEY -> OPENAI_API_KEY)\n            EVAL_EMBEDDING_BINDING_HOST: Custom endpoint URL for embeddings (fallback: EVAL_LLM_BINDING_HOST)\n\n        Raises:\n            ImportError: If ragas or datasets packages are not installed\n            EnvironmentError: If EVAL_LLM_BINDING_API_KEY and OPENAI_API_KEY are both not set\n        \"\"\"\n        # Validate RAGAS dependencies are installed\n        if not RAGAS_AVAILABLE:\n            raise ImportError(\n                \"RAGAS dependencies not installed. \"\n                \"Install with: pip install ragas datasets\"\n            )\n\n        # Configure evaluation LLM (for RAGAS scoring)\n        eval_llm_api_key = os.getenv(\"EVAL_LLM_BINDING_API_KEY\") or os.getenv(\n            \"OPENAI_API_KEY\"\n        )\n        if not eval_llm_api_key:\n            raise EnvironmentError(\n                \"EVAL_LLM_BINDING_API_KEY or OPENAI_API_KEY is required for evaluation. \"\n                \"Set EVAL_LLM_BINDING_API_KEY to use a custom API key, \"\n                \"or ensure OPENAI_API_KEY is set.\"\n            )\n\n        eval_model = os.getenv(\"EVAL_LLM_MODEL\", \"gpt-4o-mini\")\n        eval_llm_base_url = os.getenv(\"EVAL_LLM_BINDING_HOST\")\n\n        # Configure evaluation embeddings (for RAGAS scoring)\n        # Fallback chain: EVAL_EMBEDDING_BINDING_API_KEY -> EVAL_LLM_BINDING_API_KEY -> OPENAI_API_KEY\n        eval_embedding_api_key = (\n            os.getenv(\"EVAL_EMBEDDING_BINDING_API_KEY\")\n            or os.getenv(\"EVAL_LLM_BINDING_API_KEY\")\n            or os.getenv(\"OPENAI_API_KEY\")\n        )\n        eval_embedding_model = os.getenv(\n            \"EVAL_EMBEDDING_MODEL\", \"text-embedding-3-large\"\n        )\n        # Fallback chain: EVAL_EMBEDDING_BINDING_HOST -> EVAL_LLM_BINDING_HOST -> None\n        eval_embedding_base_url = os.getenv(\"EVAL_EMBEDDING_BINDING_HOST\") or os.getenv(\n            \"EVAL_LLM_BINDING_HOST\"\n        )\n\n        # Create LLM and Embeddings instances for RAGAS\n        llm_kwargs = {\n            \"model\": eval_model,\n            \"api_key\": eval_llm_api_key,\n            \"max_retries\": int(os.getenv(\"EVAL_LLM_MAX_RETRIES\", \"5\")),\n            \"request_timeout\": int(os.getenv(\"EVAL_LLM_TIMEOUT\", \"180\")),\n        }\n        embedding_kwargs = {\n            \"model\": eval_embedding_model,\n            \"api_key\": eval_embedding_api_key,\n        }\n\n        if eval_llm_base_url:\n            llm_kwargs[\"base_url\"] = eval_llm_base_url\n\n        if eval_embedding_base_url:\n            embedding_kwargs[\"base_url\"] = eval_embedding_base_url\n\n        # Create base LangChain LLM\n        base_llm = ChatOpenAI(**llm_kwargs)\n        self.eval_embeddings = OpenAIEmbeddings(**embedding_kwargs)\n\n        # Wrap LLM with LangchainLLMWrapper and enable bypass_n mode for custom endpoints\n        # This ensures compatibility with endpoints that don't support the 'n' parameter\n        # by generating multiple outputs through repeated prompts instead of using 'n' parameter\n        try:\n            self.eval_llm = LangchainLLMWrapper(\n                langchain_llm=base_llm,\n                bypass_n=True,  # Enable bypass_n to avoid passing 'n' to OpenAI API\n            )\n            logger.debug(\"Successfully configured bypass_n mode for LLM wrapper\")\n        except Exception as e:\n            logger.warning(\n                \"Could not configure LangchainLLMWrapper with bypass_n: %s. \"\n                \"Using base LLM directly, which may cause warnings with custom endpoints.\",\n                e,\n            )\n            self.eval_llm = base_llm\n\n        if test_dataset_path is None:\n            test_dataset_path = Path(__file__).parent / \"sample_dataset.json\"\n\n        if rag_api_url is None:\n            rag_api_url = os.getenv(\"LIGHTRAG_API_URL\", \"http://localhost:9621\")\n\n        self.test_dataset_path = Path(test_dataset_path)\n        self.rag_api_url = rag_api_url.rstrip(\"/\")\n        self.results_dir = Path(__file__).parent / \"results\"\n        self.results_dir.mkdir(exist_ok=True)\n\n        # Load test dataset\n        self.test_cases = self._load_test_dataset()\n\n        # Store configuration values for display\n        self.eval_model = eval_model\n        self.eval_embedding_model = eval_embedding_model\n        self.eval_llm_base_url = eval_llm_base_url\n        self.eval_embedding_base_url = eval_embedding_base_url\n        self.eval_max_retries = llm_kwargs[\"max_retries\"]\n        self.eval_timeout = llm_kwargs[\"request_timeout\"]\n\n        # Display configuration\n        self._display_configuration()\n\n    def _display_configuration(self):\n        \"\"\"Display all evaluation configuration settings\"\"\"\n        logger.info(\"Evaluation Models:\")\n        logger.info(\"  • LLM Model:            %s\", self.eval_model)\n        logger.info(\"  • Embedding Model:      %s\", self.eval_embedding_model)\n\n        # Display LLM endpoint\n        if self.eval_llm_base_url:\n            logger.info(\"  • LLM Endpoint:         %s\", self.eval_llm_base_url)\n            logger.info(\n                \"  • Bypass N-Parameter:   Enabled (use LangchainLLMWrapper for compatibility)\"\n            )\n        else:\n            logger.info(\"  • LLM Endpoint:         OpenAI Official API\")\n\n        # Display Embedding endpoint (only if different from LLM)\n        if self.eval_embedding_base_url:\n            if self.eval_embedding_base_url != self.eval_llm_base_url:\n                logger.info(\n                    \"  • Embedding Endpoint:   %s\", self.eval_embedding_base_url\n                )\n            # If same as LLM endpoint, no need to display separately\n        elif not self.eval_llm_base_url:\n            # Both using OpenAI - already displayed above\n            pass\n        else:\n            # LLM uses custom endpoint, but embeddings use OpenAI\n            logger.info(\"  • Embedding Endpoint:   OpenAI Official API\")\n\n        logger.info(\"Concurrency & Rate Limiting:\")\n        query_top_k = int(os.getenv(\"EVAL_QUERY_TOP_K\", \"10\"))\n        logger.info(\"  • Query Top-K:          %s Entities/Relations\", query_top_k)\n        logger.info(\"  • LLM Max Retries:      %s\", self.eval_max_retries)\n        logger.info(\"  • LLM Timeout:          %s seconds\", self.eval_timeout)\n\n        logger.info(\"Test Configuration:\")\n        logger.info(\"  • Total Test Cases:     %s\", len(self.test_cases))\n        logger.info(\"  • Test Dataset:         %s\", self.test_dataset_path.name)\n        logger.info(\"  • LightRAG API:         %s\", self.rag_api_url)\n        logger.info(\"  • Results Directory:    %s\", self.results_dir.name)\n\n    def _load_test_dataset(self) -> List[Dict[str, str]]:\n        \"\"\"Load test cases from JSON file\"\"\"\n        if not self.test_dataset_path.exists():\n            raise FileNotFoundError(f\"Test dataset not found: {self.test_dataset_path}\")\n\n        with open(self.test_dataset_path) as f:\n            data = json.load(f)\n\n        return data.get(\"test_cases\", [])\n\n    async def generate_rag_response(\n        self,\n        question: str,\n        client: httpx.AsyncClient,\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Generate RAG response by calling LightRAG API.\n\n        Args:\n            question: The user query.\n            client: Shared httpx AsyncClient for connection pooling.\n\n        Returns:\n            Dictionary with 'answer' and 'contexts' keys.\n            'contexts' is a list of strings (one per retrieved document).\n\n        Raises:\n            Exception: If LightRAG API is unavailable.\n        \"\"\"\n        try:\n            payload = {\n                \"query\": question,\n                \"mode\": \"mix\",\n                \"include_references\": True,\n                \"include_chunk_content\": True,  # NEW: Request chunk content in references\n                \"response_type\": \"Multiple Paragraphs\",\n                \"top_k\": int(os.getenv(\"EVAL_QUERY_TOP_K\", \"10\")),\n            }\n\n            # Get API key from environment for authentication\n            api_key = os.getenv(\"LIGHTRAG_API_KEY\")\n\n            # Prepare headers with optional authentication\n            headers = {}\n            if api_key:\n                headers[\"X-API-Key\"] = api_key\n\n            # Single optimized API call - gets both answer AND chunk content\n            response = await client.post(\n                f\"{self.rag_api_url}/query\",\n                json=payload,\n                headers=headers if headers else None,\n            )\n            response.raise_for_status()\n            result = response.json()\n\n            answer = result.get(\"response\", \"No response generated\")\n            references = result.get(\"references\", [])\n\n            # DEBUG: Inspect the API response\n            logger.debug(\"🔍 References Count: %s\", len(references))\n            if references:\n                first_ref = references[0]\n                logger.debug(\"🔍 First Reference Keys: %s\", list(first_ref.keys()))\n                if \"content\" in first_ref:\n                    content_preview = first_ref[\"content\"]\n                    if isinstance(content_preview, list) and content_preview:\n                        logger.debug(\n                            \"🔍 Content Preview (first chunk): %s...\",\n                            content_preview[0][:100],\n                        )\n                    elif isinstance(content_preview, str):\n                        logger.debug(\"🔍 Content Preview: %s...\", content_preview[:100])\n\n            # Extract chunk content from enriched references\n            # Note: content is now a list of chunks per reference (one file may have multiple chunks)\n            contexts = []\n            for ref in references:\n                content = ref.get(\"content\", [])\n                if isinstance(content, list):\n                    # Flatten the list: each chunk becomes a separate context\n                    contexts.extend(content)\n                elif isinstance(content, str):\n                    # Backward compatibility: if content is still a string (shouldn't happen)\n                    contexts.append(content)\n\n            return {\n                \"answer\": answer,\n                \"contexts\": contexts,  # List of strings from actual retrieved chunks\n            }\n\n        except httpx.ConnectError as e:\n            raise Exception(\n                f\"❌ Cannot connect to LightRAG API at {self.rag_api_url}\\n\"\n                f\"   Make sure LightRAG server is running:\\n\"\n                f\"   python -m lightrag.api.lightrag_server\\n\"\n                f\"   Error: {str(e)}\"\n            )\n        except httpx.HTTPStatusError as e:\n            raise Exception(\n                f\"LightRAG API error {e.response.status_code}: {e.response.text}\"\n            )\n        except httpx.ReadTimeout as e:\n            raise Exception(\n                f\"Request timeout after waiting for response\\n\"\n                f\"   Question: {question[:100]}...\\n\"\n                f\"   Error: {str(e)}\"\n            )\n        except Exception as e:\n            raise Exception(f\"Error calling LightRAG API: {type(e).__name__}: {str(e)}\")\n\n    async def evaluate_single_case(\n        self,\n        idx: int,\n        test_case: Dict[str, str],\n        rag_semaphore: asyncio.Semaphore,\n        eval_semaphore: asyncio.Semaphore,\n        client: httpx.AsyncClient,\n        progress_counter: Dict[str, int],\n        position_pool: asyncio.Queue,\n        pbar_creation_lock: asyncio.Lock,\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Evaluate a single test case with two-stage pipeline concurrency control\n\n        Args:\n            idx: Test case index (1-based)\n            test_case: Test case dictionary with question and ground_truth\n            rag_semaphore: Semaphore to control overall concurrency (covers entire function)\n            eval_semaphore: Semaphore to control RAGAS evaluation concurrency (Stage 2)\n            client: Shared httpx AsyncClient for connection pooling\n            progress_counter: Shared dictionary for progress tracking\n            position_pool: Queue of available tqdm position indices\n            pbar_creation_lock: Lock to serialize tqdm creation and prevent race conditions\n\n        Returns:\n            Evaluation result dictionary\n        \"\"\"\n        # rag_semaphore controls the entire evaluation process to prevent\n        # all RAG responses from being generated at once when eval is slow\n        async with rag_semaphore:\n            question = test_case[\"question\"]\n            ground_truth = test_case[\"ground_truth\"]\n\n            # Stage 1: Generate RAG response\n            try:\n                rag_response = await self.generate_rag_response(\n                    question=question, client=client\n                )\n            except Exception as e:\n                logger.error(\"Error generating response for test %s: %s\", idx, str(e))\n                progress_counter[\"completed\"] += 1\n                return {\n                    \"test_number\": idx,\n                    \"question\": question,\n                    \"error\": str(e),\n                    \"metrics\": {},\n                    \"ragas_score\": 0,\n                    \"timestamp\": datetime.now().isoformat(),\n                }\n\n            # *** CRITICAL FIX: Use actual retrieved contexts, NOT ground_truth ***\n            retrieved_contexts = rag_response[\"contexts\"]\n\n            # Prepare dataset for RAGAS evaluation with CORRECT contexts\n            eval_dataset = Dataset.from_dict(\n                {\n                    \"question\": [question],\n                    \"answer\": [rag_response[\"answer\"]],\n                    \"contexts\": [retrieved_contexts],\n                    \"ground_truth\": [ground_truth],\n                }\n            )\n\n            # Stage 2: Run RAGAS evaluation (controlled by eval_semaphore)\n            # IMPORTANT: Create fresh metric instances for each evaluation to avoid\n            # concurrent state conflicts when multiple tasks run in parallel\n            async with eval_semaphore:\n                pbar = None\n                position = None\n                try:\n                    # Acquire a position from the pool for this tqdm progress bar\n                    position = await position_pool.get()\n\n                    # Serialize tqdm creation to prevent race conditions\n                    # Multiple tasks creating tqdm simultaneously can cause display conflicts\n                    async with pbar_creation_lock:\n                        # Create tqdm progress bar with assigned position to avoid overlapping\n                        # leave=False ensures the progress bar is cleared after completion,\n                        # preventing accumulation of completed bars and allowing position reuse\n                        pbar = tqdm(\n                            total=4,\n                            desc=f\"Eval-{idx:02d}\",\n                            position=position,\n                            leave=False,\n                        )\n                        # Give tqdm time to initialize and claim its screen position\n                        await asyncio.sleep(0.05)\n\n                    eval_results = evaluate(\n                        dataset=eval_dataset,\n                        metrics=[\n                            Faithfulness(),\n                            AnswerRelevancy(),\n                            ContextRecall(),\n                            ContextPrecision(),\n                        ],\n                        llm=self.eval_llm,\n                        embeddings=self.eval_embeddings,\n                        _pbar=pbar,\n                    )\n\n                    # Convert to DataFrame (RAGAS v0.3+ API)\n                    df = eval_results.to_pandas()\n\n                    # Extract scores from first row\n                    scores_row = df.iloc[0]\n\n                    # Extract scores (RAGAS v0.3+ uses .to_pandas())\n                    result = {\n                        \"test_number\": idx,\n                        \"question\": question,\n                        \"answer\": rag_response[\"answer\"][:200] + \"...\"\n                        if len(rag_response[\"answer\"]) > 200\n                        else rag_response[\"answer\"],\n                        \"ground_truth\": ground_truth[:200] + \"...\"\n                        if len(ground_truth) > 200\n                        else ground_truth,\n                        \"project\": test_case.get(\"project\", \"unknown\"),\n                        \"metrics\": {\n                            \"faithfulness\": float(scores_row.get(\"faithfulness\", 0)),\n                            \"answer_relevance\": float(\n                                scores_row.get(\"answer_relevancy\", 0)\n                            ),\n                            \"context_recall\": float(\n                                scores_row.get(\"context_recall\", 0)\n                            ),\n                            \"context_precision\": float(\n                                scores_row.get(\"context_precision\", 0)\n                            ),\n                        },\n                        \"timestamp\": datetime.now().isoformat(),\n                    }\n\n                    # Calculate RAGAS score (average of all metrics, excluding NaN values)\n                    metrics = result[\"metrics\"]\n                    valid_metrics = [v for v in metrics.values() if not _is_nan(v)]\n                    ragas_score = (\n                        sum(valid_metrics) / len(valid_metrics) if valid_metrics else 0\n                    )\n                    result[\"ragas_score\"] = round(ragas_score, 4)\n\n                    # Update progress counter\n                    progress_counter[\"completed\"] += 1\n\n                    return result\n\n                except Exception as e:\n                    logger.error(\"Error evaluating test %s: %s\", idx, str(e))\n                    progress_counter[\"completed\"] += 1\n                    return {\n                        \"test_number\": idx,\n                        \"question\": question,\n                        \"error\": str(e),\n                        \"metrics\": {},\n                        \"ragas_score\": 0,\n                        \"timestamp\": datetime.now().isoformat(),\n                    }\n                finally:\n                    # Force close progress bar to ensure completion\n                    if pbar is not None:\n                        pbar.close()\n                    # Release the position back to the pool for reuse\n                    if position is not None:\n                        await position_pool.put(position)\n\n    async def evaluate_responses(self) -> List[Dict[str, Any]]:\n        \"\"\"\n        Evaluate all test cases in parallel with two-stage pipeline and return metrics\n\n        Returns:\n            List of evaluation results with metrics\n        \"\"\"\n        # Get evaluation concurrency from environment (default to 2 for parallel evaluation)\n        max_async = int(os.getenv(\"EVAL_MAX_CONCURRENT\", \"2\"))\n\n        logger.info(\"%s\", \"=\" * 70)\n        logger.info(\"🚀 Starting RAGAS Evaluation of LightRAG System\")\n        logger.info(\"🔧 RAGAS Evaluation (Stage 2): %s concurrent\", max_async)\n        logger.info(\"%s\", \"=\" * 70)\n\n        # Create two-stage pipeline semaphores\n        # Stage 1: RAG generation - allow x2 concurrency to keep evaluation fed\n        rag_semaphore = asyncio.Semaphore(max_async * 2)\n        # Stage 2: RAGAS evaluation - primary bottleneck\n        eval_semaphore = asyncio.Semaphore(max_async)\n\n        # Create progress counter (shared across all tasks)\n        progress_counter = {\"completed\": 0}\n\n        # Create position pool for tqdm progress bars\n        # Positions range from 0 to max_async-1, ensuring no overlapping displays\n        position_pool = asyncio.Queue()\n        for i in range(max_async):\n            await position_pool.put(i)\n\n        # Create lock to serialize tqdm creation and prevent race conditions\n        # This ensures progress bars are created one at a time, avoiding display conflicts\n        pbar_creation_lock = asyncio.Lock()\n\n        # Create shared HTTP client with connection pooling and proper timeouts\n        # Timeout: 3 minutes for connect, 5 minutes for read (LLM can be slow)\n        timeout = httpx.Timeout(\n            TOTAL_TIMEOUT_SECONDS,\n            connect=CONNECT_TIMEOUT_SECONDS,\n            read=READ_TIMEOUT_SECONDS,\n        )\n        limits = httpx.Limits(\n            max_connections=(max_async + 1) * 2,  # Allow buffer for RAG stage\n            max_keepalive_connections=max_async + 1,\n        )\n\n        async with httpx.AsyncClient(timeout=timeout, limits=limits) as client:\n            # Create tasks for all test cases\n            tasks = [\n                self.evaluate_single_case(\n                    idx,\n                    test_case,\n                    rag_semaphore,\n                    eval_semaphore,\n                    client,\n                    progress_counter,\n                    position_pool,\n                    pbar_creation_lock,\n                )\n                for idx, test_case in enumerate(self.test_cases, 1)\n            ]\n\n            # Run all evaluations in parallel (limited by two-stage semaphores)\n            results = await asyncio.gather(*tasks)\n\n        return list(results)\n\n    def _export_to_csv(self, results: List[Dict[str, Any]]) -> Path:\n        \"\"\"\n        Export evaluation results to CSV file\n\n        Args:\n            results: List of evaluation results\n\n        Returns:\n            Path to the CSV file\n\n        CSV Format:\n            - question: The test question\n            - project: Project context\n            - faithfulness: Faithfulness score (0-1)\n            - answer_relevance: Answer relevance score (0-1)\n            - context_recall: Context recall score (0-1)\n            - context_precision: Context precision score (0-1)\n            - ragas_score: Overall RAGAS score (0-1)\n            - timestamp: When evaluation was run\n        \"\"\"\n        csv_path = (\n            self.results_dir / f\"results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv\"\n        )\n\n        with open(csv_path, \"w\", newline=\"\", encoding=\"utf-8\") as f:\n            fieldnames = [\n                \"test_number\",\n                \"question\",\n                \"project\",\n                \"faithfulness\",\n                \"answer_relevance\",\n                \"context_recall\",\n                \"context_precision\",\n                \"ragas_score\",\n                \"status\",\n                \"timestamp\",\n            ]\n\n            writer = csv.DictWriter(f, fieldnames=fieldnames)\n            writer.writeheader()\n\n            for idx, result in enumerate(results, 1):\n                metrics = result.get(\"metrics\", {})\n                writer.writerow(\n                    {\n                        \"test_number\": idx,\n                        \"question\": result.get(\"question\", \"\"),\n                        \"project\": result.get(\"project\", \"unknown\"),\n                        \"faithfulness\": f\"{metrics.get('faithfulness', 0):.4f}\",\n                        \"answer_relevance\": f\"{metrics.get('answer_relevance', 0):.4f}\",\n                        \"context_recall\": f\"{metrics.get('context_recall', 0):.4f}\",\n                        \"context_precision\": f\"{metrics.get('context_precision', 0):.4f}\",\n                        \"ragas_score\": f\"{result.get('ragas_score', 0):.4f}\",\n                        \"status\": \"success\" if metrics else \"error\",\n                        \"timestamp\": result.get(\"timestamp\", \"\"),\n                    }\n                )\n\n        return csv_path\n\n    def _format_metric(self, value: float, width: int = 6) -> str:\n        \"\"\"\n        Format a metric value for display, handling NaN gracefully\n\n        Args:\n            value: The metric value to format\n            width: The width of the formatted string\n\n        Returns:\n            Formatted string (e.g., \"0.8523\" or \"  N/A \")\n        \"\"\"\n        if _is_nan(value):\n            return \"N/A\".center(width)\n        return f\"{value:.4f}\".rjust(width)\n\n    def _display_results_table(self, results: List[Dict[str, Any]]):\n        \"\"\"\n        Display evaluation results in a formatted table\n\n        Args:\n            results: List of evaluation results\n        \"\"\"\n        logger.info(\"\")\n        logger.info(\"%s\", \"=\" * 115)\n        logger.info(\"📊 EVALUATION RESULTS SUMMARY\")\n        logger.info(\"%s\", \"=\" * 115)\n\n        # Table header\n        logger.info(\n            \"%-4s | %-50s | %6s | %7s | %6s | %7s | %6s | %6s\",\n            \"#\",\n            \"Question\",\n            \"Faith\",\n            \"AnswRel\",\n            \"CtxRec\",\n            \"CtxPrec\",\n            \"RAGAS\",\n            \"Status\",\n        )\n        logger.info(\"%s\", \"-\" * 115)\n\n        # Table rows\n        for result in results:\n            test_num = result.get(\"test_number\", 0)\n            question = result.get(\"question\", \"\")\n            # Truncate question to 50 chars\n            question_display = (\n                (question[:47] + \"...\") if len(question) > 50 else question\n            )\n\n            metrics = result.get(\"metrics\", {})\n            if metrics:\n                # Success case - format each metric, handling NaN values\n                faith = metrics.get(\"faithfulness\", 0)\n                ans_rel = metrics.get(\"answer_relevance\", 0)\n                ctx_rec = metrics.get(\"context_recall\", 0)\n                ctx_prec = metrics.get(\"context_precision\", 0)\n                ragas = result.get(\"ragas_score\", 0)\n                status = \"✓\"\n\n                logger.info(\n                    \"%-4d | %-50s | %s | %s | %s | %s | %s | %6s\",\n                    test_num,\n                    question_display,\n                    self._format_metric(faith, 6),\n                    self._format_metric(ans_rel, 7),\n                    self._format_metric(ctx_rec, 6),\n                    self._format_metric(ctx_prec, 7),\n                    self._format_metric(ragas, 6),\n                    status,\n                )\n            else:\n                # Error case\n                error = result.get(\"error\", \"Unknown error\")\n                error_display = (error[:20] + \"...\") if len(error) > 23 else error\n                logger.info(\n                    \"%-4d | %-50s | %6s | %7s | %6s | %7s | %6s | ✗ %s\",\n                    test_num,\n                    question_display,\n                    \"N/A\",\n                    \"N/A\",\n                    \"N/A\",\n                    \"N/A\",\n                    \"N/A\",\n                    error_display,\n                )\n\n        logger.info(\"%s\", \"=\" * 115)\n\n    def _calculate_benchmark_stats(\n        self, results: List[Dict[str, Any]]\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Calculate benchmark statistics from evaluation results\n\n        Args:\n            results: List of evaluation results\n\n        Returns:\n            Dictionary with benchmark statistics\n        \"\"\"\n        # Filter out results with errors\n        valid_results = [r for r in results if r.get(\"metrics\")]\n        total_tests = len(results)\n        successful_tests = len(valid_results)\n        failed_tests = total_tests - successful_tests\n\n        if not valid_results:\n            return {\n                \"total_tests\": total_tests,\n                \"successful_tests\": 0,\n                \"failed_tests\": failed_tests,\n                \"success_rate\": 0.0,\n            }\n\n        # Calculate averages for each metric (handling NaN values correctly)\n        # Track both sum and count for each metric to handle NaN values properly\n        metrics_data = {\n            \"faithfulness\": {\"sum\": 0.0, \"count\": 0},\n            \"answer_relevance\": {\"sum\": 0.0, \"count\": 0},\n            \"context_recall\": {\"sum\": 0.0, \"count\": 0},\n            \"context_precision\": {\"sum\": 0.0, \"count\": 0},\n            \"ragas_score\": {\"sum\": 0.0, \"count\": 0},\n        }\n\n        for result in valid_results:\n            metrics = result.get(\"metrics\", {})\n\n            # For each metric, sum non-NaN values and count them\n            faithfulness = metrics.get(\"faithfulness\", 0)\n            if not _is_nan(faithfulness):\n                metrics_data[\"faithfulness\"][\"sum\"] += faithfulness\n                metrics_data[\"faithfulness\"][\"count\"] += 1\n\n            answer_relevance = metrics.get(\"answer_relevance\", 0)\n            if not _is_nan(answer_relevance):\n                metrics_data[\"answer_relevance\"][\"sum\"] += answer_relevance\n                metrics_data[\"answer_relevance\"][\"count\"] += 1\n\n            context_recall = metrics.get(\"context_recall\", 0)\n            if not _is_nan(context_recall):\n                metrics_data[\"context_recall\"][\"sum\"] += context_recall\n                metrics_data[\"context_recall\"][\"count\"] += 1\n\n            context_precision = metrics.get(\"context_precision\", 0)\n            if not _is_nan(context_precision):\n                metrics_data[\"context_precision\"][\"sum\"] += context_precision\n                metrics_data[\"context_precision\"][\"count\"] += 1\n\n            ragas_score = result.get(\"ragas_score\", 0)\n            if not _is_nan(ragas_score):\n                metrics_data[\"ragas_score\"][\"sum\"] += ragas_score\n                metrics_data[\"ragas_score\"][\"count\"] += 1\n\n        # Calculate averages using actual counts for each metric\n        avg_metrics = {}\n        for metric_name, data in metrics_data.items():\n            if data[\"count\"] > 0:\n                avg_val = data[\"sum\"] / data[\"count\"]\n                avg_metrics[metric_name] = (\n                    round(avg_val, 4) if not _is_nan(avg_val) else 0.0\n                )\n            else:\n                avg_metrics[metric_name] = 0.0\n\n        # Find min and max RAGAS scores (filter out NaN)\n        ragas_scores = []\n        for r in valid_results:\n            score = r.get(\"ragas_score\", 0)\n            if _is_nan(score):\n                continue  # Skip NaN values\n            ragas_scores.append(score)\n\n        min_score = min(ragas_scores) if ragas_scores else 0\n        max_score = max(ragas_scores) if ragas_scores else 0\n\n        return {\n            \"total_tests\": total_tests,\n            \"successful_tests\": successful_tests,\n            \"failed_tests\": failed_tests,\n            \"success_rate\": round(successful_tests / total_tests * 100, 2),\n            \"average_metrics\": avg_metrics,\n            \"min_ragas_score\": round(min_score, 4),\n            \"max_ragas_score\": round(max_score, 4),\n        }\n\n    async def run(self) -> Dict[str, Any]:\n        \"\"\"Run complete evaluation pipeline\"\"\"\n\n        start_time = time.time()\n\n        # Evaluate responses\n        results = await self.evaluate_responses()\n\n        elapsed_time = time.time() - start_time\n\n        # Calculate benchmark statistics\n        benchmark_stats = self._calculate_benchmark_stats(results)\n\n        # Save results\n        summary = {\n            \"timestamp\": datetime.now().isoformat(),\n            \"total_tests\": len(results),\n            \"elapsed_time_seconds\": round(elapsed_time, 2),\n            \"benchmark_stats\": benchmark_stats,\n            \"results\": results,\n        }\n\n        # Display results table\n        self._display_results_table(results)\n\n        # Save JSON results\n        json_path = (\n            self.results_dir\n            / f\"results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json\"\n        )\n        with open(json_path, \"w\") as f:\n            json.dump(summary, f, indent=2)\n\n        # Export to CSV\n        csv_path = self._export_to_csv(results)\n\n        # Print summary\n        logger.info(\"\")\n        logger.info(\"%s\", \"=\" * 70)\n        logger.info(\"📊 EVALUATION COMPLETE\")\n        logger.info(\"%s\", \"=\" * 70)\n        logger.info(\"Total Tests:    %s\", len(results))\n        logger.info(\"Successful:     %s\", benchmark_stats[\"successful_tests\"])\n        logger.info(\"Failed:         %s\", benchmark_stats[\"failed_tests\"])\n        logger.info(\"Success Rate:   %.2f%%\", benchmark_stats[\"success_rate\"])\n        logger.info(\"Elapsed Time:   %.2f seconds\", elapsed_time)\n        logger.info(\"Avg Time/Test:  %.2f seconds\", elapsed_time / len(results))\n\n        # Print benchmark metrics\n        logger.info(\"\")\n        logger.info(\"%s\", \"=\" * 70)\n        logger.info(\"📈 BENCHMARK RESULTS (Average)\")\n        logger.info(\"%s\", \"=\" * 70)\n        avg = benchmark_stats[\"average_metrics\"]\n        logger.info(\"Average Faithfulness:      %.4f\", avg[\"faithfulness\"])\n        logger.info(\"Average Answer Relevance:  %.4f\", avg[\"answer_relevance\"])\n        logger.info(\"Average Context Recall:    %.4f\", avg[\"context_recall\"])\n        logger.info(\"Average Context Precision: %.4f\", avg[\"context_precision\"])\n        logger.info(\"Average RAGAS Score:       %.4f\", avg[\"ragas_score\"])\n        logger.info(\"%s\", \"-\" * 70)\n        logger.info(\n            \"Min RAGAS Score:           %.4f\",\n            benchmark_stats[\"min_ragas_score\"],\n        )\n        logger.info(\n            \"Max RAGAS Score:           %.4f\",\n            benchmark_stats[\"max_ragas_score\"],\n        )\n\n        logger.info(\"\")\n        logger.info(\"%s\", \"=\" * 70)\n        logger.info(\"📁 GENERATED FILES\")\n        logger.info(\"%s\", \"=\" * 70)\n        logger.info(\"Results Dir:    %s\", self.results_dir.absolute())\n        logger.info(\"   • CSV:  %s\", csv_path.name)\n        logger.info(\"   • JSON: %s\", json_path.name)\n        logger.info(\"%s\", \"=\" * 70)\n\n        return summary\n\n\nasync def main():\n    \"\"\"\n    Main entry point for RAGAS evaluation\n\n    Command-line arguments:\n        --dataset, -d: Path to test dataset JSON file (default: sample_dataset.json)\n        --ragendpoint, -r: LightRAG API endpoint URL (default: http://localhost:9621 or $LIGHTRAG_API_URL)\n\n    Usage:\n        python lightrag/evaluation/eval_rag_quality.py\n        python lightrag/evaluation/eval_rag_quality.py --dataset my_test.json\n        python lightrag/evaluation/eval_rag_quality.py -d my_test.json -r http://localhost:9621\n    \"\"\"\n    try:\n        # Parse command-line arguments\n        parser = argparse.ArgumentParser(\n            description=\"RAGAS Evaluation Script for LightRAG System\",\n            formatter_class=argparse.RawDescriptionHelpFormatter,\n            epilog=\"\"\"\nExamples:\n  # Use defaults\n  python lightrag/evaluation/eval_rag_quality.py\n\n  # Specify custom dataset\n  python lightrag/evaluation/eval_rag_quality.py --dataset my_test.json\n\n  # Specify custom RAG endpoint\n  python lightrag/evaluation/eval_rag_quality.py --ragendpoint http://my-server.com:9621\n\n  # Specify both\n  python lightrag/evaluation/eval_rag_quality.py -d my_test.json -r http://localhost:9621\n            \"\"\",\n        )\n\n        parser.add_argument(\n            \"--dataset\",\n            \"-d\",\n            type=str,\n            default=None,\n            help=\"Path to test dataset JSON file (default: sample_dataset.json in evaluation directory)\",\n        )\n\n        parser.add_argument(\n            \"--ragendpoint\",\n            \"-r\",\n            type=str,\n            default=None,\n            help=\"LightRAG API endpoint URL (default: http://localhost:9621 or $LIGHTRAG_API_URL environment variable)\",\n        )\n\n        args = parser.parse_args()\n\n        logger.info(\"%s\", \"=\" * 70)\n        logger.info(\"🔍 RAGAS Evaluation - Using Real LightRAG API\")\n        logger.info(\"%s\", \"=\" * 70)\n\n        evaluator = RAGEvaluator(\n            test_dataset_path=args.dataset, rag_api_url=args.ragendpoint\n        )\n        await evaluator.run()\n    except Exception as e:\n        logger.exception(\"❌ Error: %s\", e)\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "lightrag/evaluation/sample_dataset.json",
    "content": "{\n  \"test_cases\": [\n    {\n      \"question\": \"How does LightRAG solve the hallucination problem in large language models?\",\n      \"ground_truth\": \"LightRAG solves the hallucination problem by combining large language models with external knowledge retrieval. The framework ensures accurate responses by grounding LLM outputs in actual documents. LightRAG provides contextual responses that reduce hallucinations significantly.\",\n      \"project\": \"lightrag_evaluation_sample\"\n    },\n    {\n      \"question\": \"What are the three main components required in a RAG system?\",\n      \"ground_truth\": \"A RAG system requires three main components: a retrieval system (vector database or search engine) to find relevant documents, an embedding model to convert text into vector representations for similarity search, and a large language model (LLM) to generate responses based on retrieved context.\",\n      \"project\": \"lightrag_evaluation_sample\"\n    },\n    {\n      \"question\": \"How does LightRAG's retrieval performance compare to traditional RAG approaches?\",\n      \"ground_truth\": \"LightRAG delivers faster retrieval performance than traditional RAG approaches. The framework optimizes document retrieval operations for speed. Traditional RAG systems often suffer from slow query response times. LightRAG achieves high quality results with improved performance. The framework combines speed with accuracy in retrieval operations, prioritizing ease of use without sacrificing quality.\",\n      \"project\": \"lightrag_evaluation_sample\"\n    },\n    {\n      \"question\": \"What vector databases does LightRAG support and what are their key characteristics?\",\n      \"ground_truth\": \"LightRAG supports multiple vector databases including ChromaDB for simple deployment and efficient similarity search, Neo4j for graph-based knowledge representation with vector capabilities, Milvus for high-performance vector search at scale, Qdrant for fast similarity search with filtering and production-ready infrastructure, MongoDB Atlas for combined document storage and vector search, Redis for in-memory low-latency vector search, and a built-in nano-vectordb that eliminates external dependencies for small projects. This multi-database support enables developers to choose appropriate backends based on scale, performance, and infrastructure requirements.\",\n      \"project\": \"lightrag_evaluation_sample\"\n    },\n    {\n      \"question\": \"What are the four key metrics for evaluating RAG system quality and what does each metric measure?\",\n      \"ground_truth\": \"RAG system quality is measured through four key metrics: Faithfulness measures whether answers are factually grounded in retrieved context and detects hallucinations. Answer Relevance measures how well answers address the user question and evaluates response appropriateness. Context Recall measures completeness of retrieval and whether all relevant information was retrieved from documents. Context Precision measures quality and relevance of retrieved documents without noise or irrelevant content.\",\n      \"project\": \"lightrag_evaluation_sample\"\n    },\n    {\n      \"question\": \"What are the core benefits of LightRAG and how does it improve upon traditional RAG systems?\",\n      \"ground_truth\": \"LightRAG offers five core benefits: accuracy through document-grounded responses, up-to-date information without model retraining, domain expertise through specialized document collections, cost-effectiveness by avoiding expensive fine-tuning, and transparency by showing source documents. Compared to traditional RAG systems, LightRAG provides a simpler API with intuitive interfaces, faster retrieval performance with optimized operations, better integration with multiple vector database backends for flexible selection, and optimized prompting strategies with refined templates. LightRAG prioritizes ease of use while maintaining quality and combines speed with accuracy.\",\n      \"project\": \"lightrag_evaluation_sample\"\n    }\n  ]\n}\n"
  },
  {
    "path": "lightrag/evaluation/sample_documents/01_lightrag_overview.md",
    "content": "# LightRAG Framework Overview\n\n## What is LightRAG?\n\n**LightRAG** is a Simple and Fast Retrieval-Augmented Generation framework. LightRAG was developed by HKUDS (Hong Kong University Data Science Lab). The framework provides developers with tools to build RAG applications efficiently.\n\n## Problem Statement\n\nLarge language models face several limitations. LLMs have a knowledge cutoff date that prevents them from accessing recent information. Large language models generate hallucinations when providing responses without factual grounding. LLMs lack domain-specific expertise in specialized fields.\n\n## How LightRAG Solves These Problems\n\nLightRAG solves the hallucination problem by combining large language models with external knowledge retrieval. The framework ensures accurate responses by grounding LLM outputs in actual documents. LightRAG provides contextual responses that reduce hallucinations significantly. The system enables efficient retrieval from external knowledge bases to supplement LLM capabilities.\n\n## Core Benefits\n\nLightRAG offers accuracy through document-grounded responses. The framework provides up-to-date information without model retraining. LightRAG enables domain expertise through specialized document collections. The system delivers cost-effectiveness by avoiding expensive model fine-tuning. LightRAG ensures transparency by showing source documents for each response.\n"
  },
  {
    "path": "lightrag/evaluation/sample_documents/02_rag_architecture.md",
    "content": "# RAG System Architecture\n\n## Main Components of RAG Systems\n\nA RAG system consists of three main components that work together to provide intelligent responses.\n\n### Component 1: Retrieval System\n\nThe retrieval system is the first component of a RAG system. A retrieval system finds relevant documents from large document collections. Vector databases serve as the primary storage for the retrieval system. Search engines can also function as retrieval systems in RAG architectures.\n\n### Component 2: Embedding Model\n\nThe embedding model is the second component of a RAG system. An embedding model converts text into vector representations for similarity search. The embedding model transforms documents and queries into numerical vectors. These vector representations enable semantic similarity matching between queries and documents.\n\n### Component 3: Large Language Model\n\nThe large language model is the third component of a RAG system. An LLM generates responses based on retrieved context from documents. The large language model synthesizes information from multiple sources into coherent answers. LLMs provide natural language generation capabilities for the RAG system.\n\n## How Components Work Together\n\nThe retrieval system fetches relevant documents for a user query. The embedding model enables similarity matching between query and documents. The LLM generates the final response using retrieved context. These three components collaborate to provide accurate, contextual responses.\n"
  },
  {
    "path": "lightrag/evaluation/sample_documents/03_lightrag_improvements.md",
    "content": "# LightRAG Improvements Over Traditional RAG\n\n## Key Improvements\n\nLightRAG improves upon traditional RAG approaches in several significant ways.\n\n### Simpler API Design\n\nLightRAG offers a simpler API compared to traditional RAG frameworks. The framework provides intuitive interfaces for developers. Traditional RAG systems often require complex configuration and setup. LightRAG focuses on ease of use while maintaining functionality.\n\n### Faster Retrieval Performance\n\nLightRAG delivers faster retrieval performance than traditional RAG approaches. The framework optimizes document retrieval operations for speed. Traditional RAG systems often suffer from slow query response times. LightRAG achieves high quality results with improved performance.\n\n### Better Vector Database Integration\n\nLightRAG provides better integration with various vector databases. The framework supports multiple vector database backends seamlessly. Traditional RAG approaches typically lock developers into specific database choices. LightRAG enables flexible storage backend selection.\n\n### Optimized Prompting Strategies\n\nLightRAG implements optimized prompting strategies for better results. The framework uses refined prompt templates for accurate responses. Traditional RAG systems often use generic prompting approaches. LightRAG balances simplicity with high quality output.\n\n## Design Philosophy\n\nLightRAG prioritizes ease of use without sacrificing quality. The framework combines speed with accuracy in retrieval operations. LightRAG maintains flexibility in database and model selection.\n"
  },
  {
    "path": "lightrag/evaluation/sample_documents/04_supported_databases.md",
    "content": "# LightRAG Vector Database Support\n\n## Supported Vector Databases\n\nLightRAG supports multiple vector databases for flexible deployment options.\n\n### ChromaDB\n\nChromaDB is a vector database supported by LightRAG. ChromaDB provides simple deployment for development environments. The database offers efficient vector similarity search capabilities.\n\n### Neo4j\n\nNeo4j is a graph database supported by LightRAG. Neo4j enables graph-based knowledge representation alongside vector search. The database combines relationship modeling with vector capabilities.\n\n### Milvus\n\nMilvus is a vector database supported by LightRAG. Milvus provides high-performance vector search at scale. The database handles large-scale vector collections efficiently.\n\n### Qdrant\n\nQdrant is a vector database supported by LightRAG. Qdrant offers fast similarity search with filtering capabilities. The database provides production-ready vector search infrastructure.\n\n### MongoDB Atlas Vector Search\n\nMongoDB Atlas Vector Search is supported by LightRAG. MongoDB Atlas combines document storage with vector search capabilities. The database enables unified data management for RAG applications.\n\n### Redis\n\nRedis is supported by LightRAG for vector search operations. Redis provides in-memory vector search with low latency. The database offers fast retrieval for real-time applications.\n\n### Built-in Nano-VectorDB\n\nLightRAG includes a built-in nano-vectordb for simple deployments. Nano-vectordb eliminates external database dependencies for small projects. The built-in database provides basic vector search functionality without additional setup.\n\n## Database Selection Benefits\n\nThe multiple database support enables developers to choose appropriate storage backends. LightRAG adapts to different deployment scenarios from development to production. Users can select databases based on scale, performance, and infrastructure requirements.\n"
  },
  {
    "path": "lightrag/evaluation/sample_documents/05_evaluation_and_deployment.md",
    "content": "# RAG Evaluation Metrics and Deployment\n\n## Key RAG Evaluation Metrics\n\nRAG system quality is measured through four key metrics.\n\n### Faithfulness Metric\n\nFaithfulness measures whether answers are factually grounded in retrieved context. The faithfulness metric detects hallucinations in LLM responses. High faithfulness scores indicate answers based on actual document content. The metric evaluates factual accuracy of generated responses.\n\n### Answer Relevance Metric\n\nAnswer Relevance measures how well answers address the user question. The answer relevance metric evaluates response quality and appropriateness. High answer relevance scores show responses that directly answer user queries. The metric assesses the connection between questions and generated answers.\n\n### Context Recall Metric\n\nContext Recall measures completeness of retrieval from documents. The context recall metric evaluates whether all relevant information was retrieved. High context recall scores indicate comprehensive document retrieval. The metric assesses retrieval system effectiveness.\n\n### Context Precision Metric\n\nContext Precision measures quality and relevance of retrieved documents. The context precision metric evaluates retrieval accuracy without noise. High context precision scores show clean retrieval without irrelevant content. The metric measures retrieval system selectivity.\n\n## LightRAG Deployment Options\n\nLightRAG can be deployed in production through multiple approaches.\n\n### Docker Container Deployment\n\nDocker containers enable consistent LightRAG deployment across environments. Docker provides isolated runtime environments for the framework. Container deployment simplifies dependency management and scaling.\n\n### REST API Server with FastAPI\n\nFastAPI serves as the REST API framework for LightRAG deployment. The FastAPI server exposes LightRAG functionality through HTTP endpoints. REST API deployment enables client-server architecture for RAG applications.\n\n### Direct Python Integration\n\nDirect Python integration embeds LightRAG into Python applications. Python integration provides programmatic access to RAG capabilities. Direct integration supports custom application workflows and pipelines.\n\n### Deployment Features\n\nLightRAG supports environment-based configuration for different deployment scenarios. The framework integrates with multiple LLM providers for flexibility. LightRAG enables horizontal scaling for production workloads.\n"
  },
  {
    "path": "lightrag/evaluation/sample_documents/README.md",
    "content": "# Sample Documents for Evaluation\n\nThese markdown files correspond to test questions in `../sample_dataset.json`.\n\n## Usage\n\n1. **Index documents** into LightRAG (via WebUI, API, or Python)\n2. **Run evaluation**: `python lightrag/evaluation/eval_rag_quality.py`\n3. **Expected results**: ~91-100% RAGAS score per question\n\n## Files\n\n- `01_lightrag_overview.md` - LightRAG framework and hallucination problem\n- `02_rag_architecture.md` - RAG system components\n- `03_lightrag_improvements.md` - LightRAG vs traditional RAG\n- `04_supported_databases.md` - Vector database support\n- `05_evaluation_and_deployment.md` - Metrics and deployment\n\n## Note\n\nDocuments use clear entity-relationship patterns for LightRAG's default entity extraction prompts. For better results with your data, customize `lightrag/prompt.py`.\n"
  },
  {
    "path": "lightrag/exceptions.py",
    "content": "from __future__ import annotations\n\nimport httpx\nfrom typing import Literal\n\n\nclass APIStatusError(Exception):\n    \"\"\"Raised when an API response has a status code of 4xx or 5xx.\"\"\"\n\n    response: httpx.Response\n    status_code: int\n    request_id: str | None\n\n    def __init__(\n        self, message: str, *, response: httpx.Response, body: object | None\n    ) -> None:\n        super().__init__(message, response.request, body=body)\n        self.response = response\n        self.status_code = response.status_code\n        self.request_id = response.headers.get(\"x-request-id\")\n\n\nclass APIConnectionError(Exception):\n    def __init__(\n        self, *, message: str = \"Connection error.\", request: httpx.Request\n    ) -> None:\n        super().__init__(message, request, body=None)\n\n\nclass BadRequestError(APIStatusError):\n    status_code: Literal[400] = 400  # pyright: ignore[reportIncompatibleVariableOverride]\n\n\nclass AuthenticationError(APIStatusError):\n    status_code: Literal[401] = 401  # pyright: ignore[reportIncompatibleVariableOverride]\n\n\nclass PermissionDeniedError(APIStatusError):\n    status_code: Literal[403] = 403  # pyright: ignore[reportIncompatibleVariableOverride]\n\n\nclass NotFoundError(APIStatusError):\n    status_code: Literal[404] = 404  # pyright: ignore[reportIncompatibleVariableOverride]\n\n\nclass ConflictError(APIStatusError):\n    status_code: Literal[409] = 409  # pyright: ignore[reportIncompatibleVariableOverride]\n\n\nclass UnprocessableEntityError(APIStatusError):\n    status_code: Literal[422] = 422  # pyright: ignore[reportIncompatibleVariableOverride]\n\n\nclass RateLimitError(APIStatusError):\n    status_code: Literal[429] = 429  # pyright: ignore[reportIncompatibleVariableOverride]\n\n\nclass APITimeoutError(APIConnectionError):\n    def __init__(self, request: httpx.Request) -> None:\n        super().__init__(message=\"Request timed out.\", request=request)\n\n\nclass StorageNotInitializedError(RuntimeError):\n    \"\"\"Raised when storage operations are attempted before initialization.\"\"\"\n\n    def __init__(self, storage_type: str = \"Storage\"):\n        super().__init__(\n            f\"{storage_type} not initialized. Please ensure proper initialization:\\n\"\n            f\"\\n\"\n            f\"  rag = LightRAG(...)\\n\"\n            f\"  await rag.initialize_storages()  # Required - auto-initializes pipeline_status\\n\"\n            f\"\\n\"\n            f\"See: https://github.com/HKUDS/LightRAG#important-initialization-requirements\"\n        )\n\n\nclass PipelineNotInitializedError(KeyError):\n    \"\"\"Raised when pipeline status is accessed before initialization.\"\"\"\n\n    def __init__(self, namespace: str = \"\"):\n        msg = (\n            f\"Pipeline namespace '{namespace}' not found.\\n\"\n            f\"\\n\"\n            f\"Pipeline status should be auto-initialized by initialize_storages().\\n\"\n            f\"If you see this error, please ensure:\\n\"\n            f\"\\n\"\n            f\"  1. You called await rag.initialize_storages()\\n\"\n            f\"  2. For multi-workspace setups, each LightRAG instance was properly initialized\\n\"\n            f\"\\n\"\n            f\"Standard initialization:\\n\"\n            f\"  rag = LightRAG(workspace='your_workspace')\\n\"\n            f\"  await rag.initialize_storages()  # Auto-initializes pipeline_status\\n\"\n            f\"\\n\"\n            f\"If you need manual control (advanced):\\n\"\n            f\"  from lightrag.kg.shared_storage import initialize_pipeline_status\\n\"\n            f\"  await initialize_pipeline_status(workspace='your_workspace')\"\n        )\n        super().__init__(msg)\n\n\nclass PipelineCancelledException(Exception):\n    \"\"\"Raised when pipeline processing is cancelled by user request.\"\"\"\n\n    def __init__(self, message: str = \"User cancelled\"):\n        super().__init__(message)\n        self.message = message\n\n\nclass ChunkTokenLimitExceededError(ValueError):\n    \"\"\"Raised when a chunk exceeds the configured token limit.\"\"\"\n\n    def __init__(\n        self,\n        chunk_tokens: int,\n        chunk_token_limit: int,\n        chunk_preview: str | None = None,\n    ) -> None:\n        preview = chunk_preview.strip() if chunk_preview else None\n        truncated_preview = preview[:80] if preview else None\n        preview_note = f\" Preview: '{truncated_preview}'\" if truncated_preview else \"\"\n        message = (\n            f\"Chunk token length {chunk_tokens} exceeds chunk_token_size {chunk_token_limit}.\"\n            f\"{preview_note}\"\n        )\n        super().__init__(message)\n        self.chunk_tokens = chunk_tokens\n        self.chunk_token_limit = chunk_token_limit\n        self.chunk_preview = truncated_preview\n\n\nclass DataMigrationError(Exception):\n    \"\"\"Raised when data migration from legacy collection/table fails.\"\"\"\n\n    def __init__(self, message: str):\n        super().__init__(message)\n        self.message = message\n"
  },
  {
    "path": "lightrag/kg/__init__.py",
    "content": "STORAGE_IMPLEMENTATIONS = {\n    \"KV_STORAGE\": {\n        \"implementations\": [\n            \"JsonKVStorage\",\n            \"RedisKVStorage\",\n            \"PGKVStorage\",\n            \"MongoKVStorage\",\n            \"OpenSearchKVStorage\",\n        ],\n        \"required_methods\": [\"get_by_id\", \"upsert\"],\n    },\n    \"GRAPH_STORAGE\": {\n        \"implementations\": [\n            \"NetworkXStorage\",\n            \"Neo4JStorage\",\n            \"PGGraphStorage\",\n            \"MongoGraphStorage\",\n            \"MemgraphStorage\",\n            \"OpenSearchGraphStorage\",\n        ],\n        \"required_methods\": [\"upsert_node\", \"upsert_edge\"],\n    },\n    \"VECTOR_STORAGE\": {\n        \"implementations\": [\n            \"NanoVectorDBStorage\",\n            \"MilvusVectorDBStorage\",\n            \"PGVectorStorage\",\n            \"FaissVectorDBStorage\",\n            \"QdrantVectorDBStorage\",\n            \"MongoVectorDBStorage\",\n            \"OpenSearchVectorDBStorage\",\n            # \"ChromaVectorDBStorage\",\n        ],\n        \"required_methods\": [\"query\", \"upsert\"],\n    },\n    \"DOC_STATUS_STORAGE\": {\n        \"implementations\": [\n            \"JsonDocStatusStorage\",\n            \"RedisDocStatusStorage\",\n            \"PGDocStatusStorage\",\n            \"MongoDocStatusStorage\",\n            \"OpenSearchDocStatusStorage\",\n        ],\n        \"required_methods\": [\"get_docs_by_status\"],\n    },\n}\n\n# Storage implementation environment variable without default value\nSTORAGE_ENV_REQUIREMENTS: dict[str, list[str]] = {\n    # KV Storage Implementations\n    \"JsonKVStorage\": [],\n    \"MongoKVStorage\": [\n        \"MONGO_URI\",\n        \"MONGO_DATABASE\",\n    ],\n    \"RedisKVStorage\": [\"REDIS_URI\"],\n    \"PGKVStorage\": [\"POSTGRES_USER\", \"POSTGRES_PASSWORD\", \"POSTGRES_DATABASE\"],\n    # Graph Storage Implementations\n    \"NetworkXStorage\": [],\n    \"Neo4JStorage\": [\"NEO4J_URI\", \"NEO4J_USERNAME\", \"NEO4J_PASSWORD\"],\n    \"MongoGraphStorage\": [\n        \"MONGO_URI\",\n        \"MONGO_DATABASE\",\n    ],\n    \"MemgraphStorage\": [\"MEMGRAPH_URI\"],\n    \"AGEStorage\": [\n        \"AGE_POSTGRES_DB\",\n        \"AGE_POSTGRES_USER\",\n        \"AGE_POSTGRES_PASSWORD\",\n    ],\n    \"PGGraphStorage\": [\n        \"POSTGRES_USER\",\n        \"POSTGRES_PASSWORD\",\n        \"POSTGRES_DATABASE\",\n    ],\n    # Vector Storage Implementations\n    \"NanoVectorDBStorage\": [],\n    \"MilvusVectorDBStorage\": [\n        \"MILVUS_URI\",\n        \"MILVUS_DB_NAME\",\n    ],\n    # \"ChromaVectorDBStorage\": [],\n    \"PGVectorStorage\": [\"POSTGRES_USER\", \"POSTGRES_PASSWORD\", \"POSTGRES_DATABASE\"],\n    \"FaissVectorDBStorage\": [],\n    \"QdrantVectorDBStorage\": [\"QDRANT_URL\"],  # QDRANT_API_KEY has default value None\n    \"MongoVectorDBStorage\": [\n        \"MONGO_URI\",\n        \"MONGO_DATABASE\",\n    ],\n    # Document Status Storage Implementations\n    \"JsonDocStatusStorage\": [],\n    \"RedisDocStatusStorage\": [\"REDIS_URI\"],\n    \"PGDocStatusStorage\": [\"POSTGRES_USER\", \"POSTGRES_PASSWORD\", \"POSTGRES_DATABASE\"],\n    \"MongoDocStatusStorage\": [\n        \"MONGO_URI\",\n        \"MONGO_DATABASE\",\n    ],\n    # OpenSearch Storage Implementations\n    \"OpenSearchKVStorage\": [\n        \"OPENSEARCH_HOSTS\",\n    ],\n    \"OpenSearchDocStatusStorage\": [\n        \"OPENSEARCH_HOSTS\",\n    ],\n    \"OpenSearchGraphStorage\": [\n        \"OPENSEARCH_HOSTS\",\n    ],\n    \"OpenSearchVectorDBStorage\": [\n        \"OPENSEARCH_HOSTS\",\n    ],\n}\n\n# Storage implementation module mapping\nSTORAGES = {\n    \"NetworkXStorage\": \".kg.networkx_impl\",\n    \"JsonKVStorage\": \".kg.json_kv_impl\",\n    \"NanoVectorDBStorage\": \".kg.nano_vector_db_impl\",\n    \"JsonDocStatusStorage\": \".kg.json_doc_status_impl\",\n    \"Neo4JStorage\": \".kg.neo4j_impl\",\n    \"MilvusVectorDBStorage\": \".kg.milvus_impl\",\n    \"MongoKVStorage\": \".kg.mongo_impl\",\n    \"MongoDocStatusStorage\": \".kg.mongo_impl\",\n    \"MongoGraphStorage\": \".kg.mongo_impl\",\n    \"MongoVectorDBStorage\": \".kg.mongo_impl\",\n    \"RedisKVStorage\": \".kg.redis_impl\",\n    \"RedisDocStatusStorage\": \".kg.redis_impl\",\n    \"ChromaVectorDBStorage\": \".kg.chroma_impl\",\n    \"PGKVStorage\": \".kg.postgres_impl\",\n    \"PGVectorStorage\": \".kg.postgres_impl\",\n    \"AGEStorage\": \".kg.age_impl\",\n    \"PGGraphStorage\": \".kg.postgres_impl\",\n    \"PGDocStatusStorage\": \".kg.postgres_impl\",\n    \"FaissVectorDBStorage\": \".kg.faiss_impl\",\n    \"QdrantVectorDBStorage\": \".kg.qdrant_impl\",\n    \"MemgraphStorage\": \".kg.memgraph_impl\",\n    \"OpenSearchKVStorage\": \".kg.opensearch_impl\",\n    \"OpenSearchDocStatusStorage\": \".kg.opensearch_impl\",\n    \"OpenSearchGraphStorage\": \".kg.opensearch_impl\",\n    \"OpenSearchVectorDBStorage\": \".kg.opensearch_impl\",\n}\n\n\ndef verify_storage_implementation(storage_type: str, storage_name: str) -> None:\n    \"\"\"Verify if storage implementation is compatible with specified storage type\n\n    Args:\n        storage_type: Storage type (KV_STORAGE, GRAPH_STORAGE etc.)\n        storage_name: Storage implementation name\n\n    Raises:\n        ValueError: If storage implementation is incompatible or missing required methods\n    \"\"\"\n    if storage_type not in STORAGE_IMPLEMENTATIONS:\n        raise ValueError(f\"Unknown storage type: {storage_type}\")\n\n    storage_info = STORAGE_IMPLEMENTATIONS[storage_type]\n    if storage_name not in storage_info[\"implementations\"]:\n        raise ValueError(\n            f\"Storage implementation '{storage_name}' is not compatible with {storage_type}. \"\n            f\"Compatible implementations are: {', '.join(storage_info['implementations'])}\"\n        )\n"
  },
  {
    "path": "lightrag/kg/deprecated/chroma_impl.py",
    "content": "import asyncio\nimport os\nfrom dataclasses import dataclass\nfrom typing import Any, final\nimport numpy as np\n\nfrom lightrag.base import BaseVectorStorage\nfrom lightrag.utils import logger\nimport pipmaster as pm\n\nif not pm.is_installed(\"chromadb\"):\n    pm.install(\"chromadb\")\n\nfrom chromadb import HttpClient, PersistentClient  # type: ignore\nfrom chromadb.config import Settings  # type: ignore\n\n\n@final\n@dataclass\nclass ChromaVectorDBStorage(BaseVectorStorage):\n    \"\"\"ChromaDB vector storage implementation.\"\"\"\n\n    def __post_init__(self):\n        self._validate_embedding_func()\n        try:\n            config = self.global_config.get(\"vector_db_storage_cls_kwargs\", {})\n            cosine_threshold = config.get(\"cosine_better_than_threshold\")\n            if cosine_threshold is None:\n                raise ValueError(\n                    \"cosine_better_than_threshold must be specified in vector_db_storage_cls_kwargs\"\n                )\n            self.cosine_better_than_threshold = cosine_threshold\n\n            user_collection_settings = config.get(\"collection_settings\", {})\n            # Default HNSW index settings for ChromaDB\n            default_collection_settings = {\n                # Distance metric used for similarity search (cosine similarity)\n                \"hnsw:space\": \"cosine\",\n                # Number of nearest neighbors to explore during index construction\n                # Higher values = better recall but slower indexing\n                \"hnsw:construction_ef\": 128,\n                # Number of nearest neighbors to explore during search\n                # Higher values = better recall but slower search\n                \"hnsw:search_ef\": 128,\n                # Number of connections per node in the HNSW graph\n                # Higher values = better recall but more memory usage\n                \"hnsw:M\": 16,\n                # Number of vectors to process in one batch during indexing\n                \"hnsw:batch_size\": 100,\n                # Number of updates before forcing index synchronization\n                # Lower values = more frequent syncs but slower indexing\n                \"hnsw:sync_threshold\": 1000,\n            }\n            collection_settings = {\n                **default_collection_settings,\n                **user_collection_settings,\n            }\n\n            local_path = config.get(\"local_path\", None)\n            if local_path:\n                self._client = PersistentClient(\n                    path=local_path,\n                    settings=Settings(\n                        allow_reset=True,\n                        anonymized_telemetry=False,\n                    ),\n                )\n            else:\n                auth_provider = config.get(\n                    \"auth_provider\", \"chromadb.auth.token_authn.TokenAuthClientProvider\"\n                )\n                auth_credentials = config.get(\"auth_token\", \"secret-token\")\n                headers = {}\n\n                if \"token_authn\" in auth_provider:\n                    headers = {\n                        config.get(\n                            \"auth_header_name\", \"X-Chroma-Token\"\n                        ): auth_credentials\n                    }\n                elif \"basic_authn\" in auth_provider:\n                    auth_credentials = config.get(\"auth_credentials\", \"admin:admin\")\n\n                self._client = HttpClient(\n                    host=config.get(\"host\", \"localhost\"),\n                    port=config.get(\"port\", 8000),\n                    headers=headers,\n                    settings=Settings(\n                        chroma_api_impl=\"rest\",\n                        chroma_client_auth_provider=auth_provider,\n                        chroma_client_auth_credentials=auth_credentials,\n                        allow_reset=True,\n                        anonymized_telemetry=False,\n                    ),\n                )\n\n            self._collection = self._client.get_or_create_collection(\n                name=self.namespace,\n                metadata={\n                    **collection_settings,\n                    \"dimension\": self.embedding_func.embedding_dim,\n                },\n            )\n            # Use batch size from collection settings if specified\n            self._max_batch_size = self.global_config.get(\n                \"embedding_batch_num\", collection_settings.get(\"hnsw:batch_size\", 32)\n            )\n        except Exception as e:\n            logger.error(f\"ChromaDB initialization failed: {str(e)}\")\n            raise\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        logger.debug(f\"Inserting {len(data)} to {self.namespace}\")\n        if not data:\n            return\n\n        try:\n            import time\n\n            current_time = int(time.time())\n\n            ids = list(data.keys())\n            documents = [v[\"content\"] for v in data.values()]\n            metadatas = [\n                {\n                    **{k: v for k, v in item.items() if k in self.meta_fields},\n                    \"created_at\": current_time,\n                }\n                or {\"_default\": \"true\", \"created_at\": current_time}\n                for item in data.values()\n            ]\n\n            # Process in batches\n            batches = [\n                documents[i : i + self._max_batch_size]\n                for i in range(0, len(documents), self._max_batch_size)\n            ]\n\n            embedding_tasks = [self.embedding_func(batch) for batch in batches]\n            embeddings_list = []\n\n            # Pre-allocate embeddings_list with known size\n            embeddings_list = [None] * len(embedding_tasks)\n\n            # Use asyncio.gather instead of as_completed if order doesn't matter\n            embeddings_results = await asyncio.gather(*embedding_tasks)\n            embeddings_list = list(embeddings_results)\n\n            embeddings = np.concatenate(embeddings_list)\n\n            # Upsert in batches\n            for i in range(0, len(ids), self._max_batch_size):\n                batch_slice = slice(i, i + self._max_batch_size)\n\n                self._collection.upsert(\n                    ids=ids[batch_slice],\n                    embeddings=embeddings[batch_slice].tolist(),\n                    documents=documents[batch_slice],\n                    metadatas=metadatas[batch_slice],\n                )\n\n            return ids\n\n        except Exception as e:\n            logger.error(f\"Error during ChromaDB upsert: {str(e)}\")\n            raise\n\n    async def query(self, query: str, top_k: int) -> list[dict[str, Any]]:\n        try:\n            embedding = await self.embedding_func(\n                [query], _priority=5\n            )  # higher priority for query\n\n            results = self._collection.query(\n                query_embeddings=embedding.tolist()\n                if not isinstance(embedding, list)\n                else embedding,\n                n_results=top_k * 2,  # Request more results to allow for filtering\n                include=[\"metadatas\", \"distances\", \"documents\"],\n            )\n\n            # Filter results by cosine similarity threshold and take top k\n            # We request 2x results initially to have enough after filtering\n            # ChromaDB returns cosine similarity (1 = identical, 0 = orthogonal)\n            # We convert to distance (0 = identical, 1 = orthogonal) via (1 - similarity)\n            # Only keep results with distance below threshold, then take top k\n            return [\n                {\n                    \"id\": results[\"ids\"][0][i],\n                    \"distance\": 1 - results[\"distances\"][0][i],\n                    \"content\": results[\"documents\"][0][i],\n                    \"created_at\": results[\"metadatas\"][0][i].get(\"created_at\"),\n                    **results[\"metadatas\"][0][i],\n                }\n                for i in range(len(results[\"ids\"][0]))\n                if (1 - results[\"distances\"][0][i]) >= self.cosine_better_than_threshold\n            ][:top_k]\n\n        except Exception as e:\n            logger.error(f\"Error during ChromaDB query: {str(e)}\")\n            raise\n\n    async def index_done_callback(self) -> None:\n        # ChromaDB handles persistence automatically\n        pass\n\n    async def delete_entity(self, entity_name: str) -> None:\n        \"\"\"Delete an entity by its ID.\n\n        Args:\n            entity_name: The ID of the entity to delete\n        \"\"\"\n        try:\n            logger.info(f\"Deleting entity with ID {entity_name} from {self.namespace}\")\n            self._collection.delete(ids=[entity_name])\n        except Exception as e:\n            logger.error(f\"Error during entity deletion: {str(e)}\")\n            raise\n\n    async def delete_entity_relation(self, entity_name: str) -> None:\n        \"\"\"Delete an entity and its relations by ID.\n        In vector DB context, this is equivalent to delete_entity.\n\n        Args:\n            entity_name: The ID of the entity to delete\n        \"\"\"\n        await self.delete_entity(entity_name)\n\n    async def delete(self, ids: list[str]) -> None:\n        \"\"\"Delete vectors with specified IDs\n\n        Args:\n            ids: List of vector IDs to be deleted\n        \"\"\"\n        try:\n            self._collection.delete(ids=ids)\n            logger.debug(\n                f\"Successfully deleted {len(ids)} vectors from {self.namespace}\"\n            )\n        except Exception as e:\n            logger.error(f\"Error while deleting vectors from {self.namespace}: {e}\")\n            raise\n\n        except Exception as e:\n            logger.error(f\"Error during prefix search in ChromaDB: {str(e)}\")\n            raise\n\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        \"\"\"Get vector data by its ID\n\n        Args:\n            id: The unique identifier of the vector\n\n        Returns:\n            The vector data if found, or None if not found\n        \"\"\"\n        try:\n            # Query the collection for a single vector by ID\n            result = self._collection.get(\n                ids=[id], include=[\"metadatas\", \"embeddings\", \"documents\"]\n            )\n\n            if not result or not result[\"ids\"] or len(result[\"ids\"]) == 0:\n                return None\n\n            # Format the result to match the expected structure\n            return {\n                \"id\": result[\"ids\"][0],\n                \"vector\": result[\"embeddings\"][0],\n                \"content\": result[\"documents\"][0],\n                \"created_at\": result[\"metadatas\"][0].get(\"created_at\"),\n                **result[\"metadatas\"][0],\n            }\n        except Exception as e:\n            logger.error(f\"Error retrieving vector data for ID {id}: {e}\")\n            return None\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        \"\"\"Get multiple vector data by their IDs\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            List of vector data objects that were found\n        \"\"\"\n        if not ids:\n            return []\n\n        try:\n            # Query the collection for multiple vectors by IDs\n            result = self._collection.get(\n                ids=ids, include=[\"metadatas\", \"embeddings\", \"documents\"]\n            )\n\n            if not result or not result[\"ids\"] or len(result[\"ids\"]) == 0:\n                return []\n\n            # Format the results to match the expected structure and preserve ordering\n            formatted_map: dict[str, dict[str, Any]] = {}\n            for i, result_id in enumerate(result[\"ids\"]):\n                record = {\n                    \"id\": result_id,\n                    \"vector\": result[\"embeddings\"][i],\n                    \"content\": result[\"documents\"][i],\n                    \"created_at\": result[\"metadatas\"][i].get(\"created_at\"),\n                    **result[\"metadatas\"][i],\n                }\n                formatted_map[str(result_id)] = record\n\n            ordered_results: list[dict[str, Any] | None] = []\n            for requested_id in ids:\n                ordered_results.append(formatted_map.get(str(requested_id)))\n\n            return ordered_results\n        except Exception as e:\n            logger.error(f\"Error retrieving vector data for IDs {ids}: {e}\")\n            return []\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop all vector data from storage and clean up resources\n\n        This method will delete all documents from the ChromaDB collection.\n\n        Returns:\n            dict[str, str]: Operation status and message\n            - On success: {\"status\": \"success\", \"message\": \"data dropped\"}\n            - On failure: {\"status\": \"error\", \"message\": \"<error details>\"}\n        \"\"\"\n        try:\n            # Get all IDs in the collection\n            result = self._collection.get(include=[])\n            if result and result[\"ids\"] and len(result[\"ids\"]) > 0:\n                # Delete all documents\n                self._collection.delete(ids=result[\"ids\"])\n\n            logger.info(\n                f\"Process {os.getpid()} drop ChromaDB collection {self.namespace}\"\n            )\n            return {\"status\": \"success\", \"message\": \"data dropped\"}\n        except Exception as e:\n            logger.error(f\"Error dropping ChromaDB collection {self.namespace}: {e}\")\n            return {\"status\": \"error\", \"message\": str(e)}\n"
  },
  {
    "path": "lightrag/kg/faiss_impl.py",
    "content": "import os\nimport time\nimport asyncio\nfrom typing import Any, final\nimport json\nimport numpy as np\nfrom dataclasses import dataclass\n\nfrom lightrag.utils import logger, compute_mdhash_id\nfrom lightrag.base import BaseVectorStorage\n\nfrom .shared_storage import (\n    get_namespace_lock,\n    get_update_flag,\n    set_all_update_flags,\n)\n\n# You must manually install faiss-cpu or faiss-gpu before using FAISS vector db\nimport faiss  # type: ignore\n\n\n@final\n@dataclass\nclass FaissVectorDBStorage(BaseVectorStorage):\n    \"\"\"\n    A Faiss-based Vector DB Storage for LightRAG.\n    Uses cosine similarity by storing normalized vectors in a Faiss index with inner product search.\n    \"\"\"\n\n    def __post_init__(self):\n        self._validate_embedding_func()\n        # Grab config values if available\n        kwargs = self.global_config.get(\"vector_db_storage_cls_kwargs\", {})\n        cosine_threshold = kwargs.get(\"cosine_better_than_threshold\")\n        if cosine_threshold is None:\n            raise ValueError(\n                \"cosine_better_than_threshold must be specified in vector_db_storage_cls_kwargs\"\n            )\n        self.cosine_better_than_threshold = cosine_threshold\n\n        # Where to save index file if you want persistent storage\n        working_dir = self.global_config[\"working_dir\"]\n        if self.workspace:\n            # Include workspace in the file path for data isolation\n            workspace_dir = os.path.join(working_dir, self.workspace)\n\n        else:\n            # Default behavior when workspace is empty\n            workspace_dir = working_dir\n            self.workspace = \"\"\n\n        os.makedirs(workspace_dir, exist_ok=True)\n        self._faiss_index_file = os.path.join(\n            workspace_dir, f\"faiss_index_{self.namespace}.index\"\n        )\n        self._meta_file = self._faiss_index_file + \".meta.json\"\n\n        self._max_batch_size = self.global_config[\"embedding_batch_num\"]\n        # Embedding dimension (e.g. 768) must match your embedding function\n        self._dim = self.embedding_func.embedding_dim\n\n        # Create an empty Faiss index for inner product (useful for normalized vectors = cosine similarity).\n        # If you have a large number of vectors, you might want IVF or other indexes.\n        # For demonstration, we use a simple IndexFlatIP.\n        self._index = faiss.IndexFlatIP(self._dim)\n        # Keep a local store for metadata, IDs, etc.\n        # Maps <int faiss_id> → metadata (including your original ID).\n        self._id_to_meta = {}\n\n        self._load_faiss_index()\n\n    async def initialize(self):\n        \"\"\"Initialize storage data\"\"\"\n        # Get the update flag for cross-process update notification\n        self.storage_updated = await get_update_flag(\n            self.namespace, workspace=self.workspace\n        )\n        # Get the storage lock for use in other methods\n        self._storage_lock = get_namespace_lock(\n            self.namespace, workspace=self.workspace\n        )\n\n    async def _get_index(self):\n        \"\"\"Check if the shtorage should be reloaded\"\"\"\n        # Acquire lock to prevent concurrent read and write\n        async with self._storage_lock:\n            # Check if storage was updated by another process\n            if self.storage_updated.value:\n                logger.info(\n                    f\"[{self.workspace}] Process {os.getpid()} FAISS reloading {self.namespace} due to update by another process\"\n                )\n                # Reload data\n                self._index = faiss.IndexFlatIP(self._dim)\n                self._id_to_meta = {}\n                self._load_faiss_index()\n                self.storage_updated.value = False\n            return self._index\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        \"\"\"\n        Insert or update vectors in the Faiss index.\n\n        data: {\n           \"custom_id_1\": {\n               \"content\": <text>,\n               ...metadata...\n           },\n           \"custom_id_2\": {\n               \"content\": <text>,\n               ...metadata...\n           },\n           ...\n        }\n        \"\"\"\n        logger.debug(\n            f\"[{self.workspace}] FAISS: Inserting {len(data)} to {self.namespace}\"\n        )\n        if not data:\n            return\n\n        current_time = int(time.time())\n\n        # Prepare data for embedding\n        list_data = []\n        contents = []\n        for k, v in data.items():\n            # Store only known meta fields if needed\n            meta = {mf: v[mf] for mf in self.meta_fields if mf in v}\n            meta[\"__id__\"] = k\n            meta[\"__created_at__\"] = current_time\n            list_data.append(meta)\n            contents.append(v[\"content\"])\n\n        # Split into batches for embedding if needed\n        batches = [\n            contents[i : i + self._max_batch_size]\n            for i in range(0, len(contents), self._max_batch_size)\n        ]\n\n        embedding_tasks = [self.embedding_func(batch) for batch in batches]\n        embeddings_list = await asyncio.gather(*embedding_tasks)\n\n        # Flatten the list of arrays\n        embeddings = np.concatenate(embeddings_list, axis=0)\n        if len(embeddings) != len(list_data):\n            logger.error(\n                f\"[{self.workspace}] Embedding size mismatch. Embeddings: {len(embeddings)}, Data: {len(list_data)}\"\n            )\n            return []\n\n        # Convert to float32 and normalize embeddings for cosine similarity (in-place)\n        embeddings = embeddings.astype(np.float32)\n        faiss.normalize_L2(embeddings)\n\n        # Upsert logic:\n        # 1. Identify which vectors to remove if they exist\n        # 2. Remove them\n        # 3. Add the new vectors\n        existing_ids_to_remove = []\n        for meta, emb in zip(list_data, embeddings):\n            faiss_internal_id = self._find_faiss_id_by_custom_id(meta[\"__id__\"])\n            if faiss_internal_id is not None:\n                existing_ids_to_remove.append(faiss_internal_id)\n\n        if existing_ids_to_remove:\n            await self._remove_faiss_ids(existing_ids_to_remove)\n\n        # Step 2: Add new vectors\n        index = await self._get_index()\n        start_idx = index.ntotal\n        index.add(embeddings)\n\n        # Step 3: Store metadata + vector for each new ID\n        for i, meta in enumerate(list_data):\n            fid = start_idx + i\n            # Store the raw vector so we can rebuild if something is removed\n            meta[\"__vector__\"] = embeddings[i].tolist()\n            self._id_to_meta.update({fid: meta})\n\n        logger.debug(\n            f\"[{self.workspace}] Upserted {len(list_data)} vectors into Faiss index.\"\n        )\n        return [m[\"__id__\"] for m in list_data]\n\n    async def query(\n        self, query: str, top_k: int, query_embedding: list[float] = None\n    ) -> list[dict[str, Any]]:\n        \"\"\"\n        Search by a textual query; returns top_k results with their metadata + similarity distance.\n        \"\"\"\n        if query_embedding is not None:\n            embedding = np.array([query_embedding], dtype=np.float32)\n        else:\n            embedding = await self.embedding_func(\n                [query], _priority=5\n            )  # higher priority for query\n            # embedding is shape (1, dim)\n            embedding = np.array(embedding, dtype=np.float32)\n\n        faiss.normalize_L2(embedding)  # we do in-place normalization\n\n        # Perform the similarity search\n        index = await self._get_index()\n        distances, indices = index.search(embedding, top_k)\n\n        distances = distances[0]\n        indices = indices[0]\n\n        results = []\n        for dist, idx in zip(distances, indices):\n            if idx == -1:\n                # Faiss returns -1 if no neighbor\n                continue\n\n            # Cosine similarity threshold\n            if dist < self.cosine_better_than_threshold:\n                continue\n\n            meta = self._id_to_meta.get(idx, {})\n            # Filter out __vector__ from query results to avoid returning large vector data\n            filtered_meta = {k: v for k, v in meta.items() if k != \"__vector__\"}\n            results.append(\n                {\n                    **filtered_meta,\n                    \"id\": meta.get(\"__id__\"),\n                    \"distance\": float(dist),\n                    \"created_at\": meta.get(\"__created_at__\"),\n                }\n            )\n\n        return results\n\n    @property\n    def client_storage(self):\n        # Return whatever structure LightRAG might need for debugging\n        return {\"data\": list(self._id_to_meta.values())}\n\n    async def delete(self, ids: list[str]):\n        \"\"\"\n        Delete vectors for the provided custom IDs.\n\n        Importance notes:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n        \"\"\"\n        logger.debug(\n            f\"[{self.workspace}] Deleting {len(ids)} vectors from {self.namespace}\"\n        )\n        to_remove = []\n        for cid in ids:\n            fid = self._find_faiss_id_by_custom_id(cid)\n            if fid is not None:\n                to_remove.append(fid)\n\n        if to_remove:\n            await self._remove_faiss_ids(to_remove)\n        logger.debug(\n            f\"[{self.workspace}] Successfully deleted {len(to_remove)} vectors from {self.namespace}\"\n        )\n\n    async def delete_entity(self, entity_name: str) -> None:\n        \"\"\"\n        Importance notes:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n        \"\"\"\n        entity_id = compute_mdhash_id(entity_name, prefix=\"ent-\")\n        logger.debug(\n            f\"[{self.workspace}] Attempting to delete entity {entity_name} with ID {entity_id}\"\n        )\n        await self.delete([entity_id])\n\n    async def delete_entity_relation(self, entity_name: str) -> None:\n        \"\"\"\n        Importance notes:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n        \"\"\"\n        logger.debug(f\"[{self.workspace}] Searching relations for entity {entity_name}\")\n        relations = []\n        for fid, meta in self._id_to_meta.items():\n            if meta.get(\"src_id\") == entity_name or meta.get(\"tgt_id\") == entity_name:\n                relations.append(fid)\n\n        logger.debug(\n            f\"[{self.workspace}] Found {len(relations)} relations for {entity_name}\"\n        )\n        if relations:\n            await self._remove_faiss_ids(relations)\n            logger.debug(\n                f\"[{self.workspace}] Deleted {len(relations)} relations for {entity_name}\"\n            )\n\n    # --------------------------------------------------------------------------------\n    # Internal helper methods\n    # --------------------------------------------------------------------------------\n\n    def _find_faiss_id_by_custom_id(self, custom_id: str):\n        \"\"\"\n        Return the Faiss internal ID for a given custom ID, or None if not found.\n        \"\"\"\n        for fid, meta in self._id_to_meta.items():\n            if meta.get(\"__id__\") == custom_id:\n                return fid\n        return None\n\n    async def _remove_faiss_ids(self, fid_list):\n        \"\"\"\n        Remove a list of internal Faiss IDs from the index.\n        Because IndexFlatIP doesn't support 'removals',\n        we rebuild the index excluding those vectors.\n        \"\"\"\n        keep_fids = [fid for fid in self._id_to_meta if fid not in fid_list]\n\n        # Rebuild the index\n        vectors_to_keep = []\n        new_id_to_meta = {}\n        for old_fid in keep_fids:\n            vec_meta = self._id_to_meta[old_fid]\n            if \"__vector__\" in vec_meta:\n                vec = vec_meta[\"__vector__\"]\n            elif old_fid < self._index.ntotal:\n                vec = self._index.reconstruct(old_fid).tolist()\n                vec_meta[\"__vector__\"] = vec\n            else:\n                logger.warning(\n                    f\"[{self.workspace}] Skipping fid={old_fid} during rebuild: \"\n                    f\"no vector and fid exceeds index size ({self._index.ntotal})\"\n                )\n                continue\n            new_fid = len(vectors_to_keep)\n            vectors_to_keep.append(vec)\n            new_id_to_meta[new_fid] = vec_meta\n\n        async with self._storage_lock:\n            # Re-init index\n            self._index = faiss.IndexFlatIP(self._dim)\n            if vectors_to_keep:\n                arr = np.array(vectors_to_keep, dtype=np.float32)\n                self._index.add(arr)\n\n            self._id_to_meta = new_id_to_meta\n\n    def _save_faiss_index(self):\n        \"\"\"\n        Save the current Faiss index + metadata to disk so it can persist across runs.\n        \"\"\"\n        faiss.write_index(self._index, self._faiss_index_file)\n\n        # Save metadata dict to JSON, excluding __vector__ since vectors are\n        # already stored in the Faiss index file and can be reconstructed on load.\n        serializable_dict = {}\n        for fid, meta in self._id_to_meta.items():\n            filtered_meta = {k: v for k, v in meta.items() if k != \"__vector__\"}\n            serializable_dict[str(fid)] = filtered_meta\n\n        # Atomic write: write to temp file first, then rename to reduce\n        # mismatch risk between index and meta files on crash.\n        tmp_meta_file = self._meta_file + \".tmp\"\n        with open(tmp_meta_file, \"w\", encoding=\"utf-8\") as f:\n            json.dump(serializable_dict, f)\n        os.replace(tmp_meta_file, self._meta_file)\n\n    def _load_faiss_index(self):\n        \"\"\"\n        Load the Faiss index + metadata from disk if it exists,\n        and rebuild in-memory structures so we can query.\n        \"\"\"\n        if not os.path.exists(self._faiss_index_file):\n            logger.warning(\n                f\"[{self.workspace}] No existing Faiss index file found for {self.namespace}\"\n            )\n            return\n\n        dim_mismatch = False\n        try:\n            # Load the Faiss index\n            self._index = faiss.read_index(self._faiss_index_file)\n\n            # Verify dimension consistency between loaded index and embedding function\n            if self._index.d != self._dim:\n                error_msg = (\n                    f\"Dimension mismatch: loaded Faiss index has dimension {self._index.d}, \"\n                    f\"but embedding function expects dimension {self._dim}. \"\n                    f\"Please ensure the embedding model matches the stored index or rebuild the index.\"\n                )\n                logger.error(error_msg)\n                dim_mismatch = True\n                raise ValueError(error_msg)\n\n            # Load metadata\n            with open(self._meta_file, \"r\", encoding=\"utf-8\") as f:\n                stored_dict = json.load(f)\n\n            # Convert string keys back to int and reconstruct vectors from index\n            self._id_to_meta = {}\n            for fid_str, meta in stored_dict.items():\n                fid = int(fid_str)\n                if fid >= self._index.ntotal:\n                    logger.warning(\n                        f\"[{self.workspace}] Skipping metadata row fid={fid}: \"\n                        f\"exceeds index size ({self._index.ntotal})\"\n                    )\n                    continue\n                if \"__vector__\" not in meta:\n                    meta[\"__vector__\"] = self._index.reconstruct(fid).tolist()\n                self._id_to_meta[fid] = meta\n\n            logger.info(\n                f\"[{self.workspace}] Faiss index loaded with {self._index.ntotal} vectors from {self._faiss_index_file}\"\n            )\n        except Exception as e:\n            if dim_mismatch:\n                raise\n            logger.error(\n                f\"[{self.workspace}] Failed to load Faiss index or metadata: {e}\"\n            )\n            logger.warning(f\"[{self.workspace}] Starting with an empty Faiss index.\")\n            self._index = faiss.IndexFlatIP(self._dim)\n            self._id_to_meta = {}\n\n    async def index_done_callback(self) -> None:\n        async with self._storage_lock:\n            # Check if storage was updated by another process\n            if self.storage_updated.value:\n                # Storage was updated by another process, reload data instead of saving\n                logger.warning(\n                    f\"[{self.workspace}] Storage for FAISS {self.namespace} was updated by another process, reloading...\"\n                )\n                self._index = faiss.IndexFlatIP(self._dim)\n                self._id_to_meta = {}\n                self._load_faiss_index()\n                self.storage_updated.value = False\n                return False  # Return error\n\n        # Acquire lock and perform persistence\n        async with self._storage_lock:\n            try:\n                # Save data to disk\n                self._save_faiss_index()\n                # Notify other processes that data has been updated\n                await set_all_update_flags(self.namespace, workspace=self.workspace)\n                # Reset own update flag to avoid self-reloading\n                self.storage_updated.value = False\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Error saving FAISS index for {self.namespace}: {e}\"\n                )\n                return False  # Return error\n\n        return True  # Return success\n\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        \"\"\"Get vector data by its ID\n\n        Args:\n            id: The unique identifier of the vector\n\n        Returns:\n            The vector data if found, or None if not found\n        \"\"\"\n        # Find the Faiss internal ID for the custom ID\n        fid = self._find_faiss_id_by_custom_id(id)\n        if fid is None:\n            return None\n\n        # Get the metadata for the found ID\n        metadata = self._id_to_meta.get(fid, {})\n        if not metadata:\n            return None\n\n        # Filter out __vector__ from metadata to avoid returning large vector data\n        filtered_metadata = {k: v for k, v in metadata.items() if k != \"__vector__\"}\n        return {\n            **filtered_metadata,\n            \"id\": metadata.get(\"__id__\"),\n            \"created_at\": metadata.get(\"__created_at__\"),\n        }\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        \"\"\"Get multiple vector data by their IDs\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            List of vector data objects that were found\n        \"\"\"\n        if not ids:\n            return []\n\n        results: list[dict[str, Any] | None] = []\n        for id in ids:\n            record = None\n            fid = self._find_faiss_id_by_custom_id(id)\n            if fid is not None:\n                metadata = self._id_to_meta.get(fid)\n                if metadata:\n                    # Filter out __vector__ from metadata to avoid returning large vector data\n                    filtered_metadata = {\n                        k: v for k, v in metadata.items() if k != \"__vector__\"\n                    }\n                    record = {\n                        **filtered_metadata,\n                        \"id\": metadata.get(\"__id__\"),\n                        \"created_at\": metadata.get(\"__created_at__\"),\n                    }\n            results.append(record)\n\n        return results\n\n    async def get_vectors_by_ids(self, ids: list[str]) -> dict[str, list[float]]:\n        \"\"\"Get vectors by their IDs, returning only ID and vector data for efficiency\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            Dictionary mapping IDs to their vector embeddings\n            Format: {id: [vector_values], ...}\n        \"\"\"\n        if not ids:\n            return {}\n\n        vectors_dict = {}\n        for id in ids:\n            # Find the Faiss internal ID for the custom ID\n            fid = self._find_faiss_id_by_custom_id(id)\n            if fid is not None and fid in self._id_to_meta:\n                metadata = self._id_to_meta[fid]\n                # Get the stored vector from metadata\n                if \"__vector__\" in metadata:\n                    vectors_dict[id] = metadata[\"__vector__\"]\n\n        return vectors_dict\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop all vector data from storage and clean up resources\n\n        This method will:\n        1. Remove the vector database storage file if it exists\n        2. Reinitialize the vector database client\n        3. Update flags to notify other processes\n        4. Changes is persisted to disk immediately\n\n        This method will remove all vectors from the Faiss index and delete the storage files.\n\n        Returns:\n            dict[str, str]: Operation status and message\n            - On success: {\"status\": \"success\", \"message\": \"data dropped\"}\n            - On failure: {\"status\": \"error\", \"message\": \"<error details>\"}\n        \"\"\"\n        try:\n            async with self._storage_lock:\n                # Reset the index\n                self._index = faiss.IndexFlatIP(self._dim)\n                self._id_to_meta = {}\n\n                # Remove storage files if they exist\n                if os.path.exists(self._faiss_index_file):\n                    os.remove(self._faiss_index_file)\n                if os.path.exists(self._meta_file):\n                    os.remove(self._meta_file)\n\n                self._id_to_meta = {}\n                self._load_faiss_index()\n\n                # Notify other processes\n                await set_all_update_flags(self.namespace, workspace=self.workspace)\n                self.storage_updated.value = False\n\n                logger.info(\n                    f\"[{self.workspace}] Process {os.getpid()} drop FAISS index {self.namespace}\"\n                )\n            return {\"status\": \"success\", \"message\": \"data dropped\"}\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error dropping FAISS index {self.namespace}: {e}\"\n            )\n            return {\"status\": \"error\", \"message\": str(e)}\n"
  },
  {
    "path": "lightrag/kg/json_doc_status_impl.py",
    "content": "from dataclasses import dataclass\nimport os\nfrom typing import Any, Union, final\n\nfrom lightrag.base import (\n    DocProcessingStatus,\n    DocStatus,\n    DocStatusStorage,\n)\nfrom lightrag.utils import (\n    load_json,\n    logger,\n    write_json,\n    get_pinyin_sort_key,\n)\nfrom lightrag.exceptions import StorageNotInitializedError\nfrom .shared_storage import (\n    get_namespace_data,\n    get_namespace_lock,\n    get_data_init_lock,\n    get_update_flag,\n    set_all_update_flags,\n    clear_all_update_flags,\n    try_initialize_namespace,\n)\n\n\n@final\n@dataclass\nclass JsonDocStatusStorage(DocStatusStorage):\n    \"\"\"JSON implementation of document status storage\"\"\"\n\n    def __post_init__(self):\n        working_dir = self.global_config[\"working_dir\"]\n        if self.workspace:\n            # Include workspace in the file path for data isolation\n            workspace_dir = os.path.join(working_dir, self.workspace)\n        else:\n            # Default behavior when workspace is empty\n            workspace_dir = working_dir\n            self.workspace = \"\"\n\n        os.makedirs(workspace_dir, exist_ok=True)\n        self._file_name = os.path.join(workspace_dir, f\"kv_store_{self.namespace}.json\")\n        self._data = None\n        self._storage_lock = None\n        self.storage_updated = None\n\n    async def initialize(self):\n        \"\"\"Initialize storage data\"\"\"\n        self._storage_lock = get_namespace_lock(\n            self.namespace, workspace=self.workspace\n        )\n        self.storage_updated = await get_update_flag(\n            self.namespace, workspace=self.workspace\n        )\n        async with get_data_init_lock():\n            # check need_init must before get_namespace_data\n            need_init = await try_initialize_namespace(\n                self.namespace, workspace=self.workspace\n            )\n            self._data = await get_namespace_data(\n                self.namespace, workspace=self.workspace\n            )\n            if need_init:\n                loaded_data = load_json(self._file_name) or {}\n                async with self._storage_lock:\n                    self._data.update(loaded_data)\n                    logger.info(\n                        f\"[{self.workspace}] Process {os.getpid()} doc status load {self.namespace} with {len(loaded_data)} records\"\n                    )\n\n    async def filter_keys(self, keys: set[str]) -> set[str]:\n        \"\"\"Return keys that should be processed (not in storage or not successfully processed)\"\"\"\n        if self._storage_lock is None:\n            raise StorageNotInitializedError(\"JsonDocStatusStorage\")\n        async with self._storage_lock:\n            return set(keys) - set(self._data.keys())\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        ordered_results: list[dict[str, Any] | None] = []\n        if self._storage_lock is None:\n            raise StorageNotInitializedError(\"JsonDocStatusStorage\")\n        async with self._storage_lock:\n            for id in ids:\n                data = self._data.get(id, None)\n                if data:\n                    ordered_results.append(data.copy())\n                else:\n                    ordered_results.append(None)\n        return ordered_results\n\n    async def get_status_counts(self) -> dict[str, int]:\n        \"\"\"Get counts of documents in each status\"\"\"\n        counts = {status.value: 0 for status in DocStatus}\n        if self._storage_lock is None:\n            raise StorageNotInitializedError(\"JsonDocStatusStorage\")\n        async with self._storage_lock:\n            for doc in self._data.values():\n                counts[doc[\"status\"]] += 1\n        return counts\n\n    async def get_docs_by_status(\n        self, status: DocStatus\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Get all documents with a specific status\"\"\"\n        result = {}\n        async with self._storage_lock:\n            for k, v in self._data.items():\n                if v[\"status\"] == status.value:\n                    try:\n                        # Make a copy of the data to avoid modifying the original\n                        data = v.copy()\n                        # Remove deprecated content field if it exists\n                        data.pop(\"content\", None)\n                        # Normalize missing or null file_path\n                        if not data.get(\"file_path\"):\n                            data[\"file_path\"] = \"no-file-path\"\n                        # Ensure new fields exist with default values\n                        if \"metadata\" not in data:\n                            data[\"metadata\"] = {}\n                        if \"error_msg\" not in data:\n                            data[\"error_msg\"] = None\n                        result[k] = DocProcessingStatus(**data)\n                    except KeyError as e:\n                        logger.error(\n                            f\"[{self.workspace}] Missing required field for document {k}: {e}\"\n                        )\n                        continue\n        return result\n\n    async def get_docs_by_track_id(\n        self, track_id: str\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Get all documents with a specific track_id\"\"\"\n        result = {}\n        async with self._storage_lock:\n            for k, v in self._data.items():\n                if v.get(\"track_id\") == track_id:\n                    try:\n                        # Make a copy of the data to avoid modifying the original\n                        data = v.copy()\n                        # Remove deprecated content field if it exists\n                        data.pop(\"content\", None)\n                        # Normalize missing or null file_path\n                        if not data.get(\"file_path\"):\n                            data[\"file_path\"] = \"no-file-path\"\n                        # Ensure new fields exist with default values\n                        if \"metadata\" not in data:\n                            data[\"metadata\"] = {}\n                        if \"error_msg\" not in data:\n                            data[\"error_msg\"] = None\n                        result[k] = DocProcessingStatus(**data)\n                    except KeyError as e:\n                        logger.error(\n                            f\"[{self.workspace}] Missing required field for document {k}: {e}\"\n                        )\n                        continue\n        return result\n\n    async def index_done_callback(self) -> None:\n        async with self._storage_lock:\n            if self.storage_updated.value:\n                data_dict = (\n                    dict(self._data) if hasattr(self._data, \"_getvalue\") else self._data\n                )\n                logger.debug(\n                    f\"[{self.workspace}] Process {os.getpid()} doc status writting {len(data_dict)} records to {self.namespace}\"\n                )\n\n                # Write JSON and check if sanitization was applied\n                needs_reload = write_json(data_dict, self._file_name)\n\n                # If data was sanitized, reload cleaned data to update shared memory\n                if needs_reload:\n                    logger.info(\n                        f\"[{self.workspace}] Reloading sanitized data into shared memory for {self.namespace}\"\n                    )\n                    cleaned_data = load_json(self._file_name)\n                    if cleaned_data is not None:\n                        self._data.clear()\n                        self._data.update(cleaned_data)\n\n                await clear_all_update_flags(self.namespace, workspace=self.workspace)\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        \"\"\"\n        Importance notes for in-memory storage:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. update flags to notify other processes that data persistence is needed\n        \"\"\"\n        if not data:\n            return\n        logger.debug(\n            f\"[{self.workspace}] Inserting {len(data)} records to {self.namespace}\"\n        )\n        if self._storage_lock is None:\n            raise StorageNotInitializedError(\"JsonDocStatusStorage\")\n        async with self._storage_lock:\n            # Ensure chunks_list field exists for new documents\n            for doc_id, doc_data in data.items():\n                if \"chunks_list\" not in doc_data:\n                    doc_data[\"chunks_list\"] = []\n            self._data.update(data)\n            await set_all_update_flags(self.namespace, workspace=self.workspace)\n\n        await self.index_done_callback()\n\n    async def is_empty(self) -> bool:\n        \"\"\"Check if the storage is empty\n\n        Returns:\n            bool: True if storage is empty, False otherwise\n\n        Raises:\n            StorageNotInitializedError: If storage is not initialized\n        \"\"\"\n        if self._storage_lock is None:\n            raise StorageNotInitializedError(\"JsonDocStatusStorage\")\n        async with self._storage_lock:\n            return len(self._data) == 0\n\n    async def get_by_id(self, id: str) -> Union[dict[str, Any], None]:\n        async with self._storage_lock:\n            return self._data.get(id)\n\n    async def get_docs_paginated(\n        self,\n        status_filter: DocStatus | None = None,\n        page: int = 1,\n        page_size: int = 50,\n        sort_field: str = \"updated_at\",\n        sort_direction: str = \"desc\",\n    ) -> tuple[list[tuple[str, DocProcessingStatus]], int]:\n        \"\"\"Get documents with pagination support\n\n        Args:\n            status_filter: Filter by document status, None for all statuses\n            page: Page number (1-based)\n            page_size: Number of documents per page (10-200)\n            sort_field: Field to sort by ('created_at', 'updated_at', 'id')\n            sort_direction: Sort direction ('asc' or 'desc')\n\n        Returns:\n            Tuple of (list of (doc_id, DocProcessingStatus) tuples, total_count)\n        \"\"\"\n        # Validate parameters\n        if page < 1:\n            page = 1\n        if page_size < 10:\n            page_size = 10\n        elif page_size > 200:\n            page_size = 200\n\n        if sort_field not in [\"created_at\", \"updated_at\", \"id\", \"file_path\"]:\n            sort_field = \"updated_at\"\n\n        if sort_direction.lower() not in [\"asc\", \"desc\"]:\n            sort_direction = \"desc\"\n\n        # For JSON storage, we load all data and sort/filter in memory\n        all_docs = []\n\n        async with self._storage_lock:\n            for doc_id, doc_data in self._data.items():\n                # Apply status filter\n                if (\n                    status_filter is not None\n                    and doc_data.get(\"status\") != status_filter.value\n                ):\n                    continue\n\n                try:\n                    # Prepare document data\n                    data = doc_data.copy()\n                    data.pop(\"content\", None)\n                    if not data.get(\"file_path\"):\n                        data[\"file_path\"] = \"no-file-path\"\n                    if \"metadata\" not in data:\n                        data[\"metadata\"] = {}\n                    if \"error_msg\" not in data:\n                        data[\"error_msg\"] = None\n\n                    doc_status = DocProcessingStatus(**data)\n\n                    # Add sort key for sorting\n                    if sort_field == \"id\":\n                        doc_status._sort_key = doc_id\n                    elif sort_field == \"file_path\":\n                        # Use pinyin sorting for file_path field to support Chinese characters\n                        file_path_value = getattr(doc_status, sort_field, \"\")\n                        doc_status._sort_key = get_pinyin_sort_key(file_path_value)\n                    else:\n                        doc_status._sort_key = getattr(doc_status, sort_field, \"\")\n\n                    all_docs.append((doc_id, doc_status))\n\n                except KeyError as e:\n                    logger.error(\n                        f\"[{self.workspace}] Error processing document {doc_id}: {e}\"\n                    )\n                    continue\n\n        # Sort documents\n        reverse_sort = sort_direction.lower() == \"desc\"\n        all_docs.sort(\n            key=lambda x: getattr(x[1], \"_sort_key\", \"\"), reverse=reverse_sort\n        )\n\n        # Remove sort key from documents\n        for doc_id, doc in all_docs:\n            if hasattr(doc, \"_sort_key\"):\n                delattr(doc, \"_sort_key\")\n\n        total_count = len(all_docs)\n\n        # Apply pagination\n        start_idx = (page - 1) * page_size\n        end_idx = start_idx + page_size\n        paginated_docs = all_docs[start_idx:end_idx]\n\n        return paginated_docs, total_count\n\n    async def get_all_status_counts(self) -> dict[str, int]:\n        \"\"\"Get counts of documents in each status for all documents\n\n        Returns:\n            Dictionary mapping status names to counts, including 'all' field\n        \"\"\"\n        counts = await self.get_status_counts()\n\n        # Add 'all' field with total count\n        total_count = sum(counts.values())\n        counts[\"all\"] = total_count\n\n        return counts\n\n    async def delete(self, doc_ids: list[str]) -> None:\n        \"\"\"Delete specific records from storage by their IDs\n\n        Importance notes for in-memory storage:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. update flags to notify other processes that data persistence is needed\n\n        Args:\n            ids (list[str]): List of document IDs to be deleted from storage\n\n        Returns:\n            None\n        \"\"\"\n        async with self._storage_lock:\n            any_deleted = False\n            for doc_id in doc_ids:\n                result = self._data.pop(doc_id, None)\n                if result is not None:\n                    any_deleted = True\n\n            if any_deleted:\n                await set_all_update_flags(self.namespace, workspace=self.workspace)\n\n    async def get_doc_by_file_path(self, file_path: str) -> Union[dict[str, Any], None]:\n        \"\"\"Get document by file path\n\n        Args:\n            file_path: The file path to search for\n\n        Returns:\n            Union[dict[str, Any], None]: Document data if found, None otherwise\n            Returns the same format as get_by_ids method\n        \"\"\"\n        if self._storage_lock is None:\n            raise StorageNotInitializedError(\"JsonDocStatusStorage\")\n\n        async with self._storage_lock:\n            for doc_id, doc_data in self._data.items():\n                if doc_data.get(\"file_path\") == file_path:\n                    # Return complete document data, consistent with get_by_ids method\n                    return doc_data\n\n        return None\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop all document status data from storage and clean up resources\n\n        This method will:\n        1. Clear all document status data from memory\n        2. Update flags to notify other processes\n        3. Trigger index_done_callback to save the empty state\n\n        Returns:\n            dict[str, str]: Operation status and message\n            - On success: {\"status\": \"success\", \"message\": \"data dropped\"}\n            - On failure: {\"status\": \"error\", \"message\": \"<error details>\"}\n        \"\"\"\n        try:\n            async with self._storage_lock:\n                self._data.clear()\n                await set_all_update_flags(self.namespace, workspace=self.workspace)\n\n            await self.index_done_callback()\n            logger.info(\n                f\"[{self.workspace}] Process {os.getpid()} drop {self.namespace}\"\n            )\n            return {\"status\": \"success\", \"message\": \"data dropped\"}\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error dropping {self.namespace}: {e}\")\n            return {\"status\": \"error\", \"message\": str(e)}\n"
  },
  {
    "path": "lightrag/kg/json_kv_impl.py",
    "content": "import os\nfrom dataclasses import dataclass\nfrom typing import Any, final\n\nfrom lightrag.base import (\n    BaseKVStorage,\n)\nfrom lightrag.utils import (\n    load_json,\n    logger,\n    write_json,\n)\nfrom lightrag.exceptions import StorageNotInitializedError\nfrom .shared_storage import (\n    get_namespace_data,\n    get_namespace_lock,\n    get_data_init_lock,\n    get_update_flag,\n    set_all_update_flags,\n    clear_all_update_flags,\n    try_initialize_namespace,\n)\n\n\n@final\n@dataclass\nclass JsonKVStorage(BaseKVStorage):\n    def __post_init__(self):\n        working_dir = self.global_config[\"working_dir\"]\n        if self.workspace:\n            # Include workspace in the file path for data isolation\n            workspace_dir = os.path.join(working_dir, self.workspace)\n        else:\n            # Default behavior when workspace is empty\n            workspace_dir = working_dir\n            self.workspace = \"\"\n\n        os.makedirs(workspace_dir, exist_ok=True)\n        self._file_name = os.path.join(workspace_dir, f\"kv_store_{self.namespace}.json\")\n\n        self._data = None\n        self._storage_lock = None\n        self.storage_updated = None\n\n    async def initialize(self):\n        \"\"\"Initialize storage data\"\"\"\n        self._storage_lock = get_namespace_lock(\n            self.namespace, workspace=self.workspace\n        )\n        self.storage_updated = await get_update_flag(\n            self.namespace, workspace=self.workspace\n        )\n        async with get_data_init_lock():\n            # check need_init must before get_namespace_data\n            need_init = await try_initialize_namespace(\n                self.namespace, workspace=self.workspace\n            )\n            self._data = await get_namespace_data(\n                self.namespace, workspace=self.workspace\n            )\n            if need_init:\n                loaded_data = load_json(self._file_name) or {}\n                async with self._storage_lock:\n                    # Migrate legacy cache structure if needed\n                    if self.namespace.endswith(\"_cache\"):\n                        loaded_data = await self._migrate_legacy_cache_structure(\n                            loaded_data\n                        )\n\n                    self._data.update(loaded_data)\n                    data_count = len(loaded_data)\n\n                    logger.info(\n                        f\"[{self.workspace}] Process {os.getpid()} KV load {self.namespace} with {data_count} records\"\n                    )\n\n    async def index_done_callback(self) -> None:\n        async with self._storage_lock:\n            if self.storage_updated.value:\n                data_dict = (\n                    dict(self._data) if hasattr(self._data, \"_getvalue\") else self._data\n                )\n\n                # Calculate data count - all data is now flattened\n                data_count = len(data_dict)\n\n                logger.debug(\n                    f\"[{self.workspace}] Process {os.getpid()} KV writting {data_count} records to {self.namespace}\"\n                )\n\n                # Write JSON and check if sanitization was applied\n                needs_reload = write_json(data_dict, self._file_name)\n\n                # If data was sanitized, reload cleaned data to update shared memory\n                if needs_reload:\n                    logger.info(\n                        f\"[{self.workspace}] Reloading sanitized data into shared memory for {self.namespace}\"\n                    )\n                    cleaned_data = load_json(self._file_name)\n                    if cleaned_data is not None:\n                        self._data.clear()\n                        self._data.update(cleaned_data)\n\n                await clear_all_update_flags(self.namespace, workspace=self.workspace)\n\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        async with self._storage_lock:\n            result = self._data.get(id)\n            if result:\n                # Create a copy to avoid modifying the original data\n                result = dict(result)\n                # Ensure time fields are present, provide default values for old data\n                result.setdefault(\"create_time\", 0)\n                result.setdefault(\"update_time\", 0)\n                # Ensure _id field contains the clean ID\n                result[\"_id\"] = id\n            return result\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        async with self._storage_lock:\n            results = []\n            for id in ids:\n                data = self._data.get(id, None)\n                if data:\n                    # Create a copy to avoid modifying the original data\n                    result = {k: v for k, v in data.items()}\n                    # Ensure time fields are present, provide default values for old data\n                    result.setdefault(\"create_time\", 0)\n                    result.setdefault(\"update_time\", 0)\n                    # Ensure _id field contains the clean ID\n                    result[\"_id\"] = id\n                    results.append(result)\n                else:\n                    results.append(None)\n            return results\n\n    async def filter_keys(self, keys: set[str]) -> set[str]:\n        async with self._storage_lock:\n            return set(keys) - set(self._data.keys())\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        \"\"\"\n        Importance notes for in-memory storage:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. update flags to notify other processes that data persistence is needed\n        \"\"\"\n        if not data:\n            return\n\n        import time\n\n        current_time = int(time.time())  # Get current Unix timestamp\n\n        logger.debug(\n            f\"[{self.workspace}] Inserting {len(data)} records to {self.namespace}\"\n        )\n        if self._storage_lock is None:\n            raise StorageNotInitializedError(\"JsonKVStorage\")\n        async with self._storage_lock:\n            # Add timestamps to data based on whether key exists\n            for k, v in data.items():\n                # For text_chunks namespace, ensure llm_cache_list field exists\n                if self.namespace.endswith(\"text_chunks\"):\n                    if \"llm_cache_list\" not in v:\n                        v[\"llm_cache_list\"] = []\n\n                # Add timestamps based on whether key exists\n                if k in self._data:  # Key exists, only update update_time\n                    v[\"update_time\"] = current_time\n                else:  # New key, set both create_time and update_time\n                    v[\"create_time\"] = current_time\n                    v[\"update_time\"] = current_time\n\n                v[\"_id\"] = k\n\n            self._data.update(data)\n            await set_all_update_flags(self.namespace, workspace=self.workspace)\n\n    async def delete(self, ids: list[str]) -> None:\n        \"\"\"Delete specific records from storage by their IDs\n\n        Importance notes for in-memory storage:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. update flags to notify other processes that data persistence is needed\n\n        Args:\n            ids (list[str]): List of document IDs to be deleted from storage\n\n        Returns:\n            None\n        \"\"\"\n        async with self._storage_lock:\n            any_deleted = False\n            for doc_id in ids:\n                result = self._data.pop(doc_id, None)\n                if result is not None:\n                    any_deleted = True\n\n            if any_deleted:\n                await set_all_update_flags(self.namespace, workspace=self.workspace)\n\n    async def is_empty(self) -> bool:\n        \"\"\"Check if the storage is empty\n\n        Returns:\n            bool: True if storage contains no data, False otherwise\n        \"\"\"\n        async with self._storage_lock:\n            return len(self._data) == 0\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop all data from storage and clean up resources\n           This action will persistent the data to disk immediately.\n\n        This method will:\n        1. Clear all data from memory\n        2. Update flags to notify other processes\n        3. Trigger index_done_callback to save the empty state\n\n        Returns:\n            dict[str, str]: Operation status and message\n            - On success: {\"status\": \"success\", \"message\": \"data dropped\"}\n            - On failure: {\"status\": \"error\", \"message\": \"<error details>\"}\n        \"\"\"\n        try:\n            async with self._storage_lock:\n                self._data.clear()\n                await set_all_update_flags(self.namespace, workspace=self.workspace)\n\n            await self.index_done_callback()\n            logger.info(\n                f\"[{self.workspace}] Process {os.getpid()} drop {self.namespace}\"\n            )\n            return {\"status\": \"success\", \"message\": \"data dropped\"}\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error dropping {self.namespace}: {e}\")\n            return {\"status\": \"error\", \"message\": str(e)}\n\n    async def _migrate_legacy_cache_structure(self, data: dict) -> dict:\n        \"\"\"Migrate legacy nested cache structure to flattened structure\n\n        Args:\n            data: Original data dictionary that may contain legacy structure\n\n        Returns:\n            Migrated data dictionary with flattened cache keys (sanitized if needed)\n        \"\"\"\n        from lightrag.utils import generate_cache_key\n\n        # Early return if data is empty\n        if not data:\n            return data\n\n        # Check first entry to see if it's already in new format\n        first_key = next(iter(data.keys()))\n        if \":\" in first_key and len(first_key.split(\":\")) == 3:\n            # Already in flattened format, return as-is\n            return data\n\n        migrated_data = {}\n        migration_count = 0\n\n        for key, value in data.items():\n            # Check if this is a legacy nested cache structure\n            if isinstance(value, dict) and all(\n                isinstance(v, dict) and \"return\" in v for v in value.values()\n            ):\n                # This looks like a legacy cache mode with nested structure\n                mode = key\n                for cache_hash, cache_entry in value.items():\n                    cache_type = cache_entry.get(\"cache_type\", \"extract\")\n                    flattened_key = generate_cache_key(mode, cache_type, cache_hash)\n                    migrated_data[flattened_key] = cache_entry\n                    migration_count += 1\n            else:\n                # Keep non-cache data or already flattened cache data as-is\n                migrated_data[key] = value\n\n        if migration_count > 0:\n            logger.info(\n                f\"[{self.workspace}] Migrated {migration_count} legacy cache entries to flattened structure\"\n            )\n            # Persist migrated data immediately and check if sanitization was applied\n            needs_reload = write_json(migrated_data, self._file_name)\n\n            # If data was sanitized during write, reload cleaned data\n            if needs_reload:\n                logger.info(\n                    f\"[{self.workspace}] Reloading sanitized migration data for {self.namespace}\"\n                )\n                cleaned_data = load_json(self._file_name)\n                if cleaned_data is not None:\n                    return cleaned_data  # Return cleaned data to update shared memory\n\n        return migrated_data\n\n    async def finalize(self):\n        \"\"\"Finalize storage resources\n        Persistence cache data to disk before exiting\n        \"\"\"\n        if self.namespace.endswith(\"_cache\"):\n            await self.index_done_callback()\n"
  },
  {
    "path": "lightrag/kg/memgraph_impl.py",
    "content": "import os\nimport asyncio\nimport random\nfrom dataclasses import dataclass\nfrom typing import final\nimport configparser\n\nfrom ..utils import logger\nfrom ..base import BaseGraphStorage\nfrom ..types import KnowledgeGraph, KnowledgeGraphNode, KnowledgeGraphEdge\nfrom ..kg.shared_storage import get_data_init_lock\nimport pipmaster as pm\n\nif not pm.is_installed(\"neo4j\"):\n    pm.install(\"neo4j\")\nfrom neo4j import (\n    AsyncGraphDatabase,\n    AsyncManagedTransaction,\n)\nfrom neo4j.exceptions import TransientError, ResultFailedError\n\nfrom dotenv import load_dotenv\n\n# use the .env that is inside the current folder\nload_dotenv(dotenv_path=\".env\", override=False)\n\nMAX_GRAPH_NODES = int(os.getenv(\"MAX_GRAPH_NODES\", 1000))\n\nconfig = configparser.ConfigParser()\nconfig.read(\"config.ini\", \"utf-8\")\n\n\n@final\n@dataclass\nclass MemgraphStorage(BaseGraphStorage):\n    def __init__(self, namespace, global_config, embedding_func, workspace=None):\n        # Priority: 1) MEMGRAPH_WORKSPACE env 2) user arg 3) default 'base'\n        memgraph_workspace = os.environ.get(\"MEMGRAPH_WORKSPACE\")\n        original_workspace = workspace  # Save original value for logging\n        if memgraph_workspace and memgraph_workspace.strip():\n            workspace = memgraph_workspace\n\n        if not workspace or not str(workspace).strip():\n            workspace = \"base\"\n\n        super().__init__(\n            namespace=namespace,\n            workspace=workspace,\n            global_config=global_config,\n            embedding_func=embedding_func,\n        )\n\n        # Log after super().__init__() to ensure self.workspace is initialized\n        if memgraph_workspace and memgraph_workspace.strip():\n            logger.info(\n                f\"Using MEMGRAPH_WORKSPACE environment variable: '{memgraph_workspace}' (overriding '{original_workspace}/{namespace}')\"\n            )\n\n        self._driver = None\n\n    def _get_workspace_label(self) -> str:\n        \"\"\"Return sanitized workspace label safe for use as a backtick-quoted identifier in Cypher queries.\n\n        Escapes backticks by doubling them to prevent Cypher injection\n        via the LIGHTRAG-WORKSPACE header, while preserving a 1-to-1 mapping\n        for all other characters. The returned value is intended to be used\n        inside backticks (for example, MATCH (n:`{label}`)) and is not\n        validated as a standalone unquoted identifier.\n        \"\"\"\n        workspace = self.workspace.strip()\n        if not workspace:\n            return \"base\"\n        return workspace.replace(\"`\", \"``\")\n\n    async def initialize(self):\n        async with get_data_init_lock():\n            URI = os.environ.get(\n                \"MEMGRAPH_URI\",\n                config.get(\"memgraph\", \"uri\", fallback=\"bolt://localhost:7687\"),\n            )\n            USERNAME = os.environ.get(\n                \"MEMGRAPH_USERNAME\", config.get(\"memgraph\", \"username\", fallback=\"\")\n            )\n            PASSWORD = os.environ.get(\n                \"MEMGRAPH_PASSWORD\", config.get(\"memgraph\", \"password\", fallback=\"\")\n            )\n            DATABASE = os.environ.get(\n                \"MEMGRAPH_DATABASE\",\n                config.get(\"memgraph\", \"database\", fallback=\"memgraph\"),\n            )\n\n            self._driver = AsyncGraphDatabase.driver(\n                URI,\n                auth=(USERNAME, PASSWORD),\n            )\n            self._DATABASE = DATABASE\n            try:\n                async with self._driver.session(database=DATABASE) as session:\n                    # Create index for base nodes on entity_id if it doesn't exist\n                    try:\n                        workspace_label = self._get_workspace_label()\n                        await session.run(\n                            f\"\"\"CREATE INDEX ON :{workspace_label}(entity_id)\"\"\"\n                        )\n                        logger.info(\n                            f\"[{self.workspace}] Created index on :{workspace_label}(entity_id) in Memgraph.\"\n                        )\n                    except Exception as e:\n                        # Index may already exist, which is not an error\n                        logger.warning(\n                            f\"[{self.workspace}] Index creation on :{workspace_label}(entity_id) may have failed or already exists: {e}\"\n                        )\n                    await session.run(\"RETURN 1\")\n                    logger.info(f\"[{self.workspace}] Connected to Memgraph at {URI}\")\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Failed to connect to Memgraph at {URI}: {e}\"\n                )\n                raise\n\n    async def finalize(self):\n        if self._driver is not None:\n            await self._driver.close()\n            self._driver = None\n\n    async def __aexit__(self, exc_type, exc, tb):\n        await self.finalize()\n\n    async def index_done_callback(self):\n        # Memgraph handles persistence automatically\n        pass\n\n    async def has_node(self, node_id: str) -> bool:\n        \"\"\"\n        Check if a node exists in the graph.\n\n        Args:\n            node_id: The ID of the node to check.\n\n        Returns:\n            bool: True if the node exists, False otherwise.\n\n        Raises:\n            Exception: If there is an error checking the node existence.\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            result = None\n            try:\n                workspace_label = self._get_workspace_label()\n                query = f\"MATCH (n:`{workspace_label}` {{entity_id: $entity_id}}) RETURN count(n) > 0 AS node_exists\"\n                result = await session.run(query, entity_id=node_id)\n                single_result = await result.single()\n                await result.consume()  # Ensure result is fully consumed\n                return (\n                    single_result[\"node_exists\"] if single_result is not None else False\n                )\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Error checking node existence for {node_id}: {str(e)}\"\n                )\n                if result is not None:\n                    await (\n                        result.consume()\n                    )  # Ensure the result is consumed even on error\n                raise\n\n    async def has_edge(self, source_node_id: str, target_node_id: str) -> bool:\n        \"\"\"\n        Check if an edge exists between two nodes in the graph.\n\n        Args:\n            source_node_id: The ID of the source node.\n            target_node_id: The ID of the target node.\n\n        Returns:\n            bool: True if the edge exists, False otherwise.\n\n        Raises:\n            Exception: If there is an error checking the edge existence.\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            result = None\n            try:\n                workspace_label = self._get_workspace_label()\n                query = (\n                    f\"MATCH (a:`{workspace_label}` {{entity_id: $source_entity_id}})-[r]-(b:`{workspace_label}` {{entity_id: $target_entity_id}}) \"\n                    \"RETURN COUNT(r) > 0 AS edgeExists\"\n                )\n                result = await session.run(\n                    query,\n                    source_entity_id=source_node_id,\n                    target_entity_id=target_node_id,\n                )  # type: ignore\n                single_result = await result.single()\n                await result.consume()  # Ensure result is fully consumed\n                return (\n                    single_result[\"edgeExists\"] if single_result is not None else False\n                )\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Error checking edge existence between {source_node_id} and {target_node_id}: {str(e)}\"\n                )\n                if result is not None:\n                    await (\n                        result.consume()\n                    )  # Ensure the result is consumed even on error\n                raise\n\n    async def get_node(self, node_id: str) -> dict[str, str] | None:\n        \"\"\"Get node by its label identifier, return only node properties\n\n        Args:\n            node_id: The node label to look up\n\n        Returns:\n            dict: Node properties if found\n            None: If node not found\n\n        Raises:\n            Exception: If there is an error executing the query\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            try:\n                workspace_label = self._get_workspace_label()\n                query = (\n                    f\"MATCH (n:`{workspace_label}` {{entity_id: $entity_id}}) RETURN n\"\n                )\n                result = await session.run(query, entity_id=node_id)\n                try:\n                    records = await result.fetch(\n                        2\n                    )  # Get 2 records for duplication check\n\n                    if len(records) > 1:\n                        logger.warning(\n                            f\"[{self.workspace}] Multiple nodes found with label '{node_id}'. Using first node.\"\n                        )\n                    if records:\n                        node = records[0][\"n\"]\n                        node_dict = dict(node)\n                        # Remove workspace label from labels list if it exists\n                        if \"labels\" in node_dict:\n                            node_dict[\"labels\"] = [\n                                label\n                                for label in node_dict[\"labels\"]\n                                if label != workspace_label\n                            ]\n                        return node_dict\n                    return None\n                finally:\n                    await result.consume()  # Ensure result is fully consumed\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Error getting node for {node_id}: {str(e)}\"\n                )\n                raise\n\n    async def node_degree(self, node_id: str) -> int:\n        \"\"\"Get the degree (number of relationships) of a node with the given label.\n        If multiple nodes have the same label, returns the degree of the first node.\n        If no node is found, returns 0.\n\n        Args:\n            node_id: The label of the node\n\n        Returns:\n            int: The number of relationships the node has, or 0 if no node found\n\n        Raises:\n            Exception: If there is an error executing the query\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            try:\n                workspace_label = self._get_workspace_label()\n                query = f\"\"\"\n                    MATCH (n:`{workspace_label}` {{entity_id: $entity_id}})\n                    OPTIONAL MATCH (n)-[r]-()\n                    RETURN COUNT(r) AS degree\n                \"\"\"\n                result = await session.run(query, entity_id=node_id)\n                try:\n                    record = await result.single()\n\n                    if not record:\n                        logger.warning(\n                            f\"[{self.workspace}] No node found with label '{node_id}'\"\n                        )\n                        return 0\n\n                    degree = record[\"degree\"]\n                    return degree\n                finally:\n                    await result.consume()  # Ensure result is fully consumed\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Error getting node degree for {node_id}: {str(e)}\"\n                )\n                raise\n\n    async def get_all_labels(self) -> list[str]:\n        \"\"\"\n        Get all existing node labels(entity names) in the database\n        Returns:\n            [\"Person\", \"Company\", ...]  # Alphabetically sorted label list\n\n        Raises:\n            Exception: If there is an error executing the query\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            result = None\n            try:\n                workspace_label = self._get_workspace_label()\n                query = f\"\"\"\n                MATCH (n:`{workspace_label}`)\n                WHERE n.entity_id IS NOT NULL\n                RETURN DISTINCT n.entity_id AS label\n                ORDER BY label\n                \"\"\"\n                result = await session.run(query)\n                labels = []\n                async for record in result:\n                    labels.append(record[\"label\"])\n                await result.consume()\n                return labels\n            except Exception as e:\n                logger.error(f\"[{self.workspace}] Error getting all labels: {str(e)}\")\n                if result is not None:\n                    await (\n                        result.consume()\n                    )  # Ensure the result is consumed even on error\n                raise\n\n    async def get_node_edges(self, source_node_id: str) -> list[tuple[str, str]] | None:\n        \"\"\"Retrieves all edges (relationships) for a particular node identified by its label.\n\n        Args:\n            source_node_id: Label of the node to get edges for\n\n        Returns:\n            list[tuple[str, str]]: List of (source_label, target_label) tuples representing edges\n            None: If no edges found\n\n        Raises:\n            Exception: If there is an error executing the query\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n        try:\n            async with self._driver.session(\n                database=self._DATABASE, default_access_mode=\"READ\"\n            ) as session:\n                results = None\n                try:\n                    workspace_label = self._get_workspace_label()\n                    query = f\"\"\"MATCH (n:`{workspace_label}` {{entity_id: $entity_id}})\n                            OPTIONAL MATCH (n)-[r]-(connected:`{workspace_label}`)\n                            WHERE connected.entity_id IS NOT NULL\n                            RETURN n, r, connected\"\"\"\n                    results = await session.run(query, entity_id=source_node_id)\n\n                    edges = []\n                    async for record in results:\n                        source_node = record[\"n\"]\n                        connected_node = record[\"connected\"]\n\n                        # Skip if either node is None\n                        if not source_node or not connected_node:\n                            continue\n\n                        source_label = (\n                            source_node.get(\"entity_id\")\n                            if source_node.get(\"entity_id\")\n                            else None\n                        )\n                        target_label = (\n                            connected_node.get(\"entity_id\")\n                            if connected_node.get(\"entity_id\")\n                            else None\n                        )\n\n                        if source_label and target_label:\n                            edges.append((source_label, target_label))\n\n                    await results.consume()  # Ensure results are consumed\n                    return edges\n                except Exception as e:\n                    logger.error(\n                        f\"[{self.workspace}] Error getting edges for node {source_node_id}: {str(e)}\"\n                    )\n                    if results is not None:\n                        await (\n                            results.consume()\n                        )  # Ensure results are consumed even on error\n                    raise\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error in get_node_edges for {source_node_id}: {str(e)}\"\n            )\n            raise\n\n    async def get_edge(\n        self, source_node_id: str, target_node_id: str\n    ) -> dict[str, str] | None:\n        \"\"\"Get edge properties between two nodes.\n\n        Args:\n            source_node_id: Label of the source node\n            target_node_id: Label of the target node\n\n        Returns:\n            dict: Edge properties if found, default properties if not found or on error\n\n        Raises:\n            Exception: If there is an error executing the query\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            result = None\n            try:\n                workspace_label = self._get_workspace_label()\n                query = f\"\"\"\n                MATCH (start:`{workspace_label}` {{entity_id: $source_entity_id}})-[r]-(end:`{workspace_label}` {{entity_id: $target_entity_id}})\n                RETURN properties(r) as edge_properties\n                \"\"\"\n                result = await session.run(\n                    query,\n                    source_entity_id=source_node_id,\n                    target_entity_id=target_node_id,\n                )\n                records = await result.fetch(2)\n                await result.consume()\n                if records:\n                    edge_result = dict(records[0][\"edge_properties\"])\n                    for key, default_value in {\n                        \"weight\": 1.0,\n                        \"source_id\": None,\n                        \"description\": None,\n                        \"keywords\": None,\n                    }.items():\n                        if key not in edge_result:\n                            edge_result[key] = default_value\n                            logger.warning(\n                                f\"[{self.workspace}] Edge between {source_node_id} and {target_node_id} is missing property: {key}. Using default value: {default_value}\"\n                            )\n                    return edge_result\n                return None\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Error getting edge between {source_node_id} and {target_node_id}: {str(e)}\"\n                )\n                if result is not None:\n                    await (\n                        result.consume()\n                    )  # Ensure the result is consumed even on error\n                raise\n\n    async def upsert_node(self, node_id: str, node_data: dict[str, str]) -> None:\n        \"\"\"\n        Upsert a node in the Memgraph database with manual transaction-level retry logic for transient errors.\n\n        Args:\n            node_id: The unique identifier for the node (used as label)\n            node_data: Dictionary of node properties\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n        properties = node_data\n        entity_type = properties[\"entity_type\"]\n        if \"entity_id\" not in properties:\n            raise ValueError(\n                \"Memgraph: node properties must contain an 'entity_id' field\"\n            )\n\n        # Manual transaction-level retry following official Memgraph documentation\n        max_retries = 100\n        initial_wait_time = 0.2\n        backoff_factor = 1.1\n        jitter_factor = 0.1\n\n        for attempt in range(max_retries):\n            try:\n                logger.debug(\n                    f\"[{self.workspace}] Attempting node upsert, attempt {attempt + 1}/{max_retries}\"\n                )\n                async with self._driver.session(database=self._DATABASE) as session:\n                    workspace_label = self._get_workspace_label()\n\n                    async def execute_upsert(tx: AsyncManagedTransaction):\n                        query = f\"\"\"\n                        MERGE (n:`{workspace_label}` {{entity_id: $entity_id}})\n                        SET n += $properties\n                        SET n:`{entity_type}`\n                        \"\"\"\n                        result = await tx.run(\n                            query, entity_id=node_id, properties=properties\n                        )\n                        await result.consume()  # Ensure result is fully consumed\n\n                    await session.execute_write(execute_upsert)\n                    break  # Success - exit retry loop\n\n            except (TransientError, ResultFailedError) as e:\n                # Check if the root cause is a TransientError\n                root_cause = e\n                while hasattr(root_cause, \"__cause__\") and root_cause.__cause__:\n                    root_cause = root_cause.__cause__\n\n                # Check if this is a transient error that should be retried\n                is_transient = (\n                    isinstance(root_cause, TransientError)\n                    or isinstance(e, TransientError)\n                    or \"TransientError\" in str(e)\n                    or \"Cannot resolve conflicting transactions\" in str(e)\n                )\n\n                if is_transient:\n                    if attempt < max_retries - 1:\n                        # Calculate wait time with exponential backoff and jitter\n                        jitter = random.uniform(0, jitter_factor) * initial_wait_time\n                        wait_time = (\n                            initial_wait_time * (backoff_factor**attempt) + jitter\n                        )\n                        logger.warning(\n                            f\"[{self.workspace}] Node upsert failed. Attempt #{attempt + 1} retrying in {wait_time:.3f} seconds... Error: {str(e)}\"\n                        )\n                        await asyncio.sleep(wait_time)\n                    else:\n                        logger.error(\n                            f\"[{self.workspace}] Memgraph transient error during node upsert after {max_retries} retries: {str(e)}\"\n                        )\n                        raise\n                else:\n                    # Non-transient error, don't retry\n                    logger.error(\n                        f\"[{self.workspace}] Non-transient error during node upsert: {str(e)}\"\n                    )\n                    raise\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Unexpected error during node upsert: {str(e)}\"\n                )\n                raise\n\n    async def upsert_edge(\n        self, source_node_id: str, target_node_id: str, edge_data: dict[str, str]\n    ) -> None:\n        \"\"\"\n        Upsert an edge and its properties between two nodes identified by their labels with manual transaction-level retry logic for transient errors.\n        Ensures both source and target nodes exist and are unique before creating the edge.\n        Uses entity_id property to uniquely identify nodes.\n\n        Args:\n            source_node_id (str): Label of the source node (used as identifier)\n            target_node_id (str): Label of the target node (used as identifier)\n            edge_data (dict): Dictionary of properties to set on the edge\n\n        Raises:\n            Exception: If there is an error executing the query\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n\n        edge_properties = edge_data\n\n        # Manual transaction-level retry following official Memgraph documentation\n        max_retries = 100\n        initial_wait_time = 0.2\n        backoff_factor = 1.1\n        jitter_factor = 0.1\n\n        for attempt in range(max_retries):\n            try:\n                logger.debug(\n                    f\"[{self.workspace}] Attempting edge upsert, attempt {attempt + 1}/{max_retries}\"\n                )\n                async with self._driver.session(database=self._DATABASE) as session:\n\n                    async def execute_upsert(tx: AsyncManagedTransaction):\n                        workspace_label = self._get_workspace_label()\n                        query = f\"\"\"\n                        MATCH (source:`{workspace_label}` {{entity_id: $source_entity_id}})\n                        WITH source\n                        MATCH (target:`{workspace_label}` {{entity_id: $target_entity_id}})\n                        MERGE (source)-[r:DIRECTED]-(target)\n                        SET r += $properties\n                        RETURN r, source, target\n                        \"\"\"\n                        result = await tx.run(\n                            query,\n                            source_entity_id=source_node_id,\n                            target_entity_id=target_node_id,\n                            properties=edge_properties,\n                        )\n                        try:\n                            await result.fetch(2)\n                        finally:\n                            await result.consume()  # Ensure result is consumed\n\n                    await session.execute_write(execute_upsert)\n                    break  # Success - exit retry loop\n\n            except (TransientError, ResultFailedError) as e:\n                # Check if the root cause is a TransientError\n                root_cause = e\n                while hasattr(root_cause, \"__cause__\") and root_cause.__cause__:\n                    root_cause = root_cause.__cause__\n\n                # Check if this is a transient error that should be retried\n                is_transient = (\n                    isinstance(root_cause, TransientError)\n                    or isinstance(e, TransientError)\n                    or \"TransientError\" in str(e)\n                    or \"Cannot resolve conflicting transactions\" in str(e)\n                )\n\n                if is_transient:\n                    if attempt < max_retries - 1:\n                        # Calculate wait time with exponential backoff and jitter\n                        jitter = random.uniform(0, jitter_factor) * initial_wait_time\n                        wait_time = (\n                            initial_wait_time * (backoff_factor**attempt) + jitter\n                        )\n                        logger.warning(\n                            f\"[{self.workspace}] Edge upsert failed. Attempt #{attempt + 1} retrying in {wait_time:.3f} seconds... Error: {str(e)}\"\n                        )\n                        await asyncio.sleep(wait_time)\n                    else:\n                        logger.error(\n                            f\"[{self.workspace}] Memgraph transient error during edge upsert after {max_retries} retries: {str(e)}\"\n                        )\n                        raise\n                else:\n                    # Non-transient error, don't retry\n                    logger.error(\n                        f\"[{self.workspace}] Non-transient error during edge upsert: {str(e)}\"\n                    )\n                    raise\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Unexpected error during edge upsert: {str(e)}\"\n                )\n                raise\n\n    async def delete_node(self, node_id: str) -> None:\n        \"\"\"Delete a node with the specified label\n\n        Args:\n            node_id: The label of the node to delete\n\n        Raises:\n            Exception: If there is an error executing the query\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n\n        async def _do_delete(tx: AsyncManagedTransaction):\n            workspace_label = self._get_workspace_label()\n            query = f\"\"\"\n            MATCH (n:`{workspace_label}` {{entity_id: $entity_id}})\n            DETACH DELETE n\n            \"\"\"\n            result = await tx.run(query, entity_id=node_id)\n            logger.debug(f\"[{self.workspace}] Deleted node with label {node_id}\")\n            await result.consume()\n\n        try:\n            async with self._driver.session(database=self._DATABASE) as session:\n                await session.execute_write(_do_delete)\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error during node deletion: {str(e)}\")\n            raise\n\n    async def remove_nodes(self, nodes: list[str]):\n        \"\"\"Delete multiple nodes\n\n        Args:\n            nodes: List of node labels to be deleted\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n        for node in nodes:\n            await self.delete_node(node)\n\n    async def remove_edges(self, edges: list[tuple[str, str]]):\n        \"\"\"Delete multiple edges\n\n        Args:\n            edges: List of edges to be deleted, each edge is a (source, target) tuple\n\n        Raises:\n            Exception: If there is an error executing the query\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n        for source, target in edges:\n\n            async def _do_delete_edge(tx: AsyncManagedTransaction):\n                workspace_label = self._get_workspace_label()\n                query = f\"\"\"\n                MATCH (source:`{workspace_label}` {{entity_id: $source_entity_id}})-[r]-(target:`{workspace_label}` {{entity_id: $target_entity_id}})\n                DELETE r\n                \"\"\"\n                result = await tx.run(\n                    query, source_entity_id=source, target_entity_id=target\n                )\n                logger.debug(\n                    f\"[{self.workspace}] Deleted edge from '{source}' to '{target}'\"\n                )\n                await result.consume()  # Ensure result is fully consumed\n\n            try:\n                async with self._driver.session(database=self._DATABASE) as session:\n                    await session.execute_write(_do_delete_edge)\n            except Exception as e:\n                logger.error(f\"[{self.workspace}] Error during edge deletion: {str(e)}\")\n                raise\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop all data from the current workspace and clean up resources\n\n        This method will delete all nodes and relationships in the Memgraph database.\n\n        Returns:\n            dict[str, str]: Operation status and message\n            - On success: {\"status\": \"success\", \"message\": \"data dropped\"}\n            - On failure: {\"status\": \"error\", \"message\": \"<error details>\"}\n\n        Raises:\n            Exception: If there is an error executing the query\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n        try:\n            async with self._driver.session(database=self._DATABASE) as session:\n                workspace_label = self._get_workspace_label()\n                query = f\"MATCH (n:`{workspace_label}`) DETACH DELETE n\"\n                result = await session.run(query)\n                await result.consume()\n                logger.info(\n                    f\"[{self.workspace}] Dropped workspace {workspace_label} from Memgraph database {self._DATABASE}\"\n                )\n                return {\"status\": \"success\", \"message\": \"workspace data dropped\"}\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error dropping workspace {workspace_label} from Memgraph database {self._DATABASE}: {e}\"\n            )\n            return {\"status\": \"error\", \"message\": str(e)}\n\n    async def edge_degree(self, src_id: str, tgt_id: str) -> int:\n        \"\"\"Get the total degree (sum of relationships) of two nodes.\n\n        Args:\n            src_id: Label of the source node\n            tgt_id: Label of the target node\n\n        Returns:\n            int: Sum of the degrees of both nodes\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n        src_degree = await self.node_degree(src_id)\n        trg_degree = await self.node_degree(tgt_id)\n\n        # Convert None to 0 for addition\n        src_degree = 0 if src_degree is None else src_degree\n        trg_degree = 0 if trg_degree is None else trg_degree\n\n        degrees = int(src_degree) + int(trg_degree)\n        return degrees\n\n    async def get_knowledge_graph(\n        self,\n        node_label: str,\n        max_depth: int = 3,\n        max_nodes: int = None,\n    ) -> KnowledgeGraph:\n        \"\"\"\n        Retrieve a connected subgraph of nodes where the label includes the specified `node_label`.\n\n        Args:\n            node_label: Label of the starting node, * means all nodes\n            max_depth: Maximum depth of the subgraph, Defaults to 3\n            max_nodes: Maximum nodes to return by BFS, Defaults to 1000\n\n        Returns:\n            KnowledgeGraph object containing nodes and edges, with an is_truncated flag\n            indicating whether the graph was truncated due to max_nodes limit\n        \"\"\"\n        # Get max_nodes from global_config if not provided\n        if max_nodes is None:\n            max_nodes = self.global_config.get(\"max_graph_nodes\", 1000)\n        else:\n            # Limit max_nodes to not exceed global_config max_graph_nodes\n            max_nodes = min(max_nodes, self.global_config.get(\"max_graph_nodes\", 1000))\n\n        workspace_label = self._get_workspace_label()\n        result = KnowledgeGraph()\n        seen_nodes = set()\n        seen_edges = set()\n\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            try:\n                if node_label == \"*\":\n                    # First check total node count to determine if graph is truncated\n                    count_query = (\n                        f\"MATCH (n:`{workspace_label}`) RETURN count(n) as total\"\n                    )\n                    count_result = None\n                    try:\n                        count_result = await session.run(count_query)\n                        count_record = await count_result.single()\n\n                        if count_record and count_record[\"total\"] > max_nodes:\n                            result.is_truncated = True\n                            logger.info(\n                                f\"Graph truncated: {count_record['total']} nodes found, limited to {max_nodes}\"\n                            )\n                    finally:\n                        if count_result:\n                            await count_result.consume()\n\n                    # Run main query to get nodes with highest degree\n                    main_query = f\"\"\"\n                    MATCH (n:`{workspace_label}`)\n                    OPTIONAL MATCH (n)-[r]-()\n                    WITH n, COALESCE(count(r), 0) AS degree\n                    ORDER BY degree DESC\n                    LIMIT $max_nodes\n                    WITH collect({{node: n}}) AS filtered_nodes\n                    UNWIND filtered_nodes AS node_info\n                    WITH collect(node_info.node) AS kept_nodes, filtered_nodes\n                    OPTIONAL MATCH (a)-[r]-(b)\n                    WHERE a IN kept_nodes AND b IN kept_nodes\n                    RETURN filtered_nodes AS node_info,\n                        collect(DISTINCT r) AS relationships\n                    \"\"\"\n                    result_set = None\n                    try:\n                        result_set = await session.run(\n                            main_query,\n                            {\"max_nodes\": max_nodes},\n                        )\n                        record = await result_set.single()\n                    finally:\n                        if result_set:\n                            await result_set.consume()\n\n                else:\n                    # Run subgraph query for specific node_label\n                    subgraph_query = f\"\"\"\n                    MATCH (start:`{workspace_label}`)\n                    WHERE start.entity_id = $entity_id\n\n                    MATCH path = (start)-[*BFS 0..{max_depth}]-(end:`{workspace_label}`)\n                    WHERE ALL(n IN nodes(path) WHERE '{workspace_label}' IN labels(n))\n                    WITH collect(DISTINCT end) + start AS all_nodes_unlimited\n                    WITH\n                    CASE\n                        WHEN size(all_nodes_unlimited) <= $max_nodes THEN all_nodes_unlimited\n                        ELSE all_nodes_unlimited[0..$max_nodes]\n                    END AS limited_nodes,\n                    size(all_nodes_unlimited) > $max_nodes AS is_truncated\n\n                    UNWIND limited_nodes AS n\n                    MATCH (n)-[r]-(m)\n                    WHERE m IN limited_nodes\n                    WITH collect(DISTINCT n) AS limited_nodes, collect(DISTINCT r) AS relationships, is_truncated\n\n                    RETURN\n                    [node IN limited_nodes | {{node: node}}] AS node_info,\n                    relationships,\n                    is_truncated\n                    \"\"\"\n\n                    result_set = None\n                    try:\n                        result_set = await session.run(\n                            subgraph_query,\n                            {\n                                \"entity_id\": node_label,\n                                \"max_nodes\": max_nodes,\n                            },\n                        )\n                        record = await result_set.single()\n\n                        # If no record found, return empty KnowledgeGraph\n                        if not record:\n                            logger.debug(\n                                f\"[{self.workspace}] No nodes found for entity_id: {node_label}\"\n                            )\n                            return result\n\n                        # Check if the result was truncated\n                        if record.get(\"is_truncated\"):\n                            result.is_truncated = True\n                            logger.info(\n                                f\"[{self.workspace}] Graph truncated: breadth-first search limited to {max_nodes} nodes\"\n                            )\n\n                    finally:\n                        if result_set:\n                            await result_set.consume()\n\n                if record:\n                    for node_info in record[\"node_info\"]:\n                        node = node_info[\"node\"]\n                        node_id = node.id\n                        if node_id not in seen_nodes:\n                            result.nodes.append(\n                                KnowledgeGraphNode(\n                                    id=f\"{node_id}\",\n                                    labels=[node.get(\"entity_id\")],\n                                    properties=dict(node),\n                                )\n                            )\n                            seen_nodes.add(node_id)\n\n                    for rel in record[\"relationships\"]:\n                        edge_id = rel.id\n                        if edge_id not in seen_edges:\n                            start = rel.start_node\n                            end = rel.end_node\n                            result.edges.append(\n                                KnowledgeGraphEdge(\n                                    id=f\"{edge_id}\",\n                                    type=rel.type,\n                                    source=f\"{start.id}\",\n                                    target=f\"{end.id}\",\n                                    properties=dict(rel),\n                                )\n                            )\n                            seen_edges.add(edge_id)\n\n                    logger.info(\n                        f\"[{self.workspace}] Subgraph query successful | Node count: {len(result.nodes)} | Edge count: {len(result.edges)}\"\n                    )\n\n            except Exception as e:\n                logger.warning(\n                    f\"[{self.workspace}] Memgraph error during subgraph query: {str(e)}\"\n                )\n\n        return result\n\n    async def get_all_nodes(self) -> list[dict]:\n        \"\"\"Get all nodes in the graph.\n\n        Returns:\n            A list of all nodes, where each node is a dictionary of its properties\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n        workspace_label = self._get_workspace_label()\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            query = f\"\"\"\n            MATCH (n:`{workspace_label}`)\n            RETURN n\n            \"\"\"\n            result = await session.run(query)\n            nodes = []\n            async for record in result:\n                node = record[\"n\"]\n                node_dict = dict(node)\n                # Add node id (entity_id) to the dictionary for easier access\n                node_dict[\"id\"] = node_dict.get(\"entity_id\")\n                nodes.append(node_dict)\n            await result.consume()\n            return nodes\n\n    async def get_all_edges(self) -> list[dict]:\n        \"\"\"Get all edges in the graph.\n\n        Returns:\n            A list of all edges, where each edge is a dictionary of its properties\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n        workspace_label = self._get_workspace_label()\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            query = f\"\"\"\n            MATCH (a:`{workspace_label}`)-[r]-(b:`{workspace_label}`)\n            RETURN DISTINCT a.entity_id AS source, b.entity_id AS target, properties(r) AS properties\n            \"\"\"\n            result = await session.run(query)\n            edges = []\n            async for record in result:\n                edge_properties = record[\"properties\"]\n                edge_properties[\"source\"] = record[\"source\"]\n                edge_properties[\"target\"] = record[\"target\"]\n                edges.append(edge_properties)\n            await result.consume()\n            return edges\n\n    async def get_popular_labels(self, limit: int = 300) -> list[str]:\n        \"\"\"Get popular labels by node degree (most connected entities)\n\n        Args:\n            limit: Maximum number of labels(entity names) to return\n\n        Returns:\n            List of labels(entity names) sorted by degree (highest first)\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n\n        result = None\n        try:\n            workspace_label = self._get_workspace_label()\n            async with self._driver.session(\n                database=self._DATABASE, default_access_mode=\"READ\"\n            ) as session:\n                query = f\"\"\"\n                MATCH (n:`{workspace_label}`)\n                WHERE n.entity_id IS NOT NULL\n                OPTIONAL MATCH (n)-[r]-()\n                WITH n.entity_id AS label, count(r) AS degree\n                ORDER BY degree DESC, label ASC\n                LIMIT {limit}\n                RETURN label\n                \"\"\"\n                result = await session.run(query)\n                labels = []\n                async for record in result:\n                    labels.append(record[\"label\"])\n                await result.consume()\n\n                logger.debug(\n                    f\"[{self.workspace}] Retrieved {len(labels)} popular labels (limit: {limit})\"\n                )\n                return labels\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error getting popular labels: {str(e)}\")\n            if result is not None:\n                await result.consume()\n            return []\n\n    async def search_labels(self, query: str, limit: int = 50) -> list[str]:\n        \"\"\"Search labels(entity names) with fuzzy matching\n\n        Args:\n            query: Search query string\n            limit: Maximum number of results to return\n\n        Returns:\n            List of matching labels(entity names) sorted by relevance\n        \"\"\"\n        if self._driver is None:\n            raise RuntimeError(\n                \"Memgraph driver is not initialized. Call 'await initialize()' first.\"\n            )\n\n        query_lower = query.lower().strip()\n\n        if not query_lower:\n            return []\n\n        result = None\n        try:\n            workspace_label = self._get_workspace_label()\n            async with self._driver.session(\n                database=self._DATABASE, default_access_mode=\"READ\"\n            ) as session:\n                cypher_query = f\"\"\"\n                MATCH (n:`{workspace_label}`)\n                WHERE n.entity_id IS NOT NULL\n                WITH n.entity_id AS label, toLower(n.entity_id) AS label_lower\n                WHERE label_lower CONTAINS $query_lower\n                WITH label, label_lower,\n                     CASE\n                         WHEN label_lower = $query_lower THEN 1000\n                         WHEN label_lower STARTS WITH $query_lower THEN 500\n                         ELSE 100 - size(label)\n                     END AS score\n                ORDER BY score DESC, label ASC\n                LIMIT {limit}\n                RETURN label\n                \"\"\"\n\n                result = await session.run(cypher_query, query_lower=query_lower)\n                labels = []\n                async for record in result:\n                    labels.append(record[\"label\"])\n                await result.consume()\n\n                logger.debug(\n                    f\"[{self.workspace}] Search query '{query}' returned {len(labels)} results (limit: {limit})\"\n                )\n                return labels\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error searching labels: {str(e)}\")\n            if result is not None:\n                await result.consume()\n            return []\n"
  },
  {
    "path": "lightrag/kg/milvus_impl.py",
    "content": "import asyncio\nimport os\nfrom typing import Any, final, Optional, Dict\nfrom dataclasses import dataclass, fields\nimport numpy as np\nfrom lightrag.utils import logger, compute_mdhash_id\nfrom ..base import BaseVectorStorage\nfrom ..constants import DEFAULT_MAX_FILE_PATH_LENGTH\nfrom ..kg.shared_storage import get_data_init_lock\nimport pipmaster as pm\n\nif not pm.is_installed(\"pymilvus\"):\n    pm.install(\"pymilvus>=2.6.2\")\n\nimport configparser\nfrom pymilvus import MilvusClient, DataType, CollectionSchema, FieldSchema  # type: ignore\nfrom packaging import version\n\nconfig = configparser.ConfigParser()\nconfig.read(\"config.ini\", \"utf-8\")\n\n\n# Supported index types\nSUPPORTED_INDEX_TYPES = {\n    \"AUTOINDEX\",\n    \"HNSW\",\n    \"HNSW_SQ\",\n    \"HNSW_PQ\",\n    \"HNSW_PRQ\",\n    \"IVF_FLAT\",\n    \"IVF_SQ8\",\n    \"IVF_PQ\",\n    \"DISKANN\",\n    \"SCANN\",\n}\n\n# Supported metric types\nSUPPORTED_METRIC_TYPES = {\"COSINE\", \"L2\", \"IP\"}\n\n# HNSW_SQ quantization types\nSUPPORTED_SQ_TYPES = {\"SQ4U\", \"SQ6\", \"SQ8\", \"BF16\", \"FP16\"}\nSUPPORTED_REFINE_TYPES = {\"SQ6\", \"SQ8\", \"BF16\", \"FP16\", \"FP32\"}\n\n# Index type version requirements\n# Important: HNSW_SQ was first introduced in Milvus 2.6.8 (not 2.5)\nINDEX_VERSION_REQUIREMENTS = {\n    \"HNSW_SQ\": \"2.6.8\",  # HNSW_SQ requires Milvus 2.6.8+ (supports sq_types such as SQ4U, SQ6, SQ8, BF16, FP16)\n}\n\n\ndef _get_env_bool(key: str, default: bool = False) -> bool:\n    \"\"\"Parse environment variable as boolean\"\"\"\n    val = os.environ.get(key, \"\").lower()\n    if val in (\"true\", \"1\", \"yes\", \"on\"):\n        return True\n    elif val in (\"false\", \"0\", \"no\", \"off\"):\n        return False\n    return default\n\n\ndef _get_env_int(key: str, default: int) -> int:\n    \"\"\"Parse environment variable as integer\"\"\"\n    val = os.environ.get(key, \"\")\n    if val:\n        try:\n            return int(val)\n        except ValueError:\n            logger.warning(\n                f\"Invalid integer value for {key}: {val}, using default {default}\"\n            )\n    return default\n\n\n@dataclass\nclass MilvusIndexConfig:\n    \"\"\"\n    Milvus vector index configuration class\n\n    Supports configuration via environment variables or initialization parameters.\n    Initialization parameters take precedence over environment variables.\n    \"\"\"\n\n    # Base configuration\n    index_type: Optional[str] = None\n    metric_type: Optional[str] = None\n\n    # HNSW series parameters\n    hnsw_m: Optional[int] = None\n    hnsw_ef_construction: Optional[int] = None\n    hnsw_ef: Optional[int] = None\n\n    # HNSW_SQ specific parameters\n    sq_type: Optional[str] = None\n    sq_refine: Optional[bool] = None\n    sq_refine_type: Optional[str] = None\n    sq_refine_k: Optional[int] = None\n\n    # IVF series parameters\n    ivf_nlist: Optional[int] = None\n    ivf_nprobe: Optional[int] = None\n\n    def __post_init__(self):\n        \"\"\"Load configuration from environment variables (init parameters take precedence)\"\"\"\n        # Index type\n        self.index_type = (\n            self.index_type or os.environ.get(\"MILVUS_INDEX_TYPE\", \"AUTOINDEX\")\n        ).upper()\n\n        # Metric type\n        self.metric_type = (\n            self.metric_type or os.environ.get(\"MILVUS_METRIC_TYPE\", \"COSINE\")\n        ).upper()\n\n        # HNSW parameters\n        # Defaults aligned with Milvus 2.4+ official documentation\n        if self.hnsw_m is None:\n            self.hnsw_m = _get_env_int(\"MILVUS_HNSW_M\", 16)\n        if self.hnsw_ef_construction is None:\n            self.hnsw_ef_construction = _get_env_int(\"MILVUS_HNSW_EF_CONSTRUCTION\", 360)\n        if self.hnsw_ef is None:\n            self.hnsw_ef = _get_env_int(\"MILVUS_HNSW_EF\", 200)\n\n        # HNSW_SQ parameters\n        if self.sq_type is None:\n            self.sq_type = os.environ.get(\"MILVUS_HNSW_SQ_TYPE\", \"SQ8\").upper()\n        if self.sq_refine is None:\n            self.sq_refine = _get_env_bool(\"MILVUS_HNSW_SQ_REFINE\", False)\n        if self.sq_refine_type is None:\n            self.sq_refine_type = os.environ.get(\n                \"MILVUS_HNSW_SQ_REFINE_TYPE\", \"FP32\"\n            ).upper()\n        if self.sq_refine_k is None:\n            self.sq_refine_k = _get_env_int(\"MILVUS_HNSW_SQ_REFINE_K\", 10)\n\n        # IVF parameters\n        if self.ivf_nlist is None:\n            self.ivf_nlist = _get_env_int(\"MILVUS_IVF_NLIST\", 1024)\n        if self.ivf_nprobe is None:\n            self.ivf_nprobe = _get_env_int(\"MILVUS_IVF_NPROBE\", 16)\n\n        # Validate configuration\n        self._validate()\n\n    def _validate(self):\n        \"\"\"Validate configuration validity\"\"\"\n        if self.index_type not in SUPPORTED_INDEX_TYPES:\n            raise ValueError(\n                f\"Unsupported index type: {self.index_type}. \"\n                f\"Supported: {SUPPORTED_INDEX_TYPES}\"\n            )\n\n        if self.metric_type not in SUPPORTED_METRIC_TYPES:\n            raise ValueError(\n                f\"Unsupported metric type: {self.metric_type}. \"\n                f\"Supported: {SUPPORTED_METRIC_TYPES}\"\n            )\n\n        if self.index_type == \"HNSW_SQ\":\n            if self.sq_type not in SUPPORTED_SQ_TYPES:\n                raise ValueError(\n                    f\"Unsupported sq_type: {self.sq_type}. \"\n                    f\"Supported: {SUPPORTED_SQ_TYPES}\"\n                )\n            if self.sq_refine and self.sq_refine_type not in SUPPORTED_REFINE_TYPES:\n                raise ValueError(\n                    f\"Unsupported refine_type: {self.sq_refine_type}. \"\n                    f\"Supported: {SUPPORTED_REFINE_TYPES}\"\n                )\n\n        # Parameter range validation\n        if not (2 <= self.hnsw_m <= 2048):\n            raise ValueError(f\"hnsw_m must be in [2, 2048], got {self.hnsw_m}\")\n        if self.hnsw_ef_construction < 1:\n            raise ValueError(\n                f\"hnsw_ef_construction must be >= 1, got {self.hnsw_ef_construction}\"\n            )\n        if self.ivf_nlist < 1 or self.ivf_nlist > 65536:\n            raise ValueError(f\"ivf_nlist must be in [1, 65536], got {self.ivf_nlist}\")\n\n    def validate_milvus_version(self, server_version: str) -> None:\n        \"\"\"\n        Validate Milvus server version supports the configured index type\n\n        Args:\n            server_version: Milvus server version string (e.g., \"2.6.9\")\n\n        Raises:\n            ValueError: Version does not meet index type requirements\n        \"\"\"\n        current_ver = version.parse(\n            server_version.split(\"-\")[0]\n        )  # Handle \"2.6.9-dev\" format\n\n        # Check HNSW_SQ index type version requirements (requires 2.6.8+)\n        if self.index_type == \"HNSW_SQ\":\n            required = INDEX_VERSION_REQUIREMENTS[\"HNSW_SQ\"]\n            if current_ver < version.parse(required):\n                raise ValueError(\n                    f\"HNSW_SQ requires Milvus {required}+, \"\n                    f\"current version: {server_version}\"\n                )\n\n        logger.info(\n            f\"Milvus version {server_version} validated for index type \"\n            f\"{self.index_type}\"\n            + (f\" with sq_type {self.sq_type}\" if self.index_type == \"HNSW_SQ\" else \"\")\n        )\n\n    def build_index_params(self, index_params, field_name: str = \"vector\"):\n        \"\"\"\n        Build pymilvus index parameters\n\n        Args:\n            index_params: IndexParams instance (from compatibility helper or client.prepare_index_params())\n            field_name: Vector field name\n\n        Returns:\n            IndexParams object, or a dict fallback when direct API creation is needed.\n        \"\"\"\n        if index_params is None:\n            if self.index_type == \"AUTOINDEX\":\n                logger.info(\n                    \"Using AUTOINDEX with direct API fallback because IndexParams is unavailable\"\n                )\n                return {\n                    \"field_name\": field_name,\n                    \"index_type\": self.index_type,\n                    \"metric_type\": self.metric_type,\n                    \"params\": {},\n                }\n            raise RuntimeError(\n                f\"IndexParams not available but required for index type \"\n                f\"'{self.index_type}'. Ensure pymilvus is installed correctly.\"\n            )\n\n        params: Dict[str, Any] = {}\n\n        # HNSW series indexes\n        if self.index_type in (\"HNSW\", \"HNSW_SQ\", \"HNSW_PQ\", \"HNSW_PRQ\"):\n            params[\"M\"] = self.hnsw_m\n            params[\"efConstruction\"] = self.hnsw_ef_construction\n\n            # HNSW_SQ specific parameters\n            if self.index_type == \"HNSW_SQ\":\n                params[\"sq_type\"] = self.sq_type\n                if self.sq_refine:\n                    params[\"refine\"] = True\n                    params[\"refine_type\"] = self.sq_refine_type\n\n        # IVF series indexes\n        elif self.index_type in (\"IVF_FLAT\", \"IVF_SQ8\", \"IVF_PQ\"):\n            params[\"nlist\"] = self.ivf_nlist\n\n        # DISKANN / SCANN have no additional params\n\n        index_params.add_index(\n            field_name=field_name,\n            index_type=self.index_type,\n            metric_type=self.metric_type,\n            params=params,\n        )\n\n        logger.info(\n            f\"Milvus index configured: type={self.index_type}, \"\n            f\"metric={self.metric_type}, params={params}\"\n        )\n\n        return index_params\n\n    def build_search_params(self) -> Dict[str, Any]:\n        \"\"\"\n        Build search parameters\n\n        Returns:\n            Search parameters dictionary\n        \"\"\"\n        search_params: Dict[str, Any] = {}\n\n        if self.index_type in (\"HNSW\", \"HNSW_SQ\", \"HNSW_PQ\", \"HNSW_PRQ\"):\n            search_params[\"ef\"] = self.hnsw_ef\n            if self.index_type == \"HNSW_SQ\" and self.sq_refine:\n                search_params[\"refine_k\"] = self.sq_refine_k\n\n        elif self.index_type in (\"IVF_FLAT\", \"IVF_SQ8\", \"IVF_PQ\"):\n            search_params[\"nprobe\"] = self.ivf_nprobe\n\n        return {\"params\": search_params} if search_params else {}\n\n    @classmethod\n    def get_config_field_names(cls) -> set:\n        \"\"\"Get all configuration field names from the dataclass.\n\n        This method provides a single source of truth for configuration parameter names,\n        eliminating the need to maintain duplicate hardcoded lists elsewhere.\n\n        Returns:\n            Set of field names that can be used to extract configuration from kwargs\n        \"\"\"\n        return {f.name for f in fields(cls)}\n\n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"Export configuration as dictionary (for logging/debugging)\"\"\"\n        return {\n            \"index_type\": self.index_type,\n            \"metric_type\": self.metric_type,\n            \"hnsw_m\": self.hnsw_m,\n            \"hnsw_ef_construction\": self.hnsw_ef_construction,\n            \"hnsw_ef\": self.hnsw_ef,\n            \"sq_type\": self.sq_type if self.index_type == \"HNSW_SQ\" else None,\n            \"sq_refine\": self.sq_refine if self.index_type == \"HNSW_SQ\" else None,\n            \"sq_refine_type\": (\n                self.sq_refine_type\n                if self.index_type == \"HNSW_SQ\" and self.sq_refine\n                else None\n            ),\n            \"sq_refine_k\": (\n                self.sq_refine_k\n                if self.index_type == \"HNSW_SQ\" and self.sq_refine\n                else None\n            ),\n            \"ivf_nlist\": (\n                self.ivf_nlist if self.index_type.startswith(\"IVF\") else None\n            ),\n            \"ivf_nprobe\": (\n                self.ivf_nprobe if self.index_type.startswith(\"IVF\") else None\n            ),\n        }\n\n\n@final\n@dataclass\nclass MilvusVectorDBStorage(BaseVectorStorage):\n    def _get_milvus_connection_kwargs(self, include_db_name: bool = True) -> dict:\n        \"\"\"Build Milvus connection kwargs from env/config.\"\"\"\n        connection_kwargs = {\n            \"uri\": os.environ.get(\n                \"MILVUS_URI\",\n                config.get(\n                    \"milvus\",\n                    \"uri\",\n                    fallback=os.path.join(\n                        self.global_config[\"working_dir\"], \"milvus_lite.db\"\n                    ),\n                ),\n            ),\n            \"user\": os.environ.get(\n                \"MILVUS_USER\", config.get(\"milvus\", \"user\", fallback=None)\n            ),\n            \"password\": os.environ.get(\n                \"MILVUS_PASSWORD\",\n                config.get(\"milvus\", \"password\", fallback=None),\n            ),\n            \"token\": os.environ.get(\n                \"MILVUS_TOKEN\", config.get(\"milvus\", \"token\", fallback=None)\n            ),\n        }\n\n        db_name = os.environ.get(\n            \"MILVUS_DB_NAME\",\n            config.get(\"milvus\", \"db_name\", fallback=None),\n        )\n        if include_db_name and db_name:\n            connection_kwargs[\"db_name\"] = db_name\n\n        return connection_kwargs\n\n    def _get_milvus_db_name(self) -> Optional[str]:\n        \"\"\"Return the configured Milvus database name, if any.\"\"\"\n        db_name = self._get_milvus_connection_kwargs(include_db_name=True).get(\n            \"db_name\"\n        )\n        if db_name is None:\n            return None\n\n        normalized_name = str(db_name).strip()\n        return normalized_name or None\n\n    def _create_milvus_client(self) -> MilvusClient:\n        \"\"\"Create a Milvus client and ensure the configured database exists.\"\"\"\n        client = MilvusClient(\n            **self._get_milvus_connection_kwargs(include_db_name=False)\n        )\n        db_name = self._get_milvus_db_name()\n\n        if not db_name:\n            return client\n\n        existing_databases = set(client.list_databases())\n        if db_name not in existing_databases:\n            logger.warning(\n                f\"[{self.workspace}] Milvus database '{db_name}' not found, creating it\"\n            )\n            client.create_database(db_name)\n\n        use_database = getattr(client, \"use_database\", None) or getattr(\n            client, \"using_database\", None\n        )\n        if callable(use_database):\n            use_database(db_name)\n            logger.debug(\n                f\"[{self.workspace}] Using Milvus database '{db_name}' for namespace '{self.namespace}'\"\n            )\n            return client\n\n        return MilvusClient(**self._get_milvus_connection_kwargs(include_db_name=True))\n\n    def _create_schema_for_namespace(self) -> CollectionSchema:\n        \"\"\"Create schema based on the current instance's namespace\"\"\"\n\n        # Get vector dimension from embedding_func\n        dimension = self.embedding_func.embedding_dim\n\n        # Base fields (common to all collections)\n        base_fields = [\n            FieldSchema(\n                name=\"id\", dtype=DataType.VARCHAR, max_length=64, is_primary=True\n            ),\n            FieldSchema(name=\"vector\", dtype=DataType.FLOAT_VECTOR, dim=dimension),\n            FieldSchema(name=\"created_at\", dtype=DataType.INT64),\n        ]\n\n        # Determine specific fields based on namespace\n        if self.namespace.endswith(\"entities\"):\n            specific_fields = [\n                FieldSchema(\n                    name=\"entity_name\",\n                    dtype=DataType.VARCHAR,\n                    max_length=512,\n                    nullable=True,\n                ),\n                FieldSchema(\n                    name=\"file_path\",\n                    dtype=DataType.VARCHAR,\n                    max_length=DEFAULT_MAX_FILE_PATH_LENGTH,\n                    nullable=True,\n                ),\n            ]\n            description = \"LightRAG entities vector storage\"\n\n        elif self.namespace.endswith(\"relationships\"):\n            specific_fields = [\n                FieldSchema(\n                    name=\"src_id\", dtype=DataType.VARCHAR, max_length=512, nullable=True\n                ),\n                FieldSchema(\n                    name=\"tgt_id\", dtype=DataType.VARCHAR, max_length=512, nullable=True\n                ),\n                FieldSchema(\n                    name=\"file_path\",\n                    dtype=DataType.VARCHAR,\n                    max_length=DEFAULT_MAX_FILE_PATH_LENGTH,\n                    nullable=True,\n                ),\n            ]\n            description = \"LightRAG relationships vector storage\"\n\n        elif self.namespace.endswith(\"chunks\"):\n            specific_fields = [\n                FieldSchema(\n                    name=\"full_doc_id\",\n                    dtype=DataType.VARCHAR,\n                    max_length=64,\n                    nullable=True,\n                ),\n                FieldSchema(\n                    name=\"file_path\",\n                    dtype=DataType.VARCHAR,\n                    max_length=DEFAULT_MAX_FILE_PATH_LENGTH,\n                    nullable=True,\n                ),\n            ]\n            description = \"LightRAG chunks vector storage\"\n\n        else:\n            # Default generic schema (backward compatibility)\n            specific_fields = [\n                FieldSchema(\n                    name=\"file_path\",\n                    dtype=DataType.VARCHAR,\n                    max_length=DEFAULT_MAX_FILE_PATH_LENGTH,\n                    nullable=True,\n                ),\n            ]\n            description = \"LightRAG generic vector storage\"\n\n        # Merge all fields\n        all_fields = base_fields + specific_fields\n\n        return CollectionSchema(\n            fields=all_fields,\n            description=description,\n            enable_dynamic_field=True,  # Support dynamic fields\n        )\n\n    def _get_index_params(self):\n        \"\"\"Get IndexParams in a version-compatible way\"\"\"\n        try:\n            # Try to use client's prepare_index_params method (most common)\n            if hasattr(self._client, \"prepare_index_params\"):\n                return self._client.prepare_index_params()\n        except Exception:\n            pass\n\n        try:\n            # Try to import IndexParams from different possible locations\n            from pymilvus.client.prepare import IndexParams  # type: ignore\n\n            return IndexParams()\n        except ImportError:\n            pass\n\n        try:\n            from pymilvus.client.types import IndexParams  # type: ignore\n\n            return IndexParams()\n        except ImportError:\n            pass\n\n        try:\n            from pymilvus import IndexParams  # type: ignore\n\n            return IndexParams()\n        except ImportError:\n            pass\n\n        # If all else fails, return None to use fallback method\n        return None\n\n    def _create_scalar_index_fallback(self, field_name: str, index_type: str):\n        \"\"\"Fallback method to create scalar index using direct API\"\"\"\n        # Skip unsupported index types\n        if index_type == \"SORTED\":\n            logger.info(\n                f\"[{self.workspace}] Skipping SORTED index for {field_name} (not supported in this Milvus version)\"\n            )\n            return\n\n        try:\n            self._client.create_index(\n                collection_name=self.final_namespace,\n                field_name=field_name,\n                index_params={\"index_type\": index_type},\n            )\n            logger.debug(\n                f\"[{self.workspace}] Created {field_name} index using fallback method\"\n            )\n        except Exception as e:\n            logger.info(\n                f\"[{self.workspace}] Could not create {field_name} index using fallback method: {e}\"\n            )\n\n    def _create_indexes_after_collection(self):\n        \"\"\"Create indexes after collection is created\"\"\"\n        # Build vector index using index configuration\n        # Use compatibility helper to get IndexParams\n        index_params_for_vector = self._get_index_params()\n\n        vector_index_params = self.index_config.build_index_params(\n            index_params_for_vector, field_name=\"vector\"\n        )\n\n        # Re-raise exceptions to surface vector index creation failures\n        if isinstance(vector_index_params, dict):\n            self._client.create_index(\n                collection_name=self.final_namespace,\n                field_name=vector_index_params[\"field_name\"],\n                index_params={\n                    \"index_type\": vector_index_params[\"index_type\"],\n                    \"metric_type\": vector_index_params[\"metric_type\"],\n                    \"params\": vector_index_params[\"params\"],\n                },\n            )\n        else:\n            self._client.create_index(\n                collection_name=self.final_namespace,\n                index_params=vector_index_params,\n            )\n\n        logger.debug(\n            f\"[{self.workspace}] Created vector index with config: {self.index_config.to_dict()}\"\n        )\n\n        # Create scalar indexes based on namespace\n        # Wrap scalar index creation in try-except to allow graceful degradation\n        try:\n            # Try to get IndexParams in a version-compatible way\n            scalar_index_params = self._get_index_params()\n\n            if scalar_index_params is not None:\n                # Create scalar indexes based on namespace\n                if self.namespace.endswith(\"entities\"):\n                    # Create indexes for entity fields\n                    try:\n                        entity_name_index = self._get_index_params()\n                        entity_name_index.add_index(\n                            field_name=\"entity_name\", index_type=\"INVERTED\"\n                        )\n                        self._client.create_index(\n                            collection_name=self.final_namespace,\n                            index_params=entity_name_index,\n                        )\n                    except Exception as e:\n                        logger.debug(\n                            f\"[{self.workspace}] IndexParams method failed for entity_name: {e}\"\n                        )\n                        self._create_scalar_index_fallback(\"entity_name\", \"INVERTED\")\n\n                elif self.namespace.endswith(\"relationships\"):\n                    # Create indexes for relationship fields\n                    try:\n                        src_id_index = self._get_index_params()\n                        src_id_index.add_index(\n                            field_name=\"src_id\", index_type=\"INVERTED\"\n                        )\n                        self._client.create_index(\n                            collection_name=self.final_namespace,\n                            index_params=src_id_index,\n                        )\n                    except Exception as e:\n                        logger.debug(\n                            f\"[{self.workspace}] IndexParams method failed for src_id: {e}\"\n                        )\n                        self._create_scalar_index_fallback(\"src_id\", \"INVERTED\")\n\n                    try:\n                        tgt_id_index = self._get_index_params()\n                        tgt_id_index.add_index(\n                            field_name=\"tgt_id\", index_type=\"INVERTED\"\n                        )\n                        self._client.create_index(\n                            collection_name=self.final_namespace,\n                            index_params=tgt_id_index,\n                        )\n                    except Exception as e:\n                        logger.debug(\n                            f\"[{self.workspace}] IndexParams method failed for tgt_id: {e}\"\n                        )\n                        self._create_scalar_index_fallback(\"tgt_id\", \"INVERTED\")\n\n                elif self.namespace.endswith(\"chunks\"):\n                    # Create indexes for chunk fields\n                    try:\n                        doc_id_index = self._get_index_params()\n                        doc_id_index.add_index(\n                            field_name=\"full_doc_id\", index_type=\"INVERTED\"\n                        )\n                        self._client.create_index(\n                            collection_name=self.final_namespace,\n                            index_params=doc_id_index,\n                        )\n                    except Exception as e:\n                        logger.debug(\n                            f\"[{self.workspace}] IndexParams method failed for full_doc_id: {e}\"\n                        )\n                        self._create_scalar_index_fallback(\"full_doc_id\", \"INVERTED\")\n\n            else:\n                # Fallback to direct API calls if IndexParams is not available\n                logger.info(\n                    f\"[{self.workspace}] IndexParams not available, using fallback methods for {self.namespace}\"\n                )\n\n                # Create scalar indexes using fallback\n                if self.namespace.endswith(\"entities\"):\n                    self._create_scalar_index_fallback(\"entity_name\", \"INVERTED\")\n                elif self.namespace.endswith(\"relationships\"):\n                    self._create_scalar_index_fallback(\"src_id\", \"INVERTED\")\n                    self._create_scalar_index_fallback(\"tgt_id\", \"INVERTED\")\n                elif self.namespace.endswith(\"chunks\"):\n                    self._create_scalar_index_fallback(\"full_doc_id\", \"INVERTED\")\n\n            logger.info(\n                f\"[{self.workspace}] Created indexes for collection: {self.namespace}\"\n            )\n\n        except Exception as e:\n            # Scalar index failures are logged as warnings (not critical)\n            logger.warning(\n                f\"[{self.workspace}] Failed to create some scalar indexes for {self.namespace}: {e}\"\n            )\n\n    def _get_required_fields_for_namespace(self) -> dict:\n        \"\"\"Get required core field definitions for current namespace\"\"\"\n\n        # Base fields (common to all types)\n        base_fields = {\n            \"id\": {\"type\": \"VarChar\", \"is_primary\": True},\n            \"vector\": {\"type\": \"FloatVector\"},\n            \"created_at\": {\"type\": \"Int64\"},\n        }\n\n        # Add specific fields based on namespace\n        if self.namespace.endswith(\"entities\"):\n            specific_fields = {\n                \"entity_name\": {\"type\": \"VarChar\"},\n                \"file_path\": {\"type\": \"VarChar\"},\n            }\n        elif self.namespace.endswith(\"relationships\"):\n            specific_fields = {\n                \"src_id\": {\"type\": \"VarChar\"},\n                \"tgt_id\": {\"type\": \"VarChar\"},\n                \"file_path\": {\"type\": \"VarChar\"},\n            }\n        elif self.namespace.endswith(\"chunks\"):\n            specific_fields = {\n                \"full_doc_id\": {\"type\": \"VarChar\"},\n                \"file_path\": {\"type\": \"VarChar\"},\n            }\n        else:\n            specific_fields = {\n                \"file_path\": {\"type\": \"VarChar\"},\n            }\n\n        return {**base_fields, **specific_fields}\n\n    def _is_field_compatible(self, existing_field: dict, expected_config: dict) -> bool:\n        \"\"\"Check compatibility of a single field\"\"\"\n        field_name = existing_field.get(\"name\", \"unknown\")\n        existing_type = existing_field.get(\"type\")\n        expected_type = expected_config.get(\"type\")\n\n        logger.debug(\n            f\"[{self.workspace}] Checking field '{field_name}': existing_type={existing_type} (type={type(existing_type)}), expected_type={expected_type}\"\n        )\n\n        # Convert DataType enum values to string names if needed\n        original_existing_type = existing_type\n        if hasattr(existing_type, \"name\"):\n            existing_type = existing_type.name\n            logger.debug(\n                f\"[{self.workspace}] Converted enum to name: {original_existing_type} -> {existing_type}\"\n            )\n        elif isinstance(existing_type, int):\n            # Map common Milvus internal type codes to type names for backward compatibility\n            type_mapping = {\n                21: \"VarChar\",\n                101: \"FloatVector\",\n                5: \"Int64\",\n                9: \"Double\",\n            }\n            mapped_type = type_mapping.get(existing_type, str(existing_type))\n            logger.debug(\n                f\"[{self.workspace}] Mapped numeric type: {existing_type} -> {mapped_type}\"\n            )\n            existing_type = mapped_type\n\n        # Normalize type names for comparison\n        type_aliases = {\n            \"VARCHAR\": \"VarChar\",\n            \"String\": \"VarChar\",\n            \"FLOAT_VECTOR\": \"FloatVector\",\n            \"INT64\": \"Int64\",\n            \"BigInt\": \"Int64\",\n            \"DOUBLE\": \"Double\",\n            \"Float\": \"Double\",\n        }\n\n        original_existing = existing_type\n        original_expected = expected_type\n        existing_type = type_aliases.get(existing_type, existing_type)\n        expected_type = type_aliases.get(expected_type, expected_type)\n\n        if original_existing != existing_type or original_expected != expected_type:\n            logger.debug(\n                f\"[{self.workspace}] Applied aliases: {original_existing} -> {existing_type}, {original_expected} -> {expected_type}\"\n            )\n\n        # Basic type compatibility check\n        type_compatible = existing_type == expected_type\n        logger.debug(\n            f\"[{self.workspace}] Type compatibility for '{field_name}': {existing_type} == {expected_type} -> {type_compatible}\"\n        )\n\n        if not type_compatible:\n            logger.warning(\n                f\"[{self.workspace}] Type mismatch for field '{field_name}': expected {expected_type}, got {existing_type}\"\n            )\n            return False\n\n        # Primary key check - be more flexible about primary key detection\n        if expected_config.get(\"is_primary\"):\n            # Check multiple possible field names for primary key status\n            is_primary = (\n                existing_field.get(\"is_primary_key\", False)\n                or existing_field.get(\"is_primary\", False)\n                or existing_field.get(\"primary_key\", False)\n            )\n            logger.debug(\n                f\"[{self.workspace}] Primary key check for '{field_name}': expected=True, actual={is_primary}\"\n            )\n            logger.debug(\n                f\"[{self.workspace}] Raw field data for '{field_name}': {existing_field}\"\n            )\n\n            # For ID field, be more lenient - if it's the ID field, assume it should be primary\n            if field_name == \"id\" and not is_primary:\n                logger.info(\n                    f\"[{self.workspace}] ID field '{field_name}' not marked as primary in existing collection, but treating as compatible\"\n                )\n                # Don't fail for ID field primary key mismatch\n            elif not is_primary:\n                logger.warning(\n                    f\"[{self.workspace}] Primary key mismatch for field '{field_name}': expected primary key, but field is not primary\"\n                )\n                return False\n\n        logger.debug(f\"[{self.workspace}] Field '{field_name}' is compatible\")\n        return True\n\n    def _check_vector_dimension(self, collection_info: dict):\n        \"\"\"Check vector dimension compatibility\"\"\"\n        current_dimension = self.embedding_func.embedding_dim\n\n        # Find vector field dimension\n        for field in collection_info.get(\"fields\", []):\n            if field.get(\"name\") == \"vector\":\n                field_type = field.get(\"type\")\n\n                # Extract type name from DataType enum or string\n                type_name = None\n                if hasattr(field_type, \"name\"):\n                    type_name = field_type.name\n                elif isinstance(field_type, str):\n                    type_name = field_type\n                else:\n                    type_name = str(field_type)\n\n                # Check if it's a vector type (supports multiple formats)\n                if type_name in [\"FloatVector\", \"FLOAT_VECTOR\"]:\n                    existing_dimension = field.get(\"params\", {}).get(\"dim\")\n\n                    # Convert both to int for comparison to handle type mismatches\n                    # (Milvus API may return string \"1024\" vs int 1024)\n                    try:\n                        existing_dim_int = (\n                            int(existing_dimension)\n                            if existing_dimension is not None\n                            else None\n                        )\n                        current_dim_int = (\n                            int(current_dimension)\n                            if current_dimension is not None\n                            else None\n                        )\n                    except (TypeError, ValueError) as e:\n                        logger.error(\n                            f\"[{self.workspace}] Failed to parse dimensions: existing={existing_dimension} (type={type(existing_dimension)}), \"\n                            f\"current={current_dimension} (type={type(current_dimension)}), error={e}\"\n                        )\n                        raise ValueError(\n                            f\"Invalid dimension values for collection '{self.final_namespace}': \"\n                            f\"existing={existing_dimension}, current={current_dimension}\"\n                        ) from e\n\n                    if existing_dim_int != current_dim_int:\n                        raise ValueError(\n                            f\"Vector dimension mismatch for collection '{self.final_namespace}': \"\n                            f\"existing={existing_dim_int}, current={current_dim_int}\"\n                        )\n\n                    logger.debug(\n                        f\"[{self.workspace}] Vector dimension check passed: {current_dim_int}\"\n                    )\n                    return\n\n        # If no vector field found, this might be an old collection created with simple schema\n        logger.warning(\n            f\"[{self.workspace}] Vector field not found in collection '{self.namespace}'. This might be an old collection created with simple schema.\"\n        )\n        logger.warning(\n            f\"[{self.workspace}] Consider recreating the collection for optimal performance.\"\n        )\n        return\n\n    def _check_file_path_length_restriction(self, collection_info: dict) -> bool:\n        \"\"\"Check if collection has file_path length restrictions that need migration\n\n        Returns:\n            bool: True if migration is needed, False otherwise\n        \"\"\"\n        existing_fields = {\n            field[\"name\"]: field for field in collection_info.get(\"fields\", [])\n        }\n\n        # Check if file_path field exists and has length restrictions\n        if \"file_path\" in existing_fields:\n            file_path_field = existing_fields[\"file_path\"]\n            # Get max_length from field params\n            max_length = file_path_field.get(\"params\", {}).get(\"max_length\")\n\n            if max_length and max_length < DEFAULT_MAX_FILE_PATH_LENGTH:\n                logger.info(\n                    f\"[{self.workspace}] Collection {self.namespace} has file_path max_length={max_length}, \"\n                    f\"needs migration to {DEFAULT_MAX_FILE_PATH_LENGTH}\"\n                )\n                return True\n\n        return False\n\n    def _check_schema_compatibility(self, collection_info: dict):\n        \"\"\"Check schema field compatibility and detect migration needs\"\"\"\n        existing_fields = {\n            field[\"name\"]: field for field in collection_info.get(\"fields\", [])\n        }\n\n        # Check if this is an old collection created with simple schema\n        has_vector_field = any(\n            field.get(\"name\") == \"vector\" for field in collection_info.get(\"fields\", [])\n        )\n\n        if not has_vector_field:\n            logger.warning(\n                f\"[{self.workspace}] Collection {self.namespace} appears to be created with old simple schema (no vector field)\"\n            )\n            logger.warning(\n                f\"[{self.workspace}] This collection will work but may have suboptimal performance\"\n            )\n            logger.warning(\n                f\"[{self.workspace}] Consider recreating the collection for optimal performance\"\n            )\n            return\n\n        # Check if migration is needed for file_path length restrictions\n        if self._check_file_path_length_restriction(collection_info):\n            logger.info(\n                f\"[{self.workspace}] Starting automatic migration for collection {self.namespace}\"\n            )\n            self._migrate_collection_schema()\n            return\n\n        # For collections with vector field, check basic compatibility\n        # Only check for critical incompatibilities, not missing optional fields\n        critical_fields = {\"id\": {\"type\": \"VarChar\", \"is_primary\": True}}\n\n        incompatible_fields = []\n\n        for field_name, expected_config in critical_fields.items():\n            if field_name in existing_fields:\n                existing_field = existing_fields[field_name]\n                if not self._is_field_compatible(existing_field, expected_config):\n                    incompatible_fields.append(\n                        f\"{field_name}: expected {expected_config['type']}, \"\n                        f\"got {existing_field.get('type')}\"\n                    )\n\n        if incompatible_fields:\n            raise ValueError(\n                f\"Critical schema incompatibility in collection '{self.final_namespace}': {incompatible_fields}\"\n            )\n\n        # Get all expected fields for informational purposes\n        expected_fields = self._get_required_fields_for_namespace()\n        missing_fields = [\n            field for field in expected_fields if field not in existing_fields\n        ]\n\n        if missing_fields:\n            logger.info(\n                f\"[{self.workspace}] Collection {self.namespace} missing optional fields: {missing_fields}\"\n            )\n            logger.info(\n                \"These fields would be available in a newly created collection for better performance\"\n            )\n\n        logger.debug(\n            f\"[{self.workspace}] Schema compatibility check passed for {self.namespace}\"\n        )\n\n    def _migrate_collection_schema(self):\n        \"\"\"Migrate collection schema using query_iterator - completely solves query window limitations\"\"\"\n        original_collection_name = self.final_namespace\n        temp_collection_name = f\"{self.final_namespace}_temp\"\n        iterator = None\n\n        try:\n            logger.info(\n                f\"[{self.workspace}] Starting iterator-based schema migration for {self.namespace}\"\n            )\n\n            # Step 1: Create temporary collection with new schema\n            logger.info(\n                f\"[{self.workspace}] Step 1: Creating temporary collection: {temp_collection_name}\"\n            )\n            # Temporarily update final_namespace for index creation\n            self.final_namespace = temp_collection_name\n            new_schema = self._create_schema_for_namespace()\n            self._client.create_collection(\n                collection_name=temp_collection_name, schema=new_schema\n            )\n            try:\n                self._create_indexes_after_collection()\n            except Exception as index_error:\n                logger.warning(\n                    f\"[{self.workspace}] Failed to create indexes for new collection: {index_error}\"\n                )\n                # Continue with migration even if index creation fails\n\n            # Load the new collection\n            self._client.load_collection(temp_collection_name)\n\n            # Step 2: Copy data using query_iterator (solves query window limitation)\n            logger.info(\n                f\"[{self.workspace}] Step 2: Copying data using query_iterator from: {original_collection_name}\"\n            )\n\n            # Create query iterator\n            try:\n                iterator = self._client.query_iterator(\n                    collection_name=original_collection_name,\n                    batch_size=2000,  # Adjustable batch size for optimal performance\n                    output_fields=[\"*\"],  # Get all fields\n                )\n                logger.debug(f\"[{self.workspace}] Query iterator created successfully\")\n            except Exception as iterator_error:\n                logger.error(\n                    f\"[{self.workspace}] Failed to create query iterator: {iterator_error}\"\n                )\n                raise\n\n            # Iterate through all data\n            total_migrated = 0\n            batch_number = 1\n\n            while True:\n                try:\n                    batch_data = iterator.next()\n                    if not batch_data:\n                        # No more data available\n                        break\n\n                    # Insert batch data to new collection\n                    try:\n                        self._client.insert(\n                            collection_name=temp_collection_name, data=batch_data\n                        )\n                        total_migrated += len(batch_data)\n\n                        logger.info(\n                            f\"[{self.workspace}] Iterator batch {batch_number}: \"\n                            f\"processed {len(batch_data)} records, total migrated: {total_migrated}\"\n                        )\n                        batch_number += 1\n\n                    except Exception as batch_error:\n                        logger.error(\n                            f\"[{self.workspace}] Failed to insert iterator batch {batch_number}: {batch_error}\"\n                        )\n                        raise\n\n                except Exception as next_error:\n                    logger.error(\n                        f\"[{self.workspace}] Iterator next() failed at batch {batch_number}: {next_error}\"\n                    )\n                    raise\n\n            if total_migrated > 0:\n                logger.info(\n                    f\"[{self.workspace}] Successfully migrated {total_migrated} records using iterator\"\n                )\n            else:\n                logger.info(\n                    f\"[{self.workspace}] No data found in original collection, migration completed\"\n                )\n\n            # Step 3: Rename origin collection (keep for safety)\n            logger.info(\n                f\"[{self.workspace}] Step 3: Rename origin collection to {original_collection_name}_old\"\n            )\n            try:\n                self._client.rename_collection(\n                    original_collection_name, f\"{original_collection_name}_old\"\n                )\n            except Exception as rename_error:\n                try:\n                    logger.warning(\n                        f\"[{self.workspace}] Try to drop origin collection instead\"\n                    )\n                    self._client.drop_collection(original_collection_name)\n                except Exception as e:\n                    logger.error(\n                        f\"[{self.workspace}] Rename operation failed: {rename_error}\"\n                    )\n                    raise e\n\n            # Step 4: Rename temporary collection to original name\n            logger.info(\n                f\"[{self.workspace}] Step 4: Renaming collection {temp_collection_name} -> {original_collection_name}\"\n            )\n            try:\n                self._client.rename_collection(\n                    temp_collection_name, original_collection_name\n                )\n                logger.info(f\"[{self.workspace}] Rename operation completed\")\n            except Exception as rename_error:\n                logger.error(\n                    f\"[{self.workspace}] Rename operation failed: {rename_error}\"\n                )\n                raise RuntimeError(\n                    f\"Failed to rename collection: {rename_error}\"\n                ) from rename_error\n\n            # Restore final_namespace\n            self.final_namespace = original_collection_name\n\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Iterator-based migration failed for {self.namespace}: {e}\"\n            )\n\n            # Attempt cleanup of temporary collection if it exists\n            try:\n                if self._client and self._client.has_collection(temp_collection_name):\n                    logger.info(\n                        f\"[{self.workspace}] Cleaning up failed migration temporary collection\"\n                    )\n                    self._client.drop_collection(temp_collection_name)\n            except Exception as cleanup_error:\n                logger.warning(\n                    f\"[{self.workspace}] Failed to cleanup temporary collection: {cleanup_error}\"\n                )\n\n            # Re-raise the original error\n            raise RuntimeError(\n                f\"Iterator-based migration failed for collection {self.namespace}: {e}\"\n            ) from e\n\n        finally:\n            # Ensure iterator is properly closed\n            if iterator:\n                try:\n                    iterator.close()\n                    logger.debug(\n                        f\"[{self.workspace}] Query iterator closed successfully\"\n                    )\n                except Exception as close_error:\n                    logger.warning(\n                        f\"[{self.workspace}] Failed to close query iterator: {close_error}\"\n                    )\n\n    def _validate_collection_compatibility(self):\n        \"\"\"Validate existing collection's dimension and schema compatibility\"\"\"\n        try:\n            collection_info = self._client.describe_collection(self.final_namespace)\n\n            # 1. Check vector dimension\n            self._check_vector_dimension(collection_info)\n\n            # 2. Check schema compatibility\n            self._check_schema_compatibility(collection_info)\n\n            logger.info(\n                f\"[{self.workspace}] VectorDB Collection '{self.namespace}' compatibility validation passed\"\n            )\n\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Collection compatibility validation failed for {self.namespace}: {e}\"\n            )\n            raise\n\n    @staticmethod\n    def _is_missing_vector_index_error(error: Exception) -> bool:\n        \"\"\"Return True when the error indicates the collection lacks a vector index.\"\"\"\n        error_message = str(error).lower()\n        return (\n            \"no vector index\" in error_message\n            or \"please create index firstly\" in error_message\n        )\n\n    def _repair_missing_vector_index(self):\n        \"\"\"Create indexes for an existing collection that is missing its vector index.\"\"\"\n        logger.warning(\n            f\"[{self.workspace}] Collection '{self.namespace}' is missing a vector index, attempting repair\"\n        )\n        self._create_indexes_after_collection()\n\n    def _ensure_collection_loaded(self):\n        \"\"\"Ensure the collection is loaded into memory for search operations\"\"\"\n        try:\n            # Check if collection exists first\n            if not self._client.has_collection(self.final_namespace):\n                logger.error(\n                    f\"[{self.workspace}] Collection {self.namespace} does not exist\"\n                )\n                raise ValueError(f\"Collection {self.final_namespace} does not exist\")\n\n            # Load the collection if it's not already loaded\n            # In Milvus, collections need to be loaded before they can be searched\n            self._client.load_collection(self.final_namespace)\n            # logger.debug(f\"[{self.workspace}] Collection {self.namespace} loaded successfully\")\n\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Failed to load collection {self.namespace}: {e}\"\n            )\n            raise\n\n    def _create_collection_if_not_exist(self):\n        \"\"\"Create collection if not exists and check existing collection compatibility\"\"\"\n\n        try:\n            # Check if our specific collection exists\n            collection_exists = self._client.has_collection(self.final_namespace)\n            logger.info(\n                f\"[{self.workspace}] VectorDB collection '{self.namespace}' exists check: {collection_exists}\"\n            )\n\n            if collection_exists:\n                # Double-check by trying to describe the collection\n                try:\n                    self._client.describe_collection(self.final_namespace)\n                    self._validate_collection_compatibility()\n                    try:\n                        # Ensure the collection is loaded after validation\n                        self._ensure_collection_loaded()\n                        return\n                    except Exception as load_error:\n                        if not self._is_missing_vector_index_error(load_error):\n                            raise\n\n                        try:\n                            self._repair_missing_vector_index()\n                            self._ensure_collection_loaded()\n                            logger.info(\n                                f\"[{self.workspace}] Repaired missing vector index for existing collection '{self.namespace}'\"\n                            )\n                            return\n                        except Exception as repair_error:\n                            raise RuntimeError(\n                                f\"Index repair failed for collection '{self.final_namespace}'. \"\n                                f\"Original error: {repair_error}\"\n                            ) from repair_error\n                except Exception as validation_error:\n                    # CRITICAL: Collection exists but validation failed\n                    # This indicates potential data migration failure or incompatible schema\n                    # Stop execution to prevent data loss and require manual intervention\n                    logger.error(\n                        f\"[{self.workspace}] CRITICAL ERROR: Collection '{self.namespace}' exists but validation failed!\"\n                    )\n                    logger.error(\n                        f\"[{self.workspace}] This indicates potential data migration failure or schema incompatibility.\"\n                    )\n                    logger.error(\n                        f\"[{self.workspace}] Validation error: {validation_error}\"\n                    )\n                    logger.error(f\"[{self.workspace}] MANUAL INTERVENTION REQUIRED:\")\n                    logger.error(\n                        f\"[{self.workspace}] 1. Check the existing collection schema and data integrity\"\n                    )\n                    logger.error(\n                        f\"[{self.workspace}] 2. Backup existing data if needed\"\n                    )\n                    logger.error(\n                        f\"[{self.workspace}] 3. Manually resolve schema compatibility issues\"\n                    )\n                    logger.error(\n                        f\"[{self.workspace}] 4. Consider dropping and recreating the collection if data is not critical\"\n                    )\n                    logger.error(\n                        f\"[{self.workspace}] Program execution stopped to prevent potential data loss.\"\n                    )\n\n                    # Raise a specific exception to stop execution\n                    raise RuntimeError(\n                        f\"Collection validation failed for '{self.final_namespace}'. \"\n                        f\"Data migration failure detected. Manual intervention required to prevent data loss. \"\n                        f\"Original error: {validation_error}\"\n                    )\n\n            # Collection doesn't exist, create new collection\n            logger.info(f\"[{self.workspace}] Creating new collection: {self.namespace}\")\n            schema = self._create_schema_for_namespace()\n\n            # Create collection with schema only first\n            self._client.create_collection(\n                collection_name=self.final_namespace, schema=schema\n            )\n\n            # Then create indexes\n            self._create_indexes_after_collection()\n\n            # Load the newly created collection\n            self._ensure_collection_loaded()\n\n            logger.info(\n                f\"[{self.workspace}] Successfully created Milvus collection: {self.namespace}\"\n            )\n\n        except RuntimeError:\n            # Re-raise RuntimeError (validation failures) without modification\n            # These are critical errors that should stop execution\n            raise\n\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error in _create_collection_if_not_exist for {self.namespace}: {e}\"\n            )\n\n            # If there's any error (other than validation failure), try to force create the collection\n            logger.info(\n                f\"[{self.workspace}] Attempting to force create collection {self.namespace}...\"\n            )\n            try:\n                # Try to drop the collection first if it exists in a bad state\n                try:\n                    if self._client.has_collection(self.final_namespace):\n                        logger.info(\n                            f\"[{self.workspace}] Dropping potentially corrupted collection {self.namespace}\"\n                        )\n                        self._client.drop_collection(self.final_namespace)\n                except Exception as drop_error:\n                    logger.warning(\n                        f\"[{self.workspace}] Could not drop collection {self.namespace}: {drop_error}\"\n                    )\n\n                # Create fresh collection\n                schema = self._create_schema_for_namespace()\n                self._client.create_collection(\n                    collection_name=self.final_namespace, schema=schema\n                )\n                self._create_indexes_after_collection()\n\n                # Load the newly created collection\n                self._ensure_collection_loaded()\n\n                logger.info(\n                    f\"[{self.workspace}] Successfully force-created collection {self.namespace}\"\n                )\n\n            except Exception as create_error:\n                logger.error(\n                    f\"[{self.workspace}] Failed to force-create collection {self.namespace}: {create_error}\"\n                )\n                raise\n\n    def __post_init__(self):\n        self._validate_embedding_func()\n\n        # Extract MilvusIndexConfig parameters from vector_db_storage_cls_kwargs\n        #\n        # IMPORTANT: This approach allows Milvus index configuration via vector_db_storage_cls_kwargs,\n        # which is the RECOMMENDED method for framework integration (e.g., RAGAnything).\n        #\n        # All 11 index configuration parameters can be passed through vector_db_storage_cls_kwargs:\n        #   - index_type, metric_type\n        #   - hnsw_m, hnsw_ef_construction, hnsw_ef\n        #   - sq_type, sq_refine, sq_refine_type, sq_refine_k\n        #   - ivf_nlist, ivf_nprobe\n        #\n        # Example:\n        #   LightRAG(\n        #       vector_storage=\"MilvusVectorDBStorage\",\n        #       vector_db_storage_cls_kwargs={\n        #           \"cosine_better_than_threshold\": 0.2,\n        #           \"index_type\": \"HNSW\",\n        #           \"metric_type\": \"COSINE\",\n        #           \"hnsw_m\": 32,\n        #           \"hnsw_ef_construction\": 256,\n        #       }\n        #   )\n        #\n        # Use MilvusIndexConfig.get_config_field_names() to dynamically extract valid parameters.\n        # This ensures we always stay in sync with the MilvusIndexConfig dataclass definition.\n        kwargs = self.global_config.get(\"vector_db_storage_cls_kwargs\", {})\n        index_config_keys = MilvusIndexConfig.get_config_field_names()\n        index_config_params = {\n            k: v for k, v in kwargs.items() if k in index_config_keys\n        }\n\n        # Initialize index configuration (if not already set)\n        # Configuration priority: init params from kwargs > environment variables > defaults\n        if not hasattr(self, \"index_config\") or self.index_config is None:\n            self.index_config = MilvusIndexConfig(**index_config_params)\n\n        # Check for MILVUS_WORKSPACE environment variable first (higher priority)\n        # This allows administrators to force a specific workspace for all Milvus storage instances\n        milvus_workspace = os.environ.get(\"MILVUS_WORKSPACE\")\n        if milvus_workspace and milvus_workspace.strip():\n            # Use environment variable value, overriding the passed workspace parameter\n            effective_workspace = milvus_workspace.strip()\n            logger.info(\n                f\"Using MILVUS_WORKSPACE environment variable: '{effective_workspace}' (overriding '{self.workspace}/{self.namespace}')\"\n            )\n        else:\n            # Use the workspace parameter passed during initialization\n            effective_workspace = self.workspace\n            if effective_workspace:\n                logger.debug(\n                    f\"Using passed workspace parameter: '{effective_workspace}'\"\n                )\n\n        # Build final_namespace with workspace prefix for data isolation\n        # Keep original namespace unchanged for type detection logic\n        if effective_workspace:\n            self.final_namespace = f\"{effective_workspace}_{self.namespace}\"\n            logger.debug(\n                f\"Final namespace with workspace prefix: '{self.final_namespace}'\"\n            )\n        else:\n            # When workspace is empty, final_namespace equals original namespace\n            self.final_namespace = self.namespace\n            self.workspace = \"\"\n            logger.debug(f\"Final namespace (no workspace): '{self.final_namespace}'\")\n        cosine_threshold = kwargs.get(\"cosine_better_than_threshold\")\n        if cosine_threshold is None:\n            raise ValueError(\n                \"cosine_better_than_threshold must be specified in vector_db_storage_cls_kwargs\"\n            )\n        self.cosine_better_than_threshold = cosine_threshold\n\n        # Ensure created_at is in meta_fields\n        if \"created_at\" not in self.meta_fields:\n            self.meta_fields.add(\"created_at\")\n\n        # Initialize client as None - will be created in initialize() method\n        self._client = None\n        self._max_batch_size = self.global_config[\"embedding_batch_num\"]\n        self._initialized = False\n\n    async def initialize(self):\n        \"\"\"Initialize Milvus collection\"\"\"\n        async with get_data_init_lock():\n            if self._initialized:\n                return\n\n            try:\n                # Create MilvusClient if not already created\n                if self._client is None:\n                    self._client = self._create_milvus_client()\n                    logger.debug(\n                        f\"[{self.workspace}] MilvusClient created successfully\"\n                    )\n\n                # Validate Milvus version compatibility with configured index\n                if self.index_config.index_type in INDEX_VERSION_REQUIREMENTS:\n                    try:\n                        server_version = self._client.get_server_version()\n                        self.index_config.validate_milvus_version(server_version)\n                    except Exception as version_error:\n                        logger.error(\n                            f\"[{self.workspace}] Milvus version validation failed: {version_error}\"\n                        )\n                        raise\n\n                # Create collection and check compatibility\n                self._create_collection_if_not_exist()\n                self._initialized = True\n                logger.info(\n                    f\"[{self.workspace}] Milvus collection '{self.namespace}' initialized successfully\"\n                )\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Failed to initialize Milvus collection '{self.namespace}': {e}\"\n                )\n                raise\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        # logger.debug(f\"[{self.workspace}] Inserting {len(data)} to {self.namespace}\")\n        if not data:\n            return\n\n        # Ensure collection is loaded before upserting\n        self._ensure_collection_loaded()\n\n        import time\n\n        current_time = int(time.time())\n\n        list_data: list[dict[str, Any]] = [\n            {\n                \"id\": k,\n                \"created_at\": current_time,\n                **{k1: v1 for k1, v1 in v.items() if k1 in self.meta_fields},\n            }\n            for k, v in data.items()\n        ]\n        contents = [v[\"content\"] for v in data.values()]\n        batches = [\n            contents[i : i + self._max_batch_size]\n            for i in range(0, len(contents), self._max_batch_size)\n        ]\n\n        embedding_tasks = [self.embedding_func(batch) for batch in batches]\n        embeddings_list = await asyncio.gather(*embedding_tasks)\n\n        embeddings = np.concatenate(embeddings_list)\n        for i, d in enumerate(list_data):\n            d[\"vector\"] = embeddings[i]\n        results = self._client.upsert(\n            collection_name=self.final_namespace, data=list_data\n        )\n        return results\n\n    async def query(\n        self, query: str, top_k: int, query_embedding: list[float] = None\n    ) -> list[dict[str, Any]]:\n        # Ensure collection is loaded before querying\n        self._ensure_collection_loaded()\n\n        # Use provided embedding or compute it\n        if query_embedding is not None:\n            embedding = [query_embedding]  # Milvus expects a list of embeddings\n        else:\n            embedding = await self.embedding_func(\n                [query], _priority=5\n            )  # higher priority for query\n\n        # Include all meta_fields (created_at is now always included)\n        output_fields = list(self.meta_fields)\n\n        # Build search params from index config\n        search_params_base = self.index_config.build_search_params()\n\n        # Merge with metric type and radius threshold\n        search_params = {\n            \"metric_type\": self.index_config.metric_type,\n            \"params\": {\n                **search_params_base.get(\"params\", {}),\n                \"radius\": self.cosine_better_than_threshold,\n            },\n        }\n\n        results = self._client.search(\n            collection_name=self.final_namespace,\n            data=embedding,\n            limit=top_k,\n            output_fields=output_fields,\n            search_params=search_params,\n        )\n        return [\n            {\n                **dp[\"entity\"],\n                \"id\": dp[\"id\"],\n                \"distance\": dp[\"distance\"],\n                \"created_at\": dp.get(\"created_at\"),\n            }\n            for dp in results[0]\n        ]\n\n    async def index_done_callback(self) -> None:\n        # Milvus handles persistence automatically\n        pass\n\n    async def delete_entity(self, entity_name: str) -> None:\n        \"\"\"Delete an entity from the vector database\n\n        Args:\n            entity_name: The name of the entity to delete\n        \"\"\"\n        try:\n            # Compute entity ID from name\n            entity_id = compute_mdhash_id(entity_name, prefix=\"ent-\")\n            logger.debug(\n                f\"[{self.workspace}] Attempting to delete entity {entity_name} with ID {entity_id}\"\n            )\n\n            # Delete the entity from Milvus collection\n            result = self._client.delete(\n                collection_name=self.final_namespace, pks=[entity_id]\n            )\n\n            if result and result.get(\"delete_count\", 0) > 0:\n                logger.debug(\n                    f\"[{self.workspace}] Successfully deleted entity {entity_name}\"\n                )\n            else:\n                logger.debug(\n                    f\"[{self.workspace}] Entity {entity_name} not found in storage\"\n                )\n\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error deleting entity {entity_name}: {e}\")\n\n    async def delete_entity_relation(self, entity_name: str) -> None:\n        \"\"\"Delete all relations associated with an entity\n\n        Args:\n            entity_name: The name of the entity whose relations should be deleted\n        \"\"\"\n        try:\n            # Ensure collection is loaded before querying\n            self._ensure_collection_loaded()\n\n            # Search for relations where entity is either source or target\n            expr = f'src_id == \"{entity_name}\" or tgt_id == \"{entity_name}\"'\n\n            # Find all relations involving this entity\n            results = self._client.query(\n                collection_name=self.final_namespace, filter=expr, output_fields=[\"id\"]\n            )\n\n            if not results or len(results) == 0:\n                logger.debug(\n                    f\"[{self.workspace}] No relations found for entity {entity_name}\"\n                )\n                return\n\n            # Extract IDs of relations to delete\n            relation_ids = [item[\"id\"] for item in results]\n            logger.debug(\n                f\"[{self.workspace}] Found {len(relation_ids)} relations for entity {entity_name}\"\n            )\n\n            # Delete the relations\n            if relation_ids:\n                delete_result = self._client.delete(\n                    collection_name=self.final_namespace, pks=relation_ids\n                )\n\n                logger.debug(\n                    f\"[{self.workspace}] Deleted {delete_result.get('delete_count', 0)} relations for {entity_name}\"\n                )\n\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error deleting relations for {entity_name}: {e}\"\n            )\n\n    async def delete(self, ids: list[str]) -> None:\n        \"\"\"Delete vectors with specified IDs\n\n        Args:\n            ids: List of vector IDs to be deleted\n        \"\"\"\n        try:\n            # Ensure collection is loaded before deleting\n            self._ensure_collection_loaded()\n\n            # Delete vectors by IDs\n            result = self._client.delete(collection_name=self.final_namespace, pks=ids)\n\n            if result and result.get(\"delete_count\", 0) > 0:\n                logger.debug(\n                    f\"[{self.workspace}] Successfully deleted {result.get('delete_count', 0)} vectors from {self.namespace}\"\n                )\n            else:\n                logger.debug(\n                    f\"[{self.workspace}] No vectors were deleted from {self.namespace}\"\n                )\n\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error while deleting vectors from {self.namespace}: {e}\"\n            )\n\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        \"\"\"Get vector data by its ID\n\n        Args:\n            id: The unique identifier of the vector\n\n        Returns:\n            The vector data if found, or None if not found\n        \"\"\"\n        try:\n            # Ensure collection is loaded before querying\n            self._ensure_collection_loaded()\n\n            # Include all meta_fields (created_at is now always included) plus id\n            output_fields = list(self.meta_fields) + [\"id\"]\n\n            # Query Milvus for a specific ID\n            result = self._client.query(\n                collection_name=self.final_namespace,\n                filter=f'id == \"{id}\"',\n                output_fields=output_fields,\n            )\n\n            if not result or len(result) == 0:\n                return None\n\n            return result[0]\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error retrieving vector data for ID {id}: {e}\"\n            )\n            return None\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        \"\"\"Get multiple vector data by their IDs\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            List of vector data objects that were found\n        \"\"\"\n        if not ids:\n            return []\n\n        try:\n            # Ensure collection is loaded before querying\n            self._ensure_collection_loaded()\n\n            # Include all meta_fields (created_at is now always included) plus id\n            output_fields = list(self.meta_fields) + [\"id\"]\n\n            # Prepare the ID filter expression\n            id_list = '\", \"'.join(ids)\n            filter_expr = f'id in [\"{id_list}\"]'\n\n            # Query Milvus with the filter\n            result = self._client.query(\n                collection_name=self.final_namespace,\n                filter=filter_expr,\n                output_fields=output_fields,\n            )\n\n            if not result:\n                return []\n\n            result_map: dict[str, dict[str, Any]] = {}\n            for row in result:\n                if not row:\n                    continue\n                row_id = row.get(\"id\")\n                if row_id is not None:\n                    result_map[str(row_id)] = row\n\n            ordered_results: list[dict[str, Any] | None] = []\n            for requested_id in ids:\n                ordered_results.append(result_map.get(str(requested_id)))\n\n            return ordered_results\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error retrieving vector data for IDs {ids}: {e}\"\n            )\n            return []\n\n    async def get_vectors_by_ids(self, ids: list[str]) -> dict[str, list[float]]:\n        \"\"\"Get vectors by their IDs, returning only ID and vector data for efficiency\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            Dictionary mapping IDs to their vector embeddings\n            Format: {id: [vector_values], ...}\n        \"\"\"\n        if not ids:\n            return {}\n\n        try:\n            # Ensure collection is loaded before querying\n            self._ensure_collection_loaded()\n\n            # Prepare the ID filter expression\n            id_list = '\", \"'.join(ids)\n            filter_expr = f'id in [\"{id_list}\"]'\n\n            # Query Milvus with the filter, requesting only vector field\n            result = self._client.query(\n                collection_name=self.final_namespace,\n                filter=filter_expr,\n                output_fields=[\"vector\"],\n            )\n\n            vectors_dict = {}\n            for item in result:\n                if item and \"vector\" in item and \"id\" in item:\n                    # Convert numpy array to list if needed\n                    vector_data = item[\"vector\"]\n                    if isinstance(vector_data, np.ndarray):\n                        vector_data = vector_data.tolist()\n                    vectors_dict[item[\"id\"]] = vector_data\n\n            return vectors_dict\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error retrieving vectors by IDs from {self.namespace}: {e}\"\n            )\n            return {}\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop all vector data from storage and clean up resources\n\n        This method will delete all data from the Milvus collection.\n\n        Returns:\n            dict[str, str]: Operation status and message\n            - On success: {\"status\": \"success\", \"message\": \"data dropped\"}\n            - On failure: {\"status\": \"error\", \"message\": \"<error details>\"}\n        \"\"\"\n        try:\n            # Drop the collection and recreate it\n            if self._client.has_collection(self.final_namespace):\n                self._client.drop_collection(self.final_namespace)\n\n            # Recreate the collection\n            self._create_collection_if_not_exist()\n\n            logger.info(\n                f\"[{self.workspace}] Process {os.getpid()} drop Milvus collection {self.namespace}\"\n            )\n            return {\"status\": \"success\", \"message\": \"data dropped\"}\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error dropping Milvus collection {self.namespace}: {e}\"\n            )\n            return {\"status\": \"error\", \"message\": str(e)}\n"
  },
  {
    "path": "lightrag/kg/mongo_impl.py",
    "content": "import os\nimport re\nimport time\nfrom dataclasses import dataclass, field\nimport numpy as np\nimport configparser\nimport asyncio\n\nfrom typing import Any, Union, final\n\nfrom ..base import (\n    BaseGraphStorage,\n    BaseKVStorage,\n    BaseVectorStorage,\n    DocProcessingStatus,\n    DocStatus,\n    DocStatusStorage,\n)\nfrom ..utils import logger, compute_mdhash_id\nfrom ..types import KnowledgeGraph, KnowledgeGraphNode, KnowledgeGraphEdge\nfrom ..constants import GRAPH_FIELD_SEP\nfrom ..kg.shared_storage import get_data_init_lock\n\nimport pipmaster as pm\n\nif not pm.is_installed(\"pymongo\"):\n    pm.install(\"pymongo\")\n\nfrom pymongo import AsyncMongoClient  # type: ignore\nfrom pymongo import UpdateOne  # type: ignore\nfrom pymongo.asynchronous.database import AsyncDatabase  # type: ignore\nfrom pymongo.asynchronous.collection import AsyncCollection  # type: ignore\nfrom pymongo.operations import SearchIndexModel  # type: ignore\nfrom pymongo.errors import PyMongoError  # type: ignore\n\nconfig = configparser.ConfigParser()\nconfig.read(\"config.ini\", \"utf-8\")\n\nGRAPH_BFS_MODE = os.getenv(\"MONGO_GRAPH_BFS_MODE\", \"bidirectional\")\n\n\nclass ClientManager:\n    _instances = {\"db\": None, \"ref_count\": 0}\n    _lock = asyncio.Lock()\n\n    @classmethod\n    async def get_client(cls) -> AsyncMongoClient:\n        async with cls._lock:\n            if cls._instances[\"db\"] is None:\n                uri = os.environ.get(\n                    \"MONGO_URI\",\n                    config.get(\n                        \"mongodb\",\n                        \"uri\",\n                        fallback=\"mongodb://root:root@localhost:27017/\",\n                    ),\n                )\n                database_name = os.environ.get(\n                    \"MONGO_DATABASE\",\n                    config.get(\"mongodb\", \"database\", fallback=\"LightRAG\"),\n                )\n                client = AsyncMongoClient(uri)\n                db = client.get_database(database_name)\n                cls._instances[\"db\"] = db\n                cls._instances[\"ref_count\"] = 0\n            cls._instances[\"ref_count\"] += 1\n            return cls._instances[\"db\"]\n\n    @classmethod\n    async def release_client(cls, db: AsyncDatabase):\n        async with cls._lock:\n            if db is not None:\n                if db is cls._instances[\"db\"]:\n                    cls._instances[\"ref_count\"] -= 1\n                    if cls._instances[\"ref_count\"] == 0:\n                        cls._instances[\"db\"] = None\n\n\n@final\n@dataclass\nclass MongoKVStorage(BaseKVStorage):\n    db: AsyncDatabase = field(default=None)\n    _data: AsyncCollection = field(default=None)\n\n    def __init__(self, namespace, global_config, embedding_func, workspace=None):\n        super().__init__(\n            namespace=namespace,\n            workspace=workspace or \"\",\n            global_config=global_config,\n            embedding_func=embedding_func,\n        )\n        self.__post_init__()\n\n    def __post_init__(self):\n        # Check for MONGODB_WORKSPACE environment variable first (higher priority)\n        # This allows administrators to force a specific workspace for all MongoDB storage instances\n        mongodb_workspace = os.environ.get(\"MONGODB_WORKSPACE\")\n        if mongodb_workspace and mongodb_workspace.strip():\n            # Use environment variable value, overriding the passed workspace parameter\n            effective_workspace = mongodb_workspace.strip()\n            logger.info(\n                f\"Using MONGODB_WORKSPACE environment variable: '{effective_workspace}' (overriding '{self.workspace}/{self.namespace}')\"\n            )\n        else:\n            # Use the workspace parameter passed during initialization\n            effective_workspace = self.workspace\n            if effective_workspace:\n                logger.debug(\n                    f\"Using passed workspace parameter: '{effective_workspace}'\"\n                )\n\n        # Build final_namespace with workspace prefix for data isolation\n        # Keep original namespace unchanged for type detection logic\n        if effective_workspace:\n            self.final_namespace = f\"{effective_workspace}_{self.namespace}\"\n            self.workspace = effective_workspace\n            logger.debug(\n                f\"Final namespace with workspace prefix: '{self.final_namespace}'\"\n            )\n        else:\n            # When workspace is empty, final_namespace equals original namespace\n            self.final_namespace = self.namespace\n            self.workspace = \"\"\n            logger.debug(\n                f\"[{self.workspace}] Final namespace (no workspace): '{self.namespace}'\"\n            )\n\n        self._collection_name = self.final_namespace\n\n    async def initialize(self):\n        async with get_data_init_lock():\n            if self.db is None:\n                self.db = await ClientManager.get_client()\n\n            self._data = await get_or_create_collection(self.db, self._collection_name)\n            logger.debug(\n                f\"[{self.workspace}] Use MongoDB as KV {self._collection_name}\"\n            )\n\n    async def finalize(self):\n        if self.db is not None:\n            await ClientManager.release_client(self.db)\n            self.db = None\n            self._data = None\n\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        # Unified handling for flattened keys\n        doc = await self._data.find_one({\"_id\": id})\n        if doc:\n            # Ensure time fields are present, provide default values for old data\n            doc.setdefault(\"create_time\", 0)\n            doc.setdefault(\"update_time\", 0)\n        return doc\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        cursor = self._data.find({\"_id\": {\"$in\": ids}})\n        docs = await cursor.to_list(length=None)\n\n        doc_map: dict[str, dict[str, Any]] = {}\n        for doc in docs:\n            if not doc:\n                continue\n            doc.setdefault(\"create_time\", 0)\n            doc.setdefault(\"update_time\", 0)\n            doc_map[str(doc.get(\"_id\"))] = doc\n\n        ordered_results: list[dict[str, Any] | None] = []\n        for id_value in ids:\n            ordered_results.append(doc_map.get(str(id_value)))\n        return ordered_results\n\n    async def filter_keys(self, keys: set[str]) -> set[str]:\n        cursor = self._data.find({\"_id\": {\"$in\": list(keys)}}, {\"_id\": 1})\n        existing_ids = {str(x[\"_id\"]) async for x in cursor}\n        return keys - existing_ids\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        logger.debug(f\"[{self.workspace}] Inserting {len(data)} to {self.namespace}\")\n        if not data:\n            return\n\n        # Unified handling for all namespaces with flattened keys\n        # Use bulk_write for better performance\n\n        operations = []\n        current_time = int(time.time())  # Get current Unix timestamp\n\n        for k, v in data.items():\n            # For text_chunks namespace, ensure llm_cache_list field exists\n            if self.namespace.endswith(\"text_chunks\"):\n                if \"llm_cache_list\" not in v:\n                    v[\"llm_cache_list\"] = []\n\n            # Create a copy of v for $set operation, excluding create_time to avoid conflicts\n            v_for_set = v.copy()\n            v_for_set[\"_id\"] = k  # Use flattened key as _id\n            v_for_set[\"update_time\"] = current_time  # Always update update_time\n\n            # Remove create_time from $set to avoid conflict with $setOnInsert\n            v_for_set.pop(\"create_time\", None)\n\n            operations.append(\n                UpdateOne(\n                    {\"_id\": k},\n                    {\n                        \"$set\": v_for_set,  # Update all fields except create_time\n                        \"$setOnInsert\": {\n                            \"create_time\": current_time\n                        },  # Set create_time only on insert\n                    },\n                    upsert=True,\n                )\n            )\n\n        if operations:\n            await self._data.bulk_write(operations)\n\n    async def index_done_callback(self) -> None:\n        # Mongo handles persistence automatically\n        pass\n\n    async def is_empty(self) -> bool:\n        \"\"\"Check if the storage is empty for the current workspace and namespace\n\n        Returns:\n            bool: True if storage is empty, False otherwise\n        \"\"\"\n        try:\n            # Use count_documents with limit 1 for efficiency\n            count = await self._data.count_documents({}, limit=1)\n            return count == 0\n        except PyMongoError as e:\n            logger.error(f\"[{self.workspace}] Error checking if storage is empty: {e}\")\n            return True\n\n    async def delete(self, ids: list[str]) -> None:\n        \"\"\"Delete documents with specified IDs\n\n        Args:\n            ids: List of document IDs to be deleted\n        \"\"\"\n        if not ids:\n            return\n\n        # Convert to list if it's a set (MongoDB BSON cannot encode sets)\n        if isinstance(ids, set):\n            ids = list(ids)\n\n        try:\n            result = await self._data.delete_many({\"_id\": {\"$in\": ids}})\n            logger.info(\n                f\"[{self.workspace}] Deleted {result.deleted_count} documents from {self.namespace}\"\n            )\n        except PyMongoError as e:\n            logger.error(\n                f\"[{self.workspace}] Error deleting documents from {self.namespace}: {e}\"\n            )\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop the storage by removing all documents in the collection.\n\n        Returns:\n            dict[str, str]: Status of the operation with keys 'status' and 'message'\n        \"\"\"\n        try:\n            result = await self._data.delete_many({})\n            deleted_count = result.deleted_count\n\n            logger.info(\n                f\"[{self.workspace}] Dropped {deleted_count} documents from doc status {self._collection_name}\"\n            )\n            return {\n                \"status\": \"success\",\n                \"message\": f\"{deleted_count} documents dropped\",\n            }\n        except PyMongoError as e:\n            logger.error(\n                f\"[{self.workspace}] Error dropping doc status {self._collection_name}: {e}\"\n            )\n            return {\"status\": \"error\", \"message\": str(e)}\n\n\n@final\n@dataclass\nclass MongoDocStatusStorage(DocStatusStorage):\n    db: AsyncDatabase = field(default=None)\n    _data: AsyncCollection = field(default=None)\n\n    def _prepare_doc_status_data(self, doc: dict[str, Any]) -> dict[str, Any]:\n        \"\"\"Normalize and migrate a raw Mongo document to DocProcessingStatus-compatible dict.\"\"\"\n        # Make a copy of the data to avoid modifying the original\n        data = doc.copy()\n        # Remove deprecated content field if it exists\n        data.pop(\"content\", None)\n        # Remove MongoDB _id field if it exists\n        data.pop(\"_id\", None)\n        # If file_path is not in data, use document id as file path\n        if \"file_path\" not in data:\n            data[\"file_path\"] = \"no-file-path\"\n        # Ensure new fields exist with default values\n        if \"metadata\" not in data:\n            data[\"metadata\"] = {}\n        if \"error_msg\" not in data:\n            data[\"error_msg\"] = None\n        # Backward compatibility: migrate legacy 'error' field to 'error_msg'\n        if \"error\" in data:\n            if \"error_msg\" not in data or data[\"error_msg\"] in (None, \"\"):\n                data[\"error_msg\"] = data.pop(\"error\")\n            else:\n                data.pop(\"error\", None)\n        return data\n\n    def __init__(self, namespace, global_config, embedding_func, workspace=None):\n        super().__init__(\n            namespace=namespace,\n            workspace=workspace or \"\",\n            global_config=global_config,\n            embedding_func=embedding_func,\n        )\n        self.__post_init__()\n\n    def __post_init__(self):\n        # Check for MONGODB_WORKSPACE environment variable first (higher priority)\n        # This allows administrators to force a specific workspace for all MongoDB storage instances\n        mongodb_workspace = os.environ.get(\"MONGODB_WORKSPACE\")\n        if mongodb_workspace and mongodb_workspace.strip():\n            # Use environment variable value, overriding the passed workspace parameter\n            effective_workspace = mongodb_workspace.strip()\n            logger.info(\n                f\"Using MONGODB_WORKSPACE environment variable: '{effective_workspace}' (overriding '{self.workspace}/{self.namespace}')\"\n            )\n        else:\n            # Use the workspace parameter passed during initialization\n            effective_workspace = self.workspace\n            if effective_workspace:\n                logger.debug(\n                    f\"Using passed workspace parameter: '{effective_workspace}'\"\n                )\n\n        # Build final_namespace with workspace prefix for data isolation\n        # Keep original namespace unchanged for type detection logic\n        if effective_workspace:\n            self.final_namespace = f\"{effective_workspace}_{self.namespace}\"\n            self.workspace = effective_workspace\n            logger.debug(\n                f\"Final namespace with workspace prefix: '{self.final_namespace}'\"\n            )\n        else:\n            # When workspace is empty, final_namespace equals original namespace\n            self.final_namespace = self.namespace\n            self.workspace = \"\"\n            logger.debug(f\"Final namespace (no workspace): '{self.final_namespace}'\")\n\n        self._collection_name = self.final_namespace\n\n    async def initialize(self):\n        async with get_data_init_lock():\n            if self.db is None:\n                self.db = await ClientManager.get_client()\n\n            self._data = await get_or_create_collection(self.db, self._collection_name)\n\n            # Create and migrate all indexes including Chinese collation for file_path\n            await self.create_and_migrate_indexes_if_not_exists()\n\n            logger.debug(\n                f\"[{self.workspace}] Use MongoDB as DocStatus {self._collection_name}\"\n            )\n\n    async def finalize(self):\n        if self.db is not None:\n            await ClientManager.release_client(self.db)\n            self.db = None\n            self._data = None\n\n    async def get_by_id(self, id: str) -> Union[dict[str, Any], None]:\n        return await self._data.find_one({\"_id\": id})\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        cursor = self._data.find({\"_id\": {\"$in\": ids}})\n        docs = await cursor.to_list(length=None)\n\n        doc_map: dict[str, dict[str, Any]] = {}\n        for doc in docs:\n            if not doc:\n                continue\n            doc_map[str(doc.get(\"_id\"))] = doc\n\n        ordered_results: list[dict[str, Any] | None] = []\n        for id_value in ids:\n            ordered_results.append(doc_map.get(str(id_value)))\n        return ordered_results\n\n    async def filter_keys(self, data: set[str]) -> set[str]:\n        cursor = self._data.find({\"_id\": {\"$in\": list(data)}}, {\"_id\": 1})\n        existing_ids = {str(x[\"_id\"]) async for x in cursor}\n        return data - existing_ids\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        logger.debug(f\"[{self.workspace}] Inserting {len(data)} to {self.namespace}\")\n        if not data:\n            return\n        update_tasks: list[Any] = []\n        for k, v in data.items():\n            # Ensure chunks_list field exists and is an array\n            if \"chunks_list\" not in v:\n                v[\"chunks_list\"] = []\n            data[k][\"_id\"] = k\n            update_tasks.append(\n                self._data.update_one({\"_id\": k}, {\"$set\": v}, upsert=True)\n            )\n        await asyncio.gather(*update_tasks)\n\n    async def get_status_counts(self) -> dict[str, int]:\n        \"\"\"Get counts of documents in each status\"\"\"\n        pipeline = [{\"$group\": {\"_id\": \"$status\", \"count\": {\"$sum\": 1}}}]\n        cursor = await self._data.aggregate(pipeline, allowDiskUse=True)\n        result = await cursor.to_list()\n        counts = {}\n        for doc in result:\n            counts[doc[\"_id\"]] = doc[\"count\"]\n        return counts\n\n    async def get_docs_by_status(\n        self, status: DocStatus\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Get all documents with a specific status\"\"\"\n        cursor = self._data.find({\"status\": status.value})\n        result = await cursor.to_list()\n        processed_result = {}\n        for doc in result:\n            try:\n                data = self._prepare_doc_status_data(doc)\n                processed_result[doc[\"_id\"]] = DocProcessingStatus(**data)\n            except KeyError as e:\n                logger.error(\n                    f\"[{self.workspace}] Missing required field for document {doc['_id']}: {e}\"\n                )\n                continue\n        return processed_result\n\n    async def get_docs_by_track_id(\n        self, track_id: str\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Get all documents with a specific track_id\"\"\"\n        cursor = self._data.find({\"track_id\": track_id})\n        result = await cursor.to_list()\n        processed_result = {}\n        for doc in result:\n            try:\n                data = self._prepare_doc_status_data(doc)\n                processed_result[doc[\"_id\"]] = DocProcessingStatus(**data)\n            except KeyError as e:\n                logger.error(\n                    f\"[{self.workspace}] Missing required field for document {doc['_id']}: {e}\"\n                )\n                continue\n        return processed_result\n\n    async def index_done_callback(self) -> None:\n        # Mongo handles persistence automatically\n        pass\n\n    async def is_empty(self) -> bool:\n        \"\"\"Check if the storage is empty for the current workspace and namespace\n\n        Returns:\n            bool: True if storage is empty, False otherwise\n        \"\"\"\n        try:\n            # Use count_documents with limit 1 for efficiency\n            count = await self._data.count_documents({}, limit=1)\n            return count == 0\n        except PyMongoError as e:\n            logger.error(f\"[{self.workspace}] Error checking if storage is empty: {e}\")\n            return True\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop the storage by removing all documents in the collection.\n\n        Returns:\n            dict[str, str]: Status of the operation with keys 'status' and 'message'\n        \"\"\"\n        try:\n            result = await self._data.delete_many({})\n            deleted_count = result.deleted_count\n\n            logger.info(\n                f\"[{self.workspace}] Dropped {deleted_count} documents from doc status {self._collection_name}\"\n            )\n            return {\n                \"status\": \"success\",\n                \"message\": f\"{deleted_count} documents dropped\",\n            }\n        except PyMongoError as e:\n            logger.error(\n                f\"[{self.workspace}] Error dropping doc status {self._collection_name}: {e}\"\n            )\n            return {\"status\": \"error\", \"message\": str(e)}\n\n    async def delete(self, ids: list[str]) -> None:\n        await self._data.delete_many({\"_id\": {\"$in\": ids}})\n\n    async def create_and_migrate_indexes_if_not_exists(self):\n        \"\"\"Create indexes to optimize pagination queries and migrate file_path indexes for Chinese collation\"\"\"\n        try:\n            # Get indexes for the current collection only\n            indexes_cursor = await self._data.list_indexes()\n            existing_indexes = await indexes_cursor.to_list(length=None)\n            existing_index_names = {idx.get(\"name\", \"\") for idx in existing_indexes}\n\n            # Define collation configuration for Chinese pinyin sorting\n            collation_config = {\"locale\": \"zh\", \"numericOrdering\": True}\n\n            # Use workspace-specific index names to avoid cross-workspace conflicts\n            workspace_prefix = f\"{self.workspace}_\" if self.workspace != \"\" else \"\"\n\n            # 1. Define all indexes needed with workspace-specific names\n            all_indexes = [\n                # Original pagination indexes\n                {\n                    \"name\": f\"{workspace_prefix}status_updated_at\",\n                    \"keys\": [(\"status\", 1), (\"updated_at\", -1)],\n                },\n                {\n                    \"name\": f\"{workspace_prefix}status_created_at\",\n                    \"keys\": [(\"status\", 1), (\"created_at\", -1)],\n                },\n                {\"name\": f\"{workspace_prefix}updated_at\", \"keys\": [(\"updated_at\", -1)]},\n                {\"name\": f\"{workspace_prefix}created_at\", \"keys\": [(\"created_at\", -1)]},\n                {\"name\": f\"{workspace_prefix}id\", \"keys\": [(\"_id\", 1)]},\n                {\"name\": f\"{workspace_prefix}track_id\", \"keys\": [(\"track_id\", 1)]},\n                # New file_path indexes with Chinese collation and workspace-specific names\n                {\n                    \"name\": f\"{workspace_prefix}file_path_zh_collation\",\n                    \"keys\": [(\"file_path\", 1)],\n                    \"collation\": collation_config,\n                },\n                {\n                    \"name\": f\"{workspace_prefix}status_file_path_zh_collation\",\n                    \"keys\": [(\"status\", 1), (\"file_path\", 1)],\n                    \"collation\": collation_config,\n                },\n            ]\n\n            # 2. Handle legacy index cleanup: only drop old indexes that exist in THIS collection\n            legacy_index_names = [\n                \"file_path_zh_collation\",\n                \"status_file_path_zh_collation\",\n                \"status_updated_at\",\n                \"status_created_at\",\n                \"updated_at\",\n                \"created_at\",\n                \"id\",\n                \"track_id\",\n            ]\n\n            for legacy_name in legacy_index_names:\n                if (\n                    legacy_name in existing_index_names\n                    and legacy_name\n                    != f\"{workspace_prefix}{legacy_name.replace(workspace_prefix, '')}\"\n                ):\n                    try:\n                        await self._data.drop_index(legacy_name)\n                        logger.debug(\n                            f\"[{self.workspace}] Migrated: dropped legacy index '{legacy_name}' from collection {self._collection_name}\"\n                        )\n                        existing_index_names.discard(legacy_name)\n                    except PyMongoError as drop_error:\n                        logger.warning(\n                            f\"[{self.workspace}] Failed to drop legacy index '{legacy_name}' from collection {self._collection_name}: {drop_error}\"\n                        )\n\n            # 3. Create all needed indexes with workspace-specific names\n            for index_info in all_indexes:\n                index_name = index_info[\"name\"]\n                if index_name not in existing_index_names:\n                    create_kwargs = {\"name\": index_name}\n                    if \"collation\" in index_info:\n                        create_kwargs[\"collation\"] = index_info[\"collation\"]\n\n                    try:\n                        await self._data.create_index(\n                            index_info[\"keys\"], **create_kwargs\n                        )\n                        logger.debug(\n                            f\"[{self.workspace}] Created index '{index_name}' for collection {self._collection_name}\"\n                        )\n                    except PyMongoError as create_error:\n                        # If creation still fails, log the error but continue with other indexes\n                        logger.error(\n                            f\"[{self.workspace}] Failed to create index '{index_name}' for collection {self._collection_name}: {create_error}\"\n                        )\n                else:\n                    logger.debug(\n                        f\"[{self.workspace}] Index '{index_name}' already exists for collection {self._collection_name}\"\n                    )\n\n        except PyMongoError as e:\n            logger.error(\n                f\"[{self.workspace}] Error creating/migrating indexes for {self._collection_name}: {e}\"\n            )\n\n    async def get_docs_paginated(\n        self,\n        status_filter: DocStatus | None = None,\n        page: int = 1,\n        page_size: int = 50,\n        sort_field: str = \"updated_at\",\n        sort_direction: str = \"desc\",\n    ) -> tuple[list[tuple[str, DocProcessingStatus]], int]:\n        \"\"\"Get documents with pagination support\n\n        Args:\n            status_filter: Filter by document status, None for all statuses\n            page: Page number (1-based)\n            page_size: Number of documents per page (10-200)\n            sort_field: Field to sort by ('created_at', 'updated_at', '_id')\n            sort_direction: Sort direction ('asc' or 'desc')\n\n        Returns:\n            Tuple of (list of (doc_id, DocProcessingStatus) tuples, total_count)\n        \"\"\"\n        # Validate parameters\n        if page < 1:\n            page = 1\n        if page_size < 10:\n            page_size = 10\n        elif page_size > 200:\n            page_size = 200\n\n        if sort_field not in [\"created_at\", \"updated_at\", \"_id\", \"file_path\"]:\n            sort_field = \"updated_at\"\n\n        if sort_direction.lower() not in [\"asc\", \"desc\"]:\n            sort_direction = \"desc\"\n\n        # Build query filter\n        query_filter = {}\n        if status_filter is not None:\n            query_filter[\"status\"] = status_filter.value\n\n        # Get total count\n        total_count = await self._data.count_documents(query_filter)\n\n        # Calculate skip value\n        skip = (page - 1) * page_size\n\n        # Build sort criteria\n        sort_direction_value = 1 if sort_direction.lower() == \"asc\" else -1\n        sort_criteria = [(sort_field, sort_direction_value)]\n\n        # Query for paginated data with Chinese collation for file_path sorting\n        if sort_field == \"file_path\":\n            # Use Chinese collation for pinyin sorting\n            cursor = (\n                self._data.find(query_filter)\n                .sort(sort_criteria)\n                .collation({\"locale\": \"zh\", \"numericOrdering\": True})\n                .skip(skip)\n                .limit(page_size)\n            )\n        else:\n            # Use default sorting for other fields\n            cursor = (\n                self._data.find(query_filter)\n                .sort(sort_criteria)\n                .skip(skip)\n                .limit(page_size)\n            )\n        result = await cursor.to_list(length=page_size)\n\n        # Convert to (doc_id, DocProcessingStatus) tuples\n        documents = []\n        for doc in result:\n            try:\n                doc_id = doc[\"_id\"]\n\n                data = self._prepare_doc_status_data(doc)\n\n                doc_status = DocProcessingStatus(**data)\n                documents.append((doc_id, doc_status))\n            except KeyError as e:\n                logger.error(\n                    f\"[{self.workspace}] Missing required field for document {doc['_id']}: {e}\"\n                )\n                continue\n\n        return documents, total_count\n\n    async def get_all_status_counts(self) -> dict[str, int]:\n        \"\"\"Get counts of documents in each status for all documents\n\n        Returns:\n            Dictionary mapping status names to counts, including 'all' field\n        \"\"\"\n        pipeline = [{\"$group\": {\"_id\": \"$status\", \"count\": {\"$sum\": 1}}}]\n        cursor = await self._data.aggregate(pipeline, allowDiskUse=True)\n        result = await cursor.to_list()\n\n        counts = {}\n        total_count = 0\n        for doc in result:\n            counts[doc[\"_id\"]] = doc[\"count\"]\n            total_count += doc[\"count\"]\n\n        # Add 'all' field with total count\n        counts[\"all\"] = total_count\n\n        return counts\n\n    async def get_doc_by_file_path(self, file_path: str) -> Union[dict[str, Any], None]:\n        \"\"\"Get document by file path\n\n        Args:\n            file_path: The file path to search for\n\n        Returns:\n            Union[dict[str, Any], None]: Document data if found, None otherwise\n            Returns the same format as get_by_id method\n        \"\"\"\n        return await self._data.find_one({\"file_path\": file_path})\n\n\n@final\n@dataclass\nclass MongoGraphStorage(BaseGraphStorage):\n    \"\"\"\n    A concrete implementation using MongoDB's $graphLookup to demonstrate multi-hop queries.\n    \"\"\"\n\n    db: AsyncDatabase = field(default=None)\n    # node collection storing node_id, node_properties\n    collection: AsyncCollection = field(default=None)\n    # edge collection storing source_node_id, target_node_id, and edge_properties\n    edgeCollection: AsyncCollection = field(default=None)\n\n    def __init__(self, namespace, global_config, embedding_func, workspace=None):\n        super().__init__(\n            namespace=namespace,\n            workspace=workspace or \"\",\n            global_config=global_config,\n            embedding_func=embedding_func,\n        )\n        # Check for MONGODB_WORKSPACE environment variable first (higher priority)\n        # This allows administrators to force a specific workspace for all MongoDB storage instances\n        mongodb_workspace = os.environ.get(\"MONGODB_WORKSPACE\")\n        if mongodb_workspace and mongodb_workspace.strip():\n            # Use environment variable value, overriding the passed workspace parameter\n            effective_workspace = mongodb_workspace.strip()\n            logger.info(\n                f\"Using MONGODB_WORKSPACE environment variable: '{effective_workspace}' (overriding '{self.workspace}/{self.namespace}')\"\n            )\n        else:\n            # Use the workspace parameter passed during initialization\n            effective_workspace = self.workspace\n            if effective_workspace:\n                logger.debug(\n                    f\"Using passed workspace parameter: '{effective_workspace}'\"\n                )\n\n        # Build final_namespace with workspace prefix for data isolation\n        # Keep original namespace unchanged for type detection logic\n        if effective_workspace:\n            self.final_namespace = f\"{effective_workspace}_{self.namespace}\"\n            self.workspace = effective_workspace\n            logger.debug(\n                f\"Final namespace with workspace prefix: '{self.final_namespace}'\"\n            )\n        else:\n            # When workspace is empty, final_namespace equals original namespace\n            self.final_namespace = self.namespace\n            self.workspace = \"\"\n            logger.debug(f\"Final namespace (no workspace): '{self.final_namespace}'\")\n\n        self._collection_name = self.final_namespace\n        self._edge_collection_name = f\"{self._collection_name}_edges\"\n\n    async def initialize(self):\n        async with get_data_init_lock():\n            if self.db is None:\n                self.db = await ClientManager.get_client()\n\n            self.collection = await get_or_create_collection(\n                self.db, self._collection_name\n            )\n            self.edge_collection = await get_or_create_collection(\n                self.db, self._edge_collection_name\n            )\n\n            # Create Atlas Search index for better search performance if possible\n            await self.create_search_index_if_not_exists()\n\n            logger.debug(\n                f\"[{self.workspace}] Use MongoDB as KG {self._collection_name}\"\n            )\n\n    async def finalize(self):\n        if self.db is not None:\n            await ClientManager.release_client(self.db)\n            self.db = None\n            self.collection = None\n            self.edge_collection = None\n\n    # Sample entity document\n    # \"source_ids\" is Array representation of \"source_id\" split by GRAPH_FIELD_SEP\n\n    # {\n    #     \"_id\" : \"CompanyA\",\n    #     \"entity_id\" : \"CompanyA\",\n    #     \"entity_type\" : \"Organization\",\n    #     \"description\" : \"A major technology company\",\n    #     \"source_id\" : \"chunk-eeec0036b909839e8ec4fa150c939eec\",\n    #     \"source_ids\": [\"chunk-eeec0036b909839e8ec4fa150c939eec\"],\n    #     \"file_path\" : \"custom_kg\",\n    #     \"created_at\" : 1749904575\n    # }\n\n    # Sample relation document\n    # {\n    #     \"_id\" : ObjectId(\"6856ac6e7c6bad9b5470b678\"), // MongoDB build-in ObjectId\n    #     \"description\" : \"CompanyA develops ProductX\",\n    #     \"source_node_id\" : \"CompanyA\",\n    #     \"target_node_id\" : \"ProductX\",\n    #     \"relationship\": \"Develops\", // To distinguish multiple same-target relations\n    #     \"weight\" : Double(\"1\"),\n    #     \"keywords\" : \"develop, produce\",\n    #     \"source_id\" : \"chunk-eeec0036b909839e8ec4fa150c939eec\",\n    #     \"source_ids\": [\"chunk-eeec0036b909839e8ec4fa150c939eec\"],\n    #     \"file_path\" : \"custom_kg\",\n    #     \"created_at\" : 1749904575\n    # }\n\n    #\n    # -------------------------------------------------------------------------\n    # BASIC QUERIES\n    # -------------------------------------------------------------------------\n    #\n\n    async def has_node(self, node_id: str) -> bool:\n        \"\"\"\n        Check if node_id is present in the collection by looking up its doc.\n        No real need for $graphLookup here, but let's keep it direct.\n        \"\"\"\n        doc = await self.collection.find_one({\"_id\": node_id}, {\"_id\": 1})\n        return doc is not None\n\n    async def has_edge(self, source_node_id: str, target_node_id: str) -> bool:\n        \"\"\"\n        Check if there's a direct single-hop edge between source_node_id and target_node_id.\n        \"\"\"\n        doc = await self.edge_collection.find_one(\n            {\n                \"$or\": [\n                    {\n                        \"source_node_id\": source_node_id,\n                        \"target_node_id\": target_node_id,\n                    },\n                    {\n                        \"source_node_id\": target_node_id,\n                        \"target_node_id\": source_node_id,\n                    },\n                ]\n            },\n            {\"_id\": 1},\n        )\n        return doc is not None\n\n    #\n    # -------------------------------------------------------------------------\n    # DEGREES\n    # -------------------------------------------------------------------------\n    #\n\n    async def node_degree(self, node_id: str) -> int:\n        \"\"\"\n        Returns the total number of edges connected to node_id (both inbound and outbound).\n        \"\"\"\n        return await self.edge_collection.count_documents(\n            {\"$or\": [{\"source_node_id\": node_id}, {\"target_node_id\": node_id}]}\n        )\n\n    async def edge_degree(self, src_id: str, tgt_id: str) -> int:\n        \"\"\"Get the total degree (sum of relationships) of two nodes.\n\n        Args:\n            src_id: Label of the source node\n            tgt_id: Label of the target node\n\n        Returns:\n            int: Sum of the degrees of both nodes\n        \"\"\"\n        src_degree = await self.node_degree(src_id)\n        trg_degree = await self.node_degree(tgt_id)\n\n        return src_degree + trg_degree\n\n    #\n    # -------------------------------------------------------------------------\n    # GETTERS\n    # -------------------------------------------------------------------------\n    #\n\n    async def get_node(self, node_id: str) -> dict[str, str] | None:\n        \"\"\"\n        Return the full node document, or None if missing.\n        \"\"\"\n        return await self.collection.find_one({\"_id\": node_id})\n\n    async def get_edge(\n        self, source_node_id: str, target_node_id: str\n    ) -> dict[str, str] | None:\n        return await self.edge_collection.find_one(\n            {\n                \"$or\": [\n                    {\n                        \"source_node_id\": source_node_id,\n                        \"target_node_id\": target_node_id,\n                    },\n                    {\n                        \"source_node_id\": target_node_id,\n                        \"target_node_id\": source_node_id,\n                    },\n                ]\n            }\n        )\n\n    async def get_node_edges(self, source_node_id: str) -> list[tuple[str, str]] | None:\n        \"\"\"\n        Retrieves all edges (relationships) for a particular node identified by its label.\n\n        Args:\n            source_node_id: Label of the node to get edges for\n\n        Returns:\n            list[tuple[str, str]]: List of (source_label, target_label) tuples representing edges\n            None: If no edges found\n        \"\"\"\n        cursor = self.edge_collection.find(\n            {\n                \"$or\": [\n                    {\"source_node_id\": source_node_id},\n                    {\"target_node_id\": source_node_id},\n                ]\n            },\n            {\"source_node_id\": 1, \"target_node_id\": 1},\n        )\n\n        return [\n            (e.get(\"source_node_id\"), e.get(\"target_node_id\")) async for e in cursor\n        ]\n\n    async def get_nodes_batch(self, node_ids: list[str]) -> dict[str, dict]:\n        result = {}\n\n        async for doc in self.collection.find({\"_id\": {\"$in\": node_ids}}):\n            result[doc.get(\"_id\")] = doc\n        return result\n\n    async def node_degrees_batch(self, node_ids: list[str]) -> dict[str, int]:\n        # merge the outbound and inbound results with the same \"_id\" and sum the \"degree\"\n        merged_results = {}\n\n        # Outbound degrees\n        outbound_pipeline = [\n            {\"$match\": {\"source_node_id\": {\"$in\": node_ids}}},\n            {\"$group\": {\"_id\": \"$source_node_id\", \"degree\": {\"$sum\": 1}}},\n        ]\n\n        cursor = await self.edge_collection.aggregate(\n            outbound_pipeline, allowDiskUse=True\n        )\n        async for doc in cursor:\n            merged_results[doc.get(\"_id\")] = doc.get(\"degree\")\n\n        # Inbound degrees\n        inbound_pipeline = [\n            {\"$match\": {\"target_node_id\": {\"$in\": node_ids}}},\n            {\"$group\": {\"_id\": \"$target_node_id\", \"degree\": {\"$sum\": 1}}},\n        ]\n\n        cursor = await self.edge_collection.aggregate(\n            inbound_pipeline, allowDiskUse=True\n        )\n        async for doc in cursor:\n            merged_results[doc.get(\"_id\")] = merged_results.get(\n                doc.get(\"_id\"), 0\n            ) + doc.get(\"degree\")\n\n        return merged_results\n\n    async def get_nodes_edges_batch(\n        self, node_ids: list[str]\n    ) -> dict[str, list[tuple[str, str]]]:\n        \"\"\"\n        Batch retrieve edges for multiple nodes.\n        For each node, returns both outgoing and incoming edges to properly represent\n        the undirected graph nature.\n\n        Args:\n            node_ids: List of node IDs (entity_id) for which to retrieve edges.\n\n        Returns:\n            A dictionary mapping each node ID to its list of edge tuples (source, target).\n            For each node, the list includes both:\n            - Outgoing edges: (queried_node, connected_node)\n            - Incoming edges: (connected_node, queried_node)\n        \"\"\"\n        result = {node_id: [] for node_id in node_ids}\n\n        # Query outgoing edges (where node is the source)\n        outgoing_cursor = self.edge_collection.find(\n            {\"source_node_id\": {\"$in\": node_ids}},\n            {\"source_node_id\": 1, \"target_node_id\": 1},\n        )\n        async for edge in outgoing_cursor:\n            source = edge[\"source_node_id\"]\n            target = edge[\"target_node_id\"]\n            result[source].append((source, target))\n\n        # Query incoming edges (where node is the target)\n        incoming_cursor = self.edge_collection.find(\n            {\"target_node_id\": {\"$in\": node_ids}},\n            {\"source_node_id\": 1, \"target_node_id\": 1},\n        )\n        async for edge in incoming_cursor:\n            source = edge[\"source_node_id\"]\n            target = edge[\"target_node_id\"]\n            result[target].append((source, target))\n\n        return result\n\n    #\n    # -------------------------------------------------------------------------\n    # UPSERTS\n    # -------------------------------------------------------------------------\n    #\n\n    async def upsert_node(self, node_id: str, node_data: dict[str, str]) -> None:\n        \"\"\"\n        Insert or update a node document.\n        \"\"\"\n        update_doc = {\"$set\": {**node_data}}\n        if node_data.get(\"source_id\", \"\"):\n            update_doc[\"$set\"][\"source_ids\"] = node_data[\"source_id\"].split(\n                GRAPH_FIELD_SEP\n            )\n\n        await self.collection.update_one({\"_id\": node_id}, update_doc, upsert=True)\n\n    async def upsert_edge(\n        self, source_node_id: str, target_node_id: str, edge_data: dict[str, str]\n    ) -> None:\n        \"\"\"\n        Upsert an edge between source_node_id and target_node_id with optional 'relation'.\n        If an edge with the same target exists, we remove it and re-insert with updated data.\n        \"\"\"\n        # Ensure source node exists\n        await self.upsert_node(source_node_id, {})\n\n        update_doc = {\"$set\": edge_data}\n        if edge_data.get(\"source_id\", \"\"):\n            update_doc[\"$set\"][\"source_ids\"] = edge_data[\"source_id\"].split(\n                GRAPH_FIELD_SEP\n            )\n\n        edge_data[\"source_node_id\"] = source_node_id\n        edge_data[\"target_node_id\"] = target_node_id\n\n        await self.edge_collection.update_one(\n            {\n                \"$or\": [\n                    {\n                        \"source_node_id\": source_node_id,\n                        \"target_node_id\": target_node_id,\n                    },\n                    {\n                        \"source_node_id\": target_node_id,\n                        \"target_node_id\": source_node_id,\n                    },\n                ]\n            },\n            update_doc,\n            upsert=True,\n        )\n\n    #\n    # -------------------------------------------------------------------------\n    # DELETION\n    # -------------------------------------------------------------------------\n    #\n\n    async def delete_node(self, node_id: str) -> None:\n        \"\"\"\n        1) Remove node's doc entirely.\n        2) Remove inbound & outbound edges from any doc that references node_id.\n        \"\"\"\n        # Remove all edges\n        await self.edge_collection.delete_many(\n            {\"$or\": [{\"source_node_id\": node_id}, {\"target_node_id\": node_id}]}\n        )\n\n        # Remove the node doc\n        await self.collection.delete_one({\"_id\": node_id})\n\n    #\n    # -------------------------------------------------------------------------\n    # QUERY\n    # -------------------------------------------------------------------------\n    #\n\n    async def get_all_labels(self) -> list[str]:\n        \"\"\"\n        Get all existing node _ids(entity names) in the database\n        Returns:\n            [id1, id2, ...]  # Alphabetically sorted id list\n        \"\"\"\n\n        # Use aggregation with allowDiskUse for large datasets\n        pipeline = [{\"$project\": {\"_id\": 1}}, {\"$sort\": {\"_id\": 1}}]\n        cursor = await self.collection.aggregate(pipeline, allowDiskUse=True)\n        labels = []\n        async for doc in cursor:\n            labels.append(doc[\"_id\"])\n        return labels\n\n    def _construct_graph_node(\n        self, node_id, node_data: dict[str, str]\n    ) -> KnowledgeGraphNode:\n        return KnowledgeGraphNode(\n            id=node_id,\n            labels=[node_id],\n            properties={\n                k: v\n                for k, v in node_data.items()\n                if k\n                not in [\n                    \"_id\",\n                    \"connected_edges\",\n                    \"source_ids\",\n                    \"edge_count\",\n                ]\n            },\n        )\n\n    def _construct_graph_edge(self, edge_id: str, edge: dict[str, str]):\n        return KnowledgeGraphEdge(\n            id=edge_id,\n            type=edge.get(\"relationship\", \"\"),\n            source=edge[\"source_node_id\"],\n            target=edge[\"target_node_id\"],\n            properties={\n                k: v\n                for k, v in edge.items()\n                if k\n                not in [\n                    \"_id\",\n                    \"source_node_id\",\n                    \"target_node_id\",\n                    \"relationship\",\n                    \"source_ids\",\n                ]\n            },\n        )\n\n    async def get_knowledge_graph_all_by_degree(\n        self, max_depth: int, max_nodes: int\n    ) -> KnowledgeGraph:\n        \"\"\"\n        It's possible that the node with one or multiple relationships is retrieved,\n        while its neighbor is not.  Then this node might seem like disconnected in UI.\n        \"\"\"\n\n        total_node_count = await self.collection.count_documents({})\n        result = KnowledgeGraph()\n        seen_edges = set()\n\n        result.is_truncated = total_node_count > max_nodes\n        if result.is_truncated:\n            # Get all node_ids ranked by degree if max_nodes exceeds total node count\n            pipeline = [\n                {\"$project\": {\"source_node_id\": 1, \"_id\": 0}},\n                {\"$group\": {\"_id\": \"$source_node_id\", \"degree\": {\"$sum\": 1}}},\n                {\n                    \"$unionWith\": {\n                        \"coll\": self._edge_collection_name,\n                        \"pipeline\": [\n                            {\"$project\": {\"target_node_id\": 1, \"_id\": 0}},\n                            {\n                                \"$group\": {\n                                    \"_id\": \"$target_node_id\",\n                                    \"degree\": {\"$sum\": 1},\n                                }\n                            },\n                        ],\n                    }\n                },\n                {\"$group\": {\"_id\": \"$_id\", \"degree\": {\"$sum\": \"$degree\"}}},\n                {\"$sort\": {\"degree\": -1}},\n                {\"$limit\": max_nodes},\n            ]\n            cursor = await self.edge_collection.aggregate(pipeline, allowDiskUse=True)\n\n            node_ids = []\n            async for doc in cursor:\n                node_id = str(doc[\"_id\"])\n                node_ids.append(node_id)\n\n            cursor = self.collection.find({\"_id\": {\"$in\": node_ids}}, {\"source_ids\": 0})\n            async for doc in cursor:\n                result.nodes.append(self._construct_graph_node(doc[\"_id\"], doc))\n\n            # As node count reaches the limit, only need to fetch the edges that directly connect to these nodes\n            edge_cursor = self.edge_collection.find(\n                {\n                    \"$and\": [\n                        {\"source_node_id\": {\"$in\": node_ids}},\n                        {\"target_node_id\": {\"$in\": node_ids}},\n                    ]\n                }\n            )\n        else:\n            # All nodes and edges are needed\n            cursor = self.collection.find({}, {\"source_ids\": 0})\n\n            async for doc in cursor:\n                node_id = str(doc[\"_id\"])\n                result.nodes.append(self._construct_graph_node(doc[\"_id\"], doc))\n\n            edge_cursor = self.edge_collection.find({})\n\n        async for edge in edge_cursor:\n            edge_id = f\"{edge['source_node_id']}-{edge['target_node_id']}\"\n            if edge_id not in seen_edges:\n                seen_edges.add(edge_id)\n                result.edges.append(self._construct_graph_edge(edge_id, edge))\n\n        return result\n\n    async def _bidirectional_bfs_nodes(\n        self,\n        node_labels: list[str],\n        seen_nodes: set[str],\n        result: KnowledgeGraph,\n        depth: int,\n        max_depth: int,\n        max_nodes: int,\n    ) -> KnowledgeGraph:\n        if depth > max_depth or len(result.nodes) > max_nodes:\n            return result\n\n        cursor = self.collection.find({\"_id\": {\"$in\": node_labels}})\n\n        async for node in cursor:\n            node_id = node[\"_id\"]\n            if node_id not in seen_nodes:\n                seen_nodes.add(node_id)\n                result.nodes.append(self._construct_graph_node(node_id, node))\n                if len(result.nodes) > max_nodes:\n                    return result\n\n        # Collect neighbors\n        # Get both inbound and outbound one hop nodes\n        cursor = self.edge_collection.find(\n            {\n                \"$or\": [\n                    {\"source_node_id\": {\"$in\": node_labels}},\n                    {\"target_node_id\": {\"$in\": node_labels}},\n                ]\n            }\n        )\n\n        neighbor_nodes = []\n        async for edge in cursor:\n            if edge[\"source_node_id\"] not in seen_nodes:\n                neighbor_nodes.append(edge[\"source_node_id\"])\n            if edge[\"target_node_id\"] not in seen_nodes:\n                neighbor_nodes.append(edge[\"target_node_id\"])\n\n        if neighbor_nodes:\n            result = await self._bidirectional_bfs_nodes(\n                neighbor_nodes, seen_nodes, result, depth + 1, max_depth, max_nodes\n            )\n\n        return result\n\n    async def get_knowledge_subgraph_bidirectional_bfs(\n        self,\n        node_label: str,\n        depth: int,\n        max_depth: int,\n        max_nodes: int,\n    ) -> KnowledgeGraph:\n        seen_nodes = set()\n        seen_edges = set()\n        result = KnowledgeGraph()\n\n        result = await self._bidirectional_bfs_nodes(\n            [node_label], seen_nodes, result, depth, max_depth, max_nodes\n        )\n\n        # Get all edges from seen_nodes\n        all_node_ids = list(seen_nodes)\n        cursor = self.edge_collection.find(\n            {\n                \"$and\": [\n                    {\"source_node_id\": {\"$in\": all_node_ids}},\n                    {\"target_node_id\": {\"$in\": all_node_ids}},\n                ]\n            }\n        )\n\n        async for edge in cursor:\n            edge_id = f\"{edge['source_node_id']}-{edge['target_node_id']}\"\n            if edge_id not in seen_edges:\n                result.edges.append(self._construct_graph_edge(edge_id, edge))\n                seen_edges.add(edge_id)\n\n        return result\n\n    async def get_knowledge_subgraph_in_out_bound_bfs(\n        self, node_label: str, max_depth: int, max_nodes: int\n    ) -> KnowledgeGraph:\n        seen_nodes = set()\n        seen_edges = set()\n        result = KnowledgeGraph()\n        project_doc = {\n            \"source_ids\": 0,\n            \"created_at\": 0,\n            \"entity_type\": 0,\n            \"file_path\": 0,\n        }\n\n        # Verify if starting node exists\n        start_node = await self.collection.find_one({\"_id\": node_label})\n        if not start_node:\n            logger.warning(\n                f\"[{self.workspace}] Starting node with label {node_label} does not exist!\"\n            )\n            return result\n\n        seen_nodes.add(node_label)\n        result.nodes.append(self._construct_graph_node(node_label, start_node))\n\n        if max_depth == 0:\n            return result\n\n        # In MongoDB, depth = 0 means one-hop\n        max_depth = max_depth - 1\n\n        pipeline = [\n            {\"$match\": {\"_id\": node_label}},\n            {\"$project\": project_doc},\n            {\n                \"$graphLookup\": {\n                    \"from\": self._edge_collection_name,\n                    \"startWith\": \"$_id\",\n                    \"connectFromField\": \"target_node_id\",\n                    \"connectToField\": \"source_node_id\",\n                    \"maxDepth\": max_depth,\n                    \"depthField\": \"depth\",\n                    \"as\": \"connected_edges\",\n                },\n            },\n            {\n                \"$unionWith\": {\n                    \"coll\": self._collection_name,\n                    \"pipeline\": [\n                        {\"$match\": {\"_id\": node_label}},\n                        {\"$project\": project_doc},\n                        {\n                            \"$graphLookup\": {\n                                \"from\": self._edge_collection_name,\n                                \"startWith\": \"$_id\",\n                                \"connectFromField\": \"source_node_id\",\n                                \"connectToField\": \"target_node_id\",\n                                \"maxDepth\": max_depth,\n                                \"depthField\": \"depth\",\n                                \"as\": \"connected_edges\",\n                            }\n                        },\n                    ],\n                }\n            },\n        ]\n\n        cursor = await self.collection.aggregate(pipeline, allowDiskUse=True)\n        node_edges = []\n\n        # Two records for node_label are returned capturing outbound and inbound connected_edges\n        async for doc in cursor:\n            if doc.get(\"connected_edges\", []):\n                node_edges.extend(doc.get(\"connected_edges\"))\n\n        # Sort the connected edges by depth ascending and weight descending\n        # And stores the source_node_id and target_node_id in sequence to retrieve the neighbouring nodes\n        node_edges = sorted(\n            node_edges,\n            key=lambda x: (x[\"depth\"], -x[\"weight\"]),\n        )\n\n        # As order matters, we need to use another list to store the node_id\n        # And only take the first max_nodes ones\n        node_ids = []\n        for edge in node_edges:\n            if len(node_ids) < max_nodes and edge[\"source_node_id\"] not in seen_nodes:\n                node_ids.append(edge[\"source_node_id\"])\n                seen_nodes.add(edge[\"source_node_id\"])\n\n            if len(node_ids) < max_nodes and edge[\"target_node_id\"] not in seen_nodes:\n                node_ids.append(edge[\"target_node_id\"])\n                seen_nodes.add(edge[\"target_node_id\"])\n\n        # Filter out all the node whose id is same as node_label so that we do not check existence next step\n        cursor = self.collection.find({\"_id\": {\"$in\": node_ids}})\n\n        async for doc in cursor:\n            result.nodes.append(self._construct_graph_node(str(doc[\"_id\"]), doc))\n\n        for edge in node_edges:\n            if (\n                edge[\"source_node_id\"] not in seen_nodes\n                or edge[\"target_node_id\"] not in seen_nodes\n            ):\n                continue\n\n            edge_id = f\"{edge['source_node_id']}-{edge['target_node_id']}\"\n            if edge_id not in seen_edges:\n                result.edges.append(self._construct_graph_edge(edge_id, edge))\n                seen_edges.add(edge_id)\n\n        return result\n\n    async def get_knowledge_graph(\n        self,\n        node_label: str,\n        max_depth: int = 3,\n        max_nodes: int = None,\n    ) -> KnowledgeGraph:\n        \"\"\"\n        Retrieve a connected subgraph of nodes where the label includes the specified `node_label`.\n\n        Args:\n            node_label: Label of the starting node, * means all nodes\n            max_depth: Maximum depth of the subgraph, Defaults to 3\n            max_nodes: Maximum nodes to return, Defaults to global_config max_graph_nodes\n\n        Returns:\n            KnowledgeGraph object containing nodes and edges, with an is_truncated flag\n            indicating whether the graph was truncated due to max_nodes limit\n\n        If a graph is like this and starting from B:\n        A → B ← C ← F, B -> E, C → D\n\n        Outbound BFS:\n        B → E\n\n        Inbound BFS:\n        A → B\n        C → B\n        F → C\n\n        Bidirectional BFS:\n        A → B\n        B → E\n        F → C\n        C → B\n        C → D\n        \"\"\"\n        # Use global_config max_graph_nodes as default if max_nodes is None\n        if max_nodes is None:\n            max_nodes = self.global_config.get(\"max_graph_nodes\", 1000)\n        else:\n            # Limit max_nodes to not exceed global_config max_graph_nodes\n            max_nodes = min(max_nodes, self.global_config.get(\"max_graph_nodes\", 1000))\n\n        result = KnowledgeGraph()\n        start = time.perf_counter()\n\n        try:\n            # Optimize pipeline to avoid memory issues with large datasets\n            if node_label == \"*\":\n                result = await self.get_knowledge_graph_all_by_degree(\n                    max_depth, max_nodes\n                )\n            elif GRAPH_BFS_MODE == \"in_out_bound\":\n                result = await self.get_knowledge_subgraph_in_out_bound_bfs(\n                    node_label, max_depth, max_nodes\n                )\n            else:\n                result = await self.get_knowledge_subgraph_bidirectional_bfs(\n                    node_label, 0, max_depth, max_nodes\n                )\n\n            duration = time.perf_counter() - start\n\n            logger.info(\n                f\"[{self.workspace}] Subgraph query successful in {duration:.4f} seconds | Node count: {len(result.nodes)} | Edge count: {len(result.edges)} | Truncated: {result.is_truncated}\"\n            )\n\n        except PyMongoError as e:\n            # Handle memory limit errors specifically\n            if \"memory limit\" in str(e).lower() or \"sort exceeded\" in str(e).lower():\n                logger.warning(\n                    f\"[{self.workspace}] MongoDB memory limit exceeded, falling back to simple query: {str(e)}\"\n                )\n                # Fallback to a simple query without complex aggregation\n                try:\n                    simple_cursor = self.collection.find({}).limit(max_nodes)\n                    async for doc in simple_cursor:\n                        result.nodes.append(\n                            self._construct_graph_node(str(doc[\"_id\"]), doc)\n                        )\n                    result.is_truncated = True\n                    logger.info(\n                        f\"[{self.workspace}] Fallback query completed | Node count: {len(result.nodes)}\"\n                    )\n                except PyMongoError as fallback_error:\n                    logger.error(\n                        f\"[{self.workspace}] Fallback query also failed: {str(fallback_error)}\"\n                    )\n            else:\n                logger.error(f\"[{self.workspace}] MongoDB query failed: {str(e)}\")\n\n        return result\n\n    async def index_done_callback(self) -> None:\n        # Mongo handles persistence automatically\n        pass\n\n    async def remove_nodes(self, nodes: list[str]) -> None:\n        \"\"\"Delete multiple nodes\n\n        Args:\n            nodes: List of node IDs to be deleted\n        \"\"\"\n        logger.info(f\"[{self.workspace}] Deleting {len(nodes)} nodes\")\n        if not nodes:\n            return\n\n        # 1. Remove all edges referencing these nodes\n        await self.edge_collection.delete_many(\n            {\n                \"$or\": [\n                    {\"source_node_id\": {\"$in\": nodes}},\n                    {\"target_node_id\": {\"$in\": nodes}},\n                ]\n            }\n        )\n\n        # 2. Delete the node documents\n        await self.collection.delete_many({\"_id\": {\"$in\": nodes}})\n\n        logger.debug(f\"[{self.workspace}] Successfully deleted nodes: {nodes}\")\n\n    async def remove_edges(self, edges: list[tuple[str, str]]) -> None:\n        \"\"\"Delete multiple edges\n\n        Args:\n            edges: List of edges to be deleted, each edge is a (source, target) tuple\n        \"\"\"\n        logger.info(f\"[{self.workspace}] Deleting {len(edges)} edges\")\n        if not edges:\n            return\n\n        all_edge_pairs = []\n        for source_id, target_id in edges:\n            all_edge_pairs.append(\n                {\"source_node_id\": source_id, \"target_node_id\": target_id}\n            )\n            all_edge_pairs.append(\n                {\"source_node_id\": target_id, \"target_node_id\": source_id}\n            )\n\n        await self.edge_collection.delete_many({\"$or\": all_edge_pairs})\n\n        logger.debug(f\"[{self.workspace}] Successfully deleted edges: {edges}\")\n\n    async def get_all_nodes(self) -> list[dict]:\n        \"\"\"Get all nodes in the graph.\n\n        Returns:\n            A list of all nodes, where each node is a dictionary of its properties\n        \"\"\"\n        cursor = self.collection.find({})\n        nodes = []\n        async for node in cursor:\n            node_dict = dict(node)\n            # Add node id (entity_id) to the dictionary for easier access\n            node_dict[\"id\"] = node_dict.get(\"_id\")\n            nodes.append(node_dict)\n        return nodes\n\n    async def get_all_edges(self) -> list[dict]:\n        \"\"\"Get all edges in the graph.\n\n        Returns:\n            A list of all edges, where each edge is a dictionary of its properties\n        \"\"\"\n        cursor = self.edge_collection.find({})\n        edges = []\n        async for edge in cursor:\n            edge_dict = dict(edge)\n            edge_dict[\"source\"] = edge_dict.get(\"source_node_id\")\n            edge_dict[\"target\"] = edge_dict.get(\"target_node_id\")\n            edges.append(edge_dict)\n        return edges\n\n    async def get_popular_labels(self, limit: int = 300) -> list[str]:\n        \"\"\"Get popular labels(entity names) by node degree (most connected entities)\n\n        Args:\n            limit: Maximum number of labels to return\n\n        Returns:\n            List of labels(entity names) sorted by degree (highest first)\n        \"\"\"\n        try:\n            # Use aggregation pipeline to count edges per node and sort by degree\n            pipeline = [\n                # Count outbound edges\n                {\"$group\": {\"_id\": \"$source_node_id\", \"out_degree\": {\"$sum\": 1}}},\n                # Union with inbound edges count\n                {\n                    \"$unionWith\": {\n                        \"coll\": self._edge_collection_name,\n                        \"pipeline\": [\n                            {\n                                \"$group\": {\n                                    \"_id\": \"$target_node_id\",\n                                    \"in_degree\": {\"$sum\": 1},\n                                }\n                            }\n                        ],\n                    }\n                },\n                # Group by node_id and sum degrees\n                {\n                    \"$group\": {\n                        \"_id\": \"$_id\",\n                        \"total_degree\": {\n                            \"$sum\": {\n                                \"$add\": [\n                                    {\"$ifNull\": [\"$out_degree\", 0]},\n                                    {\"$ifNull\": [\"$in_degree\", 0]},\n                                ]\n                            }\n                        },\n                    }\n                },\n                # Sort by degree descending, then by label ascending\n                {\"$sort\": {\"total_degree\": -1, \"_id\": 1}},\n                # Limit results\n                {\"$limit\": limit},\n                # Project only the label\n                {\"$project\": {\"_id\": 1}},\n            ]\n\n            cursor = await self.edge_collection.aggregate(pipeline, allowDiskUse=True)\n            labels = []\n            async for doc in cursor:\n                if doc.get(\"_id\"):\n                    labels.append(doc[\"_id\"])\n\n            logger.debug(\n                f\"[{self.workspace}] Retrieved {len(labels)} popular labels (limit: {limit})\"\n            )\n            return labels\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error getting popular labels: {str(e)}\")\n            return []\n\n    async def _try_atlas_text_search(self, query_strip: str, limit: int) -> list[str]:\n        \"\"\"Try Atlas Search using simple text search.\"\"\"\n        try:\n            pipeline = [\n                {\n                    \"$search\": {\n                        \"index\": \"entity_id_search_idx\",\n                        \"text\": {\"query\": query_strip, \"path\": \"_id\"},\n                    }\n                },\n                {\"$project\": {\"_id\": 1, \"score\": {\"$meta\": \"searchScore\"}}},\n                {\"$limit\": limit},\n            ]\n            cursor = await self.collection.aggregate(pipeline)\n            labels = [doc[\"_id\"] async for doc in cursor if doc.get(\"_id\")]\n            if labels:\n                logger.debug(\n                    f\"[{self.workspace}] Atlas text search returned {len(labels)} results\"\n                )\n                return labels\n            return []\n        except PyMongoError as e:\n            logger.debug(f\"[{self.workspace}] Atlas text search failed: {e}\")\n            return []\n\n    async def _try_atlas_autocomplete_search(\n        self, query_strip: str, limit: int\n    ) -> list[str]:\n        \"\"\"Try Atlas Search using autocomplete for prefix matching.\"\"\"\n        try:\n            pipeline = [\n                {\n                    \"$search\": {\n                        \"index\": \"entity_id_search_idx\",\n                        \"autocomplete\": {\n                            \"query\": query_strip,\n                            \"path\": \"_id\",\n                            \"fuzzy\": {\"maxEdits\": 1, \"prefixLength\": 1},\n                        },\n                    }\n                },\n                {\"$project\": {\"_id\": 1, \"score\": {\"$meta\": \"searchScore\"}}},\n                {\"$limit\": limit},\n            ]\n            cursor = await self.collection.aggregate(pipeline)\n            labels = [doc[\"_id\"] async for doc in cursor if doc.get(\"_id\")]\n            if labels:\n                logger.debug(\n                    f\"[{self.workspace}] Atlas autocomplete search returned {len(labels)} results\"\n                )\n                return labels\n            return []\n        except PyMongoError as e:\n            logger.debug(f\"[{self.workspace}] Atlas autocomplete search failed: {e}\")\n            return []\n\n    async def _try_atlas_compound_search(\n        self, query_strip: str, limit: int\n    ) -> list[str]:\n        \"\"\"Try Atlas Search using compound query for comprehensive matching.\"\"\"\n        try:\n            pipeline = [\n                {\n                    \"$search\": {\n                        \"index\": \"entity_id_search_idx\",\n                        \"compound\": {\n                            \"should\": [\n                                {\n                                    \"text\": {\n                                        \"query\": query_strip,\n                                        \"path\": \"_id\",\n                                        \"score\": {\"boost\": {\"value\": 10}},\n                                    }\n                                },\n                                {\n                                    \"autocomplete\": {\n                                        \"query\": query_strip,\n                                        \"path\": \"_id\",\n                                        \"score\": {\"boost\": {\"value\": 5}},\n                                        \"fuzzy\": {\"maxEdits\": 1, \"prefixLength\": 1},\n                                    }\n                                },\n                                {\n                                    \"wildcard\": {\n                                        \"query\": f\"*{query_strip}*\",\n                                        \"path\": \"_id\",\n                                        \"score\": {\"boost\": {\"value\": 2}},\n                                    }\n                                },\n                            ],\n                            \"minimumShouldMatch\": 1,\n                        },\n                    }\n                },\n                {\"$project\": {\"_id\": 1, \"score\": {\"$meta\": \"searchScore\"}}},\n                {\"$sort\": {\"score\": {\"$meta\": \"searchScore\"}}},\n                {\"$limit\": limit},\n            ]\n            cursor = await self.collection.aggregate(pipeline)\n            labels = [doc[\"_id\"] async for doc in cursor if doc.get(\"_id\")]\n            if labels:\n                logger.debug(\n                    f\"[{self.workspace}] Atlas compound search returned {len(labels)} results\"\n                )\n                return labels\n            return []\n        except PyMongoError as e:\n            logger.debug(f\"[{self.workspace}] Atlas compound search failed: {e}\")\n            return []\n\n    async def _fallback_regex_search(self, query_strip: str, limit: int) -> list[str]:\n        \"\"\"Fallback to regex-based search when Atlas Search fails.\"\"\"\n        try:\n            logger.debug(\n                f\"[{self.workspace}] Using regex fallback search for: '{query_strip}'\"\n            )\n\n            escaped_query = re.escape(query_strip)\n            regex_condition = {\"_id\": {\"$regex\": escaped_query, \"$options\": \"i\"}}\n            cursor = self.collection.find(regex_condition, {\"_id\": 1}).limit(limit * 2)\n            docs = await cursor.to_list(length=limit * 2)\n\n            # Extract labels\n            labels = []\n            for doc in docs:\n                doc_id = doc.get(\"_id\")\n                if doc_id:\n                    labels.append(doc_id)\n\n            # Sort results to prioritize exact matches and starts-with matches\n            def sort_key(label):\n                label_lower = label.lower()\n                query_lower_strip = query_strip.lower()\n\n                if label_lower == query_lower_strip:\n                    return (0, label_lower)  # Exact match - highest priority\n                elif label_lower.startswith(query_lower_strip):\n                    return (1, label_lower)  # Starts with - medium priority\n                else:\n                    return (2, label_lower)  # Contains - lowest priority\n\n            labels.sort(key=sort_key)\n            labels = labels[:limit]  # Apply final limit after sorting\n\n            logger.debug(\n                f\"[{self.workspace}] Regex fallback search returned {len(labels)} results (limit: {limit})\"\n            )\n            return labels\n\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Regex fallback search failed: {e}\")\n            import traceback\n\n            logger.error(f\"[{self.workspace}] Traceback: {traceback.format_exc()}\")\n            return []\n\n    async def search_labels(self, query: str, limit: int = 50) -> list[str]:\n        \"\"\"\n        Search labels(entity names) with progressive fallback strategy:\n        1. Atlas text search (simple and fast)\n        2. Atlas autocomplete search (prefix matching with fuzzy)\n        3. Atlas compound search (comprehensive matching)\n        4. Regex fallback (when Atlas Search is unavailable)\n        \"\"\"\n        query_strip = query.strip()\n        if not query_strip:\n            return []\n\n        # First check if we have any nodes at all\n        try:\n            node_count = await self.collection.count_documents({})\n            if node_count == 0:\n                logger.debug(\n                    f\"[{self.workspace}] No nodes found in collection {self._collection_name}\"\n                )\n                return []\n        except PyMongoError as e:\n            logger.error(f\"[{self.workspace}] Error counting nodes: {e}\")\n            return []\n\n        # Progressive search strategy\n        search_methods = [\n            (\"text\", self._try_atlas_text_search),\n            (\"autocomplete\", self._try_atlas_autocomplete_search),\n            (\"compound\", self._try_atlas_compound_search),\n        ]\n\n        # Try Atlas Search methods in order\n        for method_name, search_method in search_methods:\n            try:\n                labels = await search_method(query_strip, limit)\n                if labels:\n                    logger.debug(\n                        f\"[{self.workspace}] Search successful using {method_name} method: {len(labels)} results\"\n                    )\n                    return labels\n                else:\n                    logger.debug(\n                        f\"[{self.workspace}] {method_name} search returned no results, trying next method\"\n                    )\n            except Exception as e:\n                logger.debug(\n                    f\"[{self.workspace}] {method_name} search failed: {e}, trying next method\"\n                )\n                continue\n\n        # If all Atlas Search methods fail, use regex fallback\n        logger.info(\n            f\"[{self.workspace}] All Atlas Search methods failed, using regex fallback search for: '{query_strip}'\"\n        )\n        return await self._fallback_regex_search(query_strip, limit)\n\n    async def _check_if_index_needs_rebuild(\n        self, indexes: list, index_name: str\n    ) -> bool:\n        \"\"\"Check if the existing index needs to be rebuilt due to configuration issues.\"\"\"\n        for index in indexes:\n            if index[\"name\"] == index_name:\n                # Check if the index has the old problematic configuration\n                definition = index.get(\"latestDefinition\", {})\n                mappings = definition.get(\"mappings\", {})\n                fields = mappings.get(\"fields\", {})\n                id_field = fields.get(\"_id\", {})\n\n                # If it's the old single-type autocomplete configuration, rebuild\n                if (\n                    isinstance(id_field, dict)\n                    and id_field.get(\"type\") == \"autocomplete\"\n                ):\n                    logger.info(\n                        f\"[{self.workspace}] Found old index configuration for '{index_name}', will rebuild\"\n                    )\n                    return True\n\n                # If it's not a list (multi-type configuration), rebuild\n                if not isinstance(id_field, list):\n                    logger.info(\n                        f\"[{self.workspace}] Index '{index_name}' needs upgrade to multi-type configuration\"\n                    )\n                    return True\n\n                logger.info(\n                    f\"[{self.workspace}] Index '{index_name}' has correct configuration\"\n                )\n                return False\n        return True  # Index doesn't exist, needs creation\n\n    async def _safely_drop_old_index(self, index_name: str):\n        \"\"\"Safely drop the old search index.\"\"\"\n        try:\n            await self.collection.drop_search_index(index_name)\n            logger.info(\n                f\"[{self.workspace}] Successfully dropped old search index '{index_name}'\"\n            )\n        except PyMongoError as e:\n            logger.warning(\n                f\"[{self.workspace}] Could not drop old index '{index_name}': {e}\"\n            )\n\n    async def _create_improved_search_index(self, index_name: str):\n        \"\"\"Create an improved search index with multiple field types.\"\"\"\n        search_index_model = SearchIndexModel(\n            definition={\n                \"mappings\": {\n                    \"dynamic\": False,\n                    \"fields\": {\n                        \"_id\": [\n                            {\n                                \"type\": \"string\",\n                            },\n                            {\n                                \"type\": \"token\",\n                            },\n                            {\n                                \"type\": \"autocomplete\",\n                                \"maxGrams\": 15,\n                                \"minGrams\": 2,\n                            },\n                        ]\n                    },\n                },\n                \"analyzer\": \"lucene.standard\",  # Index-level analyzer for text processing\n            },\n            name=index_name,\n            type=\"search\",\n        )\n\n        await self.collection.create_search_index(search_index_model)\n        logger.info(\n            f\"[{self.workspace}] Created improved Atlas Search index '{index_name}' for collection {self._collection_name}. \"\n        )\n        logger.info(\n            f\"[{self.workspace}] Index will be built asynchronously, using regex fallback until ready.\"\n        )\n\n    async def create_search_index_if_not_exists(self):\n        \"\"\"Creates an improved Atlas Search index for entity search, rebuilding if necessary.\"\"\"\n        index_name = \"entity_id_search_idx\"\n\n        try:\n            # Check if we're using MongoDB Atlas (has search index capabilities)\n            indexes_cursor = await self.collection.list_search_indexes()\n            indexes = await indexes_cursor.to_list(length=None)\n\n            # Check if we need to rebuild the index\n            needs_rebuild = await self._check_if_index_needs_rebuild(\n                indexes, index_name\n            )\n\n            if needs_rebuild:\n                # Check if index exists and drop it\n                index_exists = any(idx[\"name\"] == index_name for idx in indexes)\n                if index_exists:\n                    await self._safely_drop_old_index(index_name)\n\n                # Create the improved search index (async, no waiting)\n                await self._create_improved_search_index(index_name)\n            else:\n                logger.info(\n                    f\"[{self.workspace}] Atlas Search index '{index_name}' already exists with correct configuration\"\n                )\n\n        except PyMongoError as e:\n            # This is expected if not using MongoDB Atlas or if search indexes are not supported\n            logger.info(\n                f\"[{self.workspace}] Could not create Atlas Search index for {self._collection_name}: {e}. \"\n                \"This is normal if not using MongoDB Atlas - search will use regex fallback.\"\n            )\n        except Exception as e:\n            logger.warning(\n                f\"[{self.workspace}] Unexpected error creating Atlas Search index for {self._collection_name}: {e}\"\n            )\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop the storage by removing all documents in the collection.\n\n        Returns:\n            dict[str, str]: Status of the operation with keys 'status' and 'message'\n        \"\"\"\n        try:\n            result = await self.collection.delete_many({})\n            deleted_count = result.deleted_count\n\n            logger.info(\n                f\"[{self.workspace}] Dropped {deleted_count} documents from graph {self._collection_name}\"\n            )\n\n            result = await self.edge_collection.delete_many({})\n            edge_count = result.deleted_count\n            logger.info(\n                f\"[{self.workspace}] Dropped {edge_count} edges from graph {self._edge_collection_name}\"\n            )\n\n            return {\n                \"status\": \"success\",\n                \"message\": f\"{deleted_count} documents and {edge_count} edges dropped\",\n            }\n        except PyMongoError as e:\n            logger.error(\n                f\"[{self.workspace}] Error dropping graph {self._collection_name}: {e}\"\n            )\n            return {\"status\": \"error\", \"message\": str(e)}\n\n\n@final\n@dataclass\nclass MongoVectorDBStorage(BaseVectorStorage):\n    db: AsyncDatabase | None = field(default=None)\n    _data: AsyncCollection | None = field(default=None)\n    _index_name: str = field(default=\"\", init=False)\n\n    def __init__(\n        self, namespace, global_config, embedding_func, workspace=None, meta_fields=None\n    ):\n        super().__init__(\n            namespace=namespace,\n            workspace=workspace or \"\",\n            global_config=global_config,\n            embedding_func=embedding_func,\n            meta_fields=meta_fields or set(),\n        )\n        self.__post_init__()\n\n    def __post_init__(self):\n        self._validate_embedding_func()\n\n        # Check for MONGODB_WORKSPACE environment variable first (higher priority)\n        # This allows administrators to force a specific workspace for all MongoDB storage instances\n        mongodb_workspace = os.environ.get(\"MONGODB_WORKSPACE\")\n        if mongodb_workspace and mongodb_workspace.strip():\n            # Use environment variable value, overriding the passed workspace parameter\n            effective_workspace = mongodb_workspace.strip()\n            logger.info(\n                f\"Using MONGODB_WORKSPACE environment variable: '{effective_workspace}' (overriding '{self.workspace}/{self.namespace}')\"\n            )\n        else:\n            # Use the workspace parameter passed during initialization\n            effective_workspace = self.workspace\n            if effective_workspace:\n                logger.debug(\n                    f\"Using passed workspace parameter: '{effective_workspace}'\"\n                )\n\n        # Build final_namespace with workspace prefix for data isolation\n        # Keep original namespace unchanged for type detection logic\n        if effective_workspace:\n            self.final_namespace = f\"{effective_workspace}_{self.namespace}\"\n            self.workspace = effective_workspace\n            logger.debug(\n                f\"Final namespace with workspace prefix: '{self.final_namespace}'\"\n            )\n        else:\n            # When workspace is empty, final_namespace equals original namespace\n            self.final_namespace = self.namespace\n            self.workspace = \"\"\n            logger.debug(f\"Final namespace (no workspace): '{self.final_namespace}'\")\n\n        # Set index name based on workspace for backward compatibility\n        if effective_workspace:\n            # Use collection-specific index name for workspaced collections to avoid conflicts\n            self._index_name = f\"vector_knn_index_{self.final_namespace}\"\n        else:\n            # Keep original index name for backward compatibility with existing deployments\n            self._index_name = \"vector_knn_index\"\n\n        kwargs = self.global_config.get(\"vector_db_storage_cls_kwargs\", {})\n        cosine_threshold = kwargs.get(\"cosine_better_than_threshold\")\n        if cosine_threshold is None:\n            raise ValueError(\n                \"cosine_better_than_threshold must be specified in vector_db_storage_cls_kwargs\"\n            )\n        self.cosine_better_than_threshold = cosine_threshold\n        self._collection_name = self.final_namespace\n        self._max_batch_size = self.global_config[\"embedding_batch_num\"]\n\n    async def initialize(self):\n        async with get_data_init_lock():\n            if self.db is None:\n                self.db = await ClientManager.get_client()\n\n            self._data = await get_or_create_collection(self.db, self._collection_name)\n\n            # Ensure vector index exists\n            await self.create_vector_index_if_not_exists()\n\n            logger.debug(\n                f\"[{self.workspace}] Use MongoDB as VDB {self._collection_name}\"\n            )\n\n    async def finalize(self):\n        if self.db is not None:\n            await ClientManager.release_client(self.db)\n            self.db = None\n            self._data = None\n\n    async def create_vector_index_if_not_exists(self):\n        \"\"\"Creates an Atlas Vector Search index.\"\"\"\n        try:\n            indexes_cursor = await self._data.list_search_indexes()\n            indexes = await indexes_cursor.to_list(length=None)\n            for index in indexes:\n                if index[\"name\"] == self._index_name:\n                    # Check if the existing index has matching vector dimensions\n                    existing_dim = None\n                    definition = index.get(\"latestDefinition\", {})\n                    fields = definition.get(\"fields\", [])\n                    for field in fields:\n                        if (\n                            field.get(\"type\") == \"vector\"\n                            and field.get(\"path\") == \"vector\"\n                        ):\n                            existing_dim = field.get(\"numDimensions\")\n                            break\n\n                    expected_dim = self.embedding_func.embedding_dim\n\n                    if existing_dim is not None and existing_dim != expected_dim:\n                        error_msg = (\n                            f\"Vector dimension mismatch! Index '{self._index_name}' has \"\n                            f\"dimension {existing_dim}, but current embedding model expects \"\n                            f\"dimension {expected_dim}. Please drop the existing index or \"\n                            f\"use an embedding model with matching dimensions.\"\n                        )\n                        logger.error(f\"[{self.workspace}] {error_msg}\")\n                        raise ValueError(error_msg)\n\n                    logger.info(\n                        f\"[{self.workspace}] vector index {self._index_name} already exists with matching dimensions ({expected_dim})\"\n                    )\n                    return\n\n            search_index_model = SearchIndexModel(\n                definition={\n                    \"fields\": [\n                        {\n                            \"type\": \"vector\",\n                            \"numDimensions\": self.embedding_func.embedding_dim,  # Ensure correct dimensions\n                            \"path\": \"vector\",\n                            \"similarity\": \"cosine\",  # Options: euclidean, cosine, dotProduct\n                        }\n                    ]\n                },\n                name=self._index_name,\n                type=\"vectorSearch\",\n            )\n\n            await self._data.create_search_index(search_index_model)\n            logger.info(\n                f\"[{self.workspace}] Vector index {self._index_name} created successfully.\"\n            )\n\n        except PyMongoError as e:\n            error_msg = f\"[{self.workspace}] Error creating vector index {self._index_name}: {e}\"\n            logger.error(error_msg)\n            raise SystemExit(\n                f\"Failed to create MongoDB vector index. Program cannot continue. {error_msg}\"\n            )\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        logger.debug(f\"[{self.workspace}] Inserting {len(data)} to {self.namespace}\")\n        if not data:\n            return\n\n        # Add current time as Unix timestamp\n        current_time = int(time.time())\n\n        list_data = [\n            {\n                \"_id\": k,\n                \"created_at\": current_time,  # Add created_at field as Unix timestamp\n                **{k1: v1 for k1, v1 in v.items() if k1 in self.meta_fields},\n            }\n            for k, v in data.items()\n        ]\n        contents = [v[\"content\"] for v in data.values()]\n        batches = [\n            contents[i : i + self._max_batch_size]\n            for i in range(0, len(contents), self._max_batch_size)\n        ]\n\n        embedding_tasks = [self.embedding_func(batch) for batch in batches]\n        embeddings_list = await asyncio.gather(*embedding_tasks)\n        embeddings = np.concatenate(embeddings_list)\n        for i, d in enumerate(list_data):\n            d[\"vector\"] = np.array(embeddings[i], dtype=np.float32).tolist()\n\n        update_tasks = []\n        for doc in list_data:\n            update_tasks.append(\n                self._data.update_one({\"_id\": doc[\"_id\"]}, {\"$set\": doc}, upsert=True)\n            )\n        await asyncio.gather(*update_tasks)\n\n        return list_data\n\n    async def query(\n        self, query: str, top_k: int, query_embedding: list[float] = None\n    ) -> list[dict[str, Any]]:\n        \"\"\"Queries the vector database using Atlas Vector Search.\"\"\"\n        if query_embedding is not None:\n            # Convert numpy array to list if needed for MongoDB compatibility\n            if hasattr(query_embedding, \"tolist\"):\n                query_vector = query_embedding.tolist()\n            else:\n                query_vector = list(query_embedding)\n        else:\n            # Generate the embedding\n            embedding = await self.embedding_func(\n                [query], _priority=5\n            )  # higher priority for query\n            # Convert numpy array to a list to ensure compatibility with MongoDB\n            query_vector = embedding[0].tolist()\n\n        # Define the aggregation pipeline with the converted query vector\n        pipeline = [\n            {\n                \"$vectorSearch\": {\n                    \"index\": self._index_name,  # Use stored index name for consistency\n                    \"path\": \"vector\",\n                    \"queryVector\": query_vector,\n                    \"numCandidates\": 100,  # Adjust for performance\n                    \"limit\": top_k,\n                }\n            },\n            {\"$addFields\": {\"score\": {\"$meta\": \"vectorSearchScore\"}}},\n            {\"$match\": {\"score\": {\"$gte\": self.cosine_better_than_threshold}}},\n            {\"$project\": {\"vector\": 0}},\n        ]\n\n        # Execute the aggregation pipeline\n        cursor = await self._data.aggregate(pipeline, allowDiskUse=True)\n        results = await cursor.to_list(length=None)\n\n        # Format and return the results with created_at field\n        return [\n            {\n                **doc,\n                \"id\": doc[\"_id\"],\n                \"distance\": doc.get(\"score\", None),\n                \"created_at\": doc.get(\"created_at\"),  # Include created_at field\n            }\n            for doc in results\n        ]\n\n    async def index_done_callback(self) -> None:\n        # Mongo handles persistence automatically\n        pass\n\n    async def delete(self, ids: list[str]) -> None:\n        \"\"\"Delete vectors with specified IDs\n\n        Args:\n            ids: List of vector IDs to be deleted\n        \"\"\"\n        logger.debug(\n            f\"[{self.workspace}] Deleting {len(ids)} vectors from {self.namespace}\"\n        )\n        if not ids:\n            return\n\n        # Convert to list if it's a set (MongoDB BSON cannot encode sets)\n        if isinstance(ids, set):\n            ids = list(ids)\n\n        try:\n            result = await self._data.delete_many({\"_id\": {\"$in\": ids}})\n            logger.debug(\n                f\"[{self.workspace}] Successfully deleted {result.deleted_count} vectors from {self.namespace}\"\n            )\n        except PyMongoError as e:\n            logger.error(\n                f\"[{self.workspace}] Error while deleting vectors from {self.namespace}: {str(e)}\"\n            )\n\n    async def delete_entity(self, entity_name: str) -> None:\n        \"\"\"Delete an entity by its name\n\n        Args:\n            entity_name: Name of the entity to delete\n        \"\"\"\n        try:\n            entity_id = compute_mdhash_id(entity_name, prefix=\"ent-\")\n            logger.debug(\n                f\"[{self.workspace}] Attempting to delete entity {entity_name} with ID {entity_id}\"\n            )\n\n            result = await self._data.delete_one({\"_id\": entity_id})\n            if result.deleted_count > 0:\n                logger.debug(\n                    f\"[{self.workspace}] Successfully deleted entity {entity_name}\"\n                )\n            else:\n                logger.debug(\n                    f\"[{self.workspace}] Entity {entity_name} not found in storage\"\n                )\n        except PyMongoError as e:\n            logger.error(\n                f\"[{self.workspace}] Error deleting entity {entity_name}: {str(e)}\"\n            )\n\n    async def delete_entity_relation(self, entity_name: str) -> None:\n        \"\"\"Delete all relations associated with an entity\n\n        Args:\n            entity_name: Name of the entity whose relations should be deleted\n        \"\"\"\n        try:\n            # Find relations where entity appears as source or target\n            relations_cursor = self._data.find(\n                {\"$or\": [{\"src_id\": entity_name}, {\"tgt_id\": entity_name}]}\n            )\n            relations = await relations_cursor.to_list(length=None)\n\n            if not relations:\n                logger.debug(\n                    f\"[{self.workspace}] No relations found for entity {entity_name}\"\n                )\n                return\n\n            # Extract IDs of relations to delete\n            relation_ids = [relation[\"_id\"] for relation in relations]\n            logger.debug(\n                f\"[{self.workspace}] Found {len(relation_ids)} relations for entity {entity_name}\"\n            )\n\n            # Delete the relations\n            result = await self._data.delete_many({\"_id\": {\"$in\": relation_ids}})\n            logger.debug(\n                f\"[{self.workspace}] Deleted {result.deleted_count} relations for {entity_name}\"\n            )\n        except PyMongoError as e:\n            logger.error(\n                f\"[{self.workspace}] Error deleting relations for {entity_name}: {str(e)}\"\n            )\n\n        except PyMongoError as e:\n            logger.error(\n                f\"[{self.workspace}] Error searching by prefix in {self.namespace}: {str(e)}\"\n            )\n            return []\n\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        \"\"\"Get vector data by its ID\n\n        Args:\n            id: The unique identifier of the vector\n\n        Returns:\n            The vector data if found, or None if not found\n        \"\"\"\n        try:\n            # Search for the specific ID in MongoDB\n            result = await self._data.find_one({\"_id\": id})\n            if result:\n                # Format the result to include id field expected by API\n                result_dict = dict(result)\n                if \"_id\" in result_dict and \"id\" not in result_dict:\n                    result_dict[\"id\"] = result_dict[\"_id\"]\n                return result_dict\n            return None\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error retrieving vector data for ID {id}: {e}\"\n            )\n            return None\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        \"\"\"Get multiple vector data by their IDs\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            List of vector data objects that were found\n        \"\"\"\n        if not ids:\n            return []\n\n        try:\n            # Query MongoDB for multiple IDs\n            cursor = self._data.find({\"_id\": {\"$in\": ids}})\n            results = await cursor.to_list(length=None)\n\n            # Format results to include id field expected by API and preserve ordering\n            formatted_map: dict[str, dict[str, Any]] = {}\n            for result in results:\n                result_dict = dict(result)\n                if \"_id\" in result_dict and \"id\" not in result_dict:\n                    result_dict[\"id\"] = result_dict[\"_id\"]\n                key = str(result_dict.get(\"id\", result_dict.get(\"_id\")))\n                formatted_map[key] = result_dict\n\n            ordered_results: list[dict[str, Any] | None] = []\n            for id_value in ids:\n                ordered_results.append(formatted_map.get(str(id_value)))\n\n            return ordered_results\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error retrieving vector data for IDs {ids}: {e}\"\n            )\n            return []\n\n    async def get_vectors_by_ids(self, ids: list[str]) -> dict[str, list[float]]:\n        \"\"\"Get vectors by their IDs, returning only ID and vector data for efficiency\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            Dictionary mapping IDs to their vector embeddings\n            Format: {id: [vector_values], ...}\n        \"\"\"\n        if not ids:\n            return {}\n\n        try:\n            # Query MongoDB for the specified IDs, only retrieving the vector field\n            cursor = self._data.find({\"_id\": {\"$in\": ids}}, {\"vector\": 1})\n            results = await cursor.to_list(length=None)\n\n            vectors_dict = {}\n            for result in results:\n                if result and \"vector\" in result and \"_id\" in result:\n                    # MongoDB stores vectors as arrays, so they should already be lists\n                    vectors_dict[result[\"_id\"]] = result[\"vector\"]\n\n            return vectors_dict\n        except PyMongoError as e:\n            logger.error(\n                f\"[{self.workspace}] Error retrieving vectors by IDs from {self.namespace}: {e}\"\n            )\n            return {}\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop the storage by removing all documents in the collection and recreating vector index.\n\n        Returns:\n            dict[str, str]: Status of the operation with keys 'status' and 'message'\n        \"\"\"\n        try:\n            # Delete all documents\n            result = await self._data.delete_many({})\n            deleted_count = result.deleted_count\n\n            # Recreate vector index\n            await self.create_vector_index_if_not_exists()\n\n            logger.info(\n                f\"[{self.workspace}] Dropped {deleted_count} documents from vector storage {self._collection_name} and recreated vector index\"\n            )\n            return {\n                \"status\": \"success\",\n                \"message\": f\"{deleted_count} documents dropped and vector index recreated\",\n            }\n        except PyMongoError as e:\n            logger.error(\n                f\"[{self.workspace}] Error dropping vector storage {self._collection_name}: {e}\"\n            )\n            return {\"status\": \"error\", \"message\": str(e)}\n\n\nasync def get_or_create_collection(db: AsyncDatabase, collection_name: str):\n    collection_names = await db.list_collection_names()\n\n    if collection_name not in collection_names:\n        collection = await db.create_collection(collection_name)\n        logger.info(f\"Created collection: {collection_name}\")\n        return collection\n    else:\n        logger.debug(f\"Collection '{collection_name}' already exists.\")\n        return db.get_collection(collection_name)\n"
  },
  {
    "path": "lightrag/kg/nano_vector_db_impl.py",
    "content": "import asyncio\nimport base64\nimport os\nimport zlib\nfrom typing import Any, final\nfrom dataclasses import dataclass\nimport numpy as np\nimport time\n\nfrom lightrag.utils import (\n    logger,\n    compute_mdhash_id,\n)\n\nfrom lightrag.base import BaseVectorStorage\nfrom nano_vectordb import NanoVectorDB\nfrom .shared_storage import (\n    get_namespace_lock,\n    get_update_flag,\n    set_all_update_flags,\n)\n\n\n@final\n@dataclass\nclass NanoVectorDBStorage(BaseVectorStorage):\n    def __post_init__(self):\n        self._validate_embedding_func()\n        # Initialize basic attributes\n        self._client = None\n        self._storage_lock = None\n        self.storage_updated = None\n\n        # Use global config value if specified, otherwise use default\n        kwargs = self.global_config.get(\"vector_db_storage_cls_kwargs\", {})\n        cosine_threshold = kwargs.get(\"cosine_better_than_threshold\")\n        if cosine_threshold is None:\n            raise ValueError(\n                \"cosine_better_than_threshold must be specified in vector_db_storage_cls_kwargs\"\n            )\n        self.cosine_better_than_threshold = cosine_threshold\n\n        working_dir = self.global_config[\"working_dir\"]\n        if self.workspace:\n            # Include workspace in the file path for data isolation\n            workspace_dir = os.path.join(working_dir, self.workspace)\n            self.final_namespace = f\"{self.workspace}_{self.namespace}\"\n        else:\n            # Default behavior when workspace is empty\n            self.final_namespace = self.namespace\n            self.workspace = \"\"\n            workspace_dir = working_dir\n\n        os.makedirs(workspace_dir, exist_ok=True)\n        self._client_file_name = os.path.join(\n            workspace_dir, f\"vdb_{self.namespace}.json\"\n        )\n\n        self._max_batch_size = self.global_config[\"embedding_batch_num\"]\n\n        self._client = NanoVectorDB(\n            self.embedding_func.embedding_dim,\n            storage_file=self._client_file_name,\n        )\n\n    async def initialize(self):\n        \"\"\"Initialize storage data\"\"\"\n        # Get the update flag for cross-process update notification\n        self.storage_updated = await get_update_flag(\n            self.namespace, workspace=self.workspace\n        )\n        # Get the storage lock for use in other methods\n        self._storage_lock = get_namespace_lock(\n            self.namespace, workspace=self.workspace\n        )\n\n    async def _get_client(self):\n        \"\"\"Check if the storage should be reloaded\"\"\"\n        # Acquire lock to prevent concurrent read and write\n        async with self._storage_lock:\n            # Check if data needs to be reloaded\n            if self.storage_updated.value:\n                logger.info(\n                    f\"[{self.workspace}] Process {os.getpid()} reloading {self.namespace} due to update by another process\"\n                )\n                # Reload data\n                self._client = NanoVectorDB(\n                    self.embedding_func.embedding_dim,\n                    storage_file=self._client_file_name,\n                )\n                # Reset update flag\n                self.storage_updated.value = False\n\n            return self._client\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        \"\"\"\n        Importance notes:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n        \"\"\"\n        # logger.debug(f\"[{self.workspace}] Inserting {len(data)} to {self.namespace}\")\n        if not data:\n            return\n\n        current_time = int(time.time())\n        list_data = [\n            {\n                \"__id__\": k,\n                \"__created_at__\": current_time,\n                **{k1: v1 for k1, v1 in v.items() if k1 in self.meta_fields},\n            }\n            for k, v in data.items()\n        ]\n        contents = [v[\"content\"] for v in data.values()]\n        batches = [\n            contents[i : i + self._max_batch_size]\n            for i in range(0, len(contents), self._max_batch_size)\n        ]\n\n        # Execute embedding outside of lock to avoid long lock times\n        embedding_tasks = [self.embedding_func(batch) for batch in batches]\n        embeddings_list = await asyncio.gather(*embedding_tasks)\n\n        embeddings = np.concatenate(embeddings_list)\n        if len(embeddings) == len(list_data):\n            for i, d in enumerate(list_data):\n                # Compress vector using Float16 + zlib + Base64 for storage optimization\n                vector_f16 = embeddings[i].astype(np.float16)\n                compressed_vector = zlib.compress(vector_f16.tobytes())\n                encoded_vector = base64.b64encode(compressed_vector).decode(\"utf-8\")\n                d[\"vector\"] = encoded_vector\n                d[\"__vector__\"] = embeddings[i]\n            client = await self._get_client()\n            results = client.upsert(datas=list_data)\n            return results\n        else:\n            # sometimes the embedding is not returned correctly. just log it.\n            logger.error(\n                f\"[{self.workspace}] embedding is not 1-1 with data, {len(embeddings)} != {len(list_data)}\"\n            )\n\n    async def query(\n        self, query: str, top_k: int, query_embedding: list[float] = None\n    ) -> list[dict[str, Any]]:\n        # Use provided embedding or compute it\n        if query_embedding is not None:\n            embedding = query_embedding\n        else:\n            # Execute embedding outside of lock to avoid improve cocurrent\n            embedding = await self.embedding_func(\n                [query], _priority=5\n            )  # higher priority for query\n            embedding = embedding[0]\n\n        client = await self._get_client()\n        results = client.query(\n            query=embedding,\n            top_k=top_k,\n            better_than_threshold=self.cosine_better_than_threshold,\n        )\n        results = [\n            {\n                **{k: v for k, v in dp.items() if k != \"vector\"},\n                \"id\": dp[\"__id__\"],\n                \"distance\": dp[\"__metrics__\"],\n                \"created_at\": dp.get(\"__created_at__\"),\n            }\n            for dp in results\n        ]\n        return results\n\n    @property\n    async def client_storage(self):\n        client = await self._get_client()\n        return getattr(client, \"_NanoVectorDB__storage\")\n\n    async def delete(self, ids: list[str]):\n        \"\"\"Delete vectors with specified IDs\n\n        Importance notes:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n\n        Args:\n            ids: List of vector IDs to be deleted\n        \"\"\"\n        try:\n            client = await self._get_client()\n            # Record count before deletion\n            before_count = len(client)\n\n            client.delete(ids)\n\n            # Calculate actual deleted count\n            after_count = len(client)\n            deleted_count = before_count - after_count\n\n            logger.debug(\n                f\"[{self.workspace}] Successfully deleted {deleted_count} vectors from {self.namespace}\"\n            )\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error while deleting vectors from {self.namespace}: {e}\"\n            )\n\n    async def delete_entity(self, entity_name: str) -> None:\n        \"\"\"\n        Importance notes:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n        \"\"\"\n\n        try:\n            entity_id = compute_mdhash_id(entity_name, prefix=\"ent-\")\n            logger.debug(\n                f\"[{self.workspace}] Attempting to delete entity {entity_name} with ID {entity_id}\"\n            )\n\n            # Check if the entity exists\n            client = await self._get_client()\n            if client.get([entity_id]):\n                client.delete([entity_id])\n                logger.debug(\n                    f\"[{self.workspace}] Successfully deleted entity {entity_name}\"\n                )\n            else:\n                logger.debug(\n                    f\"[{self.workspace}] Entity {entity_name} not found in storage\"\n                )\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error deleting entity {entity_name}: {e}\")\n\n    async def delete_entity_relation(self, entity_name: str) -> None:\n        \"\"\"\n        Importance notes:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n        \"\"\"\n\n        try:\n            client = await self._get_client()\n            storage = getattr(client, \"_NanoVectorDB__storage\")\n            relations = [\n                dp\n                for dp in storage[\"data\"]\n                if dp[\"src_id\"] == entity_name or dp[\"tgt_id\"] == entity_name\n            ]\n            logger.debug(\n                f\"[{self.workspace}] Found {len(relations)} relations for entity {entity_name}\"\n            )\n            ids_to_delete = [relation[\"__id__\"] for relation in relations]\n\n            if ids_to_delete:\n                client = await self._get_client()\n                client.delete(ids_to_delete)\n                logger.debug(\n                    f\"[{self.workspace}] Deleted {len(ids_to_delete)} relations for {entity_name}\"\n                )\n            else:\n                logger.debug(\n                    f\"[{self.workspace}] No relations found for entity {entity_name}\"\n                )\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error deleting relations for {entity_name}: {e}\"\n            )\n\n    async def index_done_callback(self) -> bool:\n        \"\"\"Save data to disk\"\"\"\n        async with self._storage_lock:\n            # Check if storage was updated by another process\n            if self.storage_updated.value:\n                # Storage was updated by another process, reload data instead of saving\n                logger.warning(\n                    f\"[{self.workspace}] Storage for {self.namespace} was updated by another process, reloading...\"\n                )\n                self._client = NanoVectorDB(\n                    self.embedding_func.embedding_dim,\n                    storage_file=self._client_file_name,\n                )\n                # Reset update flag\n                self.storage_updated.value = False\n                return False  # Return error\n\n        # Acquire lock and perform persistence\n        async with self._storage_lock:\n            try:\n                # Save data to disk\n                self._client.save()\n                # Notify other processes that data has been updated\n                await set_all_update_flags(self.namespace, workspace=self.workspace)\n                # Reset own update flag to avoid self-reloading\n                self.storage_updated.value = False\n                return True  # Return success\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Error saving data for {self.namespace}: {e}\"\n                )\n                return False  # Return error\n\n        return True  # Return success\n\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        \"\"\"Get vector data by its ID\n\n        Args:\n            id: The unique identifier of the vector\n\n        Returns:\n            The vector data if found, or None if not found\n        \"\"\"\n        client = await self._get_client()\n        result = client.get([id])\n        if result:\n            dp = result[0]\n            return {\n                **{k: v for k, v in dp.items() if k != \"vector\"},\n                \"id\": dp.get(\"__id__\"),\n                \"created_at\": dp.get(\"__created_at__\"),\n            }\n        return None\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        \"\"\"Get multiple vector data by their IDs\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            List of vector data objects that were found\n        \"\"\"\n        if not ids:\n            return []\n\n        client = await self._get_client()\n        results = client.get(ids)\n        result_map: dict[str, dict[str, Any]] = {}\n\n        for dp in results:\n            if not dp:\n                continue\n            record = {\n                **{k: v for k, v in dp.items() if k != \"vector\"},\n                \"id\": dp.get(\"__id__\"),\n                \"created_at\": dp.get(\"__created_at__\"),\n            }\n            key = record.get(\"id\")\n            if key is not None:\n                result_map[str(key)] = record\n\n        ordered_results: list[dict[str, Any] | None] = []\n        for requested_id in ids:\n            ordered_results.append(result_map.get(str(requested_id)))\n\n        return ordered_results\n\n    async def get_vectors_by_ids(self, ids: list[str]) -> dict[str, list[float]]:\n        \"\"\"Get vectors by their IDs, returning only ID and vector data for efficiency\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            Dictionary mapping IDs to their vector embeddings\n            Format: {id: [vector_values], ...}\n        \"\"\"\n        if not ids:\n            return {}\n\n        client = await self._get_client()\n        results = client.get(ids)\n\n        vectors_dict = {}\n        for result in results:\n            if result and \"vector\" in result and \"__id__\" in result:\n                # Decompress vector data (Base64 + zlib + Float16 compressed)\n                decoded = base64.b64decode(result[\"vector\"])\n                decompressed = zlib.decompress(decoded)\n                vector_f16 = np.frombuffer(decompressed, dtype=np.float16)\n                vector_f32 = vector_f16.astype(np.float32).tolist()\n                vectors_dict[result[\"__id__\"]] = vector_f32\n\n        return vectors_dict\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop all vector data from storage and clean up resources\n\n        This method will:\n        1. Remove the vector database storage file if it exists\n        2. Reinitialize the vector database client\n        3. Update flags to notify other processes\n        4. Changes is persisted to disk immediately\n\n        This method is intended for use in scenarios where all data needs to be removed,\n\n        Returns:\n            dict[str, str]: Operation status and message\n            - On success: {\"status\": \"success\", \"message\": \"data dropped\"}\n            - On failure: {\"status\": \"error\", \"message\": \"<error details>\"}\n        \"\"\"\n        try:\n            async with self._storage_lock:\n                # delete _client_file_name\n                if os.path.exists(self._client_file_name):\n                    os.remove(self._client_file_name)\n\n                self._client = NanoVectorDB(\n                    self.embedding_func.embedding_dim,\n                    storage_file=self._client_file_name,\n                )\n\n                # Notify other processes that data has been updated\n                await set_all_update_flags(self.namespace, workspace=self.workspace)\n                # Reset own update flag to avoid self-reloading\n                self.storage_updated.value = False\n\n                logger.info(\n                    f\"[{self.workspace}] Process {os.getpid()} drop {self.namespace}(file:{self._client_file_name})\"\n                )\n            return {\"status\": \"success\", \"message\": \"data dropped\"}\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error dropping {self.namespace}: {e}\")\n            return {\"status\": \"error\", \"message\": str(e)}\n"
  },
  {
    "path": "lightrag/kg/neo4j_impl.py",
    "content": "import os\nimport re\nfrom dataclasses import dataclass\nfrom typing import final\nimport configparser\n\n\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\n\nimport logging\nfrom ..utils import logger\nfrom ..base import BaseGraphStorage\nfrom ..types import KnowledgeGraph, KnowledgeGraphNode, KnowledgeGraphEdge\nfrom ..kg.shared_storage import get_data_init_lock\nimport pipmaster as pm\n\nif not pm.is_installed(\"neo4j\"):\n    pm.install(\"neo4j\")\n\nfrom neo4j import (  # type: ignore\n    AsyncGraphDatabase,\n    exceptions as neo4jExceptions,\n    AsyncDriver,\n    AsyncManagedTransaction,\n)\n\nfrom dotenv import load_dotenv\n\n# use the .env that is inside the current folder\n# allows to use different .env file for each lightrag instance\n# the OS environment variables take precedence over the .env file\nload_dotenv(dotenv_path=\".env\", override=False)\n\nconfig = configparser.ConfigParser()\nconfig.read(\"config.ini\", \"utf-8\")\n\n\n# Set neo4j logger level to ERROR to suppress warning logs\nlogging.getLogger(\"neo4j\").setLevel(logging.ERROR)\n\n\nREAD_RETRY_EXCEPTIONS = (\n    neo4jExceptions.ServiceUnavailable,\n    neo4jExceptions.TransientError,\n    neo4jExceptions.SessionExpired,\n    ConnectionResetError,\n    OSError,\n    AttributeError,\n)\n\nREAD_RETRY = retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=10),\n    retry=retry_if_exception_type(READ_RETRY_EXCEPTIONS),\n    reraise=True,\n)\n\n\n@final\n@dataclass\nclass Neo4JStorage(BaseGraphStorage):\n    def __init__(self, namespace, global_config, embedding_func, workspace=None):\n        # Read env and override the arg if present\n        neo4j_workspace = os.environ.get(\"NEO4J_WORKSPACE\")\n        original_workspace = workspace  # Save original value for logging\n        if neo4j_workspace and neo4j_workspace.strip():\n            workspace = neo4j_workspace\n\n        # Default to 'base' when both arg and env are empty\n        if not workspace or not str(workspace).strip():\n            workspace = \"base\"\n\n        super().__init__(\n            namespace=namespace,\n            workspace=workspace,\n            global_config=global_config,\n            embedding_func=embedding_func,\n        )\n\n        # Log after super().__init__() to ensure self.workspace is initialized\n        if neo4j_workspace and neo4j_workspace.strip():\n            logger.info(\n                f\"Using NEO4J_WORKSPACE environment variable: '{neo4j_workspace}' (overriding '{original_workspace}/{namespace}')\"\n            )\n\n        self._driver = None\n\n    def _get_workspace_label(self) -> str:\n        \"\"\"Return sanitized workspace label safe for use as a backtick-quoted identifier in Cypher queries.\n\n        Escapes backticks by doubling them to prevent Cypher injection\n        via the LIGHTRAG-WORKSPACE header, while preserving a 1-to-1 mapping\n        for all other characters. The returned value is intended to be used\n        inside backticks (for example, MATCH (n:`{label}`)) and is not\n        validated as a standalone unquoted identifier.\n        \"\"\"\n        workspace = self.workspace.strip()\n        if not workspace:\n            return \"base\"\n        return workspace.replace(\"`\", \"``\")\n\n    def _normalize_index_suffix(self, workspace_label: str) -> str:\n        \"\"\"Normalize workspace label for safe use in index names.\"\"\"\n        normalized = re.sub(r\"[^A-Za-z0-9_]+\", \"_\", workspace_label).strip(\"_\")\n        if not normalized:\n            normalized = \"base\"\n        if not re.match(r\"[A-Za-z_]\", normalized[0]):\n            normalized = f\"ws_{normalized}\"\n        return normalized\n\n    def _get_fulltext_index_name(self, workspace_label: str) -> str:\n        \"\"\"Return a full-text index name derived from the normalized workspace label.\"\"\"\n        suffix = self._normalize_index_suffix(workspace_label)\n        return f\"entity_id_fulltext_idx_{suffix}\"\n\n    def _is_chinese_text(self, text: str) -> bool:\n        \"\"\"Check if text contains Chinese/CJK characters.\n\n        Covers:\n        - CJK Unified Ideographs (U+4E00-U+9FFF)\n        - CJK Extension A (U+3400-U+4DBF)\n        - CJK Compatibility Ideographs (U+F900-U+FAFF)\n        - CJK Extension B-F (U+20000-U+2FA1F) - supplementary planes\n        \"\"\"\n        cjk_pattern = re.compile(\n            r\"[\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff]|[\\U00020000-\\U0002fa1f]\"\n        )\n        return bool(cjk_pattern.search(text))\n\n    async def initialize(self):\n        async with get_data_init_lock():\n            URI = os.environ.get(\"NEO4J_URI\", config.get(\"neo4j\", \"uri\", fallback=None))\n            USERNAME = os.environ.get(\n                \"NEO4J_USERNAME\", config.get(\"neo4j\", \"username\", fallback=None)\n            )\n            PASSWORD = os.environ.get(\n                \"NEO4J_PASSWORD\", config.get(\"neo4j\", \"password\", fallback=None)\n            )\n            MAX_CONNECTION_POOL_SIZE = int(\n                os.environ.get(\n                    \"NEO4J_MAX_CONNECTION_POOL_SIZE\",\n                    config.get(\"neo4j\", \"connection_pool_size\", fallback=100),\n                )\n            )\n            CONNECTION_TIMEOUT = float(\n                os.environ.get(\n                    \"NEO4J_CONNECTION_TIMEOUT\",\n                    config.get(\"neo4j\", \"connection_timeout\", fallback=30.0),\n                ),\n            )\n            CONNECTION_ACQUISITION_TIMEOUT = float(\n                os.environ.get(\n                    \"NEO4J_CONNECTION_ACQUISITION_TIMEOUT\",\n                    config.get(\n                        \"neo4j\", \"connection_acquisition_timeout\", fallback=30.0\n                    ),\n                ),\n            )\n            MAX_TRANSACTION_RETRY_TIME = float(\n                os.environ.get(\n                    \"NEO4J_MAX_TRANSACTION_RETRY_TIME\",\n                    config.get(\"neo4j\", \"max_transaction_retry_time\", fallback=30.0),\n                ),\n            )\n            MAX_CONNECTION_LIFETIME = float(\n                os.environ.get(\n                    \"NEO4J_MAX_CONNECTION_LIFETIME\",\n                    config.get(\"neo4j\", \"max_connection_lifetime\", fallback=300.0),\n                ),\n            )\n            LIVENESS_CHECK_TIMEOUT = float(\n                os.environ.get(\n                    \"NEO4J_LIVENESS_CHECK_TIMEOUT\",\n                    config.get(\"neo4j\", \"liveness_check_timeout\", fallback=30.0),\n                ),\n            )\n            KEEP_ALIVE = os.environ.get(\n                \"NEO4J_KEEP_ALIVE\",\n                config.get(\"neo4j\", \"keep_alive\", fallback=\"true\"),\n            ).lower() in (\"true\", \"1\", \"yes\", \"on\")\n            DATABASE = os.environ.get(\n                \"NEO4J_DATABASE\", re.sub(r\"[^a-zA-Z0-9-]\", \"-\", self.namespace)\n            )\n            \"\"\"The default value approach for the DATABASE is only intended to maintain compatibility with legacy practices.\"\"\"\n\n            self._driver: AsyncDriver = AsyncGraphDatabase.driver(\n                URI,\n                auth=(USERNAME, PASSWORD),\n                max_connection_pool_size=MAX_CONNECTION_POOL_SIZE,\n                connection_timeout=CONNECTION_TIMEOUT,\n                connection_acquisition_timeout=CONNECTION_ACQUISITION_TIMEOUT,\n                max_transaction_retry_time=MAX_TRANSACTION_RETRY_TIME,\n                max_connection_lifetime=MAX_CONNECTION_LIFETIME,\n                liveness_check_timeout=LIVENESS_CHECK_TIMEOUT,\n                keep_alive=KEEP_ALIVE,\n            )\n\n            # Try to connect to the database and create it if it doesn't exist\n            for database in (DATABASE, None):\n                self._DATABASE = database\n                connected = False\n\n                try:\n                    async with self._driver.session(database=database) as session:\n                        try:\n                            result = await session.run(\"MATCH (n) RETURN n LIMIT 0\")\n                            await result.consume()  # Ensure result is consumed\n                            logger.info(\n                                f\"[{self.workspace}] Connected to {database} at {URI}\"\n                            )\n                            connected = True\n                        except neo4jExceptions.ServiceUnavailable as e:\n                            logger.error(\n                                f\"[{self.workspace}] \"\n                                + f\"Database {database} at {URI} is not available\"\n                            )\n                            raise e\n                except neo4jExceptions.AuthError as e:\n                    logger.error(\n                        f\"[{self.workspace}] Authentication failed for {database} at {URI}\"\n                    )\n                    raise e\n                except neo4jExceptions.ClientError as e:\n                    if e.code == \"Neo.ClientError.Database.DatabaseNotFound\":\n                        logger.info(\n                            f\"[{self.workspace}] \"\n                            + f\"Database {database} at {URI} not found. Try to create specified database.\"\n                        )\n                        try:\n                            async with self._driver.session() as session:\n                                result = await session.run(\n                                    f\"CREATE DATABASE `{database}` IF NOT EXISTS\"\n                                )\n                                await result.consume()  # Ensure result is consumed\n                                logger.info(\n                                    f\"[{self.workspace}] \"\n                                    + f\"Database {database} at {URI} created\"\n                                )\n                                connected = True\n                        except (\n                            neo4jExceptions.ClientError,\n                            neo4jExceptions.DatabaseError,\n                        ) as e:\n                            if (\n                                e.code\n                                == \"Neo.ClientError.Statement.UnsupportedAdministrationCommand\"\n                            ) or (\n                                e.code == \"Neo.DatabaseError.Statement.ExecutionFailed\"\n                            ):\n                                if database is not None:\n                                    logger.warning(\n                                        f\"[{self.workspace}] This Neo4j instance does not support creating databases. Try to use Neo4j Desktop/Enterprise version or DozerDB instead. Fallback to use the default database.\"\n                                    )\n                            if database is None:\n                                logger.error(\n                                    f\"[{self.workspace}] Failed to create {database} at {URI}\"\n                                )\n                                raise e\n\n                if connected:\n                    workspace_label = self._get_workspace_label()\n                    # Create B-Tree index for entity_id for faster lookups\n                    try:\n                        async with self._driver.session(database=database) as session:\n                            await session.run(\n                                f\"CREATE INDEX IF NOT EXISTS FOR (n:`{workspace_label}`) ON (n.entity_id)\"\n                            )\n                            logger.info(\n                                f\"[{self.workspace}] Ensured B-Tree index on entity_id for {workspace_label} in {database}\"\n                            )\n                    except Exception as e:\n                        logger.warning(\n                            f\"[{self.workspace}] Failed to create B-Tree index: {str(e)}\"\n                        )\n\n                    # Create full-text index for entity_id for faster text searches\n                    await self._create_fulltext_index(\n                        self._driver, self._DATABASE, workspace_label\n                    )\n                    break\n\n    async def _create_fulltext_index(\n        self, driver: AsyncDriver, database: str, workspace_label: str\n    ):\n        \"\"\"Create a full-text index on the entity_id property with Chinese tokenizer support.\"\"\"\n        index_name = self._get_fulltext_index_name(workspace_label)\n        legacy_index_name = \"entity_id_fulltext_idx\"\n        try:\n            async with driver.session(database=database) as session:\n                # Check if the full-text index exists and get its configuration\n                check_index_query = \"SHOW FULLTEXT INDEXES\"\n                result = await session.run(check_index_query)\n                indexes = await result.data()\n                await result.consume()\n\n                existing_index = None\n                legacy_index = None\n                for idx in indexes:\n                    if idx[\"name\"] == index_name:\n                        existing_index = idx\n                    elif idx[\"name\"] == legacy_index_name:\n                        legacy_index = idx\n                    # Break early if we found both indexes\n                    if existing_index and legacy_index:\n                        break\n\n                # Handle legacy index migration\n                if legacy_index and not existing_index:\n                    logger.info(\n                        f\"[{self.workspace}] Found legacy index '{legacy_index_name}'. Migrating to '{index_name}'.\"\n                    )\n                    try:\n                        # Drop the legacy index (use IF EXISTS for safety)\n                        drop_query = f\"DROP INDEX {legacy_index_name} IF EXISTS\"\n                        result = await session.run(drop_query)\n                        await result.consume()\n                        logger.info(\n                            f\"[{self.workspace}] Dropped legacy index '{legacy_index_name}'\"\n                        )\n                    except Exception as drop_error:\n                        logger.warning(\n                            f\"[{self.workspace}] Failed to drop legacy index: {str(drop_error)}\"\n                        )\n\n                # Check if index exists and is online\n                if existing_index:\n                    index_state = existing_index.get(\"state\", \"UNKNOWN\")\n                    logger.info(\n                        f\"[{self.workspace}] Found existing index '{index_name}' with state: {index_state}\"\n                    )\n\n                    if index_state == \"ONLINE\":\n                        logger.info(\n                            f\"[{self.workspace}] Full-text index '{index_name}' already exists and is online. Skipping recreation.\"\n                        )\n                        return\n                    else:\n                        logger.warning(\n                            f\"[{self.workspace}] Existing index '{index_name}' is not online (state: {index_state}). Will recreate.\"\n                        )\n                else:\n                    logger.info(\n                        f\"[{self.workspace}] No existing index '{index_name}' found. Creating new index.\"\n                    )\n\n                # Create or recreate the index if needed\n                needs_recreation = (\n                    existing_index is not None\n                    and existing_index.get(\"state\") != \"ONLINE\"\n                )\n                needs_creation = existing_index is None\n\n                if needs_recreation or needs_creation:\n                    # Drop existing index if it needs recreation (use IF EXISTS for safety)\n                    if needs_recreation:\n                        try:\n                            drop_query = f\"DROP INDEX {index_name} IF EXISTS\"\n                            result = await session.run(drop_query)\n                            await result.consume()\n                            logger.info(\n                                f\"[{self.workspace}] Dropped existing index '{index_name}'\"\n                            )\n                        except Exception as drop_error:\n                            logger.warning(\n                                f\"[{self.workspace}] Failed to drop existing index: {str(drop_error)}\"\n                            )\n\n                    # Create new index with CJK analyzer\n                    logger.info(\n                        f\"[{self.workspace}] Creating full-text index '{index_name}' with Chinese tokenizer support.\"\n                    )\n\n                    try:\n                        create_index_query = f\"\"\"\n                        CREATE FULLTEXT INDEX {index_name}\n                        FOR (n:`{workspace_label}`) ON EACH [n.entity_id]\n                        OPTIONS {{\n                            indexConfig: {{\n                                `fulltext.analyzer`: 'cjk',\n                                `fulltext.eventually_consistent`: true\n                            }}\n                        }}\n                        \"\"\"\n                        result = await session.run(create_index_query)\n                        await result.consume()\n                        logger.info(\n                            f\"[{self.workspace}] Successfully created full-text index '{index_name}' with CJK analyzer.\"\n                        )\n                    except Exception as cjk_error:\n                        # Fallback to standard analyzer if CJK is not supported\n                        logger.warning(\n                            f\"[{self.workspace}] CJK analyzer not supported: {str(cjk_error)}. \"\n                            \"Falling back to standard analyzer.\"\n                        )\n                        create_index_query = f\"\"\"\n                        CREATE FULLTEXT INDEX {index_name}\n                        FOR (n:`{workspace_label}`) ON EACH [n.entity_id]\n                        \"\"\"\n                        result = await session.run(create_index_query)\n                        await result.consume()\n                        logger.info(\n                            f\"[{self.workspace}] Successfully created full-text index '{index_name}' with standard analyzer.\"\n                        )\n\n        except Exception as e:\n            # Handle cases where the command might not be supported\n            if \"Unknown command\" in str(e) or \"invalid syntax\" in str(e).lower():\n                logger.warning(\n                    f\"[{self.workspace}] Could not create or verify full-text index '{index_name}'. \"\n                    \"This might be because you are using a Neo4j version that does not support it. \"\n                    \"Search functionality will fall back to slower, non-indexed queries.\"\n                )\n            else:\n                logger.error(\n                    f\"[{self.workspace}] Failed to create or verify full-text index '{index_name}': {str(e)}\"\n                )\n\n    async def finalize(self):\n        \"\"\"Close the Neo4j driver and release all resources\"\"\"\n        if self._driver:\n            await self._driver.close()\n            self._driver = None\n\n    async def __aexit__(self, exc_type, exc, tb):\n        \"\"\"Ensure driver is closed when context manager exits\"\"\"\n        await self.finalize()\n\n    async def index_done_callback(self) -> None:\n        # Neo4J handles persistence automatically\n        pass\n\n    @READ_RETRY\n    async def has_node(self, node_id: str) -> bool:\n        \"\"\"\n        Check if a node with the given label exists in the database\n\n        Args:\n            node_id: Label of the node to check\n\n        Returns:\n            bool: True if node exists, False otherwise\n\n        Raises:\n            ValueError: If node_id is invalid\n            Exception: If there is an error executing the query\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            result = None\n            try:\n                query = f\"MATCH (n:`{workspace_label}` {{entity_id: $entity_id}}) RETURN count(n) > 0 AS node_exists\"\n                result = await session.run(query, entity_id=node_id)\n                single_result = await result.single()\n                await result.consume()  # Ensure result is fully consumed\n                return single_result[\"node_exists\"]\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Error checking node existence for {node_id}: {str(e)}\"\n                )\n                if result is not None:\n                    await result.consume()  # Ensure results are consumed even on error\n                raise\n\n    @READ_RETRY\n    async def has_edge(self, source_node_id: str, target_node_id: str) -> bool:\n        \"\"\"\n        Check if an edge exists between two nodes\n\n        Args:\n            source_node_id: Label of the source node\n            target_node_id: Label of the target node\n\n        Returns:\n            bool: True if edge exists, False otherwise\n\n        Raises:\n            ValueError: If either node_id is invalid\n            Exception: If there is an error executing the query\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            result = None\n            try:\n                query = (\n                    f\"MATCH (a:`{workspace_label}` {{entity_id: $source_entity_id}})-[r]-(b:`{workspace_label}` {{entity_id: $target_entity_id}}) \"\n                    \"RETURN COUNT(r) > 0 AS edgeExists\"\n                )\n                result = await session.run(\n                    query,\n                    source_entity_id=source_node_id,\n                    target_entity_id=target_node_id,\n                )\n                single_result = await result.single()\n                await result.consume()  # Ensure result is fully consumed\n                return single_result[\"edgeExists\"]\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Error checking edge existence between {source_node_id} and {target_node_id}: {str(e)}\"\n                )\n                if result is not None:\n                    await result.consume()  # Ensure results are consumed even on error\n                raise\n\n    @READ_RETRY\n    async def get_node(self, node_id: str) -> dict[str, str] | None:\n        \"\"\"Get node by its label identifier, return only node properties\n\n        Args:\n            node_id: The node label to look up\n\n        Returns:\n            dict: Node properties if found\n            None: If node not found\n\n        Raises:\n            ValueError: If node_id is invalid\n            Exception: If there is an error executing the query\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            try:\n                query = (\n                    f\"MATCH (n:`{workspace_label}` {{entity_id: $entity_id}}) RETURN n\"\n                )\n                result = await session.run(query, entity_id=node_id)\n                try:\n                    records = await result.fetch(\n                        2\n                    )  # Get 2 records for duplication check\n\n                    if len(records) > 1:\n                        logger.warning(\n                            f\"[{self.workspace}] Multiple nodes found with label '{node_id}'. Using first node.\"\n                        )\n                    if records:\n                        node = records[0][\"n\"]\n                        node_dict = dict(node)\n                        # Remove workspace label from labels list if it exists\n                        if \"labels\" in node_dict:\n                            node_dict[\"labels\"] = [\n                                label\n                                for label in node_dict[\"labels\"]\n                                if label != workspace_label\n                            ]\n                        # logger.debug(f\"Neo4j query node {query} return: {node_dict}\")\n                        return node_dict\n                    return None\n                finally:\n                    await result.consume()  # Ensure result is fully consumed\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Error getting node for {node_id}: {str(e)}\"\n                )\n                raise\n\n    @READ_RETRY\n    async def get_nodes_batch(self, node_ids: list[str]) -> dict[str, dict]:\n        \"\"\"\n        Retrieve multiple nodes in one query using UNWIND.\n\n        Args:\n            node_ids: List of node entity IDs to fetch.\n\n        Returns:\n            A dictionary mapping each node_id to its node data (or None if not found).\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            query = f\"\"\"\n            UNWIND $node_ids AS id\n            MATCH (n:`{workspace_label}` {{entity_id: id}})\n            RETURN n.entity_id AS entity_id, n\n            \"\"\"\n            result = await session.run(query, node_ids=node_ids)\n            nodes = {}\n            async for record in result:\n                entity_id = record[\"entity_id\"]\n                node = record[\"n\"]\n                node_dict = dict(node)\n                # Remove the workspace label if present in a 'labels' property\n                if \"labels\" in node_dict:\n                    node_dict[\"labels\"] = [\n                        label\n                        for label in node_dict[\"labels\"]\n                        if label != workspace_label\n                    ]\n                nodes[entity_id] = node_dict\n            await result.consume()  # Make sure to consume the result fully\n            return nodes\n\n    @READ_RETRY\n    async def node_degree(self, node_id: str) -> int:\n        \"\"\"Get the degree (number of relationships) of a node with the given label.\n        If multiple nodes have the same label, returns the degree of the first node.\n        If no node is found, returns 0.\n\n        Args:\n            node_id: The label of the node\n\n        Returns:\n            int: The number of relationships the node has, or 0 if no node found\n\n        Raises:\n            ValueError: If node_id is invalid\n            Exception: If there is an error executing the query\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            try:\n                query = f\"\"\"\n                    MATCH (n:`{workspace_label}` {{entity_id: $entity_id}})\n                    OPTIONAL MATCH (n)-[r]-()\n                    RETURN COUNT(r) AS degree\n                \"\"\"\n                result = await session.run(query, entity_id=node_id)\n                try:\n                    record = await result.single()\n\n                    if not record:\n                        logger.warning(\n                            f\"[{self.workspace}] No node found with label '{node_id}'\"\n                        )\n                        return 0\n\n                    degree = record[\"degree\"]\n                    # logger.debug(\n                    #     f\"[{self.workspace}] Neo4j query node degree for {node_id} return: {degree}\"\n                    # )\n                    return degree\n                finally:\n                    await result.consume()  # Ensure result is fully consumed\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Error getting node degree for {node_id}: {str(e)}\"\n                )\n                raise\n\n    @READ_RETRY\n    async def node_degrees_batch(self, node_ids: list[str]) -> dict[str, int]:\n        \"\"\"\n        Retrieve the degree for multiple nodes in a single query using UNWIND.\n\n        Args:\n            node_ids: List of node labels (entity_id values) to look up.\n\n        Returns:\n            A dictionary mapping each node_id to its degree (number of relationships).\n            If a node is not found, its degree will be set to 0.\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            query = f\"\"\"\n                UNWIND $node_ids AS id\n                MATCH (n:`{workspace_label}` {{entity_id: id}})\n                RETURN n.entity_id AS entity_id, count {{ (n)--() }} AS degree;\n            \"\"\"\n            result = await session.run(query, node_ids=node_ids)\n            degrees = {}\n            async for record in result:\n                entity_id = record[\"entity_id\"]\n                degrees[entity_id] = record[\"degree\"]\n            await result.consume()  # Ensure result is fully consumed\n\n            # For any node_id that did not return a record, set degree to 0.\n            for nid in node_ids:\n                if nid not in degrees:\n                    logger.warning(\n                        f\"[{self.workspace}] No node found with label '{nid}'\"\n                    )\n                    degrees[nid] = 0\n\n            # logger.debug(f\"[{self.workspace}] Neo4j batch node degree query returned: {degrees}\")\n            return degrees\n\n    async def edge_degree(self, src_id: str, tgt_id: str) -> int:\n        \"\"\"Get the total degree (sum of relationships) of two nodes.\n\n        Args:\n            src_id: Label of the source node\n            tgt_id: Label of the target node\n\n        Returns:\n            int: Sum of the degrees of both nodes\n        \"\"\"\n        src_degree = await self.node_degree(src_id)\n        trg_degree = await self.node_degree(tgt_id)\n\n        # Convert None to 0 for addition\n        src_degree = 0 if src_degree is None else src_degree\n        trg_degree = 0 if trg_degree is None else trg_degree\n\n        degrees = int(src_degree) + int(trg_degree)\n        return degrees\n\n    @READ_RETRY\n    async def edge_degrees_batch(\n        self, edge_pairs: list[tuple[str, str]]\n    ) -> dict[tuple[str, str], int]:\n        \"\"\"\n        Calculate the combined degree for each edge (sum of the source and target node degrees)\n        in batch using the already implemented node_degrees_batch.\n\n        Args:\n            edge_pairs: List of (src, tgt) tuples.\n\n        Returns:\n            A dictionary mapping each (src, tgt) tuple to the sum of their degrees.\n        \"\"\"\n        # Collect unique node IDs from all edge pairs.\n        unique_node_ids = {src for src, _ in edge_pairs}\n        unique_node_ids.update({tgt for _, tgt in edge_pairs})\n\n        # Get degrees for all nodes in one go.\n        degrees = await self.node_degrees_batch(list(unique_node_ids))\n\n        # Sum up degrees for each edge pair.\n        edge_degrees = {}\n        for src, tgt in edge_pairs:\n            edge_degrees[(src, tgt)] = degrees.get(src, 0) + degrees.get(tgt, 0)\n        return edge_degrees\n\n    @READ_RETRY\n    async def get_edge(\n        self, source_node_id: str, target_node_id: str\n    ) -> dict[str, str] | None:\n        \"\"\"Get edge properties between two nodes.\n\n        Args:\n            source_node_id: Label of the source node\n            target_node_id: Label of the target node\n\n        Returns:\n            dict: Edge properties if found, default properties if not found or on error\n\n        Raises:\n            ValueError: If either node_id is invalid\n            Exception: If there is an error executing the query\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        try:\n            async with self._driver.session(\n                database=self._DATABASE, default_access_mode=\"READ\"\n            ) as session:\n                query = f\"\"\"\n                MATCH (start:`{workspace_label}` {{entity_id: $source_entity_id}})-[r]-(end:`{workspace_label}` {{entity_id: $target_entity_id}})\n                RETURN properties(r) as edge_properties\n                \"\"\"\n                result = await session.run(\n                    query,\n                    source_entity_id=source_node_id,\n                    target_entity_id=target_node_id,\n                )\n                try:\n                    records = await result.fetch(2)\n\n                    if len(records) > 1:\n                        logger.warning(\n                            f\"[{self.workspace}] Multiple edges found between '{source_node_id}' and '{target_node_id}'. Using first edge.\"\n                        )\n                    if records:\n                        try:\n                            edge_result = dict(records[0][\"edge_properties\"])\n                            # logger.debug(f\"Result: {edge_result}\")\n                            # Ensure required keys exist with defaults\n                            required_keys = {\n                                \"weight\": 1.0,\n                                \"source_id\": None,\n                                \"description\": None,\n                                \"keywords\": None,\n                            }\n                            for key, default_value in required_keys.items():\n                                if key not in edge_result:\n                                    edge_result[key] = default_value\n                                    logger.warning(\n                                        f\"[{self.workspace}] Edge between {source_node_id} and {target_node_id} \"\n                                        f\"missing {key}, using default: {default_value}\"\n                                    )\n\n                            # logger.debug(\n                            #     f\"{inspect.currentframe().f_code.co_name}:query:{query}:result:{edge_result}\"\n                            # )\n                            return edge_result\n                        except (KeyError, TypeError, ValueError) as e:\n                            logger.error(\n                                f\"[{self.workspace}] Error processing edge properties between {source_node_id} \"\n                                f\"and {target_node_id}: {str(e)}\"\n                            )\n                            # Return default edge properties on error\n                            return {\n                                \"weight\": 1.0,\n                                \"source_id\": None,\n                                \"description\": None,\n                                \"keywords\": None,\n                            }\n\n                    # logger.debug(\n                    #     f\"{inspect.currentframe().f_code.co_name}: No edge found between {source_node_id} and {target_node_id}\"\n                    # )\n                    # Return None when no edge found\n                    return None\n                finally:\n                    await result.consume()  # Ensure result is fully consumed\n\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error in get_edge between {source_node_id} and {target_node_id}: {str(e)}\"\n            )\n            raise\n\n    @READ_RETRY\n    async def get_edges_batch(\n        self, pairs: list[dict[str, str]]\n    ) -> dict[tuple[str, str], dict]:\n        \"\"\"\n        Retrieve edge properties for multiple (src, tgt) pairs in one query.\n\n        Args:\n            pairs: List of dictionaries, e.g. [{\"src\": \"node1\", \"tgt\": \"node2\"}, ...]\n\n        Returns:\n            A dictionary mapping (src, tgt) tuples to their edge properties.\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            query = f\"\"\"\n            UNWIND $pairs AS pair\n            MATCH (start:`{workspace_label}` {{entity_id: pair.src}})-[r:DIRECTED]-(end:`{workspace_label}` {{entity_id: pair.tgt}})\n            RETURN pair.src AS src_id, pair.tgt AS tgt_id, collect(properties(r)) AS edges\n            \"\"\"\n            result = await session.run(query, pairs=pairs)\n            edges_dict = {}\n            async for record in result:\n                src = record[\"src_id\"]\n                tgt = record[\"tgt_id\"]\n                edges = record[\"edges\"]\n                if edges and len(edges) > 0:\n                    edge_props = edges[0]  # choose the first if multiple exist\n                    # Ensure required keys exist with defaults\n                    for key, default in {\n                        \"weight\": 1.0,\n                        \"source_id\": None,\n                        \"description\": None,\n                        \"keywords\": None,\n                    }.items():\n                        if key not in edge_props:\n                            edge_props[key] = default\n                    edges_dict[(src, tgt)] = edge_props\n                else:\n                    # No edge found – set default edge properties\n                    edges_dict[(src, tgt)] = {\n                        \"weight\": 1.0,\n                        \"source_id\": None,\n                        \"description\": None,\n                        \"keywords\": None,\n                    }\n            await result.consume()\n            return edges_dict\n\n    @READ_RETRY\n    async def get_node_edges(self, source_node_id: str) -> list[tuple[str, str]] | None:\n        \"\"\"Retrieves all edges (relationships) for a particular node identified by its label.\n\n        Args:\n            source_node_id: Label of the node to get edges for\n\n        Returns:\n            list[tuple[str, str]]: List of (source_label, target_label) tuples representing edges\n            None: If no edges found\n\n        Raises:\n            ValueError: If source_node_id is invalid\n            Exception: If there is an error executing the query\n        \"\"\"\n        try:\n            async with self._driver.session(\n                database=self._DATABASE, default_access_mode=\"READ\"\n            ) as session:\n                results = None\n                try:\n                    workspace_label = self._get_workspace_label()\n                    query = f\"\"\"MATCH (n:`{workspace_label}` {{entity_id: $entity_id}})\n                            OPTIONAL MATCH (n)-[r]-(connected:`{workspace_label}`)\n                            WHERE connected.entity_id IS NOT NULL\n                            RETURN n, r, connected\"\"\"\n                    results = await session.run(query, entity_id=source_node_id)\n\n                    edges = []\n                    async for record in results:\n                        source_node = record[\"n\"]\n                        connected_node = record[\"connected\"]\n\n                        # Skip if either node is None\n                        if not source_node or not connected_node:\n                            continue\n\n                        source_label = (\n                            source_node.get(\"entity_id\")\n                            if source_node.get(\"entity_id\")\n                            else None\n                        )\n                        target_label = (\n                            connected_node.get(\"entity_id\")\n                            if connected_node.get(\"entity_id\")\n                            else None\n                        )\n\n                        if source_label and target_label:\n                            edges.append((source_label, target_label))\n\n                    await results.consume()  # Ensure results are consumed\n                    return edges\n                except Exception as e:\n                    logger.error(\n                        f\"[{self.workspace}] Error getting edges for node {source_node_id}: {str(e)}\"\n                    )\n                    if results is not None:\n                        await (\n                            results.consume()\n                        )  # Ensure results are consumed even on error\n                    raise\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error in get_node_edges for {source_node_id}: {str(e)}\"\n            )\n            raise\n\n    @READ_RETRY\n    async def get_nodes_edges_batch(\n        self, node_ids: list[str]\n    ) -> dict[str, list[tuple[str, str]]]:\n        \"\"\"\n        Batch retrieve edges for multiple nodes in one query using UNWIND.\n        For each node, returns both outgoing and incoming edges to properly represent\n        the undirected graph nature.\n\n        Args:\n            node_ids: List of node IDs (entity_id) for which to retrieve edges.\n\n        Returns:\n            A dictionary mapping each node ID to its list of edge tuples (source, target).\n            For each node, the list includes both:\n            - Outgoing edges: (queried_node, connected_node)\n            - Incoming edges: (connected_node, queried_node)\n        \"\"\"\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            # Query to get both outgoing and incoming edges\n            workspace_label = self._get_workspace_label()\n            query = f\"\"\"\n                UNWIND $node_ids AS id\n                MATCH (n:`{workspace_label}` {{entity_id: id}})\n                OPTIONAL MATCH (n)-[r]-(connected:`{workspace_label}`)\n                RETURN id AS queried_id, n.entity_id AS node_entity_id,\n                       connected.entity_id AS connected_entity_id,\n                       startNode(r).entity_id AS start_entity_id\n            \"\"\"\n            result = await session.run(query, node_ids=node_ids)\n\n            # Initialize the dictionary with empty lists for each node ID\n            edges_dict = {node_id: [] for node_id in node_ids}\n\n            # Process results to include both outgoing and incoming edges\n            async for record in result:\n                queried_id = record[\"queried_id\"]\n                node_entity_id = record[\"node_entity_id\"]\n                connected_entity_id = record[\"connected_entity_id\"]\n                start_entity_id = record[\"start_entity_id\"]\n\n                # Skip if either node is None\n                if not node_entity_id or not connected_entity_id:\n                    continue\n\n                # Determine the actual direction of the edge\n                # If the start node is the queried node, it's an outgoing edge\n                # Otherwise, it's an incoming edge\n                if start_entity_id == node_entity_id:\n                    # Outgoing edge: (queried_node -> connected_node)\n                    edges_dict[queried_id].append((node_entity_id, connected_entity_id))\n                else:\n                    # Incoming edge: (connected_node -> queried_node)\n                    edges_dict[queried_id].append((connected_entity_id, node_entity_id))\n\n            await result.consume()  # Ensure results are fully consumed\n            return edges_dict\n\n    @retry(\n        stop=stop_after_attempt(3),\n        wait=wait_exponential(multiplier=1, min=4, max=10),\n        retry=retry_if_exception_type(\n            (\n                neo4jExceptions.ServiceUnavailable,\n                neo4jExceptions.TransientError,\n                neo4jExceptions.WriteServiceUnavailable,\n                neo4jExceptions.ClientError,\n                neo4jExceptions.SessionExpired,\n                ConnectionResetError,\n                OSError,\n            )\n        ),\n    )\n    async def upsert_node(self, node_id: str, node_data: dict[str, str]) -> None:\n        \"\"\"\n        Upsert a node in the Neo4j database.\n\n        Args:\n            node_id: The unique identifier for the node (used as label)\n            node_data: Dictionary of node properties\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        properties = node_data\n        entity_type = properties[\"entity_type\"]\n        if \"entity_id\" not in properties:\n            raise ValueError(\"Neo4j: node properties must contain an 'entity_id' field\")\n\n        # Coerce to str first so membership checks below never raise TypeError\n        # regardless of what upstream callers (e.g. API payloads) pass in.\n        entity_type = (\n            str(entity_type) if not isinstance(entity_type, str) else entity_type\n        )\n\n        # Sanitize entity_type: strip backticks and handle comma-separated values.\n        # This guards against dirty data from LLM extraction or database read-back.\n        if \"`\" in entity_type or \",\" in entity_type or not entity_type.strip():\n            original = entity_type\n            entity_type = entity_type.replace(\"`\", \"\").strip()\n            if \",\" in entity_type:\n                entity_type = entity_type.split(\",\")[0].strip()\n            if not entity_type:\n                entity_type = \"UNKNOWN\"\n            logger.warning(\n                f\"[{self.workspace}] Entity type sanitized in upsert_node: '{original}' -> '{entity_type}'\"\n            )\n            properties = dict(properties)\n            properties[\"entity_type\"] = entity_type\n\n        try:\n            async with self._driver.session(database=self._DATABASE) as session:\n\n                async def execute_upsert(tx: AsyncManagedTransaction):\n                    query = f\"\"\"\n                    MERGE (n:`{workspace_label}` {{entity_id: $entity_id}})\n                    SET n += $properties\n                    SET n:`{entity_type}`\n                    \"\"\"\n                    result = await tx.run(\n                        query, entity_id=node_id, properties=properties\n                    )\n                    await result.consume()  # Ensure result is fully consumed\n\n                await session.execute_write(execute_upsert)\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error during upsert: {str(e)}\")\n            raise\n\n    @retry(\n        stop=stop_after_attempt(3),\n        wait=wait_exponential(multiplier=1, min=4, max=10),\n        retry=retry_if_exception_type(\n            (\n                neo4jExceptions.ServiceUnavailable,\n                neo4jExceptions.TransientError,\n                neo4jExceptions.WriteServiceUnavailable,\n                neo4jExceptions.ClientError,\n                neo4jExceptions.SessionExpired,\n                ConnectionResetError,\n                OSError,\n            )\n        ),\n    )\n    async def upsert_edge(\n        self, source_node_id: str, target_node_id: str, edge_data: dict[str, str]\n    ) -> None:\n        \"\"\"\n        Upsert an edge and its properties between two nodes identified by their labels.\n        Ensures both source and target nodes exist and are unique before creating the edge.\n        Uses entity_id property to uniquely identify nodes.\n\n        Args:\n            source_node_id (str): Label of the source node (used as identifier)\n            target_node_id (str): Label of the target node (used as identifier)\n            edge_data (dict): Dictionary of properties to set on the edge\n\n        Raises:\n            ValueError: If either source or target node does not exist or is not unique\n        \"\"\"\n        try:\n            edge_properties = edge_data\n            async with self._driver.session(database=self._DATABASE) as session:\n\n                async def execute_upsert(tx: AsyncManagedTransaction):\n                    workspace_label = self._get_workspace_label()\n                    query = f\"\"\"\n                    MATCH (source:`{workspace_label}` {{entity_id: $source_entity_id}})\n                    WITH source\n                    MATCH (target:`{workspace_label}` {{entity_id: $target_entity_id}})\n                    MERGE (source)-[r:DIRECTED]-(target)\n                    SET r += $properties\n                    RETURN r, source, target\n                    \"\"\"\n                    result = await tx.run(\n                        query,\n                        source_entity_id=source_node_id,\n                        target_entity_id=target_node_id,\n                        properties=edge_properties,\n                    )\n                    try:\n                        await result.fetch(2)\n                    finally:\n                        await result.consume()  # Ensure result is consumed\n\n                await session.execute_write(execute_upsert)\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error during edge upsert: {str(e)}\")\n            raise\n\n    async def get_knowledge_graph(\n        self,\n        node_label: str,\n        max_depth: int = 3,\n        max_nodes: int = None,\n    ) -> KnowledgeGraph:\n        \"\"\"\n        Retrieve a connected subgraph of nodes where the label includes the specified `node_label`.\n\n        Args:\n            node_label: Label of the starting node, * means all nodes\n            max_depth: Maximum depth of the subgraph, Defaults to 3\n            max_nodes: Maxiumu nodes to return by BFS, Defaults to 1000\n\n        Returns:\n            KnowledgeGraph object containing nodes and edges, with an is_truncated flag\n            indicating whether the graph was truncated due to max_nodes limit\n        \"\"\"\n        # Get max_nodes from global_config if not provided\n        if max_nodes is None:\n            max_nodes = self.global_config.get(\"max_graph_nodes\", 1000)\n        else:\n            # Limit max_nodes to not exceed global_config max_graph_nodes\n            max_nodes = min(max_nodes, self.global_config.get(\"max_graph_nodes\", 1000))\n\n        workspace_label = self._get_workspace_label()\n        result = KnowledgeGraph()\n        seen_nodes = set()\n        seen_edges = set()\n\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            try:\n                if node_label == \"*\":\n                    # First check total node count to determine if graph is truncated\n                    count_query = (\n                        f\"MATCH (n:`{workspace_label}`) RETURN count(n) as total\"\n                    )\n                    count_result = None\n                    try:\n                        count_result = await session.run(count_query)\n                        count_record = await count_result.single()\n\n                        if count_record and count_record[\"total\"] > max_nodes:\n                            result.is_truncated = True\n                            logger.info(\n                                f\"[{self.workspace}] Graph truncated: {count_record['total']} nodes found, limited to {max_nodes}\"\n                            )\n                    finally:\n                        if count_result:\n                            await count_result.consume()\n\n                    # Run main query to get nodes with highest degree\n                    main_query = f\"\"\"\n                    MATCH (n:`{workspace_label}`)\n                    OPTIONAL MATCH (n)-[r]-()\n                    WITH n, COALESCE(count(r), 0) AS degree\n                    ORDER BY degree DESC\n                    LIMIT $max_nodes\n                    WITH collect({{node: n}}) AS filtered_nodes\n                    UNWIND filtered_nodes AS node_info\n                    WITH collect(node_info.node) AS kept_nodes, filtered_nodes\n                    OPTIONAL MATCH (a)-[r]-(b)\n                    WHERE a IN kept_nodes AND b IN kept_nodes\n                    RETURN filtered_nodes AS node_info,\n                           collect(DISTINCT r) AS relationships\n                    \"\"\"\n                    result_set = None\n                    try:\n                        result_set = await session.run(\n                            main_query,\n                            {\"max_nodes\": max_nodes},\n                        )\n                        record = await result_set.single()\n                    finally:\n                        if result_set:\n                            await result_set.consume()\n\n                else:\n                    # return await self._robust_fallback(node_label, max_depth, max_nodes)\n                    # First try without limit to check if we need to truncate\n                    full_query = f\"\"\"\n                    MATCH (start:`{workspace_label}`)\n                    WHERE start.entity_id = $entity_id\n                    WITH start\n                    CALL apoc.path.subgraphAll(start, {{\n                        relationshipFilter: '',\n                        labelFilter: '{workspace_label}',\n                        minLevel: 0,\n                        maxLevel: $max_depth,\n                        bfs: true\n                    }})\n                    YIELD nodes, relationships\n                    WITH nodes, relationships, size(nodes) AS total_nodes\n                    UNWIND nodes AS node\n                    WITH collect({{node: node}}) AS node_info, relationships, total_nodes\n                    RETURN node_info, relationships, total_nodes\n                    \"\"\"\n\n                    # Try to get full result\n                    full_result = None\n                    try:\n                        full_result = await session.run(\n                            full_query,\n                            {\n                                \"entity_id\": node_label,\n                                \"max_depth\": max_depth,\n                            },\n                        )\n                        full_record = await full_result.single()\n\n                        # If no record found, return empty KnowledgeGraph\n                        if not full_record:\n                            logger.debug(\n                                f\"[{self.workspace}] No nodes found for entity_id: {node_label}\"\n                            )\n                            return result\n\n                        # If record found, check node count\n                        total_nodes = full_record[\"total_nodes\"]\n\n                        if total_nodes <= max_nodes:\n                            # If node count is within limit, use full result directly\n                            logger.debug(\n                                f\"[{self.workspace}] Using full result with {total_nodes} nodes (no truncation needed)\"\n                            )\n                            record = full_record\n                        else:\n                            # If node count exceeds limit, set truncated flag and run limited query\n                            result.is_truncated = True\n                            logger.info(\n                                f\"[{self.workspace}] Graph truncated: {total_nodes} nodes found, breadth-first search limited to {max_nodes}\"\n                            )\n\n                            # Run limited query\n                            limited_query = f\"\"\"\n                            MATCH (start:`{workspace_label}`)\n                            WHERE start.entity_id = $entity_id\n                            WITH start\n                            CALL apoc.path.subgraphAll(start, {{\n                                relationshipFilter: '',\n                                labelFilter: '{workspace_label}',\n                                minLevel: 0,\n                                maxLevel: $max_depth,\n                                limit: $max_nodes,\n                                bfs: true\n                            }})\n                            YIELD nodes, relationships\n                            UNWIND nodes AS node\n                            WITH collect({{node: node}}) AS node_info, relationships\n                            RETURN node_info, relationships\n                            \"\"\"\n                            result_set = None\n                            try:\n                                result_set = await session.run(\n                                    limited_query,\n                                    {\n                                        \"entity_id\": node_label,\n                                        \"max_depth\": max_depth,\n                                        \"max_nodes\": max_nodes,\n                                    },\n                                )\n                                record = await result_set.single()\n                            finally:\n                                if result_set:\n                                    await result_set.consume()\n                    finally:\n                        if full_result:\n                            await full_result.consume()\n\n                if record:\n                    # Handle nodes (compatible with multi-label cases)\n                    for node_info in record[\"node_info\"]:\n                        node = node_info[\"node\"]\n                        node_id = node.id\n                        if node_id not in seen_nodes:\n                            result.nodes.append(\n                                KnowledgeGraphNode(\n                                    id=f\"{node_id}\",\n                                    labels=[node.get(\"entity_id\")],\n                                    properties=dict(node),\n                                )\n                            )\n                            seen_nodes.add(node_id)\n\n                    # Handle relationships (including direction information)\n                    for rel in record[\"relationships\"]:\n                        edge_id = rel.id\n                        if edge_id not in seen_edges:\n                            start = rel.start_node\n                            end = rel.end_node\n                            result.edges.append(\n                                KnowledgeGraphEdge(\n                                    id=f\"{edge_id}\",\n                                    type=rel.type,\n                                    source=f\"{start.id}\",\n                                    target=f\"{end.id}\",\n                                    properties=dict(rel),\n                                )\n                            )\n                            seen_edges.add(edge_id)\n\n                    logger.info(\n                        f\"[{self.workspace}] Subgraph query successful | Node count: {len(result.nodes)} | Edge count: {len(result.edges)}\"\n                    )\n\n            except neo4jExceptions.ClientError as e:\n                logger.warning(f\"[{self.workspace}] APOC plugin error: {str(e)}\")\n                if node_label != \"*\":\n                    logger.warning(\n                        f\"[{self.workspace}] Neo4j: falling back to basic Cypher recursive search...\"\n                    )\n                    return await self._robust_fallback(node_label, max_depth, max_nodes)\n                else:\n                    logger.warning(\n                        f\"[{self.workspace}] Neo4j: APOC plugin error with wildcard query, returning empty result\"\n                    )\n\n        return result\n\n    async def _robust_fallback(\n        self, node_label: str, max_depth: int, max_nodes: int\n    ) -> KnowledgeGraph:\n        \"\"\"\n        Fallback implementation when APOC plugin is not available or incompatible.\n        This method implements the same functionality as get_knowledge_graph but uses\n        only basic Cypher queries and true breadth-first traversal instead of APOC procedures.\n        \"\"\"\n        from collections import deque\n\n        result = KnowledgeGraph()\n        visited_nodes = set()\n        visited_edges = set()\n        visited_edge_pairs = set()\n\n        # Get the starting node's data\n        workspace_label = self._get_workspace_label()\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            query = f\"\"\"\n            MATCH (n:`{workspace_label}` {{entity_id: $entity_id}})\n            RETURN id(n) as node_id, n\n            \"\"\"\n            node_result = await session.run(query, entity_id=node_label)\n            try:\n                node_record = await node_result.single()\n                if not node_record:\n                    return result\n\n                # Create initial KnowledgeGraphNode\n                start_node = KnowledgeGraphNode(\n                    id=f\"{node_record['n'].get('entity_id')}\",\n                    labels=[node_record[\"n\"].get(\"entity_id\")],\n                    properties=dict(node_record[\"n\"]._properties),\n                )\n            finally:\n                await node_result.consume()  # Ensure results are consumed\n\n        # Initialize queue for BFS with (node, edge, depth) tuples\n        # edge is None for the starting node\n        queue = deque([(start_node, None, 0)])\n\n        # True BFS implementation using a queue\n        while queue and len(visited_nodes) < max_nodes:\n            # Dequeue the next node to process\n            current_node, current_edge, current_depth = queue.popleft()\n\n            # Skip if already visited or exceeds max depth\n            if current_node.id in visited_nodes:\n                continue\n\n            if current_depth > max_depth:\n                logger.debug(\n                    f\"[{self.workspace}] Skipping node at depth {current_depth} (max_depth: {max_depth})\"\n                )\n                continue\n\n            # Add current node to result\n            result.nodes.append(current_node)\n            visited_nodes.add(current_node.id)\n\n            # Add edge to result if it exists and not already added\n            if current_edge and current_edge.id not in visited_edges:\n                result.edges.append(current_edge)\n                visited_edges.add(current_edge.id)\n\n            # Stop if we've reached the node limit\n            if len(visited_nodes) >= max_nodes:\n                result.is_truncated = True\n                logger.info(\n                    f\"[{self.workspace}] Graph truncated: breadth-first search limited to: {max_nodes} nodes\"\n                )\n                break\n\n            # Get all edges and target nodes for the current node (even at max_depth)\n            async with self._driver.session(\n                database=self._DATABASE, default_access_mode=\"READ\"\n            ) as session:\n                workspace_label = self._get_workspace_label()\n                query = f\"\"\"\n                MATCH (a:`{workspace_label}` {{entity_id: $entity_id}})-[r]-(b)\n                WITH r, b, id(r) as edge_id, id(b) as target_id\n                RETURN r, b, edge_id, target_id\n                \"\"\"\n                results = await session.run(query, entity_id=current_node.id)\n\n                # Get all records and release database connection\n                records = await results.fetch(1000)  # Max neighbor nodes we can handle\n                await results.consume()  # Ensure results are consumed\n\n                # Process all neighbors - capture all edges but only queue unvisited nodes\n                for record in records:\n                    rel = record[\"r\"]\n                    edge_id = str(record[\"edge_id\"])\n\n                    if edge_id not in visited_edges:\n                        b_node = record[\"b\"]\n                        target_id = b_node.get(\"entity_id\")\n\n                        if target_id:  # Only process if target node has entity_id\n                            # Create KnowledgeGraphNode for target\n                            target_node = KnowledgeGraphNode(\n                                id=f\"{target_id}\",\n                                labels=[target_id],\n                                properties=dict(b_node._properties),\n                            )\n\n                            # Create KnowledgeGraphEdge\n                            target_edge = KnowledgeGraphEdge(\n                                id=f\"{edge_id}\",\n                                type=rel.type,\n                                source=f\"{current_node.id}\",\n                                target=f\"{target_id}\",\n                                properties=dict(rel),\n                            )\n\n                            # Sort source_id and target_id to ensure (A,B) and (B,A) are treated as the same edge\n                            sorted_pair = tuple(sorted([current_node.id, target_id]))\n\n                            # Check if the same edge already exists (considering undirectedness)\n                            if sorted_pair not in visited_edge_pairs:\n                                # Only add the edge if the target node is already in the result or will be added\n                                if target_id in visited_nodes or (\n                                    target_id not in visited_nodes\n                                    and current_depth < max_depth\n                                ):\n                                    result.edges.append(target_edge)\n                                    visited_edges.add(edge_id)\n                                    visited_edge_pairs.add(sorted_pair)\n\n                            # Only add unvisited nodes to the queue for further expansion\n                            if target_id not in visited_nodes:\n                                # Only add to queue if we're not at max depth yet\n                                if current_depth < max_depth:\n                                    # Add node to queue with incremented depth\n                                    # Edge is already added to result, so we pass None as edge\n                                    queue.append((target_node, None, current_depth + 1))\n                                else:\n                                    # At max depth, we've already added the edge but we don't add the node\n                                    # This prevents adding nodes beyond max_depth to the result\n                                    logger.debug(\n                                        f\"[{self.workspace}] Node {target_id} beyond max depth {max_depth}, edge added but node not included\"\n                                    )\n                            else:\n                                # If target node already exists in result, we don't need to add it again\n                                logger.debug(\n                                    f\"[{self.workspace}] Node {target_id} already visited, edge added but node not queued\"\n                                )\n                        else:\n                            logger.warning(\n                                f\"[{self.workspace}] Skipping edge {edge_id} due to missing entity_id on target node\"\n                            )\n\n        logger.info(\n            f\"[{self.workspace}] BFS subgraph query successful | Node count: {len(result.nodes)} | Edge count: {len(result.edges)}\"\n        )\n        return result\n\n    async def get_all_labels(self) -> list[str]:\n        \"\"\"\n        Get all existing entity_ids(entity names) in the database\n        Returns:\n            [\"Person\", \"Company\", ...]  # Alphabetically sorted label list\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            # Method 1: Direct metadata query (Available for Neo4j 4.3+)\n            # query = \"CALL db.labels() YIELD label RETURN label\"\n\n            # Method 2: Query compatible with older versions\n            query = f\"\"\"\n            MATCH (n:`{workspace_label}`)\n            WHERE n.entity_id IS NOT NULL\n            RETURN DISTINCT n.entity_id AS label\n            ORDER BY label\n            \"\"\"\n            result = await session.run(query)\n            labels = []\n            try:\n                async for record in result:\n                    labels.append(record[\"label\"])\n            finally:\n                await (\n                    result.consume()\n                )  # Ensure results are consumed even if processing fails\n            return labels\n\n    @retry(\n        stop=stop_after_attempt(3),\n        wait=wait_exponential(multiplier=1, min=4, max=10),\n        retry=retry_if_exception_type(\n            (\n                neo4jExceptions.ServiceUnavailable,\n                neo4jExceptions.TransientError,\n                neo4jExceptions.WriteServiceUnavailable,\n                neo4jExceptions.ClientError,\n                neo4jExceptions.SessionExpired,\n                ConnectionResetError,\n                OSError,\n            )\n        ),\n    )\n    async def delete_node(self, node_id: str) -> None:\n        \"\"\"Delete a node with the specified label\n\n        Args:\n            node_id: The label of the node to delete\n        \"\"\"\n\n        async def _do_delete(tx: AsyncManagedTransaction):\n            workspace_label = self._get_workspace_label()\n            query = f\"\"\"\n            MATCH (n:`{workspace_label}` {{entity_id: $entity_id}})\n            DETACH DELETE n\n            \"\"\"\n            result = await tx.run(query, entity_id=node_id)\n            logger.debug(f\"[{self.workspace}] Deleted node with label '{node_id}'\")\n            await result.consume()  # Ensure result is fully consumed\n\n        try:\n            async with self._driver.session(database=self._DATABASE) as session:\n                await session.execute_write(_do_delete)\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error during node deletion: {str(e)}\")\n            raise\n\n    @retry(\n        stop=stop_after_attempt(3),\n        wait=wait_exponential(multiplier=1, min=4, max=10),\n        retry=retry_if_exception_type(\n            (\n                neo4jExceptions.ServiceUnavailable,\n                neo4jExceptions.TransientError,\n                neo4jExceptions.WriteServiceUnavailable,\n                neo4jExceptions.ClientError,\n                neo4jExceptions.SessionExpired,\n                ConnectionResetError,\n                OSError,\n            )\n        ),\n    )\n    async def remove_nodes(self, nodes: list[str]):\n        \"\"\"Delete multiple nodes\n\n        Args:\n            nodes: List of node labels to be deleted\n        \"\"\"\n        for node in nodes:\n            await self.delete_node(node)\n\n    @retry(\n        stop=stop_after_attempt(3),\n        wait=wait_exponential(multiplier=1, min=4, max=10),\n        retry=retry_if_exception_type(\n            (\n                neo4jExceptions.ServiceUnavailable,\n                neo4jExceptions.TransientError,\n                neo4jExceptions.WriteServiceUnavailable,\n                neo4jExceptions.ClientError,\n                neo4jExceptions.SessionExpired,\n                ConnectionResetError,\n                OSError,\n            )\n        ),\n    )\n    async def remove_edges(self, edges: list[tuple[str, str]]):\n        \"\"\"Delete multiple edges\n\n        Args:\n            edges: List of edges to be deleted, each edge is a (source, target) tuple\n        \"\"\"\n        for source, target in edges:\n\n            async def _do_delete_edge(tx: AsyncManagedTransaction):\n                workspace_label = self._get_workspace_label()\n                query = f\"\"\"\n                MATCH (source:`{workspace_label}` {{entity_id: $source_entity_id}})-[r]-(target:`{workspace_label}` {{entity_id: $target_entity_id}})\n                DELETE r\n                \"\"\"\n                result = await tx.run(\n                    query, source_entity_id=source, target_entity_id=target\n                )\n                logger.debug(\n                    f\"[{self.workspace}] Deleted edge from '{source}' to '{target}'\"\n                )\n                await result.consume()  # Ensure result is fully consumed\n\n            try:\n                async with self._driver.session(database=self._DATABASE) as session:\n                    await session.execute_write(_do_delete_edge)\n            except Exception as e:\n                logger.error(f\"[{self.workspace}] Error during edge deletion: {str(e)}\")\n                raise\n\n    async def get_all_nodes(self) -> list[dict]:\n        \"\"\"Get all nodes in the graph.\n\n        Returns:\n            A list of all nodes, where each node is a dictionary of its properties\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            query = f\"\"\"\n            MATCH (n:`{workspace_label}`)\n            RETURN n\n            \"\"\"\n            result = await session.run(query)\n            nodes = []\n            async for record in result:\n                node = record[\"n\"]\n                node_dict = dict(node)\n                # Add node id (entity_id) to the dictionary for easier access\n                node_dict[\"id\"] = node_dict.get(\"entity_id\")\n                nodes.append(node_dict)\n            await result.consume()\n            return nodes\n\n    async def get_all_edges(self) -> list[dict]:\n        \"\"\"Get all edges in the graph.\n\n        Returns:\n            A list of all edges, where each edge is a dictionary of its properties\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            query = f\"\"\"\n            MATCH (a:`{workspace_label}`)-[r]-(b:`{workspace_label}`)\n            RETURN DISTINCT a.entity_id AS source, b.entity_id AS target, properties(r) AS properties\n            \"\"\"\n            result = await session.run(query)\n            edges = []\n            async for record in result:\n                edge_properties = record[\"properties\"]\n                edge_properties[\"source\"] = record[\"source\"]\n                edge_properties[\"target\"] = record[\"target\"]\n                edges.append(edge_properties)\n            await result.consume()\n            return edges\n\n    async def get_popular_labels(self, limit: int = 300) -> list[str]:\n        \"\"\"Get popular labels(entity names) by node degree (most connected entities)\n\n        Args:\n            limit: Maximum number of labels to return\n\n        Returns:\n            List of labels(entity names) sorted by degree (highest first)\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        async with self._driver.session(\n            database=self._DATABASE, default_access_mode=\"READ\"\n        ) as session:\n            result = None\n            try:\n                query = f\"\"\"\n                MATCH (n:`{workspace_label}`)\n                WHERE n.entity_id IS NOT NULL\n                OPTIONAL MATCH (n)-[r]-()\n                WITH n.entity_id AS label, count(r) AS degree\n                ORDER BY degree DESC, label ASC\n                LIMIT $limit\n                RETURN label\n                \"\"\"\n                result = await session.run(query, limit=limit)\n                labels = []\n                async for record in result:\n                    labels.append(record[\"label\"])\n                await result.consume()\n\n                logger.debug(\n                    f\"[{self.workspace}] Retrieved {len(labels)} popular labels (limit: {limit})\"\n                )\n                return labels\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Error getting popular labels: {str(e)}\"\n                )\n                if result is not None:\n                    await result.consume()\n                raise\n\n    async def search_labels(self, query: str, limit: int = 50) -> list[str]:\n        \"\"\"\n        Search labels(entity names) with fuzzy matching, using a full-text index for performance if available.\n        Enhanced with Chinese text support using CJK analyzer.\n        Falls back to a slower CONTAINS search if the index is not available or fails.\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        query_strip = query.strip()\n        if not query_strip:\n            return []\n\n        query_lower = query_strip.lower()\n        is_chinese = self._is_chinese_text(query_strip)\n        index_name = self._get_fulltext_index_name(workspace_label)\n\n        # Attempt to use the full-text index first\n        try:\n            async with self._driver.session(\n                database=self._DATABASE, default_access_mode=\"READ\"\n            ) as session:\n                if is_chinese:\n                    # For Chinese text, use different search strategies\n                    cypher_query = f\"\"\"\n                    CALL db.index.fulltext.queryNodes($index_name, $search_query) YIELD node, score\n                    WITH node, score\n                    WHERE node:`{workspace_label}`\n                    WITH node.entity_id AS label, score\n                    WITH label, score,\n                         CASE\n                             WHEN label = $query_strip THEN score + 1000\n                             WHEN label CONTAINS $query_strip THEN score + 500\n                             ELSE score\n                         END AS final_score\n                    RETURN label\n                    ORDER BY final_score DESC, label ASC\n                    LIMIT $limit\n                    \"\"\"\n                    # For Chinese, don't add wildcard as it may not work properly with CJK analyzer\n                    search_query = query_strip\n                else:\n                    # For non-Chinese text, use the original logic with wildcard\n                    cypher_query = f\"\"\"\n                    CALL db.index.fulltext.queryNodes($index_name, $search_query) YIELD node, score\n                    WITH node, score\n                    WHERE node:`{workspace_label}`\n                    WITH node.entity_id AS label, toLower(node.entity_id) AS label_lower, score\n                    WITH label, label_lower, score,\n                         CASE\n                             WHEN label_lower = $query_lower THEN score + 1000\n                             WHEN label_lower STARTS WITH $query_lower THEN score + 500\n                             WHEN label_lower CONTAINS ' ' + $query_lower OR label_lower CONTAINS '_' + $query_lower THEN score + 50\n                             ELSE score\n                         END AS final_score\n                    RETURN label\n                    ORDER BY final_score DESC, label ASC\n                    LIMIT $limit\n                    \"\"\"\n                    search_query = f\"{query_strip}*\"\n\n                result = await session.run(\n                    cypher_query,\n                    index_name=index_name,\n                    search_query=search_query,\n                    query_lower=query_lower,\n                    query_strip=query_strip,\n                    limit=limit,\n                )\n                labels = [record[\"label\"] async for record in result]\n                await result.consume()\n\n                logger.debug(\n                    f\"[{self.workspace}] Full-text search ({'Chinese' if is_chinese else 'Latin'}) for '{query}' returned {len(labels)} results (limit: {limit})\"\n                )\n                return labels\n\n        except Exception as e:\n            # If the full-text search fails, fall back to CONTAINS search\n            logger.warning(\n                f\"[{self.workspace}] Full-text search failed with error: {str(e)}. \"\n                \"Falling back to slower, non-indexed search.\"\n            )\n\n            # Enhanced fallback implementation\n            async with self._driver.session(\n                database=self._DATABASE, default_access_mode=\"READ\"\n            ) as session:\n                if is_chinese:\n                    # For Chinese text, use direct CONTAINS without case conversion\n                    cypher_query = f\"\"\"\n                    MATCH (n:`{workspace_label}`)\n                    WHERE n.entity_id IS NOT NULL\n                    WITH n.entity_id AS label\n                    WHERE label CONTAINS $query_strip\n                    WITH label,\n                         CASE\n                             WHEN label = $query_strip THEN 1000\n                             WHEN label STARTS WITH $query_strip THEN 500\n                             ELSE 100 - size(label)\n                         END AS score\n                    ORDER BY score DESC, label ASC\n                    LIMIT $limit\n                    RETURN label\n                    \"\"\"\n                    result = await session.run(\n                        cypher_query, query_strip=query_strip, limit=limit\n                    )\n                else:\n                    # For non-Chinese text, use the original fallback logic\n                    cypher_query = f\"\"\"\n                    MATCH (n:`{workspace_label}`)\n                    WHERE n.entity_id IS NOT NULL\n                    WITH n.entity_id AS label, toLower(n.entity_id) AS label_lower\n                    WHERE label_lower CONTAINS $query_lower\n                    WITH label, label_lower,\n                         CASE\n                             WHEN label_lower = $query_lower THEN 1000\n                             WHEN label_lower STARTS WITH $query_lower THEN 500\n                             ELSE 100 - size(label)\n                         END AS score\n                    ORDER BY score DESC, label ASC\n                    LIMIT $limit\n                    RETURN label\n                    \"\"\"\n                    result = await session.run(\n                        cypher_query, query_lower=query_lower, limit=limit\n                    )\n\n                labels = [record[\"label\"] async for record in result]\n                await result.consume()\n                logger.debug(\n                    f\"[{self.workspace}] Fallback search ({'Chinese' if is_chinese else 'Latin'}) for '{query}' returned {len(labels)} results (limit: {limit})\"\n                )\n                return labels\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop all data from current workspace storage and clean up resources\n\n        This method will delete all nodes and relationships in the current workspace only.\n\n        Returns:\n            dict[str, str]: Operation status and message\n            - On success: {\"status\": \"success\", \"message\": \"workspace data dropped\"}\n            - On failure: {\"status\": \"error\", \"message\": \"<error details>\"}\n        \"\"\"\n        workspace_label = self._get_workspace_label()\n        try:\n            async with self._driver.session(database=self._DATABASE) as session:\n                # Delete all nodes and relationships in current workspace only\n                query = f\"MATCH (n:`{workspace_label}`) DETACH DELETE n\"\n                result = await session.run(query)\n                await result.consume()  # Ensure result is fully consumed\n\n                # logger.debug(\n                #     f\"[{self.workspace}] Process {os.getpid()} drop Neo4j workspace '{workspace_label}' in database {self._DATABASE}\"\n                # )\n                return {\n                    \"status\": \"success\",\n                    \"message\": f\"workspace '{workspace_label}' data dropped\",\n                }\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error dropping Neo4j workspace '{workspace_label}' in database {self._DATABASE}: {e}\"\n            )\n            return {\"status\": \"error\", \"message\": str(e)}\n"
  },
  {
    "path": "lightrag/kg/networkx_impl.py",
    "content": "import os\nfrom collections import deque\nfrom dataclasses import dataclass\nfrom typing import final\n\nfrom lightrag.types import KnowledgeGraph, KnowledgeGraphNode, KnowledgeGraphEdge\nfrom lightrag.utils import logger\nfrom lightrag.base import BaseGraphStorage\nimport networkx as nx\nfrom .shared_storage import (\n    get_namespace_lock,\n    get_update_flag,\n    set_all_update_flags,\n)\n\nfrom dotenv import load_dotenv\n\n# use the .env that is inside the current folder\n# allows to use different .env file for each lightrag instance\n# the OS environment variables take precedence over the .env file\nload_dotenv(dotenv_path=\".env\", override=False)\n\n\n@final\n@dataclass\nclass NetworkXStorage(BaseGraphStorage):\n    @staticmethod\n    def load_nx_graph(file_name) -> nx.Graph:\n        if os.path.exists(file_name):\n            return nx.read_graphml(file_name)\n        return None\n\n    @staticmethod\n    def write_nx_graph(graph: nx.Graph, file_name, workspace=\"_\"):\n        logger.info(\n            f\"[{workspace}] Writing graph with {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges\"\n        )\n        nx.write_graphml(graph, file_name)\n\n    def __post_init__(self):\n        working_dir = self.global_config[\"working_dir\"]\n        if self.workspace:\n            # Include workspace in the file path for data isolation\n            workspace_dir = os.path.join(working_dir, self.workspace)\n        else:\n            # Default behavior when workspace is empty\n            workspace_dir = working_dir\n            self.workspace = \"\"\n\n        os.makedirs(workspace_dir, exist_ok=True)\n        self._graphml_xml_file = os.path.join(\n            workspace_dir, f\"graph_{self.namespace}.graphml\"\n        )\n        self._storage_lock = None\n        self.storage_updated = None\n        self._graph = None\n\n        # Load initial graph\n        preloaded_graph = NetworkXStorage.load_nx_graph(self._graphml_xml_file)\n        if preloaded_graph is not None:\n            logger.info(\n                f\"[{self.workspace}] Loaded graph from {self._graphml_xml_file} with {preloaded_graph.number_of_nodes()} nodes, {preloaded_graph.number_of_edges()} edges\"\n            )\n        else:\n            logger.info(\n                f\"[{self.workspace}] Created new empty graph file: {self._graphml_xml_file}\"\n            )\n        self._graph = preloaded_graph or nx.Graph()\n\n    async def initialize(self):\n        \"\"\"Initialize storage data\"\"\"\n        # Get the update flag for cross-process update notification\n        self.storage_updated = await get_update_flag(\n            self.namespace, workspace=self.workspace\n        )\n        # Get the storage lock for use in other methods\n        self._storage_lock = get_namespace_lock(\n            self.namespace, workspace=self.workspace\n        )\n\n    async def _get_graph(self):\n        \"\"\"Check if the storage should be reloaded\"\"\"\n        # Acquire lock to prevent concurrent read and write\n        async with self._storage_lock:\n            # Check if data needs to be reloaded\n            if self.storage_updated.value:\n                logger.info(\n                    f\"[{self.workspace}] Process {os.getpid()} reloading graph {self._graphml_xml_file} due to modifications by another process\"\n                )\n                # Reload data\n                self._graph = (\n                    NetworkXStorage.load_nx_graph(self._graphml_xml_file) or nx.Graph()\n                )\n                # Reset update flag\n                self.storage_updated.value = False\n\n            return self._graph\n\n    async def has_node(self, node_id: str) -> bool:\n        graph = await self._get_graph()\n        return graph.has_node(node_id)\n\n    async def has_edge(self, source_node_id: str, target_node_id: str) -> bool:\n        graph = await self._get_graph()\n        return graph.has_edge(source_node_id, target_node_id)\n\n    async def get_node(self, node_id: str) -> dict[str, str] | None:\n        graph = await self._get_graph()\n        return graph.nodes.get(node_id)\n\n    async def node_degree(self, node_id: str) -> int:\n        graph = await self._get_graph()\n        return graph.degree(node_id)\n\n    async def edge_degree(self, src_id: str, tgt_id: str) -> int:\n        graph = await self._get_graph()\n        src_degree = graph.degree(src_id) if graph.has_node(src_id) else 0\n        tgt_degree = graph.degree(tgt_id) if graph.has_node(tgt_id) else 0\n        return src_degree + tgt_degree\n\n    async def get_edge(\n        self, source_node_id: str, target_node_id: str\n    ) -> dict[str, str] | None:\n        graph = await self._get_graph()\n        return graph.edges.get((source_node_id, target_node_id))\n\n    async def get_node_edges(self, source_node_id: str) -> list[tuple[str, str]] | None:\n        graph = await self._get_graph()\n        if graph.has_node(source_node_id):\n            return list(graph.edges(source_node_id))\n        return None\n\n    async def upsert_node(self, node_id: str, node_data: dict[str, str]) -> None:\n        \"\"\"\n        Importance notes:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n        \"\"\"\n        graph = await self._get_graph()\n        graph.add_node(node_id, **node_data)\n\n    async def upsert_edge(\n        self, source_node_id: str, target_node_id: str, edge_data: dict[str, str]\n    ) -> None:\n        \"\"\"\n        Importance notes:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n        \"\"\"\n        graph = await self._get_graph()\n        graph.add_edge(source_node_id, target_node_id, **edge_data)\n\n    async def delete_node(self, node_id: str) -> None:\n        \"\"\"\n        Importance notes:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n        \"\"\"\n        graph = await self._get_graph()\n        if graph.has_node(node_id):\n            graph.remove_node(node_id)\n            logger.debug(f\"[{self.workspace}] Node {node_id} deleted from the graph\")\n        else:\n            logger.warning(\n                f\"[{self.workspace}] Node {node_id} not found in the graph for deletion\"\n            )\n\n    async def remove_nodes(self, nodes: list[str]):\n        \"\"\"Delete multiple nodes\n\n        Importance notes:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n\n        Args:\n            nodes: List of node IDs to be deleted\n        \"\"\"\n        graph = await self._get_graph()\n        for node in nodes:\n            if graph.has_node(node):\n                graph.remove_node(node)\n\n    async def remove_edges(self, edges: list[tuple[str, str]]):\n        \"\"\"Delete multiple edges\n\n        Importance notes:\n        1. Changes will be persisted to disk during the next index_done_callback\n        2. Only one process should updating the storage at a time before index_done_callback,\n           KG-storage-log should be used to avoid data corruption\n\n        Args:\n            edges: List of edges to be deleted, each edge is a (source, target) tuple\n        \"\"\"\n        graph = await self._get_graph()\n        for source, target in edges:\n            if graph.has_edge(source, target):\n                graph.remove_edge(source, target)\n\n    async def get_all_labels(self) -> list[str]:\n        \"\"\"\n        Get all node labels(entity names) in the graph\n        Returns:\n            [label1, label2, ...]  # Alphabetically sorted label list\n        \"\"\"\n        graph = await self._get_graph()\n        labels = set()\n        for node in graph.nodes():\n            labels.add(str(node))  # Add node id as a label\n\n        # Return sorted list\n        return sorted(list(labels))\n\n    async def get_popular_labels(self, limit: int = 300) -> list[str]:\n        \"\"\"\n        Get popular labels(entity names) by node degree (most connected entities)\n\n        Args:\n            limit: Maximum number of labels to return\n\n        Returns:\n            List of labels sorted by degree (highest first)\n        \"\"\"\n        graph = await self._get_graph()\n\n        # Get degrees of all nodes and sort by degree descending\n        degrees = dict(graph.degree())\n        sorted_nodes = sorted(degrees.items(), key=lambda x: x[1], reverse=True)\n\n        # Return top labels limited by the specified limit\n        popular_labels = [str(node) for node, _ in sorted_nodes[:limit]]\n\n        logger.debug(\n            f\"[{self.workspace}] Retrieved {len(popular_labels)} popular labels (limit: {limit})\"\n        )\n\n        return popular_labels\n\n    async def search_labels(self, query: str, limit: int = 50) -> list[str]:\n        \"\"\"\n        Search labels(entity names) with fuzzy matching\n\n        Args:\n            query: Search query string\n            limit: Maximum number of results to return\n\n        Returns:\n            List of matching labels sorted by relevance\n        \"\"\"\n        graph = await self._get_graph()\n        query_lower = query.lower().strip()\n\n        if not query_lower:\n            return []\n\n        # Collect matching nodes with relevance scores\n        matches = []\n        for node in graph.nodes():\n            node_str = str(node)\n            node_lower = node_str.lower()\n\n            # Skip if no match\n            if query_lower not in node_lower:\n                continue\n\n            # Calculate relevance score\n            # Exact match gets highest score\n            if node_lower == query_lower:\n                score = 1000\n            # Prefix match gets high score\n            elif node_lower.startswith(query_lower):\n                score = 500\n            # Contains match gets base score, with bonus for shorter strings\n            else:\n                # Shorter strings with matches are more relevant\n                score = 100 - len(node_str)\n                # Bonus for word boundary matches\n                if f\" {query_lower}\" in node_lower or f\"_{query_lower}\" in node_lower:\n                    score += 50\n\n            matches.append((node_str, score))\n\n        # Sort by relevance score (desc) then alphabetically\n        matches.sort(key=lambda x: (-x[1], x[0]))\n\n        # Return top matches limited by the specified limit\n        search_results = [match[0] for match in matches[:limit]]\n\n        logger.debug(\n            f\"[{self.workspace}] Search query '{query}' returned {len(search_results)} results (limit: {limit})\"\n        )\n\n        return search_results\n\n    async def get_knowledge_graph(\n        self,\n        node_label: str,\n        max_depth: int = 3,\n        max_nodes: int = None,\n    ) -> KnowledgeGraph:\n        \"\"\"\n        Retrieve a connected subgraph of nodes where the label includes the specified `node_label`.\n\n        Args:\n            node_label: Label of the starting node，* means all nodes\n            max_depth: Maximum depth of the subgraph, Defaults to 3\n            max_nodes: Maxiumu nodes to return by BFS, Defaults to 1000\n\n        Returns:\n            KnowledgeGraph object containing nodes and edges, with an is_truncated flag\n            indicating whether the graph was truncated due to max_nodes limit\n        \"\"\"\n        # Get max_nodes from global_config if not provided\n        if max_nodes is None:\n            max_nodes = self.global_config.get(\"max_graph_nodes\", 1000)\n        else:\n            # Limit max_nodes to not exceed global_config max_graph_nodes\n            max_nodes = min(max_nodes, self.global_config.get(\"max_graph_nodes\", 1000))\n\n        graph = await self._get_graph()\n\n        result = KnowledgeGraph()\n\n        # Handle special case for \"*\" label\n        if node_label == \"*\":\n            # Get degrees of all nodes\n            degrees = dict(graph.degree())\n            # Sort nodes by degree in descending order and take top max_nodes\n            sorted_nodes = sorted(degrees.items(), key=lambda x: x[1], reverse=True)\n\n            # Check if graph is truncated\n            if len(sorted_nodes) > max_nodes:\n                result.is_truncated = True\n                logger.info(\n                    f\"[{self.workspace}] Graph truncated: {len(sorted_nodes)} nodes found, limited to {max_nodes}\"\n                )\n\n            limited_nodes = [node for node, _ in sorted_nodes[:max_nodes]]\n            # Create subgraph with the highest degree nodes\n            subgraph = graph.subgraph(limited_nodes)\n        else:\n            # Check if node exists\n            if node_label not in graph:\n                logger.warning(\n                    f\"[{self.workspace}] Node {node_label} not found in the graph\"\n                )\n                return KnowledgeGraph()  # Return empty graph\n\n            # Use modified BFS to get nodes, prioritizing high-degree nodes at the same depth\n            bfs_nodes = []\n            visited = set()\n            # Store (node, depth, degree) in the queue\n            queue = deque([(node_label, 0, graph.degree(node_label))])\n\n            # Flag to track if there are unexplored neighbors due to depth limit\n            has_unexplored_neighbors = False\n\n            # Modified breadth-first search with degree-based prioritization\n            while queue and len(bfs_nodes) < max_nodes:\n                # Get the current depth from the first node in queue\n                current_depth = queue[0][1]\n\n                # Collect all nodes at the current depth\n                current_level_nodes = []\n                while queue and queue[0][1] == current_depth:\n                    current_level_nodes.append(queue.popleft())\n\n                # Sort nodes at current depth by degree (highest first)\n                current_level_nodes.sort(key=lambda x: x[2], reverse=True)\n\n                # Process all nodes at current depth in order of degree\n                for current_node, depth, degree in current_level_nodes:\n                    if current_node not in visited:\n                        visited.add(current_node)\n                        bfs_nodes.append(current_node)\n\n                        # Only explore neighbors if we haven't reached max_depth\n                        if depth < max_depth:\n                            # Add neighbor nodes to queue with incremented depth\n                            neighbors = list(graph.neighbors(current_node))\n                            # Filter out already visited neighbors\n                            unvisited_neighbors = [\n                                n for n in neighbors if n not in visited\n                            ]\n                            # Add neighbors to the queue with their degrees\n                            for neighbor in unvisited_neighbors:\n                                neighbor_degree = graph.degree(neighbor)\n                                queue.append((neighbor, depth + 1, neighbor_degree))\n                        else:\n                            # Check if there are unexplored neighbors (skipped due to depth limit)\n                            neighbors = list(graph.neighbors(current_node))\n                            unvisited_neighbors = [\n                                n for n in neighbors if n not in visited\n                            ]\n                            if unvisited_neighbors:\n                                has_unexplored_neighbors = True\n\n                    # Check if we've reached max_nodes\n                    if len(bfs_nodes) >= max_nodes:\n                        break\n\n            # Check if graph is truncated - either due to max_nodes limit or depth limit\n            if (queue and len(bfs_nodes) >= max_nodes) or has_unexplored_neighbors:\n                if len(bfs_nodes) >= max_nodes:\n                    result.is_truncated = True\n                    logger.info(\n                        f\"[{self.workspace}] Graph truncated: max_nodes limit {max_nodes} reached\"\n                    )\n                else:\n                    logger.info(\n                        f\"[{self.workspace}] Graph truncated: found {len(bfs_nodes)} nodes within max_depth {max_depth}\"\n                    )\n\n            # Create subgraph with BFS discovered nodes\n            subgraph = graph.subgraph(bfs_nodes)\n\n        # Add nodes to result\n        seen_nodes = set()\n        seen_edges = set()\n        for node in subgraph.nodes():\n            if str(node) in seen_nodes:\n                continue\n\n            node_data = dict(subgraph.nodes[node])\n            # Get entity_type as labels\n            labels = []\n            if \"entity_type\" in node_data:\n                if isinstance(node_data[\"entity_type\"], list):\n                    labels.extend(node_data[\"entity_type\"])\n                else:\n                    labels.append(node_data[\"entity_type\"])\n\n            # Create node with properties\n            node_properties = {k: v for k, v in node_data.items()}\n\n            result.nodes.append(\n                KnowledgeGraphNode(\n                    id=str(node), labels=[str(node)], properties=node_properties\n                )\n            )\n            seen_nodes.add(str(node))\n\n        # Add edges to result\n        for edge in subgraph.edges():\n            source, target = edge\n            # Esure unique edge_id for undirect graph\n            if str(source) > str(target):\n                source, target = target, source\n            edge_id = f\"{source}-{target}\"\n            if edge_id in seen_edges:\n                continue\n\n            edge_data = dict(subgraph.edges[edge])\n\n            # Create edge with complete information\n            result.edges.append(\n                KnowledgeGraphEdge(\n                    id=edge_id,\n                    type=\"DIRECTED\",\n                    source=str(source),\n                    target=str(target),\n                    properties=edge_data,\n                )\n            )\n            seen_edges.add(edge_id)\n\n        logger.info(\n            f\"[{self.workspace}] Subgraph query successful | Node count: {len(result.nodes)} | Edge count: {len(result.edges)}\"\n        )\n        return result\n\n    async def get_all_nodes(self) -> list[dict]:\n        \"\"\"Get all nodes in the graph.\n\n        Returns:\n            A list of all nodes, where each node is a dictionary of its properties\n        \"\"\"\n        graph = await self._get_graph()\n        all_nodes = []\n        for node_id, node_data in graph.nodes(data=True):\n            node_data_with_id = node_data.copy()\n            node_data_with_id[\"id\"] = node_id\n            all_nodes.append(node_data_with_id)\n        return all_nodes\n\n    async def get_all_edges(self) -> list[dict]:\n        \"\"\"Get all edges in the graph.\n\n        Returns:\n            A list of all edges, where each edge is a dictionary of its properties\n        \"\"\"\n        graph = await self._get_graph()\n        all_edges = []\n        for u, v, edge_data in graph.edges(data=True):\n            edge_data_with_nodes = edge_data.copy()\n            edge_data_with_nodes[\"source\"] = u\n            edge_data_with_nodes[\"target\"] = v\n            all_edges.append(edge_data_with_nodes)\n        return all_edges\n\n    async def index_done_callback(self) -> bool:\n        \"\"\"Save data to disk\"\"\"\n        async with self._storage_lock:\n            # Check if storage was updated by another process\n            if self.storage_updated.value:\n                # Storage was updated by another process, reload data instead of saving\n                logger.info(\n                    f\"[{self.workspace}] Graph was updated by another process, reloading...\"\n                )\n                self._graph = (\n                    NetworkXStorage.load_nx_graph(self._graphml_xml_file) or nx.Graph()\n                )\n                # Reset update flag\n                self.storage_updated.value = False\n                return False  # Return error\n\n        # Acquire lock and perform persistence\n        async with self._storage_lock:\n            try:\n                # Save data to disk\n                NetworkXStorage.write_nx_graph(\n                    self._graph, self._graphml_xml_file, self.workspace\n                )\n                # Notify other processes that data has been updated\n                await set_all_update_flags(self.namespace, workspace=self.workspace)\n                # Reset own update flag to avoid self-reloading\n                self.storage_updated.value = False\n                return True  # Return success\n            except Exception as e:\n                logger.error(f\"[{self.workspace}] Error saving graph: {e}\")\n                return False  # Return error\n\n        return True\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop all graph data from storage and clean up resources\n\n        This method will:\n        1. Remove the graph storage file if it exists\n        2. Reset the graph to an empty state\n        3. Update flags to notify other processes\n        4. Changes is persisted to disk immediately\n\n        Returns:\n            dict[str, str]: Operation status and message\n            - On success: {\"status\": \"success\", \"message\": \"data dropped\"}\n            - On failure: {\"status\": \"error\", \"message\": \"<error details>\"}\n        \"\"\"\n        try:\n            async with self._storage_lock:\n                # delete _client_file_name\n                if os.path.exists(self._graphml_xml_file):\n                    os.remove(self._graphml_xml_file)\n                self._graph = nx.Graph()\n                # Notify other processes that data has been updated\n                await set_all_update_flags(self.namespace, workspace=self.workspace)\n                # Reset own update flag to avoid self-reloading\n                self.storage_updated.value = False\n                logger.info(\n                    f\"[{self.workspace}] Process {os.getpid()} drop graph file:{self._graphml_xml_file}\"\n                )\n            return {\"status\": \"success\", \"message\": \"data dropped\"}\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error dropping graph file:{self._graphml_xml_file}: {e}\"\n            )\n            return {\"status\": \"error\", \"message\": str(e)}\n"
  },
  {
    "path": "lightrag/kg/opensearch_impl.py",
    "content": "\"\"\"\nOpenSearch Storage Implementation for LightRAG\n\nThis module provides OpenSearch-based storage backends for LightRAG,\nincluding KV storage, document status storage, graph storage, and vector storage.\n\nRequirements:\n    - opensearch-py >= 3.0.0\n    - OpenSearch 3.x or higher with k-NN plugin enabled\n\"\"\"\n\nimport os\nimport re\nimport ssl as ssl_module\nimport time\nimport asyncio\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Union, final\nimport numpy as np\nimport configparser\n\nfrom ..base import (\n    BaseGraphStorage,\n    BaseKVStorage,\n    BaseVectorStorage,\n    DocProcessingStatus,\n    DocStatus,\n    DocStatusStorage,\n)\nfrom ..utils import logger, compute_mdhash_id\nfrom ..types import KnowledgeGraph, KnowledgeGraphNode, KnowledgeGraphEdge\nfrom ..constants import GRAPH_FIELD_SEP\nfrom ..kg.shared_storage import get_data_init_lock\n\nimport pipmaster as pm\n\nif not pm.is_installed(\"opensearch-py\"):\n    pm.install(\"opensearch-py\")\n\nfrom opensearchpy import AsyncOpenSearch, helpers  # type: ignore\nfrom opensearchpy.exceptions import OpenSearchException, NotFoundError, RequestError  # type: ignore\n\nconfig = configparser.ConfigParser()\nconfig.read(\"config.ini\", \"utf-8\")\n\n\ndef _get_opensearch_env(key, fallback):\n    cfg_key = key.replace(\"OPENSEARCH_\", \"\").lower()\n    return os.environ.get(key, config.get(\"opensearch\", cfg_key, fallback=fallback))\n\n\ndef _sanitize_index_name(name: str) -> str:\n    \"\"\"Sanitize a string to be a valid OpenSearch index name.\"\"\"\n    sanitized = re.sub(r\"[^a-z0-9_-]\", \"_\", name.lower())\n    if sanitized and sanitized[0] in \"-_+\":\n        sanitized = \"x\" + sanitized\n    return sanitized\n\n\nclass ClientManager:\n    \"\"\"Singleton manager for OpenSearch client connections.\"\"\"\n\n    _instances = {\"client\": None, \"ref_count\": 0}\n    _lock = asyncio.Lock()\n\n    @classmethod\n    async def get_client(cls) -> AsyncOpenSearch:\n        \"\"\"Get or create a shared AsyncOpenSearch client with reference counting.\"\"\"\n        async with cls._lock:\n            if cls._instances[\"client\"] is None:\n                hosts_str = _get_opensearch_env(\"OPENSEARCH_HOSTS\", \"localhost:9200\")\n                hosts = [h.strip() for h in hosts_str.split(\",\") if h.strip()]\n                username = _get_opensearch_env(\"OPENSEARCH_USER\", \"admin\")\n                password = _get_opensearch_env(\"OPENSEARCH_PASSWORD\", \"admin\")\n                use_ssl = _get_opensearch_env(\"OPENSEARCH_USE_SSL\", \"true\").lower() in (\n                    \"true\",\n                    \"1\",\n                    \"yes\",\n                )\n                verify_certs = _get_opensearch_env(\n                    \"OPENSEARCH_VERIFY_CERTS\", \"false\"\n                ).lower() in (\"true\", \"1\", \"yes\")\n                timeout = int(_get_opensearch_env(\"OPENSEARCH_TIMEOUT\", \"30\"))\n                max_retries = int(_get_opensearch_env(\"OPENSEARCH_MAX_RETRIES\", \"3\"))\n\n                ssl_context = None\n                if use_ssl and not verify_certs:\n                    ssl_context = ssl_module.create_default_context()\n                    ssl_context.check_hostname = False\n                    ssl_context.verify_mode = ssl_module.CERT_NONE\n\n                client = AsyncOpenSearch(\n                    hosts=hosts,\n                    http_auth=(username, password) if username else None,\n                    use_ssl=use_ssl,\n                    verify_certs=verify_certs,\n                    ssl_context=ssl_context,\n                    ssl_show_warn=False,\n                    timeout=timeout,\n                    max_retries=max_retries,\n                    retry_on_timeout=True,\n                )\n                cls._instances[\"client\"] = client\n                cls._instances[\"ref_count\"] = 0\n                logger.info(f\"OpenSearch client connected to {hosts}\")\n\n            cls._instances[\"ref_count\"] += 1\n            return cls._instances[\"client\"]\n\n    @classmethod\n    async def release_client(cls, client: AsyncOpenSearch):\n        \"\"\"Release a client reference. Closes the connection when ref count reaches 0.\"\"\"\n        async with cls._lock:\n            if client is not None and client is cls._instances[\"client\"]:\n                cls._instances[\"ref_count\"] -= 1\n                if cls._instances[\"ref_count\"] <= 0:\n                    try:\n                        await cls._instances[\"client\"].close()\n                    except Exception:\n                        pass\n                    cls._instances[\"client\"] = None\n                    cls._instances[\"ref_count\"] = 0\n                    logger.info(\"OpenSearch client connection closed\")\n\n\ndef _resolve_workspace(workspace: str, namespace: str):\n    \"\"\"Resolve effective workspace from env or parameter.\"\"\"\n    opensearch_workspace = os.environ.get(\"OPENSEARCH_WORKSPACE\")\n    if opensearch_workspace and opensearch_workspace.strip():\n        effective = opensearch_workspace.strip()\n        logger.info(\n            f\"Using OPENSEARCH_WORKSPACE: '{effective}' (overriding '{workspace}/{namespace}')\"\n        )\n        return effective\n    return workspace\n\n\ndef _build_index_name(workspace: str, namespace: str) -> tuple[str, str, str]:\n    \"\"\"Build index name and return (effective_workspace, final_namespace, index_name).\"\"\"\n    effective = _resolve_workspace(workspace, namespace)\n    if effective:\n        final_ns = f\"{effective}_{namespace}\"\n    else:\n        final_ns = namespace\n        effective = \"\"\n    index_name = _sanitize_index_name(final_ns)\n    return effective, final_ns, index_name\n\n\nasync def _mget_optional_doc(\n    client: AsyncOpenSearch, index_name: str, doc_id: str\n) -> dict[str, Any] | None:\n    \"\"\"Fetch a single document via mget and return None when it is absent.\"\"\"\n    response = await client.mget(index=index_name, body={\"ids\": [doc_id]})\n    docs = response.get(\"docs\", [])\n    if not docs:\n        return None\n    doc = docs[0]\n    if not doc.get(\"found\"):\n        return None\n    return doc\n\n\ndef _is_missing_index_error(exc: Exception) -> bool:\n    \"\"\"Return True when an OpenSearch exception means the target index is missing.\"\"\"\n    return \"index_not_found_exception\" in str(exc)\n\n\n@final\n@dataclass\nclass OpenSearchKVStorage(BaseKVStorage):\n    \"\"\"Key-Value storage using OpenSearch. Uses dynamic mapping to support varied schemas.\"\"\"\n\n    client: AsyncOpenSearch = field(default=None)\n    _index_name: str = field(default=\"\", init=False)\n    _index_ready: bool = field(default=False, init=False)\n\n    def __init__(self, namespace, global_config, embedding_func, workspace=None):\n        super().__init__(\n            namespace=namespace,\n            workspace=workspace or \"\",\n            global_config=global_config,\n            embedding_func=embedding_func,\n        )\n        self.__post_init__()\n\n    def __post_init__(self):\n        self.workspace, self.final_namespace, self._index_name = _build_index_name(\n            self.workspace, self.namespace\n        )\n\n    async def initialize(self):\n        \"\"\"Initialize client connection and create index if needed.\"\"\"\n        async with get_data_init_lock():\n            if self.client is None:\n                self.client = await ClientManager.get_client()\n            await self._create_index_if_not_exists()\n            self._index_ready = True\n            logger.debug(\n                f\"[{self.workspace}] OpenSearch KV storage initialized: {self._index_name}\"\n            )\n\n    async def _ensure_index_ready(self):\n        \"\"\"Recreate the KV index after drop before the next write.\"\"\"\n        if self._index_ready:\n            return\n        async with get_data_init_lock():\n            if self.client is None:\n                self.client = await ClientManager.get_client()\n            if not self._index_ready:\n                await self._create_index_if_not_exists()\n                self._index_ready = True\n\n    def _mark_index_missing(self):\n        \"\"\"Mark the KV index as unavailable for subsequent read short-circuiting.\"\"\"\n        self._index_ready = False\n\n    async def _create_index_if_not_exists(self):\n        try:\n            if not await self.client.indices.exists(index=self._index_name):\n                # Use dynamic mapping so any namespace schema works\n                body = {\n                    \"mappings\": {\"dynamic\": True},\n                    \"settings\": {\n                        \"index\": {\"number_of_shards\": 1, \"number_of_replicas\": 0},\n                    },\n                }\n                await self.client.indices.create(index=self._index_name, body=body)\n                logger.info(f\"[{self.workspace}] Created index: {self._index_name}\")\n        except RequestError as e:\n            if \"resource_already_exists_exception\" not in str(e):\n                raise\n        except OpenSearchException as e:\n            logger.error(f\"[{self.workspace}] Error creating index: {e}\")\n            raise\n\n    async def finalize(self):\n        \"\"\"Release the OpenSearch client connection.\"\"\"\n        if self.client is not None:\n            await ClientManager.release_client(self.client)\n            self.client = None\n\n    async def _iter_raw_docs(\n        self, batch_size: int = 1000\n    ) -> AsyncIterator[list[dict[str, Any]]]:\n        \"\"\"Yield raw OpenSearch hits using PIT + search_after pagination.\"\"\"\n        if not self._index_ready:\n            return\n\n        try:\n            pit = await self.client.create_pit(\n                index=self._index_name, params={\"keep_alive\": \"1m\"}\n            )\n            pit_id = pit[\"pit_id\"]\n            try:\n                search_after = None\n                while True:\n                    body = {\n                        \"query\": {\"match_all\": {}},\n                        \"size\": batch_size,\n                        \"pit\": {\"id\": pit_id, \"keep_alive\": \"1m\"},\n                        \"sort\": [{\"_shard_doc\": \"asc\"}],\n                    }\n                    if search_after:\n                        body[\"search_after\"] = search_after\n\n                    response = await self.client.search(body=body)\n                    hits = response[\"hits\"][\"hits\"]\n                    if not hits:\n                        break\n\n                    yield hits\n\n                    search_after = hits[-1][\"sort\"]\n                    if len(hits) < batch_size:\n                        break\n            finally:\n                try:\n                    await self.client.delete_pit(body={\"pit_id\": [pit_id]})\n                except Exception:\n                    pass\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return\n            logger.error(f\"[{self.workspace}] Error scanning documents: {e}\")\n            raise\n\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        \"\"\"Get a document by its ID, or None if not found.\"\"\"\n        if not self._index_ready:\n            return None\n        try:\n            response = await _mget_optional_doc(self.client, self._index_name, id)\n            if response is None:\n                return None\n            doc = response[\"_source\"]\n            doc[\"_id\"] = response[\"_id\"]\n            doc.setdefault(\"create_time\", 0)\n            doc.setdefault(\"update_time\", 0)\n            return doc\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return None\n            logger.error(f\"[{self.workspace}] Error getting document {id}: {e}\")\n            return None\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        \"\"\"Get multiple documents by IDs, preserving input order.\"\"\"\n        if not self._index_ready:\n            return [None] * len(ids)\n        try:\n            response = await self.client.mget(index=self._index_name, body={\"ids\": ids})\n            doc_map = {}\n            for doc in response[\"docs\"]:\n                if doc.get(\"found\"):\n                    data = doc[\"_source\"]\n                    data[\"_id\"] = doc[\"_id\"]\n                    data.setdefault(\"create_time\", 0)\n                    data.setdefault(\"update_time\", 0)\n                    doc_map[doc[\"_id\"]] = data\n            return [doc_map.get(id) for id in ids]\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return [None] * len(ids)\n            logger.error(f\"[{self.workspace}] Error getting documents: {e}\")\n            return [None] * len(ids)\n\n    async def filter_keys(self, keys: set[str]) -> set[str]:\n        \"\"\"Return the subset of keys that do not exist in storage.\"\"\"\n        if not self._index_ready:\n            return keys\n        try:\n            response = await self.client.mget(\n                index=self._index_name, body={\"ids\": list(keys)}, _source=False\n            )\n            existing_ids = {doc[\"_id\"] for doc in response[\"docs\"] if doc.get(\"found\")}\n            return keys - existing_ids\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return keys\n            logger.error(f\"[{self.workspace}] Error filtering keys: {e}\")\n            return keys\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        \"\"\"Insert or update documents with automatic timestamping.\"\"\"\n        if not data:\n            return\n        await self._ensure_index_ready()\n        logger.debug(\n            f\"[{self.workspace}] Upserting {len(data)} documents to {self.namespace}\"\n        )\n        current_time = int(time.time())\n        actions = []\n        for doc_id, doc_data in data.items():\n            doc_data[\"update_time\"] = current_time\n            doc_data.setdefault(\"create_time\", current_time)\n            actions.append(\n                {\n                    \"_op_type\": \"index\",\n                    \"_index\": self._index_name,\n                    \"_id\": doc_id,\n                    \"_source\": {k: v for k, v in doc_data.items() if k != \"_id\"},\n                }\n            )\n        try:\n            # No per-operation refresh: immediate reads use ID-based mget (translog),\n            # search visibility is guaranteed after index_done_callback() batch refresh.\n            success, failed = await helpers.async_bulk(\n                self.client, actions, raise_on_error=False\n            )\n            if failed:\n                logger.warning(\n                    f\"[{self.workspace}] {len(failed)} documents failed to upsert\"\n                )\n        except OpenSearchException as e:\n            logger.error(f\"[{self.workspace}] Error upserting documents: {e}\")\n            raise\n\n    async def index_done_callback(self) -> None:\n        \"\"\"Refresh index to make recently indexed documents searchable.\"\"\"\n        if not self._index_ready:\n            return\n        try:\n            await self.client.indices.refresh(index=self._index_name)\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return\n        except Exception:\n            pass\n\n    async def is_empty(self) -> bool:\n        \"\"\"Return True if the index contains no documents.\"\"\"\n        if not self._index_ready:\n            return True\n        try:\n            response = await self.client.count(index=self._index_name)\n            return response[\"count\"] == 0\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n            return True\n\n    async def delete(self, ids: list[str]) -> None:\n        \"\"\"Delete documents by their IDs.\"\"\"\n        if not ids:\n            return\n        if not self._index_ready:\n            return\n        if isinstance(ids, set):\n            ids = list(ids)\n        try:\n            # No per-operation refresh: immediate reads use ID-based mget (translog),\n            # search visibility is guaranteed after index_done_callback() batch refresh.\n            actions = [\n                {\"_op_type\": \"delete\", \"_index\": self._index_name, \"_id\": doc_id}\n                for doc_id in ids\n            ]\n            success, _ = await helpers.async_bulk(\n                self.client, actions, raise_on_error=False\n            )\n            logger.info(\n                f\"[{self.workspace}] Deleted {success} documents from {self.namespace}\"\n            )\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return\n            logger.error(f\"[{self.workspace}] Error deleting documents: {e}\")\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Delete the entire index.\"\"\"\n        try:\n            try:\n                await self.client.indices.delete(index=self._index_name)\n                logger.info(f\"[{self.workspace}] Dropped index: {self._index_name}\")\n            except NotFoundError:\n                logger.info(\n                    f\"[{self.workspace}] Index already missing during drop: {self._index_name}\"\n                )\n            self._mark_index_missing()\n            return {\"status\": \"success\", \"message\": f\"Index {self._index_name} dropped\"}\n        except OpenSearchException as e:\n            self._mark_index_missing()\n            logger.error(f\"[{self.workspace}] Error dropping index: {e}\")\n            return {\"status\": \"error\", \"message\": str(e)}\n        except Exception as e:\n            self._mark_index_missing()\n            logger.error(f\"[{self.workspace}] Unexpected error dropping index: {e}\")\n            return {\"status\": \"error\", \"message\": str(e)}\n\n\n@final\n@dataclass\nclass OpenSearchDocStatusStorage(DocStatusStorage):\n    \"\"\"Document status storage using OpenSearch.\"\"\"\n\n    client: AsyncOpenSearch = field(default=None)\n    _index_name: str = field(default=\"\", init=False)\n    _index_ready: bool = field(default=False, init=False)\n\n    def __init__(self, namespace, global_config, embedding_func, workspace=None):\n        super().__init__(\n            namespace=namespace,\n            workspace=workspace or \"\",\n            global_config=global_config,\n            embedding_func=embedding_func,\n        )\n        self.__post_init__()\n\n    def __post_init__(self):\n        self.workspace, self.final_namespace, self._index_name = _build_index_name(\n            self.workspace, self.namespace\n        )\n\n    def _prepare_doc_status_data(self, doc: dict[str, Any]) -> dict[str, Any]:\n        \"\"\"Normalize a raw OpenSearch document to DocProcessingStatus-compatible dict.\"\"\"\n        data = doc.copy()\n        data.pop(\"_id\", None)\n        if \"file_path\" not in data:\n            data[\"file_path\"] = \"no-file-path\"\n        data.setdefault(\"metadata\", {})\n        data.setdefault(\"error_msg\", None)\n        if \"error\" in data:\n            if not data.get(\"error_msg\"):\n                data[\"error_msg\"] = data.pop(\"error\")\n            else:\n                data.pop(\"error\", None)\n        return data\n\n    async def initialize(self):\n        \"\"\"Initialize client connection and create doc status index.\"\"\"\n        async with get_data_init_lock():\n            if self.client is None:\n                self.client = await ClientManager.get_client()\n            await self._create_index_if_not_exists()\n            self._index_ready = True\n            logger.debug(\n                f\"[{self.workspace}] OpenSearch DocStatus storage initialized: {self._index_name}\"\n            )\n\n    async def _ensure_index_ready(self):\n        \"\"\"Recreate the doc status index after drop before the next write.\"\"\"\n        if self._index_ready:\n            return\n        async with get_data_init_lock():\n            if self.client is None:\n                self.client = await ClientManager.get_client()\n            if not self._index_ready:\n                await self._create_index_if_not_exists()\n                self._index_ready = True\n\n    def _mark_index_missing(self):\n        \"\"\"Mark the doc status index as unavailable for subsequent read short-circuiting.\"\"\"\n        self._index_ready = False\n\n    async def _create_index_if_not_exists(self):\n        try:\n            if not await self.client.indices.exists(index=self._index_name):\n                body = {\n                    \"mappings\": {\n                        \"dynamic\": True,\n                        \"properties\": {\n                            \"status\": {\"type\": \"keyword\"},\n                            \"file_path\": {\"type\": \"keyword\"},\n                            \"track_id\": {\"type\": \"keyword\"},\n                            \"created_at\": {\"type\": \"date\"},\n                            \"updated_at\": {\"type\": \"date\"},\n                        },\n                    },\n                    \"settings\": {\n                        \"index\": {\"number_of_shards\": 1, \"number_of_replicas\": 0},\n                    },\n                }\n                await self.client.indices.create(index=self._index_name, body=body)\n                logger.info(\n                    f\"[{self.workspace}] Created doc status index: {self._index_name}\"\n                )\n        except RequestError as e:\n            if \"resource_already_exists_exception\" not in str(e):\n                raise\n        except OpenSearchException as e:\n            logger.error(f\"[{self.workspace}] Error creating doc status index: {e}\")\n            raise\n\n    async def finalize(self):\n        \"\"\"Release the OpenSearch client connection.\"\"\"\n        if self.client is not None:\n            await ClientManager.release_client(self.client)\n            self.client = None\n\n    async def get_by_id(self, id: str) -> Union[dict[str, Any], None]:\n        \"\"\"Get a document status record by ID.\"\"\"\n        if not self._index_ready:\n            return None\n        try:\n            response = await _mget_optional_doc(self.client, self._index_name, id)\n            if response is None:\n                return None\n            doc = response[\"_source\"]\n            doc[\"_id\"] = response[\"_id\"]\n            return doc\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return None\n            logger.error(f\"[{self.workspace}] Error getting doc status {id}: {e}\")\n            return None\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        \"\"\"Get multiple document status records by IDs.\"\"\"\n        if not self._index_ready:\n            return [None] * len(ids)\n        try:\n            response = await self.client.mget(index=self._index_name, body={\"ids\": ids})\n            doc_map = {}\n            for doc in response[\"docs\"]:\n                if doc.get(\"found\"):\n                    data = doc[\"_source\"]\n                    data[\"_id\"] = doc[\"_id\"]\n                    doc_map[doc[\"_id\"]] = data\n            return [doc_map.get(id) for id in ids]\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return [None] * len(ids)\n            logger.error(f\"[{self.workspace}] Error getting doc statuses: {e}\")\n            return [None] * len(ids)\n\n    async def filter_keys(self, keys: set[str]) -> set[str]:\n        \"\"\"Return the subset of keys that do not exist in storage.\"\"\"\n        if not self._index_ready:\n            return keys\n        try:\n            response = await self.client.mget(\n                index=self._index_name, body={\"ids\": list(keys)}, _source=False\n            )\n            existing_ids = {doc[\"_id\"] for doc in response[\"docs\"] if doc.get(\"found\")}\n            return keys - existing_ids\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return keys\n            logger.error(f\"[{self.workspace}] Error filtering keys: {e}\")\n            return keys\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        \"\"\"Insert or update document status records.\"\"\"\n        if not data:\n            return\n        await self._ensure_index_ready()\n        logger.debug(f\"[{self.workspace}] Upserting {len(data)} doc statuses\")\n        actions = []\n        for k, v in data.items():\n            v.setdefault(\"chunks_list\", [])\n            actions.append(\n                {\n                    \"_op_type\": \"index\",\n                    \"_index\": self._index_name,\n                    \"_id\": k,\n                    \"_source\": {fk: fv for fk, fv in v.items() if fk != \"_id\"},\n                }\n            )\n        try:\n            # DocStatus needs refresh=\"wait_for\" because get_docs_by_status\n            # (search-based) is called immediately after enqueue upserts.\n            await helpers.async_bulk(\n                self.client, actions, raise_on_error=False, refresh=\"wait_for\"\n            )\n        except OpenSearchException as e:\n            logger.error(f\"[{self.workspace}] Error upserting doc statuses: {e}\")\n\n    async def get_status_counts(self) -> dict[str, int]:\n        \"\"\"Get document counts grouped by status.\"\"\"\n        if not self._index_ready:\n            return {}\n        try:\n            body = {\n                \"size\": 0,\n                \"aggs\": {\"status_counts\": {\"terms\": {\"field\": \"status\", \"size\": 100}}},\n            }\n            response = await self.client.search(index=self._index_name, body=body)\n            return {\n                bucket[\"key\"]: bucket[\"doc_count\"]\n                for bucket in response[\"aggregations\"][\"status_counts\"][\"buckets\"]\n            }\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return {}\n            logger.error(f\"[{self.workspace}] Error getting status counts: {e}\")\n            return {}\n\n    async def _search_all_docs(self, query: dict) -> dict[str, DocProcessingStatus]:\n        \"\"\"Fetch all documents matching a query using PIT + search_after.\"\"\"\n        if not self._index_ready:\n            return {}\n        result = {}\n        batch_size = 10000\n        try:\n            pit = await self.client.create_pit(\n                index=self._index_name, params={\"keep_alive\": \"1m\"}\n            )\n            pit_id = pit[\"pit_id\"]\n            try:\n                search_after = None\n                while True:\n                    body = {\n                        \"query\": query,\n                        \"size\": batch_size,\n                        \"pit\": {\"id\": pit_id, \"keep_alive\": \"1m\"},\n                        \"sort\": [{\"_shard_doc\": \"asc\"}],\n                    }\n                    if search_after:\n                        body[\"search_after\"] = search_after\n                    response = await self.client.search(body=body)\n                    hits = response[\"hits\"][\"hits\"]\n                    if not hits:\n                        break\n                    for hit in hits:\n                        try:\n                            data = self._prepare_doc_status_data(hit[\"_source\"])\n                            result[hit[\"_id\"]] = DocProcessingStatus(**data)\n                        except (KeyError, TypeError) as e:\n                            logger.error(\n                                f\"[{self.workspace}] Error parsing doc {hit['_id']}: {e}\"\n                            )\n                    search_after = hits[-1][\"sort\"]\n                    if len(hits) < batch_size:\n                        break\n            finally:\n                try:\n                    await self.client.delete_pit(body={\"pit_id\": [pit_id]})\n                except Exception:\n                    pass\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return {}\n            logger.error(f\"[{self.workspace}] Error fetching docs: {e}\")\n        return result\n\n    async def get_docs_by_status(\n        self, status: DocStatus\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Get all documents matching a specific processing status.\"\"\"\n        return await self._search_all_docs({\"term\": {\"status\": status.value}})\n\n    async def get_docs_by_track_id(\n        self, track_id: str\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Get all documents matching a specific track ID.\"\"\"\n        return await self._search_all_docs({\"term\": {\"track_id\": track_id}})\n\n    async def get_docs_paginated(\n        self,\n        status_filter: DocStatus | None = None,\n        page: int = 1,\n        page_size: int = 50,\n        sort_field: str = \"updated_at\",\n        sort_direction: str = \"desc\",\n    ) -> tuple[list[tuple[str, DocProcessingStatus]], int]:\n        \"\"\"Get documents with pagination using PIT + search_after.\"\"\"\n        if not self._index_ready:\n            return [], 0\n        page = max(1, page)\n        page_size = max(10, min(200, page_size))\n        if sort_field == \"id\":\n            sort_field = \"_id\"\n        if sort_field not in (\"created_at\", \"updated_at\", \"_id\", \"file_path\"):\n            sort_field = \"updated_at\"\n        sort_order = \"asc\" if sort_direction.lower() == \"asc\" else \"desc\"\n\n        query = {\"match_all\": {}}\n        if status_filter is not None:\n            query = {\"term\": {\"status\": status_filter.value}}\n\n        skip_count = (page - 1) * page_size\n\n        try:\n            count_resp = await self.client.count(\n                index=self._index_name, body={\"query\": query}\n            )\n            total_count = count_resp.get(\"count\", 0)\n            if total_count == 0 or skip_count >= total_count:\n                return [], total_count\n\n            sort_clause = [{sort_field: {\"order\": sort_order}}, {\"_shard_doc\": \"asc\"}]\n\n            pit = await self.client.create_pit(\n                index=self._index_name, params={\"keep_alive\": \"1m\"}\n            )\n            pit_id = pit[\"pit_id\"]\n            try:\n                search_after = None\n                skipped = 0\n                while skipped < skip_count:\n                    batch = min(page_size, skip_count - skipped)\n                    body = {\n                        \"query\": query,\n                        \"sort\": sort_clause,\n                        \"size\": batch,\n                        \"pit\": {\"id\": pit_id, \"keep_alive\": \"1m\"},\n                    }\n                    if search_after:\n                        body[\"search_after\"] = search_after\n                    resp = await self.client.search(body=body)\n                    hits = resp[\"hits\"][\"hits\"]\n                    if not hits:\n                        return [], total_count\n                    search_after = hits[-1][\"sort\"]\n                    skipped += len(hits)\n\n                body = {\n                    \"query\": query,\n                    \"sort\": sort_clause,\n                    \"size\": page_size,\n                    \"pit\": {\"id\": pit_id, \"keep_alive\": \"1m\"},\n                }\n                if search_after:\n                    body[\"search_after\"] = search_after\n                response = await self.client.search(body=body)\n            finally:\n                try:\n                    await self.client.delete_pit(body={\"pit_id\": [pit_id]})\n                except Exception:\n                    pass\n\n            documents = []\n            for hit in response[\"hits\"][\"hits\"]:\n                try:\n                    data = self._prepare_doc_status_data(hit[\"_source\"])\n                    documents.append((hit[\"_id\"], DocProcessingStatus(**data)))\n                except (KeyError, TypeError) as e:\n                    logger.error(\n                        f\"[{self.workspace}] Error parsing doc {hit['_id']}: {e}\"\n                    )\n            return documents, total_count\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return [], 0\n            logger.error(f\"[{self.workspace}] Error in paginated query: {e}\")\n            return [], 0\n\n    async def get_all_status_counts(self) -> dict[str, int]:\n        \"\"\"Get document counts for all statuses including an 'all' total.\"\"\"\n        if not self._index_ready:\n            return {}\n        try:\n            body = {\n                \"size\": 0,\n                \"aggs\": {\"status_counts\": {\"terms\": {\"field\": \"status\", \"size\": 100}}},\n            }\n            response = await self.client.search(index=self._index_name, body=body)\n            counts = {}\n            total = 0\n            for bucket in response[\"aggregations\"][\"status_counts\"][\"buckets\"]:\n                counts[bucket[\"key\"]] = bucket[\"doc_count\"]\n                total += bucket[\"doc_count\"]\n            counts[\"all\"] = total\n            return counts\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return {}\n            logger.error(f\"[{self.workspace}] Error getting all status counts: {e}\")\n            return {}\n\n    async def get_doc_by_file_path(self, file_path: str) -> Union[dict[str, Any], None]:\n        \"\"\"Find a document status record by its file_path field.\"\"\"\n        if not self._index_ready:\n            return None\n        try:\n            body = {\"query\": {\"term\": {\"file_path\": file_path}}, \"size\": 1}\n            response = await self.client.search(index=self._index_name, body=body)\n            hits = response[\"hits\"][\"hits\"]\n            if hits:\n                doc = hits[0][\"_source\"]\n                doc[\"_id\"] = hits[0][\"_id\"]\n                return doc\n            return None\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return None\n            logger.error(f\"[{self.workspace}] Error getting doc by file_path: {e}\")\n            return None\n\n    async def index_done_callback(self) -> None:\n        \"\"\"Refresh index to make recently indexed documents searchable.\"\"\"\n        if not self._index_ready:\n            return\n        try:\n            await self.client.indices.refresh(index=self._index_name)\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return\n        except Exception:\n            pass\n\n    async def is_empty(self) -> bool:\n        \"\"\"Return True if the index contains no documents.\"\"\"\n        if not self._index_ready:\n            return True\n        try:\n            response = await self.client.count(index=self._index_name)\n            return response[\"count\"] == 0\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n            return True\n\n    async def delete(self, ids: list[str]) -> None:\n        \"\"\"Delete document status records by IDs.\"\"\"\n        if not ids:\n            return\n        if not self._index_ready:\n            return\n        if isinstance(ids, set):\n            ids = list(ids)\n        try:\n            # DocStatus needs refresh=\"wait_for\" because downstream readers\n            # (get_docs_by_status, get_docs_paginated, etc.) are search-based\n            # and callers like _validate_and_fix_document_consistency() may\n            # query immediately after deletion without index_done_callback().\n            actions = [\n                {\"_op_type\": \"delete\", \"_index\": self._index_name, \"_id\": doc_id}\n                for doc_id in ids\n            ]\n            await helpers.async_bulk(\n                self.client, actions, raise_on_error=False, refresh=\"wait_for\"\n            )\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return\n            logger.error(f\"[{self.workspace}] Error deleting doc statuses: {e}\")\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Delete the entire doc status index.\"\"\"\n        try:\n            try:\n                await self.client.indices.delete(index=self._index_name)\n                logger.info(\n                    f\"[{self.workspace}] Dropped doc status index: {self._index_name}\"\n                )\n            except NotFoundError:\n                logger.info(\n                    f\"[{self.workspace}] Doc status index already missing during drop: {self._index_name}\"\n                )\n            self._mark_index_missing()\n            return {\"status\": \"success\", \"message\": f\"Index {self._index_name} dropped\"}\n        except OpenSearchException as e:\n            self._mark_index_missing()\n            logger.error(f\"[{self.workspace}] Error dropping doc status index: {e}\")\n            return {\"status\": \"error\", \"message\": str(e)}\n        except Exception as e:\n            self._mark_index_missing()\n            logger.error(\n                f\"[{self.workspace}] Unexpected error dropping doc status index: {e}\"\n            )\n            return {\"status\": \"error\", \"message\": str(e)}\n\n\n@final\n@dataclass\nclass OpenSearchGraphStorage(BaseGraphStorage):\n    \"\"\"Graph storage using OpenSearch with separate nodes and edges indices.\n\n    Supports two BFS traversal strategies:\n    - PPL graphlookup (server-side BFS, requires OpenSearch SQL plugin with Calcite engine)\n    - Application-level batched BFS (fallback, works on any OpenSearch 3.x+)\n\n    The strategy is auto-detected during initialize() and can be overridden via\n    the OPENSEARCH_USE_PPL_GRAPHLOOKUP environment variable (true/false).\n    \"\"\"\n\n    client: AsyncOpenSearch = field(default=None)\n    _nodes_index: str = field(default=\"\", init=False)\n    _edges_index: str = field(default=\"\", init=False)\n    _indices_ready: bool = field(default=False, init=False)\n    _ppl_graphlookup_available: bool = field(default=False, init=False)\n\n    def __init__(self, namespace, global_config, embedding_func, workspace=None):\n        super().__init__(\n            namespace=namespace,\n            workspace=workspace or \"\",\n            global_config=global_config,\n            embedding_func=embedding_func,\n        )\n        self.__post_init__()\n\n    def __post_init__(self):\n        self.workspace, self.final_namespace, base_name = _build_index_name(\n            self.workspace, self.namespace\n        )\n        self._nodes_index = f\"{base_name}-nodes\"\n        self._edges_index = f\"{base_name}-edges\"\n\n    async def initialize(self):\n        \"\"\"Initialize client, create indices, and detect PPL graphlookup support.\"\"\"\n        async with get_data_init_lock():\n            if self.client is None:\n                self.client = await ClientManager.get_client()\n            await self._create_indices_if_not_exist()\n            self._indices_ready = True\n            await self._detect_ppl_graphlookup()\n            logger.debug(\n                f\"[{self.workspace}] OpenSearch Graph storage initialized: \"\n                f\"{self._nodes_index}, {self._edges_index} \"\n                f\"(PPL graphlookup: {self._ppl_graphlookup_available})\"\n            )\n\n    async def _ensure_indices_ready(self):\n        \"\"\"Recreate graph indices after drop before the next write.\"\"\"\n        if self._indices_ready:\n            return\n        async with get_data_init_lock():\n            if self.client is None:\n                self.client = await ClientManager.get_client()\n            if not self._indices_ready:\n                await self._create_indices_if_not_exist()\n                self._indices_ready = True\n\n    def _mark_indices_missing(self):\n        \"\"\"Mark graph indices as unavailable for subsequent read short-circuiting.\"\"\"\n        self._indices_ready = False\n\n    async def _detect_ppl_graphlookup(self):\n        \"\"\"Detect whether PPL graphlookup command is available on this cluster.\"\"\"\n        env_override = os.environ.get(\"OPENSEARCH_USE_PPL_GRAPHLOOKUP\", \"\").lower()\n        if env_override == \"true\":\n            self._ppl_graphlookup_available = True\n            return\n        if env_override == \"false\":\n            self._ppl_graphlookup_available = False\n            return\n        # Auto-detect by sending a minimal PPL query\n        try:\n            await self.client.transport.perform_request(\n                \"POST\",\n                \"/_plugins/_ppl\",\n                body={\"query\": f\"source = {self._edges_index} | head 0\"},\n            )\n            # PPL endpoint works; now test graphlookup syntax with a no-op query\n            await self.client.transport.perform_request(\n                \"POST\",\n                \"/_plugins/_ppl\",\n                body={\n                    \"query\": (\n                        f\"source = {self._edges_index} | head 1 \"\n                        f\"| graphLookup {self._edges_index} \"\n                        f\"start=source_node_id edge=target_node_id-->source_node_id \"\n                        f\"maxDepth=0 as _gl_probe\"\n                    )\n                },\n            )\n            self._ppl_graphlookup_available = True\n            logger.info(\n                f\"[{self.workspace}] PPL graphlookup is available, using server-side BFS\"\n            )\n        except Exception:\n            self._ppl_graphlookup_available = False\n            logger.info(\n                f\"[{self.workspace}] PPL graphlookup not available, using client-side BFS\"\n            )\n\n    async def _create_indices_if_not_exist(self):\n        try:\n            if not await self.client.indices.exists(index=self._nodes_index):\n                body = {\n                    \"mappings\": {\n                        \"dynamic\": True,\n                        \"properties\": {\n                            \"entity_id\": {\"type\": \"keyword\"},\n                            \"entity_type\": {\"type\": \"keyword\"},\n                            \"description\": {\"type\": \"text\"},\n                            \"source_id\": {\"type\": \"text\"},\n                            \"source_ids\": {\"type\": \"keyword\"},\n                            \"file_path\": {\"type\": \"keyword\"},\n                            \"created_at\": {\"type\": \"long\"},\n                        },\n                    },\n                    \"settings\": {\n                        \"index\": {\"number_of_shards\": 1, \"number_of_replicas\": 0}\n                    },\n                }\n                await self.client.indices.create(index=self._nodes_index, body=body)\n                logger.info(\n                    f\"[{self.workspace}] Created nodes index: {self._nodes_index}\"\n                )\n        except RequestError as e:\n            if \"resource_already_exists_exception\" not in str(e):\n                raise\n\n        try:\n            if not await self.client.indices.exists(index=self._edges_index):\n                body = {\n                    \"mappings\": {\n                        \"dynamic\": True,\n                        \"properties\": {\n                            \"source_node_id\": {\"type\": \"keyword\"},\n                            \"target_node_id\": {\"type\": \"keyword\"},\n                            \"relationship\": {\"type\": \"keyword\"},\n                            \"description\": {\"type\": \"text\"},\n                            \"weight\": {\"type\": \"float\"},\n                            \"keywords\": {\"type\": \"text\"},\n                            \"source_id\": {\"type\": \"text\"},\n                            \"source_ids\": {\"type\": \"keyword\"},\n                            \"file_path\": {\"type\": \"keyword\"},\n                            \"created_at\": {\"type\": \"long\"},\n                        },\n                    },\n                    \"settings\": {\n                        \"index\": {\"number_of_shards\": 1, \"number_of_replicas\": 0}\n                    },\n                }\n                await self.client.indices.create(index=self._edges_index, body=body)\n                logger.info(\n                    f\"[{self.workspace}] Created edges index: {self._edges_index}\"\n                )\n        except RequestError as e:\n            if \"resource_already_exists_exception\" not in str(e):\n                raise\n\n    async def finalize(self):\n        \"\"\"Release the OpenSearch client connection.\"\"\"\n        if self.client is not None:\n            await ClientManager.release_client(self.client)\n            self.client = None\n\n    # --- Basic queries ---\n\n    async def has_node(self, node_id: str) -> bool:\n        \"\"\"Check whether a node exists in the graph.\"\"\"\n        if not self._indices_ready:\n            return False\n        try:\n            return await self.client.exists(index=self._nodes_index, id=node_id)\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n            return False\n\n    async def has_edge(self, source_node_id: str, target_node_id: str) -> bool:\n        \"\"\"Check whether an edge exists between two nodes (bidirectional).\"\"\"\n        if not self._indices_ready:\n            return False\n        try:\n            body = {\n                \"query\": {\n                    \"bool\": {\n                        \"should\": [\n                            {\n                                \"bool\": {\n                                    \"must\": [\n                                        {\"term\": {\"source_node_id\": source_node_id}},\n                                        {\"term\": {\"target_node_id\": target_node_id}},\n                                    ]\n                                }\n                            },\n                            {\n                                \"bool\": {\n                                    \"must\": [\n                                        {\"term\": {\"source_node_id\": target_node_id}},\n                                        {\"term\": {\"target_node_id\": source_node_id}},\n                                    ]\n                                }\n                            },\n                        ]\n                    }\n                },\n                \"size\": 0,\n            }\n            response = await self.client.search(index=self._edges_index, body=body)\n            return response[\"hits\"][\"total\"][\"value\"] > 0\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n            return False\n\n    async def node_degree(self, node_id: str) -> int:\n        \"\"\"Count the number of edges connected to a node.\"\"\"\n        if not self._indices_ready:\n            return 0\n        try:\n            response = await self.client.count(\n                index=self._edges_index,\n                body={\n                    \"query\": {\n                        \"bool\": {\n                            \"should\": [\n                                {\"term\": {\"source_node_id\": node_id}},\n                                {\"term\": {\"target_node_id\": node_id}},\n                            ]\n                        }\n                    }\n                },\n            )\n            return response.get(\"count\", 0)\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n            return 0\n\n    async def edge_degree(self, src_id: str, tgt_id: str) -> int:\n        \"\"\"Sum of degrees of both endpoint nodes.\"\"\"\n        src_degree = await self.node_degree(src_id)\n        tgt_degree = await self.node_degree(tgt_id)\n        return src_degree + tgt_degree\n\n    async def get_node(self, node_id: str) -> dict[str, str] | None:\n        \"\"\"Get a node document by ID, or None if not found.\"\"\"\n        if not self._indices_ready:\n            return None\n        try:\n            response = await _mget_optional_doc(self.client, self._nodes_index, node_id)\n            if response is None:\n                return None\n            doc = response[\"_source\"]\n            doc[\"_id\"] = response[\"_id\"]\n            return doc\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n            return None\n\n    async def get_edge(\n        self, source_node_id: str, target_node_id: str\n    ) -> dict[str, str] | None:\n        \"\"\"Get an edge between two nodes (bidirectional), or None.\"\"\"\n        if not self._indices_ready:\n            return None\n        try:\n            body = {\n                \"query\": {\n                    \"bool\": {\n                        \"should\": [\n                            {\n                                \"bool\": {\n                                    \"must\": [\n                                        {\"term\": {\"source_node_id\": source_node_id}},\n                                        {\"term\": {\"target_node_id\": target_node_id}},\n                                    ]\n                                }\n                            },\n                            {\n                                \"bool\": {\n                                    \"must\": [\n                                        {\"term\": {\"source_node_id\": target_node_id}},\n                                        {\"term\": {\"target_node_id\": source_node_id}},\n                                    ]\n                                }\n                            },\n                        ]\n                    }\n                },\n                \"size\": 1,\n            }\n            response = await self.client.search(index=self._edges_index, body=body)\n            hits = response[\"hits\"][\"hits\"]\n            if hits:\n                doc = hits[0][\"_source\"]\n                doc[\"_id\"] = hits[0][\"_id\"]\n                return doc\n            return None\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n            return None\n\n    async def get_node_edges(self, source_node_id: str) -> list[tuple[str, str]] | None:\n        \"\"\"Get all (source, target) edge tuples connected to a node.\"\"\"\n        if not self._indices_ready:\n            return None\n        try:\n            query = {\n                \"bool\": {\n                    \"should\": [\n                        {\"term\": {\"source_node_id\": source_node_id}},\n                        {\"term\": {\"target_node_id\": source_node_id}},\n                    ]\n                }\n            }\n            edges = []\n            pit = await self.client.create_pit(\n                index=self._edges_index, params={\"keep_alive\": \"1m\"}\n            )\n            pit_id = pit[\"pit_id\"]\n            try:\n                search_after = None\n                while True:\n                    body = {\n                        \"query\": query,\n                        \"_source\": [\"source_node_id\", \"target_node_id\"],\n                        \"size\": 10000,\n                        \"pit\": {\"id\": pit_id, \"keep_alive\": \"1m\"},\n                        \"sort\": [{\"_shard_doc\": \"asc\"}],\n                    }\n                    if search_after:\n                        body[\"search_after\"] = search_after\n                    response = await self.client.search(body=body)\n                    hits = response[\"hits\"][\"hits\"]\n                    if not hits:\n                        break\n                    for hit in hits:\n                        edges.append(\n                            (\n                                hit[\"_source\"][\"source_node_id\"],\n                                hit[\"_source\"][\"target_node_id\"],\n                            )\n                        )\n                    search_after = hits[-1][\"sort\"]\n                    if len(hits) < 10000:\n                        break\n            finally:\n                try:\n                    await self.client.delete_pit(body={\"pit_id\": [pit_id]})\n                except Exception:\n                    pass\n            return edges\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n            return None\n\n    # --- Batch operations ---\n\n    async def get_nodes_batch(self, node_ids: list[str]) -> dict[str, dict]:\n        \"\"\"Batch-fetch multiple nodes by ID.\"\"\"\n        if not self._indices_ready:\n            return {}\n        try:\n            response = await self.client.mget(\n                index=self._nodes_index, body={\"ids\": node_ids}\n            )\n            result = {}\n            for doc in response[\"docs\"]:\n                if doc.get(\"found\"):\n                    data = doc[\"_source\"]\n                    data[\"_id\"] = doc[\"_id\"]\n                    result[doc[\"_id\"]] = data\n            return result\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n            return {}\n\n    async def node_degrees_batch(self, node_ids: list[str]) -> dict[str, int]:\n        \"\"\"Batch-fetch edge counts for multiple nodes using aggregations.\"\"\"\n        if not node_ids:\n            return {}\n        if not self._indices_ready:\n            return {}\n        try:\n            # Use a single query with aggregations for both source and target\n            body = {\n                \"size\": 0,\n                \"query\": {\n                    \"bool\": {\n                        \"should\": [\n                            {\"terms\": {\"source_node_id\": node_ids}},\n                            {\"terms\": {\"target_node_id\": node_ids}},\n                        ]\n                    }\n                },\n                \"aggs\": {\n                    \"source_degrees\": {\n                        \"terms\": {\n                            \"field\": \"source_node_id\",\n                            \"size\": len(node_ids) * 2,\n                        }\n                    },\n                    \"target_degrees\": {\n                        \"terms\": {\n                            \"field\": \"target_node_id\",\n                            \"size\": len(node_ids) * 2,\n                        }\n                    },\n                },\n            }\n            response = await self.client.search(index=self._edges_index, body=body)\n            result = {}\n            for bucket in response[\"aggregations\"][\"source_degrees\"][\"buckets\"]:\n                if bucket[\"key\"] in node_ids:\n                    result[bucket[\"key\"]] = (\n                        result.get(bucket[\"key\"], 0) + bucket[\"doc_count\"]\n                    )\n            for bucket in response[\"aggregations\"][\"target_degrees\"][\"buckets\"]:\n                if bucket[\"key\"] in node_ids:\n                    result[bucket[\"key\"]] = (\n                        result.get(bucket[\"key\"], 0) + bucket[\"doc_count\"]\n                    )\n            return result\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n            return {}\n\n    async def get_nodes_edges_batch(\n        self, node_ids: list[str]\n    ) -> dict[str, list[tuple[str, str]]]:\n        \"\"\"Batch-fetch edge tuples for multiple nodes.\"\"\"\n        result = {nid: [] for nid in node_ids}\n        if not self._indices_ready:\n            return result\n        try:\n            query = {\n                \"bool\": {\n                    \"should\": [\n                        {\"terms\": {\"source_node_id\": node_ids}},\n                        {\"terms\": {\"target_node_id\": node_ids}},\n                    ]\n                }\n            }\n            pit = await self.client.create_pit(\n                index=self._edges_index, params={\"keep_alive\": \"1m\"}\n            )\n            pit_id = pit[\"pit_id\"]\n            try:\n                search_after = None\n                while True:\n                    body = {\n                        \"query\": query,\n                        \"_source\": [\"source_node_id\", \"target_node_id\"],\n                        \"size\": 10000,\n                        \"pit\": {\"id\": pit_id, \"keep_alive\": \"1m\"},\n                        \"sort\": [{\"_shard_doc\": \"asc\"}],\n                    }\n                    if search_after:\n                        body[\"search_after\"] = search_after\n                    response = await self.client.search(body=body)\n                    hits = response[\"hits\"][\"hits\"]\n                    if not hits:\n                        break\n                    for hit in hits:\n                        src = hit[\"_source\"][\"source_node_id\"]\n                        tgt = hit[\"_source\"][\"target_node_id\"]\n                        if src in result:\n                            result[src].append((src, tgt))\n                        if tgt in result:\n                            result[tgt].append((src, tgt))\n                    search_after = hits[-1][\"sort\"]\n                    if len(hits) < 10000:\n                        break\n            finally:\n                try:\n                    await self.client.delete_pit(body={\"pit_id\": [pit_id]})\n                except Exception:\n                    pass\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n            pass\n        return result\n\n    # --- Upsert operations ---\n\n    async def upsert_node(self, node_id: str, node_data: dict[str, str]) -> None:\n        \"\"\"Insert or update a node. Adds entity_id for PPL compatibility.\"\"\"\n        try:\n            await self._ensure_indices_ready()\n            doc = {k: v for k, v in node_data.items() if k != \"_id\"}\n            doc[\"entity_id\"] = node_id\n            if node_data.get(\"source_id\", \"\"):\n                doc[\"source_ids\"] = node_data[\"source_id\"].split(GRAPH_FIELD_SEP)\n            # No per-operation refresh: node reads use ID-based mget/exists\n            # (translog, real-time). Search visibility after index_done_callback().\n            await self.client.index(index=self._nodes_index, id=node_id, body=doc)\n        except OpenSearchException as e:\n            logger.error(f\"[{self.workspace}] Error upserting node {node_id}: {e}\")\n\n    async def upsert_edge(\n        self, source_node_id: str, target_node_id: str, edge_data: dict[str, str]\n    ) -> None:\n        \"\"\"Insert or update an edge with deterministic ID for bidirectional handling.\"\"\"\n        try:\n            await self._ensure_indices_ready()\n            # Ensure source node exists (don't overwrite if it already has data)\n            if not await self.has_node(source_node_id):\n                await self.upsert_node(source_node_id, {})\n\n            doc = {k: v for k, v in edge_data.items() if k != \"_id\"}\n            doc[\"source_node_id\"] = source_node_id\n            doc[\"target_node_id\"] = target_node_id\n            if edge_data.get(\"source_id\", \"\"):\n                doc[\"source_ids\"] = edge_data[\"source_id\"].split(GRAPH_FIELD_SEP)\n\n            # Use a deterministic ID for the edge so upserts work\n            edge_id = compute_mdhash_id(\n                f\"{source_node_id}-{target_node_id}\", prefix=\"edge-\"\n            )\n\n            # Check if reverse edge exists\n            reverse_id = compute_mdhash_id(\n                f\"{target_node_id}-{source_node_id}\", prefix=\"edge-\"\n            )\n            try:\n                if await self.client.exists(index=self._edges_index, id=reverse_id):\n                    edge_id = reverse_id\n            except OpenSearchException:\n                pass\n\n            # No per-operation refresh: the reverse-edge check above uses\n            # client.exists() which reads from the translog (real-time).\n            # Note: has_edge() and get_edge() use the search API, so they may\n            # not see this write until the next index_done_callback() refresh.\n            await self.client.index(index=self._edges_index, id=edge_id, body=doc)\n        except OpenSearchException as e:\n            logger.error(\n                f\"[{self.workspace}] Error upserting edge {source_node_id}->{target_node_id}: {e}\"\n            )\n\n    # --- Delete operations ---\n\n    async def delete_node(self, node_id: str) -> None:\n        \"\"\"Delete a node and all its connected edges.\n\n        No per-operation refresh: delete_node is called from document deletion\n        pipelines that invoke index_done_callback() afterward.\n        Uses conflicts=\"proceed\" to tolerate stale search views when prior\n        delete_by_query calls have already removed some edges.\n        \"\"\"\n        try:\n            # Delete all edges referencing this node\n            body = {\n                \"query\": {\n                    \"bool\": {\n                        \"should\": [\n                            {\"term\": {\"source_node_id\": node_id}},\n                            {\"term\": {\"target_node_id\": node_id}},\n                        ]\n                    }\n                }\n            }\n            await self.client.delete_by_query(\n                index=self._edges_index, body=body, params={\"conflicts\": \"proceed\"}\n            )\n            # Delete the node\n            try:\n                await self.client.delete(index=self._nodes_index, id=node_id)\n            except NotFoundError:\n                pass\n        except OpenSearchException as e:\n            logger.error(f\"[{self.workspace}] Error deleting node {node_id}: {e}\")\n\n    async def remove_nodes(self, nodes: list[str]) -> None:\n        \"\"\"Batch-delete multiple nodes and their connected edges.\n\n        No per-operation refresh: callers invoke index_done_callback() afterward.\n        Uses conflicts=\"proceed\" to tolerate stale search views when prior\n        remove_edges() calls have already removed some edges without refresh.\n        \"\"\"\n        if not nodes:\n            return\n        logger.info(f\"[{self.workspace}] Deleting {len(nodes)} nodes\")\n        try:\n            # Delete edges\n            body = {\n                \"query\": {\n                    \"bool\": {\n                        \"should\": [\n                            {\"terms\": {\"source_node_id\": nodes}},\n                            {\"terms\": {\"target_node_id\": nodes}},\n                        ]\n                    }\n                }\n            }\n            await self.client.delete_by_query(\n                index=self._edges_index, body=body, params={\"conflicts\": \"proceed\"}\n            )\n            # Delete nodes\n            actions = [\n                {\"_op_type\": \"delete\", \"_index\": self._nodes_index, \"_id\": nid}\n                for nid in nodes\n            ]\n            await helpers.async_bulk(self.client, actions, raise_on_error=False)\n        except OpenSearchException as e:\n            logger.error(f\"[{self.workspace}] Error removing nodes: {e}\")\n\n    async def remove_edges(self, edges: list[tuple[str, str]]) -> None:\n        \"\"\"Batch-delete multiple edges (bidirectional matching).\n\n        No per-operation refresh: callers invoke index_done_callback() afterward.\n        Uses conflicts=\"proceed\" to tolerate stale search views when\n        subsequent remove_nodes() may target already-deleted edges.\n        \"\"\"\n        if not edges:\n            return\n        logger.info(f\"[{self.workspace}] Deleting {len(edges)} edges\")\n        try:\n            should_clauses = []\n            for src, tgt in edges:\n                should_clauses.append(\n                    {\n                        \"bool\": {\n                            \"must\": [\n                                {\"term\": {\"source_node_id\": src}},\n                                {\"term\": {\"target_node_id\": tgt}},\n                            ]\n                        }\n                    }\n                )\n                should_clauses.append(\n                    {\n                        \"bool\": {\n                            \"must\": [\n                                {\"term\": {\"source_node_id\": tgt}},\n                                {\"term\": {\"target_node_id\": src}},\n                            ]\n                        }\n                    }\n                )\n            body = {\"query\": {\"bool\": {\"should\": should_clauses}}}\n            await self.client.delete_by_query(\n                index=self._edges_index, body=body, params={\"conflicts\": \"proceed\"}\n            )\n        except OpenSearchException as e:\n            logger.error(f\"[{self.workspace}] Error removing edges: {e}\")\n\n    # --- Query operations ---\n\n    async def get_all_labels(self) -> list[str]:\n        \"\"\"Get all node IDs (entity names) sorted alphabetically.\"\"\"\n        if not self._indices_ready:\n            return []\n        try:\n            labels = []\n            pit = await self.client.create_pit(\n                index=self._nodes_index, params={\"keep_alive\": \"1m\"}\n            )\n            pit_id = pit[\"pit_id\"]\n            try:\n                search_after = None\n                while True:\n                    body = {\n                        \"query\": {\"match_all\": {}},\n                        \"_source\": False,\n                        \"size\": 10000,\n                        \"pit\": {\"id\": pit_id, \"keep_alive\": \"1m\"},\n                        \"sort\": [{\"_shard_doc\": \"asc\"}],\n                    }\n                    if search_after:\n                        body[\"search_after\"] = search_after\n                    response = await self.client.search(body=body)\n                    hits = response[\"hits\"][\"hits\"]\n                    if not hits:\n                        break\n                    for hit in hits:\n                        labels.append(hit[\"_id\"])\n                    search_after = hits[-1][\"sort\"]\n                    if len(hits) < 10000:\n                        break\n            finally:\n                try:\n                    await self.client.delete_pit(body={\"pit_id\": [pit_id]})\n                except Exception:\n                    pass\n            labels.sort()\n            return labels\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n            return []\n\n    def _construct_graph_node(self, node_id, node_data: dict) -> KnowledgeGraphNode:\n        return KnowledgeGraphNode(\n            id=node_id,\n            labels=[node_id],\n            properties={\n                k: v\n                for k, v in node_data.items()\n                if k\n                not in (\n                    \"_id\",\n                    \"entity_id\",\n                    \"source_ids\",\n                    \"connected_edges\",\n                    \"edge_count\",\n                )\n            },\n        )\n\n    def _construct_graph_edge(self, edge_id: str, edge: dict) -> KnowledgeGraphEdge:\n        return KnowledgeGraphEdge(\n            id=edge_id,\n            type=edge.get(\"relationship\", \"\"),\n            source=edge[\"source_node_id\"],\n            target=edge[\"target_node_id\"],\n            properties={\n                k: v\n                for k, v in edge.items()\n                if k\n                not in (\n                    \"_id\",\n                    \"source_node_id\",\n                    \"target_node_id\",\n                    \"relationship\",\n                    \"source_ids\",\n                )\n            },\n        )\n\n    async def get_knowledge_graph(\n        self,\n        node_label: str,\n        max_depth: int = 3,\n        max_nodes: int = None,\n    ) -> KnowledgeGraph:\n        \"\"\"Retrieve a subgraph via PPL graphlookup (if available) or client-side BFS.\"\"\"\n        if not self._indices_ready:\n            return KnowledgeGraph()\n        if max_nodes is None:\n            max_nodes = self.global_config.get(\"max_graph_nodes\", 1000)\n        else:\n            max_nodes = min(max_nodes, self.global_config.get(\"max_graph_nodes\", 1000))\n\n        result = KnowledgeGraph()\n        start = time.perf_counter()\n\n        try:\n            if node_label == \"*\":\n                result = await self._get_knowledge_graph_all(max_nodes)\n            elif self._ppl_graphlookup_available:\n                result = await self._bfs_subgraph_ppl(node_label, max_depth, max_nodes)\n            else:\n                result = await self._bfs_subgraph(node_label, max_depth, max_nodes)\n\n            duration = time.perf_counter() - start\n            logger.info(\n                f\"[{self.workspace}] Subgraph query in {duration:.4f}s | \"\n                f\"Nodes: {len(result.nodes)} | Edges: {len(result.edges)} | Truncated: {result.is_truncated}\"\n            )\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n                return KnowledgeGraph()\n            logger.error(f\"[{self.workspace}] Graph query failed: {e}\")\n\n        return result\n\n    async def _get_knowledge_graph_all(self, max_nodes: int) -> KnowledgeGraph:\n        \"\"\"Get all nodes (up to max_nodes, ranked by degree) and their interconnecting edges.\"\"\"\n        result = KnowledgeGraph()\n        if not self._indices_ready:\n            return result\n        try:\n            total = (await self.client.count(index=self._nodes_index))[\"count\"]\n            result.is_truncated = total > max_nodes\n\n            if result.is_truncated:\n                # Get top nodes by degree\n                body = {\n                    \"size\": 0,\n                    \"aggs\": {\n                        \"src\": {\n                            \"terms\": {\n                                \"field\": \"source_node_id\",\n                                \"size\": max_nodes,\n                            }\n                        },\n                        \"tgt\": {\n                            \"terms\": {\n                                \"field\": \"target_node_id\",\n                                \"size\": max_nodes,\n                            }\n                        },\n                    },\n                }\n                resp = await self.client.search(index=self._edges_index, body=body)\n                degree_map = {}\n                for bucket in resp[\"aggregations\"][\"src\"][\"buckets\"]:\n                    degree_map[bucket[\"key\"]] = (\n                        degree_map.get(bucket[\"key\"], 0) + bucket[\"doc_count\"]\n                    )\n                for bucket in resp[\"aggregations\"][\"tgt\"][\"buckets\"]:\n                    degree_map[bucket[\"key\"]] = (\n                        degree_map.get(bucket[\"key\"], 0) + bucket[\"doc_count\"]\n                    )\n                top_ids = sorted(degree_map, key=degree_map.get, reverse=True)[\n                    :max_nodes\n                ]\n            else:\n                # Get all node IDs — use PIT scrolling if max_nodes > 10000\n                top_ids = []\n                if max_nodes <= 10000:\n                    body = {\n                        \"query\": {\"match_all\": {}},\n                        \"_source\": False,\n                        \"size\": max_nodes,\n                    }\n                    resp = await self.client.search(index=self._nodes_index, body=body)\n                    top_ids = [hit[\"_id\"] for hit in resp[\"hits\"][\"hits\"]]\n                else:\n                    pit = await self.client.create_pit(\n                        index=self._nodes_index, params={\"keep_alive\": \"1m\"}\n                    )\n                    pit_id = pit[\"pit_id\"]\n                    try:\n                        search_after = None\n                        while len(top_ids) < max_nodes:\n                            body = {\n                                \"query\": {\"match_all\": {}},\n                                \"_source\": False,\n                                \"size\": 10000,\n                                \"pit\": {\"id\": pit_id, \"keep_alive\": \"1m\"},\n                                \"sort\": [{\"_shard_doc\": \"asc\"}],\n                            }\n                            if search_after:\n                                body[\"search_after\"] = search_after\n                            resp = await self.client.search(body=body)\n                            hits = resp[\"hits\"][\"hits\"]\n                            if not hits:\n                                break\n                            for hit in hits:\n                                top_ids.append(hit[\"_id\"])\n                                if len(top_ids) >= max_nodes:\n                                    break\n                            search_after = hits[-1][\"sort\"]\n                            if len(hits) < 10000:\n                                break\n                    finally:\n                        try:\n                            await self.client.delete_pit(body={\"pit_id\": [pit_id]})\n                        except Exception:\n                            pass\n\n            # Fetch node data\n            if top_ids:\n                node_resp = await self.client.mget(\n                    index=self._nodes_index, body={\"ids\": top_ids}\n                )\n                for doc in node_resp[\"docs\"]:\n                    if doc.get(\"found\"):\n                        result.nodes.append(\n                            self._construct_graph_node(doc[\"_id\"], doc[\"_source\"])\n                        )\n\n                # Fetch edges between these nodes\n                edge_body = {\n                    \"query\": {\n                        \"bool\": {\n                            \"must\": [\n                                {\"terms\": {\"source_node_id\": top_ids}},\n                                {\"terms\": {\"target_node_id\": top_ids}},\n                            ]\n                        }\n                    },\n                    \"size\": 10000,\n                }\n                edge_resp = await self.client.search(\n                    index=self._edges_index, body=edge_body\n                )\n                seen_edges = set()\n                for hit in edge_resp[\"hits\"][\"hits\"]:\n                    e = hit[\"_source\"]\n                    eid = f\"{e['source_node_id']}-{e['target_node_id']}\"\n                    if eid not in seen_edges:\n                        seen_edges.add(eid)\n                        result.edges.append(self._construct_graph_edge(eid, e))\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n                return result\n            logger.error(f\"[{self.workspace}] Error in get_knowledge_graph_all: {e}\")\n        return result\n\n    async def _bfs_subgraph_ppl(\n        self, start_label: str, max_depth: int, max_nodes: int\n    ) -> KnowledgeGraph:\n        \"\"\"Server-side BFS using PPL graphlookup command.\n\n        Queries the nodes index for the start node, then uses graphLookup to traverse\n        the edges index with bidirectional BFS. Uses `flatten` to unnest results and\n        `depthField` for depth-based sorting. Falls back to client-side BFS on failure.\n        \"\"\"\n        result = KnowledgeGraph()\n\n        # Verify start node exists\n        start_node = await self.get_node(start_label)\n        if not start_node:\n            return result\n\n        seen_nodes = {start_label}\n        result.nodes.append(self._construct_graph_node(start_label, start_node))\n\n        if max_depth == 0:\n            return result\n\n        # PPL maxDepth=0 means 1 hop (direct match), so max_depth-1\n        ppl_depth = max(0, max_depth - 1)\n        escaped = self._escape_ppl(start_label)\n        ppl_query = (\n            f\"source = {self._nodes_index}\"\n            f\" | where entity_id = '{escaped}'\"\n            f\" | graphLookup {self._edges_index}\"\n            f\" start=entity_id\"\n            f\" edge=target_node_id<->source_node_id\"\n            f\" maxDepth={ppl_depth}\"\n            f\" depthField=_depth\"\n            f\" usePIT=true\"\n            f\" as connected_edges\"\n        )\n\n        try:\n            resp = await self.client.transport.perform_request(\n                \"POST\",\n                \"/_plugins/_ppl\",\n                body={\"query\": ppl_query},\n            )\n        except Exception as e:\n            logger.warning(\n                f\"[{self.workspace}] PPL graphlookup failed, falling back to client BFS: {e}\"\n            )\n            return await self._bfs_subgraph(start_label, max_depth, max_nodes)\n\n        # Parse PPL response — schema-driven to avoid fragile positional access\n        try:\n            datarows = resp.get(\"datarows\", [])\n            schema = [col[\"name\"] for col in resp.get(\"schema\", [])]\n            ce_idx = (\n                schema.index(\"connected_edges\") if \"connected_edges\" in schema else -1\n            )\n\n            # Collect all edge rows from connected_edges arrays\n            all_edge_rows = []\n            for row in datarows:\n                edges_arr = row[ce_idx] if ce_idx >= 0 else []\n                if isinstance(edges_arr, list):\n                    all_edge_rows.extend(edges_arr)\n\n            if not all_edge_rows:\n                return result\n\n            # Build field index map from the first edge row if it's a dict,\n            # otherwise fall back to known edge schema order\n            if isinstance(all_edge_rows[0], dict):\n                # Dict-based response (ideal)\n                for edge_row in all_edge_rows:\n                    src = edge_row.get(\"source_node_id\")\n                    tgt = edge_row.get(\"target_node_id\")\n                    if src:\n                        seen_nodes.add(src)\n                    if tgt:\n                        seen_nodes.add(tgt)\n            else:\n                # Positional array — column positions are unknown, fall back to client BFS\n                logger.warning(\n                    f\"[{self.workspace}] PPL returned positional arrays, falling back to client BFS\"\n                )\n                return await self._bfs_subgraph(start_label, max_depth, max_nodes)\n\n        except (KeyError, IndexError, TypeError, ValueError) as e:\n            logger.warning(\n                f\"[{self.workspace}] Error parsing PPL response, falling back: {e}\"\n            )\n            return await self._bfs_subgraph(start_label, max_depth, max_nodes)\n\n        # Limit to max_nodes\n        node_ids = list(seen_nodes)[:max_nodes]\n        result.is_truncated = len(seen_nodes) > max_nodes\n\n        # Batch fetch node data (start node already added)\n        new_node_ids = [nid for nid in node_ids if nid != start_label]\n        if new_node_ids:\n            node_resp = await self.client.mget(\n                index=self._nodes_index, body={\"ids\": new_node_ids}\n            )\n            for doc in node_resp[\"docs\"]:\n                if doc.get(\"found\"):\n                    result.nodes.append(\n                        self._construct_graph_node(doc[\"_id\"], doc[\"_source\"])\n                    )\n\n        # Re-fetch full edge data between collected nodes for complete properties\n        if node_ids:\n            edge_body = {\n                \"query\": {\n                    \"bool\": {\n                        \"must\": [\n                            {\"terms\": {\"source_node_id\": node_ids}},\n                            {\"terms\": {\"target_node_id\": node_ids}},\n                        ]\n                    }\n                },\n                \"size\": 10000,\n            }\n            edge_resp = await self.client.search(\n                index=self._edges_index, body=edge_body\n            )\n            seen_edges = set()\n            for hit in edge_resp[\"hits\"][\"hits\"]:\n                e = hit[\"_source\"]\n                eid = f\"{e['source_node_id']}-{e['target_node_id']}\"\n                if eid not in seen_edges:\n                    seen_edges.add(eid)\n                    result.edges.append(self._construct_graph_edge(eid, e))\n\n        return result\n\n    @staticmethod\n    def _escape_ppl(value: str) -> str:\n        \"\"\"Escape a string for safe inclusion in a PPL single-quoted literal.\"\"\"\n        return value.replace(\"\\\\\", \"\\\\\\\\\").replace(\"'\", \"\\\\'\")\n\n    async def _bfs_subgraph(\n        self, start_label: str, max_depth: int, max_nodes: int\n    ) -> KnowledgeGraph:\n        \"\"\"BFS traversal from a starting node, batching neighbor lookups per level.\"\"\"\n        result = KnowledgeGraph()\n        seen_nodes = set()\n\n        # Verify start node exists\n        start_node = await self.get_node(start_label)\n        if not start_node:\n            return result\n\n        seen_nodes.add(start_label)\n        result.nodes.append(self._construct_graph_node(start_label, start_node))\n\n        current_level = [start_label]\n        for _ in range(max_depth):\n            if not current_level or len(seen_nodes) >= max_nodes:\n                break\n\n            # Batch fetch all edges for current level\n            body = {\n                \"query\": {\n                    \"bool\": {\n                        \"should\": [\n                            {\"terms\": {\"source_node_id\": current_level}},\n                            {\"terms\": {\"target_node_id\": current_level}},\n                        ]\n                    }\n                },\n                \"_source\": [\"source_node_id\", \"target_node_id\"],\n                \"size\": 10000,\n            }\n            try:\n                resp = await self.client.search(index=self._edges_index, body=body)\n            except OpenSearchException:\n                break\n\n            next_level = set()\n            for hit in resp[\"hits\"][\"hits\"]:\n                src = hit[\"_source\"][\"source_node_id\"]\n                tgt = hit[\"_source\"][\"target_node_id\"]\n                if src not in seen_nodes:\n                    next_level.add(src)\n                if tgt not in seen_nodes:\n                    next_level.add(tgt)\n\n            # Limit to max_nodes\n            new_ids = []\n            for nid in next_level:\n                if len(seen_nodes) + len(new_ids) >= max_nodes:\n                    break\n                new_ids.append(nid)\n\n            if new_ids:\n                # Batch fetch node data\n                node_resp = await self.client.mget(\n                    index=self._nodes_index, body={\"ids\": new_ids}\n                )\n                for doc in node_resp[\"docs\"]:\n                    if doc.get(\"found\"):\n                        seen_nodes.add(doc[\"_id\"])\n                        result.nodes.append(\n                            self._construct_graph_node(doc[\"_id\"], doc[\"_source\"])\n                        )\n\n            current_level = new_ids\n\n        # Fetch all edges between seen nodes using PIT scrolling\n        all_ids = list(seen_nodes)\n        if all_ids:\n            edge_query = {\n                \"bool\": {\n                    \"must\": [\n                        {\"terms\": {\"source_node_id\": all_ids}},\n                        {\"terms\": {\"target_node_id\": all_ids}},\n                    ]\n                }\n            }\n            try:\n                seen_edges = set()\n                pit = await self.client.create_pit(\n                    index=self._edges_index, params={\"keep_alive\": \"1m\"}\n                )\n                pit_id = pit[\"pit_id\"]\n                try:\n                    search_after = None\n                    while True:\n                        edge_body = {\n                            \"query\": edge_query,\n                            \"size\": 10000,\n                            \"pit\": {\"id\": pit_id, \"keep_alive\": \"1m\"},\n                            \"sort\": [{\"_shard_doc\": \"asc\"}],\n                        }\n                        if search_after:\n                            edge_body[\"search_after\"] = search_after\n                        edge_resp = await self.client.search(body=edge_body)\n                        hits = edge_resp[\"hits\"][\"hits\"]\n                        if not hits:\n                            break\n                        for hit in hits:\n                            e = hit[\"_source\"]\n                            eid = f\"{e['source_node_id']}-{e['target_node_id']}\"\n                            if eid not in seen_edges:\n                                seen_edges.add(eid)\n                                result.edges.append(self._construct_graph_edge(eid, e))\n                        search_after = hits[-1][\"sort\"]\n                        if len(hits) < 10000:\n                            break\n                finally:\n                    try:\n                        await self.client.delete_pit(body={\"pit_id\": [pit_id]})\n                    except Exception:\n                        pass\n            except OpenSearchException:\n                pass\n\n        result.is_truncated = len(seen_nodes) >= max_nodes\n        return result\n\n    async def get_all_nodes(self) -> list[dict]:\n        \"\"\"Get all nodes with their properties.\"\"\"\n        if not self._indices_ready:\n            return []\n        try:\n            nodes = []\n            pit = await self.client.create_pit(\n                index=self._nodes_index, params={\"keep_alive\": \"1m\"}\n            )\n            pit_id = pit[\"pit_id\"]\n            try:\n                search_after = None\n                while True:\n                    body = {\n                        \"query\": {\"match_all\": {}},\n                        \"size\": 10000,\n                        \"pit\": {\"id\": pit_id, \"keep_alive\": \"1m\"},\n                        \"sort\": [{\"_shard_doc\": \"asc\"}],\n                    }\n                    if search_after:\n                        body[\"search_after\"] = search_after\n                    response = await self.client.search(body=body)\n                    hits = response[\"hits\"][\"hits\"]\n                    if not hits:\n                        break\n                    for hit in hits:\n                        node = hit[\"_source\"]\n                        node[\"id\"] = hit[\"_id\"]\n                        nodes.append(node)\n                    search_after = hits[-1][\"sort\"]\n                    if len(hits) < 10000:\n                        break\n            finally:\n                try:\n                    await self.client.delete_pit(body={\"pit_id\": [pit_id]})\n                except Exception:\n                    pass\n            return nodes\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n            return []\n\n    async def get_all_edges(self) -> list[dict]:\n        \"\"\"Get all edges with source/target fields added.\"\"\"\n        if not self._indices_ready:\n            return []\n        try:\n            edges = []\n            pit = await self.client.create_pit(\n                index=self._edges_index, params={\"keep_alive\": \"1m\"}\n            )\n            pit_id = pit[\"pit_id\"]\n            try:\n                search_after = None\n                while True:\n                    body = {\n                        \"query\": {\"match_all\": {}},\n                        \"size\": 10000,\n                        \"pit\": {\"id\": pit_id, \"keep_alive\": \"1m\"},\n                        \"sort\": [{\"_shard_doc\": \"asc\"}],\n                    }\n                    if search_after:\n                        body[\"search_after\"] = search_after\n                    response = await self.client.search(body=body)\n                    hits = response[\"hits\"][\"hits\"]\n                    if not hits:\n                        break\n                    for hit in hits:\n                        edge = hit[\"_source\"]\n                        edge[\"source\"] = edge.get(\"source_node_id\")\n                        edge[\"target\"] = edge.get(\"target_node_id\")\n                        edges.append(edge)\n                    search_after = hits[-1][\"sort\"]\n                    if len(hits) < 10000:\n                        break\n            finally:\n                try:\n                    await self.client.delete_pit(body={\"pit_id\": [pit_id]})\n                except Exception:\n                    pass\n            return edges\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n            return []\n\n    async def get_popular_labels(self, limit: int = 300) -> list[str]:\n        \"\"\"Get node labels ranked by edge degree (most connected first).\"\"\"\n        if not self._indices_ready:\n            return []\n        try:\n            body = {\n                \"size\": 0,\n                \"aggs\": {\n                    \"src\": {\"terms\": {\"field\": \"source_node_id\", \"size\": limit * 2}},\n                    \"tgt\": {\"terms\": {\"field\": \"target_node_id\", \"size\": limit * 2}},\n                },\n            }\n            response = await self.client.search(index=self._edges_index, body=body)\n            degree_map = {}\n            for bucket in response[\"aggregations\"][\"src\"][\"buckets\"]:\n                degree_map[bucket[\"key\"]] = (\n                    degree_map.get(bucket[\"key\"], 0) + bucket[\"doc_count\"]\n                )\n            for bucket in response[\"aggregations\"][\"tgt\"][\"buckets\"]:\n                degree_map[bucket[\"key\"]] = (\n                    degree_map.get(bucket[\"key\"], 0) + bucket[\"doc_count\"]\n                )\n            sorted_labels = sorted(degree_map, key=degree_map.get, reverse=True)[:limit]\n            return sorted_labels\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n            return []\n\n    async def search_labels(self, query: str, limit: int = 50) -> list[str]:\n        \"\"\"Search node labels with wildcard and prefix matching.\"\"\"\n        query = query.strip()\n        if not query:\n            return []\n        if not self._indices_ready:\n            return []\n        try:\n            body = {\n                \"query\": {\n                    \"bool\": {\n                        \"should\": [\n                            {\"term\": {\"entity_id\": {\"value\": query, \"boost\": 10}}},\n                            {\n                                \"prefix\": {\n                                    \"entity_id\": {\"value\": query.lower(), \"boost\": 5}\n                                }\n                            },\n                            {\n                                \"wildcard\": {\n                                    \"entity_id\": {\n                                        \"value\": f\"*{query.lower()}*\",\n                                        \"case_insensitive\": True,\n                                        \"boost\": 2,\n                                    }\n                                }\n                            },\n                        ]\n                    }\n                },\n                \"_source\": False,\n                \"size\": limit,\n            }\n            response = await self.client.search(index=self._nodes_index, body=body)\n            return [hit[\"_id\"] for hit in response[\"hits\"][\"hits\"]]\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n            return []\n\n    async def index_done_callback(self) -> None:\n        \"\"\"Refresh both node and edge indices.\"\"\"\n        if not self._indices_ready:\n            return\n        try:\n            await self.client.indices.refresh(index=self._nodes_index)\n            await self.client.indices.refresh(index=self._edges_index)\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_indices_missing()\n                return\n        except Exception:\n            pass\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Delete both node and edge indices.\"\"\"\n        errors = []\n        for idx in (self._nodes_index, self._edges_index):\n            try:\n                await self.client.indices.delete(index=idx)\n                logger.info(f\"[{self.workspace}] Dropped graph index: {idx}\")\n            except NotFoundError:\n                logger.info(\n                    f\"[{self.workspace}] Graph index already missing during drop: {idx}\"\n                )\n            except OpenSearchException as e:\n                errors.append(f\"{idx}: {e}\")\n                logger.error(\n                    f\"[{self.workspace}] Error dropping graph index {idx}: {e}\"\n                )\n            except Exception as e:\n                errors.append(f\"{idx}: {e}\")\n                logger.error(\n                    f\"[{self.workspace}] Unexpected error dropping graph index {idx}: {e}\"\n                )\n\n        self._mark_indices_missing()\n\n        if errors:\n            return {\n                \"status\": \"error\",\n                \"message\": \"Failed to drop graph indices: \" + \"; \".join(errors),\n            }\n\n        try:\n            logger.info(f\"[{self.workspace}] Dropped graph indices\")\n            return {\"status\": \"success\", \"message\": \"Graph indices dropped\"}\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error finalizing graph drop: {e}\")\n            return {\"status\": \"error\", \"message\": str(e)}\n\n\n@final\n@dataclass\nclass OpenSearchVectorDBStorage(BaseVectorStorage):\n    \"\"\"Vector storage using OpenSearch k-NN plugin with corrected cosine score handling.\"\"\"\n\n    client: AsyncOpenSearch = field(default=None)\n    _index_name: str = field(default=\"\", init=False)\n    _index_ready: bool = field(default=False, init=False)\n\n    def __init__(\n        self, namespace, global_config, embedding_func, workspace=None, meta_fields=None\n    ):\n        super().__init__(\n            namespace=namespace,\n            workspace=workspace or \"\",\n            global_config=global_config,\n            embedding_func=embedding_func,\n            meta_fields=meta_fields or set(),\n        )\n        self.__post_init__()\n\n    def __post_init__(self):\n        self._validate_embedding_func()\n        self.workspace, self.final_namespace, self._index_name = _build_index_name(\n            self.workspace, self.namespace\n        )\n        kwargs = self.global_config.get(\"vector_db_storage_cls_kwargs\", {})\n        cosine_threshold = kwargs.get(\"cosine_better_than_threshold\")\n        if cosine_threshold is None:\n            raise ValueError(\n                \"cosine_better_than_threshold must be specified in vector_db_storage_cls_kwargs\"\n            )\n        self.cosine_better_than_threshold = cosine_threshold\n        self._max_batch_size = self.global_config[\"embedding_batch_num\"]\n\n    async def initialize(self):\n        \"\"\"Initialize client and create k-NN vector index.\"\"\"\n        async with get_data_init_lock():\n            if self.client is None:\n                self.client = await ClientManager.get_client()\n            await self._create_knn_index_if_not_exists()\n            self._index_ready = True\n            logger.debug(\n                f\"[{self.workspace}] OpenSearch Vector storage initialized: {self._index_name}\"\n            )\n\n    async def _ensure_index_ready(self):\n        \"\"\"Recreate the vector index before the next write if it is missing.\"\"\"\n        if self._index_ready:\n            return\n        async with get_data_init_lock():\n            if self.client is None:\n                self.client = await ClientManager.get_client()\n            if not self._index_ready:\n                await self._create_knn_index_if_not_exists()\n                self._index_ready = True\n\n    def _mark_index_missing(self):\n        \"\"\"Mark the vector index as unavailable for subsequent read short-circuiting.\"\"\"\n        self._index_ready = False\n\n    async def _create_knn_index_if_not_exists(self):\n        try:\n            if await self.client.indices.exists(index=self._index_name):\n                # Validate existing index dimension\n                try:\n                    mapping = await self.client.indices.get_mapping(\n                        index=self._index_name\n                    )\n                    existing_dim = (\n                        mapping[self._index_name][\"mappings\"][\"properties\"]\n                        .get(\"vector\", {})\n                        .get(\"dimension\")\n                    )\n                    expected_dim = self.embedding_func.embedding_dim\n                    if existing_dim is not None and existing_dim != expected_dim:\n                        raise ValueError(\n                            f\"Vector dimension mismatch! Index '{self._index_name}' has \"\n                            f\"dimension {existing_dim}, but current embedding model expects \"\n                            f\"dimension {expected_dim}. Please drop the existing index or \"\n                            f\"use an embedding model with matching dimensions.\"\n                        )\n                except (KeyError, TypeError):\n                    logger.warning(\n                        f\"[{self.workspace}] Could not read vector mapping for index \"\n                        f\"'{self._index_name}'; skipping dimension validation\"\n                    )\n                return\n\n            ef_construction = int(\n                _get_opensearch_env(\"OPENSEARCH_KNN_EF_CONSTRUCTION\", \"200\")\n            )\n            m = int(_get_opensearch_env(\"OPENSEARCH_KNN_M\", \"16\"))\n            ef_search = int(_get_opensearch_env(\"OPENSEARCH_KNN_EF_SEARCH\", \"100\"))\n\n            body = {\n                \"settings\": {\n                    \"index\": {\n                        \"knn\": True,\n                        \"knn.algo_param.ef_search\": ef_search,\n                        \"number_of_shards\": 1,\n                        \"number_of_replicas\": 0,\n                    }\n                },\n                \"mappings\": {\n                    \"properties\": {\n                        \"vector\": {\n                            \"type\": \"knn_vector\",\n                            \"dimension\": self.embedding_func.embedding_dim,\n                            \"method\": {\n                                \"name\": \"hnsw\",\n                                \"space_type\": \"cosinesimil\",\n                                \"engine\": \"lucene\",\n                                \"parameters\": {\n                                    \"ef_construction\": ef_construction,\n                                    \"m\": m,\n                                },\n                            },\n                        },\n                        \"content\": {\"type\": \"text\"},\n                        \"entity_name\": {\"type\": \"keyword\"},\n                        \"src_id\": {\"type\": \"keyword\"},\n                        \"tgt_id\": {\"type\": \"keyword\"},\n                        \"file_path\": {\"type\": \"keyword\"},\n                        \"created_at\": {\"type\": \"long\"},\n                    },\n                    \"dynamic\": True,\n                },\n            }\n            await self.client.indices.create(index=self._index_name, body=body)\n            logger.info(\n                f\"[{self.workspace}] Created k-NN index: {self._index_name} \"\n                f\"(dim={self.embedding_func.embedding_dim})\"\n            )\n        except RequestError as e:\n            if \"resource_already_exists_exception\" not in str(e):\n                logger.error(f\"[{self.workspace}] Error creating k-NN index: {e}\")\n                raise\n        except OpenSearchException as e:\n            logger.error(f\"[{self.workspace}] Error creating k-NN index: {e}\")\n            raise\n\n    async def finalize(self):\n        \"\"\"Release the OpenSearch client connection.\"\"\"\n        if self.client is not None:\n            await ClientManager.release_client(self.client)\n            self.client = None\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        \"\"\"Generate embeddings and upsert vectors in batches.\"\"\"\n        if not data:\n            return\n        await self._ensure_index_ready()\n        logger.debug(\n            f\"[{self.workspace}] Upserting {len(data)} vectors to {self.namespace}\"\n        )\n        current_time = int(time.time())\n\n        list_data = [\n            {\n                \"_id\": k,\n                \"created_at\": current_time,\n                **{k1: v1 for k1, v1 in v.items() if k1 in self.meta_fields},\n            }\n            for k, v in data.items()\n        ]\n        contents = [v[\"content\"] for v in data.values()]\n\n        batches = [\n            contents[i : i + self._max_batch_size]\n            for i in range(0, len(contents), self._max_batch_size)\n        ]\n        embeddings_list = await asyncio.gather(\n            *[self.embedding_func(batch) for batch in batches]\n        )\n        embeddings = np.concatenate(embeddings_list)\n\n        for i, doc in enumerate(list_data):\n            doc[\"vector\"] = embeddings[i].tolist()\n\n        actions = [\n            {\n                \"_op_type\": \"index\",\n                \"_index\": self._index_name,\n                \"_id\": doc[\"_id\"],\n                \"_source\": {k: v for k, v in doc.items() if k != \"_id\"},\n            }\n            for doc in list_data\n        ]\n        try:\n            # No per-operation refresh: immediate reads use ID-based mget (translog),\n            # k-NN search visibility is guaranteed after index_done_callback() batch refresh.\n            success, failed = await helpers.async_bulk(\n                self.client, actions, raise_on_error=False\n            )\n            if failed:\n                logger.warning(\n                    f\"[{self.workspace}] {len(failed)} vectors failed to upsert\"\n                )\n        except OpenSearchException as e:\n            logger.error(f\"[{self.workspace}] Error upserting vectors: {e}\")\n            raise\n\n    async def query(\n        self, query: str, top_k: int, query_embedding: list[float] = None\n    ) -> list[dict[str, Any]]:\n        \"\"\"k-NN similarity search with cosine score conversion for lucene engine.\"\"\"\n        if not self._index_ready:\n            return []\n        if query_embedding is not None:\n            query_vector = (\n                query_embedding.tolist()\n                if hasattr(query_embedding, \"tolist\")\n                else list(query_embedding)\n            )\n        else:\n            embedding = await self.embedding_func([query], _priority=5)\n            query_vector = embedding[0].tolist()\n\n        search_body = {\n            \"size\": top_k,\n            \"query\": {\"knn\": {\"vector\": {\"vector\": query_vector, \"k\": top_k}}},\n            \"_source\": {\"excludes\": [\"vector\"]},\n        }\n        try:\n            response = await self.client.search(\n                index=self._index_name, body=search_body\n            )\n            results = []\n            for hit in response[\"hits\"][\"hits\"]:\n                # OpenSearch k-NN with lucene engine and cosinesimil space type\n                # returns scores that can be used directly as similarity measure.\n                score = hit[\"_score\"]\n\n                if score >= self.cosine_better_than_threshold:\n                    doc = hit[\"_source\"]\n                    doc[\"id\"] = hit[\"_id\"]\n                    doc[\"distance\"] = score\n                    results.append(doc)\n            logger.info(\n                f\"[{self.workspace}] Vector query on {self._index_name}: \"\n                f\"top_k={top_k}, threshold={self.cosine_better_than_threshold}, \"\n                f\"total_hits={len(response['hits']['hits'])}, \"\n                f\"passed_filter={len(results)}, \"\n                f\"score_range=[{min((h['_score'] for h in response['hits']['hits']), default=0):.4f}, \"\n                f\"{max((h['_score'] for h in response['hits']['hits']), default=0):.4f}]\"\n            )\n            return results\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return []\n            logger.error(f\"[{self.workspace}] Error querying vectors: {e}\")\n            return []\n\n    async def index_done_callback(self) -> None:\n        \"\"\"Refresh index to make recently indexed vectors searchable.\"\"\"\n        if not self._index_ready:\n            return\n        try:\n            await self.client.indices.refresh(index=self._index_name)\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return\n        except Exception:\n            pass\n\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        \"\"\"Get a vector document by ID.\"\"\"\n        if not self._index_ready:\n            return None\n        try:\n            response = await _mget_optional_doc(self.client, self._index_name, id)\n            if response is None:\n                return None\n            doc = response[\"_source\"]\n            doc[\"id\"] = response[\"_id\"]\n            return doc\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return None\n            logger.error(f\"[{self.workspace}] Error getting vector {id}: {e}\")\n            return None\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        \"\"\"Get multiple vector documents by IDs, preserving order.\"\"\"\n        if not ids:\n            return []\n        if not self._index_ready:\n            return [None] * len(ids)\n        try:\n            response = await self.client.mget(index=self._index_name, body={\"ids\": ids})\n            doc_map = {}\n            for doc in response[\"docs\"]:\n                if doc.get(\"found\"):\n                    data = doc[\"_source\"]\n                    data[\"id\"] = doc[\"_id\"]\n                    doc_map[doc[\"_id\"]] = data\n            return [doc_map.get(id) for id in ids]\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return [None] * len(ids)\n            logger.error(f\"[{self.workspace}] Error getting vectors by ids: {e}\")\n            return [None] * len(ids)\n\n    async def get_vectors_by_ids(self, ids: list[str]) -> dict[str, list[float]]:\n        \"\"\"Get only the vector embeddings for given IDs.\"\"\"\n        if not ids:\n            return {}\n        if not self._index_ready:\n            return {}\n        try:\n            response = await self.client.mget(\n                index=self._index_name, body={\"ids\": ids}, _source_includes=[\"vector\"]\n            )\n            result = {}\n            for doc in response[\"docs\"]:\n                if doc.get(\"found\") and \"vector\" in doc.get(\"_source\", {}):\n                    result[doc[\"_id\"]] = doc[\"_source\"][\"vector\"]\n            return result\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return {}\n            logger.error(f\"[{self.workspace}] Error getting vectors: {e}\")\n            return {}\n\n    async def delete(self, ids: list[str]) -> None:\n        \"\"\"Delete vectors by their IDs.\"\"\"\n        if not ids:\n            return\n        if not self._index_ready:\n            return\n        if isinstance(ids, set):\n            ids = list(ids)\n        try:\n            # No per-operation refresh: search visibility after index_done_callback().\n            actions = [\n                {\"_op_type\": \"delete\", \"_index\": self._index_name, \"_id\": doc_id}\n                for doc_id in ids\n            ]\n            result = await helpers.async_bulk(\n                self.client, actions, raise_on_error=False\n            )\n            logger.debug(\n                f\"[{self.workspace}] Deleted {result[0]} vectors from {self.namespace}\"\n            )\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return\n            logger.error(f\"[{self.workspace}] Error deleting vectors: {e}\")\n\n    async def delete_entity(self, entity_name: str) -> None:\n        \"\"\"Delete an entity vector by computing its hash ID.\"\"\"\n        if not self._index_ready:\n            return\n        try:\n            # No per-operation refresh: search visibility after index_done_callback().\n            entity_id = compute_mdhash_id(entity_name, prefix=\"ent-\")\n            try:\n                await self.client.delete(index=self._index_name, id=entity_id)\n                logger.debug(f\"[{self.workspace}] Deleted entity {entity_name}\")\n            except NotFoundError as e:\n                if _is_missing_index_error(e):\n                    self._mark_index_missing()\n                    return\n                logger.debug(f\"[{self.workspace}] Entity {entity_name} not found\")\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return\n            logger.error(f\"[{self.workspace}] Error deleting entity {entity_name}: {e}\")\n\n    async def delete_entity_relation(self, entity_name: str) -> None:\n        \"\"\"Delete all relation vectors where entity appears as src or tgt.\"\"\"\n        if not self._index_ready:\n            return\n        try:\n            # No per-operation refresh: search visibility after index_done_callback().\n            body = {\n                \"query\": {\n                    \"bool\": {\n                        \"should\": [\n                            {\"term\": {\"src_id\": entity_name}},\n                            {\"term\": {\"tgt_id\": entity_name}},\n                        ]\n                    }\n                }\n            }\n            # conflicts=\"proceed\" tolerates stale search view after refresh removal.\n            await self.client.delete_by_query(\n                index=self._index_name, body=body, params={\"conflicts\": \"proceed\"}\n            )\n            logger.debug(\n                f\"[{self.workspace}] Deleted relations for entity {entity_name}\"\n            )\n        except OpenSearchException as e:\n            if _is_missing_index_error(e):\n                self._mark_index_missing()\n                return\n            logger.error(\n                f\"[{self.workspace}] Error deleting relations for {entity_name}: {e}\"\n            )\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Delete and recreate the vector index.\"\"\"\n        try:\n            try:\n                await self.client.indices.delete(index=self._index_name)\n                logger.info(\n                    f\"[{self.workspace}] Dropped vector index: {self._index_name}\"\n                )\n            except NotFoundError:\n                logger.info(\n                    f\"[{self.workspace}] Vector index already missing during drop: {self._index_name}\"\n                )\n            # Recreate the index\n            await self._create_knn_index_if_not_exists()\n            self._index_ready = True\n            logger.info(\n                f\"[{self.workspace}] Dropped and recreated vector index: {self._index_name}\"\n            )\n            return {\n                \"status\": \"success\",\n                \"message\": f\"Vector index {self._index_name} dropped and recreated\",\n            }\n        except OpenSearchException as e:\n            self._mark_index_missing()\n            logger.error(f\"[{self.workspace}] Error dropping vector index: {e}\")\n            return {\"status\": \"error\", \"message\": str(e)}\n        except Exception as e:\n            self._mark_index_missing()\n            logger.error(\n                f\"[{self.workspace}] Unexpected error dropping vector index: {e}\"\n            )\n            return {\"status\": \"error\", \"message\": str(e)}\n"
  },
  {
    "path": "lightrag/kg/postgres_impl.py",
    "content": "import asyncio\nimport hashlib\nimport json\nimport os\nimport re\nimport datetime\nfrom datetime import timezone\nfrom dataclasses import dataclass, field\nfrom typing import Any, Awaitable, Callable, TypeVar, Union, final\nimport numpy as np\nimport configparser\nimport ssl\nimport itertools\n\nfrom lightrag.types import KnowledgeGraph, KnowledgeGraphNode, KnowledgeGraphEdge\n\nfrom tenacity import (\n    AsyncRetrying,\n    RetryCallState,\n    retry,\n    retry_if_exception_type,\n    stop_after_attempt,\n    wait_exponential,\n    wait_fixed,\n)\n\nfrom ..base import (\n    BaseGraphStorage,\n    BaseKVStorage,\n    BaseVectorStorage,\n    DocProcessingStatus,\n    DocStatus,\n    DocStatusStorage,\n)\nfrom ..exceptions import DataMigrationError\nfrom ..namespace import NameSpace, is_namespace\nfrom ..utils import logger\nfrom ..kg.shared_storage import get_data_init_lock\n\nimport pipmaster as pm\n\nif not pm.is_installed(\"asyncpg\"):\n    pm.install(\"asyncpg\")\nif not pm.is_installed(\"pgvector\"):\n    pm.install(\"pgvector\")\n\nimport asyncpg  # type: ignore\nfrom asyncpg import Pool  # type: ignore\nfrom pgvector.asyncpg import register_vector  # type: ignore\n\nfrom dotenv import load_dotenv\n\n# use the .env that is inside the current folder\n# allows to use different .env file for each lightrag instance\n# the OS environment variables take precedence over the .env file\nload_dotenv(dotenv_path=\".env\", override=False)\n\nT = TypeVar(\"T\")\n\n# PostgreSQL identifier length limit (in bytes)\nPG_MAX_IDENTIFIER_LENGTH = 63\n\n\ndef _safe_index_name(table_name: str, index_suffix: str) -> str:\n    \"\"\"\n    Generate a PostgreSQL-safe index name that won't be truncated.\n\n    PostgreSQL silently truncates identifiers to 63 bytes. This function\n    ensures index names stay within that limit by hashing long table names.\n\n    Args:\n        table_name: The table name (may be long with model suffix)\n        index_suffix: The index type suffix (e.g., 'hnsw_cosine', 'id', 'workspace_id')\n\n    Returns:\n        A deterministic index name that fits within 63 bytes\n    \"\"\"\n    # Construct the full index name\n    full_name = f\"idx_{table_name.lower()}_{index_suffix}\"\n\n    # If it fits within the limit, use it as-is\n    if len(full_name.encode(\"utf-8\")) <= PG_MAX_IDENTIFIER_LENGTH:\n        return full_name\n\n    # Otherwise, hash the table name to create a shorter unique identifier\n    # Keep 'idx_' prefix and suffix readable, hash the middle\n    hash_input = table_name.lower().encode(\"utf-8\")\n    table_hash = hashlib.md5(hash_input).hexdigest()[:12]  # 12 hex chars\n\n    # Format: idx_{hash}_{suffix} - guaranteed to fit\n    # Maximum: idx_ (4) + hash (12) + _ (1) + suffix (variable) = 17 + suffix\n    shortened_name = f\"idx_{table_hash}_{index_suffix}\"\n\n    return shortened_name\n\n\ndef _dollar_quote(s: str, tag_prefix: str = \"AGE\") -> str:\n    \"\"\"\n    Generate a PostgreSQL dollar-quoted string with a unique tag.\n\n    PostgreSQL dollar-quoting uses $tag$ as delimiters. If the content contains\n    the same delimiter (e.g., $$ or $AGE1$), it will break the query.\n    This function finds a unique tag that doesn't conflict with the content.\n\n    Args:\n        s: The string to quote\n        tag_prefix: Prefix for generating unique tags (default: \"AGE\")\n\n    Returns:\n        The dollar-quoted string with a unique tag, e.g., $AGE1$content$AGE1$\n\n    Example:\n        >>> _dollar_quote(\"hello\")\n        '$AGE1$hello$AGE1$'\n        >>> _dollar_quote(\"$AGE1$ test\")\n        '$AGE2$$AGE1$ test$AGE2$'\n        >>> _dollar_quote(\"$$$\")  # Content with dollar signs\n        '$AGE1$$$$AGE1$'\n    \"\"\"\n    s = \"\" if s is None else str(s)\n    for i in itertools.count(1):\n        tag = f\"{tag_prefix}{i}\"\n        wrapper = f\"${tag}$\"\n        if wrapper not in s:\n            return f\"{wrapper}{s}{wrapper}\"\n\n\nclass PostgreSQLDB:\n    def __init__(self, config: dict[str, Any], **kwargs: Any):\n        self.host = config[\"host\"]\n        self.port = config[\"port\"]\n        self.user = config[\"user\"]\n        self.password = config[\"password\"]\n        self.database = config[\"database\"]\n        self.workspace = config[\"workspace\"]\n        self.max = int(config[\"max_connections\"])\n        self.increment = 1\n        self.pool: Pool | None = None\n\n        # SSL configuration\n        self.ssl_mode = config.get(\"ssl_mode\")\n        self.ssl_cert = config.get(\"ssl_cert\")\n        self.ssl_key = config.get(\"ssl_key\")\n        self.ssl_root_cert = config.get(\"ssl_root_cert\")\n        self.ssl_crl = config.get(\"ssl_crl\")\n\n        # Vector configuration\n        _ev = config.get(\"enable_vector\", True)\n        self.enable_vector = (\n            _ev\n            if isinstance(_ev, bool)\n            else str(_ev).lower() in (\"true\", \"1\", \"yes\", \"on\")\n        )  # True for backward compatibility, can be set to False to disable vector features\n        self.vector_index_type = config.get(\"vector_index_type\")\n        self.hnsw_m = config.get(\"hnsw_m\")\n        self.hnsw_ef = config.get(\"hnsw_ef\")\n        self.ivfflat_lists = config.get(\"ivfflat_lists\")\n        self.vchordrq_build_options = config.get(\"vchordrq_build_options\")\n        self.vchordrq_probes = config.get(\"vchordrq_probes\")\n        self.vchordrq_epsilon = config.get(\"vchordrq_epsilon\")\n\n        # Server settings\n        self.server_settings = config.get(\"server_settings\")\n\n        # Statement LRU cache size (keep as-is, allow None for optional configuration)\n        self.statement_cache_size = config.get(\"statement_cache_size\")\n\n        if self.user is None or self.password is None or self.database is None:\n            raise ValueError(\"Missing database user, password, or database\")\n\n        # Guard concurrent pool resets\n        self._pool_reconnect_lock = asyncio.Lock()\n\n        self._transient_exceptions = (\n            asyncio.TimeoutError,\n            TimeoutError,\n            ConnectionError,\n            OSError,\n            asyncpg.exceptions.InterfaceError,\n            asyncpg.exceptions.TooManyConnectionsError,\n            asyncpg.exceptions.CannotConnectNowError,\n            asyncpg.exceptions.PostgresConnectionError,\n            asyncpg.exceptions.ConnectionDoesNotExistError,\n            asyncpg.exceptions.ConnectionFailureError,\n        )\n\n        # Connection retry configuration\n        self.connection_retry_attempts = config[\"connection_retry_attempts\"]\n        self.connection_retry_backoff = config[\"connection_retry_backoff\"]\n        self.connection_retry_backoff_max = max(\n            self.connection_retry_backoff,\n            config[\"connection_retry_backoff_max\"],\n        )\n        self.pool_close_timeout = config[\"pool_close_timeout\"]\n        logger.info(\n            \"PostgreSQL, Retry config: attempts=%s, backoff=%.1fs, backoff_max=%.1fs, pool_close_timeout=%.1fs\",\n            self.connection_retry_attempts,\n            self.connection_retry_backoff,\n            self.connection_retry_backoff_max,\n            self.pool_close_timeout,\n        )\n\n    def _create_ssl_context(self) -> ssl.SSLContext | None:\n        \"\"\"Create SSL context based on configuration parameters.\"\"\"\n        if not self.ssl_mode:\n            return None\n\n        ssl_mode = self.ssl_mode.lower()\n\n        # For simple modes that don't require custom context\n        if ssl_mode in [\"disable\", \"allow\", \"prefer\", \"require\"]:\n            if ssl_mode == \"disable\":\n                return None\n            elif ssl_mode in [\"require\", \"prefer\", \"allow\"]:\n                # Return None for simple SSL requirement, handled in initdb\n                return None\n\n        # For modes that require certificate verification\n        if ssl_mode in [\"verify-ca\", \"verify-full\"]:\n            try:\n                context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)\n\n                # Configure certificate verification\n                if ssl_mode == \"verify-ca\":\n                    context.check_hostname = False\n                elif ssl_mode == \"verify-full\":\n                    context.check_hostname = True\n\n                # Load root certificate if provided\n                if self.ssl_root_cert:\n                    if os.path.exists(self.ssl_root_cert):\n                        context.load_verify_locations(cafile=self.ssl_root_cert)\n                        logger.info(\n                            f\"PostgreSQL, Loaded SSL root certificate: {self.ssl_root_cert}\"\n                        )\n                    else:\n                        logger.warning(\n                            f\"PostgreSQL, SSL root certificate file not found: {self.ssl_root_cert}\"\n                        )\n\n                # Load client certificate and key if provided\n                if self.ssl_cert and self.ssl_key:\n                    if os.path.exists(self.ssl_cert) and os.path.exists(self.ssl_key):\n                        context.load_cert_chain(self.ssl_cert, self.ssl_key)\n                        logger.info(\n                            f\"PostgreSQL, Loaded SSL client certificate: {self.ssl_cert}\"\n                        )\n                    else:\n                        logger.warning(\n                            \"PostgreSQL, SSL client certificate or key file not found\"\n                        )\n\n                # Load certificate revocation list if provided\n                if self.ssl_crl:\n                    if os.path.exists(self.ssl_crl):\n                        context.load_verify_locations(crlfile=self.ssl_crl)\n                        logger.info(f\"PostgreSQL, Loaded SSL CRL: {self.ssl_crl}\")\n                    else:\n                        logger.warning(\n                            f\"PostgreSQL, SSL CRL file not found: {self.ssl_crl}\"\n                        )\n\n                return context\n\n            except Exception as e:\n                logger.error(f\"PostgreSQL, Failed to create SSL context: {e}\")\n                raise ValueError(f\"SSL configuration error: {e}\")\n\n        # Unknown SSL mode\n        logger.warning(f\"PostgreSQL, Unknown SSL mode: {ssl_mode}, SSL disabled\")\n        return None\n\n    async def initdb(self):\n        # Prepare connection parameters\n        connection_params = {\n            \"user\": self.user,\n            \"password\": self.password,\n            \"database\": self.database,\n            \"host\": self.host,\n            \"port\": self.port,\n            \"min_size\": 1,\n            \"max_size\": self.max,\n        }\n\n        # Only add statement_cache_size if it's configured\n        if self.statement_cache_size is not None:\n            connection_params[\"statement_cache_size\"] = int(self.statement_cache_size)\n            logger.info(\n                f\"PostgreSQL, statement LRU cache size set as: {self.statement_cache_size}\"\n            )\n\n        # Add SSL configuration if provided\n        ssl_context = self._create_ssl_context()\n        if ssl_context is not None:\n            connection_params[\"ssl\"] = ssl_context\n            logger.info(\"PostgreSQL, SSL configuration applied\")\n        elif self.ssl_mode:\n            # Handle simple SSL modes without custom context\n            if self.ssl_mode.lower() in [\"require\", \"prefer\"]:\n                connection_params[\"ssl\"] = True\n            elif self.ssl_mode.lower() == \"disable\":\n                connection_params[\"ssl\"] = False\n            logger.info(f\"PostgreSQL, SSL mode set to: {self.ssl_mode}\")\n\n        # Add server settings if provided\n        if self.server_settings:\n            try:\n                settings = {}\n                # The format is expected to be a query string, e.g., \"key1=value1&key2=value2\"\n                pairs = self.server_settings.split(\"&\")\n                for pair in pairs:\n                    if \"=\" in pair:\n                        key, value = pair.split(\"=\", 1)\n                        settings[key] = value\n                if settings:\n                    connection_params[\"server_settings\"] = settings\n                    logger.info(f\"PostgreSQL, Server settings applied: {settings}\")\n            except Exception as e:\n                logger.warning(\n                    f\"PostgreSQL, Failed to parse server_settings: {self.server_settings}, error: {e}\"\n                )\n\n        wait_strategy = (\n            wait_exponential(\n                multiplier=self.connection_retry_backoff,\n                min=self.connection_retry_backoff,\n                max=self.connection_retry_backoff_max,\n            )\n            if self.connection_retry_backoff > 0\n            else wait_fixed(0)\n        )\n\n        async def _init_connection(connection: asyncpg.Connection) -> None:\n            \"\"\"Initialize each connection with pgvector codec.\n\n            This callback is invoked by asyncpg for every new connection in the pool.\n            Registering the vector codec here ensures ALL connections can properly\n            encode/decode vector columns, eliminating non-deterministic behavior\n            where some connections have the codec and others don't.\n            \"\"\"\n            if self.enable_vector:\n                await register_vector(connection)\n\n        async def _create_pool_once() -> None:\n            # STEP 1: Bootstrap - ensure vector extension exists BEFORE pool creation.\n            # On a fresh database, register_vector() in _init_connection will fail\n            # if the vector extension doesn't exist yet, because the 'vector' type\n            # won't be found in pg_catalog. We must create the extension first\n            # using a standalone bootstrap connection.\n            # Skip this step if vector support is not enabled.\n            if self.enable_vector:\n                bootstrap_conn = await asyncpg.connect(\n                    user=self.user,\n                    password=self.password,\n                    database=self.database,\n                    host=self.host,\n                    port=self.port,\n                    ssl=connection_params.get(\"ssl\"),\n                )\n                try:\n                    await self.configure_vector_extension(bootstrap_conn)\n                finally:\n                    await bootstrap_conn.close()\n\n            # STEP 2: Now safe to create pool with register_vector callback.\n            # The vector extension is guaranteed to exist at this point (if enabled).\n            pool = await asyncpg.create_pool(\n                **connection_params,\n                init=_init_connection,  # Register pgvector codec on every connection (if enabled)\n            )  # type: ignore\n            self.pool = pool\n\n        try:\n            async for attempt in AsyncRetrying(\n                stop=stop_after_attempt(self.connection_retry_attempts),\n                retry=retry_if_exception_type(self._transient_exceptions),\n                wait=wait_strategy,\n                before_sleep=self._before_sleep,\n                reraise=True,\n            ):\n                with attempt:\n                    await _create_pool_once()\n\n            ssl_status = \"with SSL\" if connection_params.get(\"ssl\") else \"without SSL\"\n            logger.info(\n                f\"PostgreSQL, Connected to database at {self.host}:{self.port}/{self.database} {ssl_status}\"\n            )\n        except Exception as e:\n            logger.error(\n                f\"PostgreSQL, Failed to connect database at {self.host}:{self.port}/{self.database}, Got:{e}\"\n            )\n            raise\n\n    async def _ensure_pool(self) -> None:\n        \"\"\"Ensure the connection pool is initialised.\"\"\"\n        if self.pool is None:\n            async with self._pool_reconnect_lock:\n                if self.pool is None:\n                    await self.initdb()\n\n    async def _reset_pool(self) -> None:\n        async with self._pool_reconnect_lock:\n            if self.pool is not None:\n                try:\n                    await asyncio.wait_for(\n                        self.pool.close(), timeout=self.pool_close_timeout\n                    )\n                except asyncio.TimeoutError:\n                    logger.error(\n                        \"PostgreSQL, Timed out closing connection pool after %.2fs\",\n                        self.pool_close_timeout,\n                    )\n                except Exception as close_error:  # pragma: no cover - defensive logging\n                    logger.warning(\n                        f\"PostgreSQL, Failed to close existing connection pool cleanly: {close_error!r}\"\n                    )\n            self.pool = None\n\n    async def _before_sleep(self, retry_state: RetryCallState) -> None:\n        \"\"\"Hook invoked by tenacity before sleeping between retries.\"\"\"\n        exc = retry_state.outcome.exception() if retry_state.outcome else None\n        logger.warning(\n            \"PostgreSQL transient connection issue on attempt %s/%s: %r\",\n            retry_state.attempt_number,\n            self.connection_retry_attempts,\n            exc,\n        )\n        await self._reset_pool()\n\n    async def _run_with_retry(\n        self,\n        operation: Callable[[asyncpg.Connection], Awaitable[T]],\n        *,\n        with_age: bool = False,\n        graph_name: str | None = None,\n    ) -> T:\n        \"\"\"\n        Execute a database operation with automatic retry for transient failures.\n\n        Args:\n            operation: Async callable that receives an active connection.\n            with_age: Whether to configure Apache AGE on the connection.\n            graph_name: AGE graph name; required when with_age is True.\n\n        Returns:\n            The result returned by the operation.\n\n        Raises:\n            Exception: Propagates the last error if all retry attempts fail or a non-transient error occurs.\n        \"\"\"\n        wait_strategy = (\n            wait_exponential(\n                multiplier=self.connection_retry_backoff,\n                min=self.connection_retry_backoff,\n                max=self.connection_retry_backoff_max,\n            )\n            if self.connection_retry_backoff > 0\n            else wait_fixed(0)\n        )\n\n        async for attempt in AsyncRetrying(\n            stop=stop_after_attempt(self.connection_retry_attempts),\n            retry=retry_if_exception_type(self._transient_exceptions),\n            wait=wait_strategy,\n            before_sleep=self._before_sleep,\n            reraise=True,\n        ):\n            with attempt:\n                await self._ensure_pool()\n                assert self.pool is not None\n                async with self.pool.acquire() as connection:  # type: ignore[arg-type]\n                    if with_age and graph_name:\n                        await self.configure_age(connection, graph_name)\n                    elif with_age and not graph_name:\n                        raise ValueError(\"Graph name is required when with_age is True\")\n                    if self.enable_vector and self.vector_index_type == \"VCHORDRQ\":\n                        await self.configure_vchordrq(connection)\n                    return await operation(connection)\n\n    @staticmethod\n    async def configure_vector_extension(connection: asyncpg.Connection) -> None:\n        \"\"\"Create VECTOR extension if it doesn't exist for vector similarity operations.\"\"\"\n        try:\n            await connection.execute(\"CREATE EXTENSION IF NOT EXISTS vector\")  # type: ignore\n            logger.info(\"PostgreSQL, VECTOR extension enabled\")\n        except Exception as e:\n            logger.warning(f\"Could not create VECTOR extension: {e}\")\n            # Don't raise - let the system continue without vector extension\n\n    @staticmethod\n    async def configure_age_extension(connection: asyncpg.Connection) -> None:\n        \"\"\"Create AGE extension if it doesn't exist for graph operations.\"\"\"\n        try:\n            await connection.execute(\"CREATE EXTENSION IF NOT EXISTS AGE CASCADE\")  # type: ignore\n            logger.info(\"PostgreSQL, AGE extension enabled\")\n        except Exception as e:\n            logger.warning(f\"Could not create AGE extension: {e}\")\n            # Don't raise - let the system continue without AGE extension\n\n    @staticmethod\n    async def configure_age(connection: asyncpg.Connection, graph_name: str) -> None:\n        \"\"\"Set the Apache AGE environment and creates a graph if it does not exist.\n\n        This method:\n        - Sets the PostgreSQL `search_path` to include `ag_catalog`, ensuring that Apache AGE functions can be used without specifying the schema.\n        - Attempts to create a new graph with the provided `graph_name` if it does not already exist.\n        - Silently ignores errors related to the graph already existing.\n\n        \"\"\"\n        try:\n            await connection.execute(  # type: ignore\n                'SET search_path = ag_catalog, \"$user\", public'\n            )\n            await connection.execute(  # type: ignore\n                f\"select create_graph('{graph_name}')\"\n            )\n        except (\n            asyncpg.exceptions.InvalidSchemaNameError,\n            asyncpg.exceptions.UniqueViolationError,\n        ):\n            pass\n\n    async def configure_vchordrq(self, connection: asyncpg.Connection) -> None:\n        \"\"\"Configure VCHORDRQ extension for vector similarity search.\n\n        Raises:\n            asyncpg.exceptions.UndefinedObjectError: If VCHORDRQ extension is not installed\n            asyncpg.exceptions.InvalidParameterValueError: If parameter value is invalid\n\n        Note:\n            This method does not catch exceptions. Configuration errors will fail-fast,\n            while transient connection errors will be retried by _run_with_retry.\n        \"\"\"\n        # Handle probes parameter - only set if non-empty value is provided\n        if self.vchordrq_probes and str(self.vchordrq_probes).strip():\n            await connection.execute(f\"SET vchordrq.probes TO '{self.vchordrq_probes}'\")\n            logger.debug(f\"PostgreSQL, VCHORDRQ probes set to: {self.vchordrq_probes}\")\n\n        # Handle epsilon parameter independently - check for None to allow 0.0 as valid value\n        if self.vchordrq_epsilon is not None:\n            await connection.execute(f\"SET vchordrq.epsilon TO {self.vchordrq_epsilon}\")\n            logger.debug(\n                f\"PostgreSQL, VCHORDRQ epsilon set to: {self.vchordrq_epsilon}\"\n            )\n\n    async def _migrate_llm_cache_schema(self):\n        \"\"\"Migrate LLM cache schema: add new columns and remove deprecated mode field\"\"\"\n        try:\n            # Check if all columns exist\n            check_columns_sql = \"\"\"\n            SELECT column_name\n            FROM information_schema.columns\n            WHERE table_name = 'lightrag_llm_cache'\n            AND column_name IN ('chunk_id', 'cache_type', 'queryparam', 'mode')\n            \"\"\"\n\n            existing_columns = await self.query(check_columns_sql, multirows=True)\n            existing_column_names = (\n                {col[\"column_name\"] for col in existing_columns}\n                if existing_columns\n                else set()\n            )\n\n            # Add missing chunk_id column\n            if \"chunk_id\" not in existing_column_names:\n                logger.info(\"Adding chunk_id column to LIGHTRAG_LLM_CACHE table\")\n                add_chunk_id_sql = \"\"\"\n                ALTER TABLE LIGHTRAG_LLM_CACHE\n                ADD COLUMN chunk_id VARCHAR(255) NULL\n                \"\"\"\n                await self.execute(add_chunk_id_sql)\n                logger.info(\n                    \"Successfully added chunk_id column to LIGHTRAG_LLM_CACHE table\"\n                )\n            else:\n                logger.info(\n                    \"chunk_id column already exists in LIGHTRAG_LLM_CACHE table\"\n                )\n\n            # Add missing cache_type column\n            if \"cache_type\" not in existing_column_names:\n                logger.info(\"Adding cache_type column to LIGHTRAG_LLM_CACHE table\")\n                add_cache_type_sql = \"\"\"\n                ALTER TABLE LIGHTRAG_LLM_CACHE\n                ADD COLUMN cache_type VARCHAR(32) NULL\n                \"\"\"\n                await self.execute(add_cache_type_sql)\n                logger.info(\n                    \"Successfully added cache_type column to LIGHTRAG_LLM_CACHE table\"\n                )\n\n                # Migrate existing data using optimized regex pattern\n                logger.info(\n                    \"Migrating existing LLM cache data to populate cache_type field (optimized)\"\n                )\n                optimized_update_sql = \"\"\"\n                UPDATE LIGHTRAG_LLM_CACHE\n                SET cache_type = CASE\n                    WHEN id ~ '^[^:]+:[^:]+:' THEN split_part(id, ':', 2)\n                    ELSE 'extract'\n                END\n                WHERE cache_type IS NULL\n                \"\"\"\n                await self.execute(optimized_update_sql)\n                logger.info(\"Successfully migrated existing LLM cache data\")\n            else:\n                logger.info(\n                    \"cache_type column already exists in LIGHTRAG_LLM_CACHE table\"\n                )\n\n            # Add missing queryparam column\n            if \"queryparam\" not in existing_column_names:\n                logger.info(\"Adding queryparam column to LIGHTRAG_LLM_CACHE table\")\n                add_queryparam_sql = \"\"\"\n                ALTER TABLE LIGHTRAG_LLM_CACHE\n                ADD COLUMN queryparam JSONB NULL\n                \"\"\"\n                await self.execute(add_queryparam_sql)\n                logger.info(\n                    \"Successfully added queryparam column to LIGHTRAG_LLM_CACHE table\"\n                )\n            else:\n                logger.info(\n                    \"queryparam column already exists in LIGHTRAG_LLM_CACHE table\"\n                )\n\n            # Remove deprecated mode field if it exists\n            if \"mode\" in existing_column_names:\n                logger.info(\n                    \"Removing deprecated mode column from LIGHTRAG_LLM_CACHE table\"\n                )\n\n                # First, drop the primary key constraint that includes mode\n                drop_pk_sql = \"\"\"\n                ALTER TABLE LIGHTRAG_LLM_CACHE\n                DROP CONSTRAINT IF EXISTS LIGHTRAG_LLM_CACHE_PK\n                \"\"\"\n                await self.execute(drop_pk_sql)\n                logger.info(\"Dropped old primary key constraint\")\n\n                # Drop the mode column\n                drop_mode_sql = \"\"\"\n                ALTER TABLE LIGHTRAG_LLM_CACHE\n                DROP COLUMN mode\n                \"\"\"\n                await self.execute(drop_mode_sql)\n                logger.info(\n                    \"Successfully removed mode column from LIGHTRAG_LLM_CACHE table\"\n                )\n\n                # Create new primary key constraint without mode\n                add_pk_sql = \"\"\"\n                ALTER TABLE LIGHTRAG_LLM_CACHE\n                ADD CONSTRAINT LIGHTRAG_LLM_CACHE_PK PRIMARY KEY (workspace, id)\n                \"\"\"\n                await self.execute(add_pk_sql)\n                logger.info(\"Created new primary key constraint (workspace, id)\")\n            else:\n                logger.info(\"mode column does not exist in LIGHTRAG_LLM_CACHE table\")\n\n        except Exception as e:\n            logger.warning(f\"Failed to migrate LLM cache schema: {e}\")\n\n    async def _migrate_timestamp_columns(self):\n        \"\"\"Migrate timestamp columns in tables to witimezone-free types, assuming original data is in UTC time\"\"\"\n        # Tables and columns that need migration\n        tables_to_migrate = {\n            \"LIGHTRAG_VDB_ENTITY\": [\"create_time\", \"update_time\"],\n            \"LIGHTRAG_VDB_RELATION\": [\"create_time\", \"update_time\"],\n            \"LIGHTRAG_DOC_CHUNKS\": [\"create_time\", \"update_time\"],\n            \"LIGHTRAG_DOC_STATUS\": [\"created_at\", \"updated_at\"],\n        }\n\n        try:\n            # Filter out tables that don't exist (e.g., legacy vector tables may not exist)\n            existing_tables = {}\n            for table_name, columns in tables_to_migrate.items():\n                if await self.check_table_exists(table_name):\n                    existing_tables[table_name] = columns\n                else:\n                    logger.debug(\n                        f\"Table {table_name} does not exist, skipping timestamp migration\"\n                    )\n\n            # Skip if no tables to migrate\n            if not existing_tables:\n                logger.debug(\"No tables found for timestamp migration\")\n                return\n\n            # Use filtered tables for migration\n            tables_to_migrate = existing_tables\n\n            # Optimization: Batch check all columns in one query instead of 8 separate queries\n            table_names_lower = [t.lower() for t in tables_to_migrate.keys()]\n            all_column_names = list(\n                set(col for cols in tables_to_migrate.values() for col in cols)\n            )\n\n            check_all_columns_sql = \"\"\"\n            SELECT table_name, column_name, data_type\n            FROM information_schema.columns\n            WHERE table_name = ANY($1)\n            AND column_name = ANY($2)\n            \"\"\"\n\n            all_columns_result = await self.query(\n                check_all_columns_sql,\n                [table_names_lower, all_column_names],\n                multirows=True,\n            )\n\n            # Build lookup dict: (table_name, column_name) -> data_type\n            column_types = {}\n            if all_columns_result:\n                column_types = {\n                    (row[\"table_name\"].upper(), row[\"column_name\"]): row[\"data_type\"]\n                    for row in all_columns_result\n                }\n\n            # Now iterate and migrate only what's needed\n            for table_name, columns in tables_to_migrate.items():\n                for column_name in columns:\n                    try:\n                        data_type = column_types.get((table_name, column_name))\n\n                        if not data_type:\n                            logger.warning(\n                                f\"Column {table_name}.{column_name} does not exist, skipping migration\"\n                            )\n                            continue\n\n                        # Check column type\n                        if data_type == \"timestamp without time zone\":\n                            logger.debug(\n                                f\"Column {table_name}.{column_name} is already witimezone-free, no migration needed\"\n                            )\n                            continue\n\n                        # Execute migration, explicitly specifying UTC timezone for interpreting original data\n                        logger.info(\n                            f\"Migrating {table_name}.{column_name} from {data_type} to TIMESTAMP(0) type\"\n                        )\n                        migration_sql = f\"\"\"\n                        ALTER TABLE {table_name}\n                        ALTER COLUMN {column_name} TYPE TIMESTAMP(0),\n                        ALTER COLUMN {column_name} SET DEFAULT CURRENT_TIMESTAMP\n                        \"\"\"\n\n                        await self.execute(migration_sql)\n                        logger.info(\n                            f\"Successfully migrated {table_name}.{column_name} to timezone-free type\"\n                        )\n                    except Exception as e:\n                        # Log error but don't interrupt the process\n                        logger.warning(\n                            f\"Failed to migrate {table_name}.{column_name}: {e}\"\n                        )\n        except Exception as e:\n            logger.error(f\"Failed to batch check timestamp columns: {e}\")\n\n    async def _migrate_doc_chunks_to_vdb_chunks(self):\n        \"\"\"\n        Migrate data from LIGHTRAG_DOC_CHUNKS to LIGHTRAG_VDB_CHUNKS if specific conditions are met.\n        This migration is intended for users who are upgrading and have an older table structure\n        where LIGHTRAG_DOC_CHUNKS contained a `content_vector` column.\n\n        \"\"\"\n        try:\n            # 0. Check if both tables exist before proceeding\n            vdb_chunks_exists = await self.check_table_exists(\"LIGHTRAG_VDB_CHUNKS\")\n            doc_chunks_exists = await self.check_table_exists(\"LIGHTRAG_DOC_CHUNKS\")\n\n            if not vdb_chunks_exists:\n                logger.debug(\n                    \"Skipping migration: LIGHTRAG_VDB_CHUNKS table does not exist\"\n                )\n                return\n\n            if not doc_chunks_exists:\n                logger.debug(\n                    \"Skipping migration: LIGHTRAG_DOC_CHUNKS table does not exist\"\n                )\n                return\n\n            # 1. Check if the new table LIGHTRAG_VDB_CHUNKS is empty\n            vdb_chunks_count_sql = \"SELECT COUNT(1) as count FROM LIGHTRAG_VDB_CHUNKS\"\n            vdb_chunks_count_result = await self.query(vdb_chunks_count_sql)\n            if vdb_chunks_count_result and vdb_chunks_count_result[\"count\"] > 0:\n                logger.info(\n                    \"Skipping migration: LIGHTRAG_VDB_CHUNKS already contains data.\"\n                )\n                return\n\n            # 2. Check if `content_vector` column exists in the old table\n            check_column_sql = \"\"\"\n            SELECT 1 FROM information_schema.columns\n            WHERE table_name = 'lightrag_doc_chunks' AND column_name = 'content_vector'\n            \"\"\"\n            column_exists = await self.query(check_column_sql)\n            if not column_exists:\n                logger.info(\n                    \"Skipping migration: `content_vector` not found in LIGHTRAG_DOC_CHUNKS\"\n                )\n                return\n\n            # 3. Check if the old table LIGHTRAG_DOC_CHUNKS has data\n            doc_chunks_count_sql = \"SELECT COUNT(1) as count FROM LIGHTRAG_DOC_CHUNKS\"\n            doc_chunks_count_result = await self.query(doc_chunks_count_sql)\n            if not doc_chunks_count_result or doc_chunks_count_result[\"count\"] == 0:\n                logger.info(\"Skipping migration: LIGHTRAG_DOC_CHUNKS is empty.\")\n                return\n\n            # 4. Perform the migration\n            logger.info(\n                \"Starting data migration from LIGHTRAG_DOC_CHUNKS to LIGHTRAG_VDB_CHUNKS...\"\n            )\n            migration_sql = \"\"\"\n            INSERT INTO LIGHTRAG_VDB_CHUNKS (\n                id, workspace, full_doc_id, chunk_order_index, tokens, content,\n                content_vector, file_path, create_time, update_time\n            )\n            SELECT\n                id, workspace, full_doc_id, chunk_order_index, tokens, content,\n                content_vector, file_path, create_time, update_time\n            FROM LIGHTRAG_DOC_CHUNKS\n            ON CONFLICT (workspace, id) DO NOTHING;\n            \"\"\"\n            await self.execute(migration_sql)\n            logger.info(\"Data migration to LIGHTRAG_VDB_CHUNKS completed successfully.\")\n\n        except Exception as e:\n            logger.error(f\"Failed during data migration to LIGHTRAG_VDB_CHUNKS: {e}\")\n            # Do not re-raise, to allow the application to start\n\n    async def _check_llm_cache_needs_migration(self):\n        \"\"\"Check if LLM cache data needs migration by examining any record with old format\"\"\"\n        try:\n            # Optimized query: directly check for old format records without sorting\n            check_sql = \"\"\"\n            SELECT 1 FROM LIGHTRAG_LLM_CACHE\n            WHERE id NOT LIKE '%:%'\n            LIMIT 1\n            \"\"\"\n            result = await self.query(check_sql)\n\n            # If any old format record exists, migration is needed\n            return result is not None\n\n        except Exception as e:\n            logger.warning(f\"Failed to check LLM cache migration status: {e}\")\n            return False\n\n    async def _migrate_llm_cache_to_flattened_keys(self):\n        \"\"\"Optimized version: directly execute single UPDATE migration to migrate old format cache keys to flattened format\"\"\"\n        try:\n            # Check if migration is needed\n            check_sql = \"\"\"\n            SELECT COUNT(*) as count FROM LIGHTRAG_LLM_CACHE\n            WHERE id NOT LIKE '%:%'\n            \"\"\"\n            result = await self.query(check_sql)\n\n            if not result or result[\"count\"] == 0:\n                logger.info(\"No old format LLM cache data found, skipping migration\")\n                return\n\n            old_count = result[\"count\"]\n            logger.info(f\"Found {old_count} old format cache records\")\n\n            # Check potential primary key conflicts (optional but recommended)\n            conflict_check_sql = \"\"\"\n            WITH new_ids AS (\n                SELECT\n                    workspace,\n                    mode,\n                    id as old_id,\n                    mode || ':' ||\n                    CASE WHEN mode = 'default' THEN 'extract' ELSE 'unknown' END || ':' ||\n                    md5(original_prompt) as new_id\n                FROM LIGHTRAG_LLM_CACHE\n                WHERE id NOT LIKE '%:%'\n            )\n            SELECT COUNT(*) as conflicts\n            FROM new_ids n1\n            JOIN LIGHTRAG_LLM_CACHE existing\n            ON existing.workspace = n1.workspace\n            AND existing.mode = n1.mode\n            AND existing.id = n1.new_id\n            WHERE existing.id LIKE '%:%'  -- Only check conflicts with existing new format records\n            \"\"\"\n\n            conflict_result = await self.query(conflict_check_sql)\n            if conflict_result and conflict_result[\"conflicts\"] > 0:\n                logger.warning(\n                    f\"Found {conflict_result['conflicts']} potential ID conflicts with existing records\"\n                )\n                # Can choose to continue or abort, here we choose to continue and log warning\n\n            # Execute single UPDATE migration\n            logger.info(\"Starting optimized LLM cache migration...\")\n            migration_sql = \"\"\"\n            UPDATE LIGHTRAG_LLM_CACHE\n            SET\n                id = mode || ':' ||\n                     CASE WHEN mode = 'default' THEN 'extract' ELSE 'unknown' END || ':' ||\n                     md5(original_prompt),\n                cache_type = CASE WHEN mode = 'default' THEN 'extract' ELSE 'unknown' END,\n                update_time = CURRENT_TIMESTAMP\n            WHERE id NOT LIKE '%:%'\n            \"\"\"\n\n            # Execute migration\n            await self.execute(migration_sql)\n\n            # Verify migration results\n            verify_sql = \"\"\"\n            SELECT COUNT(*) as remaining_old FROM LIGHTRAG_LLM_CACHE\n            WHERE id NOT LIKE '%:%'\n            \"\"\"\n            verify_result = await self.query(verify_sql)\n            remaining = verify_result[\"remaining_old\"] if verify_result else -1\n\n            if remaining == 0:\n                logger.info(\n                    f\"✅ Successfully migrated {old_count} LLM cache records to flattened format\"\n                )\n            else:\n                logger.warning(\n                    f\"⚠️ Migration completed but {remaining} old format records remain\"\n                )\n\n        except Exception as e:\n            logger.error(f\"Optimized LLM cache migration failed: {e}\")\n            raise\n\n    async def _migrate_doc_status_add_chunks_list(self):\n        \"\"\"Add chunks_list column to LIGHTRAG_DOC_STATUS table if it doesn't exist\"\"\"\n        try:\n            # Check if chunks_list column exists\n            check_column_sql = \"\"\"\n            SELECT column_name\n            FROM information_schema.columns\n            WHERE table_name = 'lightrag_doc_status'\n            AND column_name = 'chunks_list'\n            \"\"\"\n\n            column_info = await self.query(check_column_sql)\n            if not column_info:\n                logger.info(\"Adding chunks_list column to LIGHTRAG_DOC_STATUS table\")\n                add_column_sql = \"\"\"\n                ALTER TABLE LIGHTRAG_DOC_STATUS\n                ADD COLUMN chunks_list JSONB NULL DEFAULT '[]'::jsonb\n                \"\"\"\n                await self.execute(add_column_sql)\n                logger.info(\n                    \"Successfully added chunks_list column to LIGHTRAG_DOC_STATUS table\"\n                )\n            else:\n                logger.info(\n                    \"chunks_list column already exists in LIGHTRAG_DOC_STATUS table\"\n                )\n        except Exception as e:\n            logger.warning(\n                f\"Failed to add chunks_list column to LIGHTRAG_DOC_STATUS: {e}\"\n            )\n\n    async def _migrate_text_chunks_add_llm_cache_list(self):\n        \"\"\"Add llm_cache_list column to LIGHTRAG_DOC_CHUNKS table if it doesn't exist\"\"\"\n        try:\n            # Check if llm_cache_list column exists\n            check_column_sql = \"\"\"\n            SELECT column_name\n            FROM information_schema.columns\n            WHERE table_name = 'lightrag_doc_chunks'\n            AND column_name = 'llm_cache_list'\n            \"\"\"\n\n            column_info = await self.query(check_column_sql)\n            if not column_info:\n                logger.info(\"Adding llm_cache_list column to LIGHTRAG_DOC_CHUNKS table\")\n                add_column_sql = \"\"\"\n                ALTER TABLE LIGHTRAG_DOC_CHUNKS\n                ADD COLUMN llm_cache_list JSONB NULL DEFAULT '[]'::jsonb\n                \"\"\"\n                await self.execute(add_column_sql)\n                logger.info(\n                    \"Successfully added llm_cache_list column to LIGHTRAG_DOC_CHUNKS table\"\n                )\n            else:\n                logger.info(\n                    \"llm_cache_list column already exists in LIGHTRAG_DOC_CHUNKS table\"\n                )\n        except Exception as e:\n            logger.warning(\n                f\"Failed to add llm_cache_list column to LIGHTRAG_DOC_CHUNKS: {e}\"\n            )\n\n    async def _migrate_doc_status_add_track_id(self):\n        \"\"\"Add track_id column to LIGHTRAG_DOC_STATUS table if it doesn't exist and create index\"\"\"\n        try:\n            # Check if track_id column exists\n            check_column_sql = \"\"\"\n            SELECT column_name\n            FROM information_schema.columns\n            WHERE table_name = 'lightrag_doc_status'\n            AND column_name = 'track_id'\n            \"\"\"\n\n            column_info = await self.query(check_column_sql)\n            if not column_info:\n                logger.info(\"Adding track_id column to LIGHTRAG_DOC_STATUS table\")\n                add_column_sql = \"\"\"\n                ALTER TABLE LIGHTRAG_DOC_STATUS\n                ADD COLUMN track_id VARCHAR(255) NULL\n                \"\"\"\n                await self.execute(add_column_sql)\n                logger.info(\n                    \"Successfully added track_id column to LIGHTRAG_DOC_STATUS table\"\n                )\n            else:\n                logger.info(\n                    \"track_id column already exists in LIGHTRAG_DOC_STATUS table\"\n                )\n\n            # Check if track_id index exists\n            check_index_sql = \"\"\"\n            SELECT indexname\n            FROM pg_indexes\n            WHERE tablename = 'lightrag_doc_status'\n            AND indexname = 'idx_lightrag_doc_status_track_id'\n            \"\"\"\n\n            index_info = await self.query(check_index_sql)\n            if not index_info:\n                logger.info(\n                    \"Creating index on track_id column for LIGHTRAG_DOC_STATUS table\"\n                )\n                create_index_sql = \"\"\"\n                CREATE INDEX idx_lightrag_doc_status_track_id ON LIGHTRAG_DOC_STATUS (track_id)\n                \"\"\"\n                await self.execute(create_index_sql)\n                logger.info(\n                    \"Successfully created index on track_id column for LIGHTRAG_DOC_STATUS table\"\n                )\n            else:\n                logger.info(\n                    \"Index on track_id column already exists for LIGHTRAG_DOC_STATUS table\"\n                )\n\n        except Exception as e:\n            logger.warning(\n                f\"Failed to add track_id column or index to LIGHTRAG_DOC_STATUS: {e}\"\n            )\n\n    async def _migrate_doc_status_add_metadata_error_msg(self):\n        \"\"\"Add metadata and error_msg columns to LIGHTRAG_DOC_STATUS table if they don't exist\"\"\"\n        try:\n            # Check if metadata column exists\n            check_metadata_sql = \"\"\"\n            SELECT column_name\n            FROM information_schema.columns\n            WHERE table_name = 'lightrag_doc_status'\n            AND column_name = 'metadata'\n            \"\"\"\n\n            metadata_info = await self.query(check_metadata_sql)\n            if not metadata_info:\n                logger.info(\"Adding metadata column to LIGHTRAG_DOC_STATUS table\")\n                add_metadata_sql = \"\"\"\n                ALTER TABLE LIGHTRAG_DOC_STATUS\n                ADD COLUMN metadata JSONB NULL DEFAULT '{}'::jsonb\n                \"\"\"\n                await self.execute(add_metadata_sql)\n                logger.info(\n                    \"Successfully added metadata column to LIGHTRAG_DOC_STATUS table\"\n                )\n            else:\n                logger.info(\n                    \"metadata column already exists in LIGHTRAG_DOC_STATUS table\"\n                )\n\n            # Check if error_msg column exists\n            check_error_msg_sql = \"\"\"\n            SELECT column_name\n            FROM information_schema.columns\n            WHERE table_name = 'lightrag_doc_status'\n            AND column_name = 'error_msg'\n            \"\"\"\n\n            error_msg_info = await self.query(check_error_msg_sql)\n            if not error_msg_info:\n                logger.info(\"Adding error_msg column to LIGHTRAG_DOC_STATUS table\")\n                add_error_msg_sql = \"\"\"\n                ALTER TABLE LIGHTRAG_DOC_STATUS\n                ADD COLUMN error_msg TEXT NULL\n                \"\"\"\n                await self.execute(add_error_msg_sql)\n                logger.info(\n                    \"Successfully added error_msg column to LIGHTRAG_DOC_STATUS table\"\n                )\n            else:\n                logger.info(\n                    \"error_msg column already exists in LIGHTRAG_DOC_STATUS table\"\n                )\n\n        except Exception as e:\n            logger.warning(\n                f\"Failed to add metadata/error_msg columns to LIGHTRAG_DOC_STATUS: {e}\"\n            )\n\n    async def _migrate_field_lengths(self):\n        \"\"\"Migrate database field lengths: entity_name, source_id, target_id, and file_path\"\"\"\n        # Define the field changes needed\n        field_migrations = [\n            {\n                \"table\": \"LIGHTRAG_VDB_ENTITY\",\n                \"column\": \"entity_name\",\n                \"old_type\": \"character varying(255)\",\n                \"new_type\": \"VARCHAR(512)\",\n                \"description\": \"entity_name from 255 to 512\",\n            },\n            {\n                \"table\": \"LIGHTRAG_VDB_RELATION\",\n                \"column\": \"source_id\",\n                \"old_type\": \"character varying(256)\",\n                \"new_type\": \"VARCHAR(512)\",\n                \"description\": \"source_id from 256 to 512\",\n            },\n            {\n                \"table\": \"LIGHTRAG_VDB_RELATION\",\n                \"column\": \"target_id\",\n                \"old_type\": \"character varying(256)\",\n                \"new_type\": \"VARCHAR(512)\",\n                \"description\": \"target_id from 256 to 512\",\n            },\n            {\n                \"table\": \"LIGHTRAG_DOC_CHUNKS\",\n                \"column\": \"file_path\",\n                \"old_type\": \"character varying(256)\",\n                \"new_type\": \"TEXT\",\n                \"description\": \"file_path to TEXT NULL\",\n            },\n            {\n                \"table\": \"LIGHTRAG_VDB_CHUNKS\",\n                \"column\": \"file_path\",\n                \"old_type\": \"character varying(256)\",\n                \"new_type\": \"TEXT\",\n                \"description\": \"file_path to TEXT NULL\",\n            },\n        ]\n\n        try:\n            # Filter out tables that don't exist (e.g., legacy vector tables may not exist)\n            existing_migrations = []\n            for migration in field_migrations:\n                if await self.check_table_exists(migration[\"table\"]):\n                    existing_migrations.append(migration)\n                else:\n                    logger.debug(\n                        f\"Table {migration['table']} does not exist, skipping field length migration for {migration['column']}\"\n                    )\n\n            # Skip if no migrations to process\n            if not existing_migrations:\n                logger.debug(\"No tables found for field length migration\")\n                return\n\n            # Use filtered migrations for processing\n            field_migrations = existing_migrations\n\n            # Optimization: Batch check all columns in one query instead of 5 separate queries\n            unique_tables = list(set(m[\"table\"].lower() for m in field_migrations))\n            unique_columns = list(set(m[\"column\"] for m in field_migrations))\n\n            check_all_columns_sql = \"\"\"\n            SELECT table_name, column_name, data_type, character_maximum_length, is_nullable\n            FROM information_schema.columns\n            WHERE table_name = ANY($1)\n            AND column_name = ANY($2)\n            \"\"\"\n\n            all_columns_result = await self.query(\n                check_all_columns_sql, [unique_tables, unique_columns], multirows=True\n            )\n\n            # Build lookup dict: (table_name, column_name) -> column_info\n            column_info_map = {}\n            if all_columns_result:\n                column_info_map = {\n                    (row[\"table_name\"].upper(), row[\"column_name\"]): row\n                    for row in all_columns_result\n                }\n\n            # Now iterate and migrate only what's needed\n            for migration in field_migrations:\n                try:\n                    column_info = column_info_map.get(\n                        (migration[\"table\"], migration[\"column\"])\n                    )\n\n                    if not column_info:\n                        logger.warning(\n                            f\"Column {migration['table']}.{migration['column']} does not exist, skipping migration\"\n                        )\n                        continue\n\n                    current_type = column_info.get(\"data_type\", \"\").lower()\n                    current_length = column_info.get(\"character_maximum_length\")\n\n                    # Check if migration is needed\n                    needs_migration = False\n\n                    if migration[\"column\"] == \"entity_name\" and current_length == 255:\n                        needs_migration = True\n                    elif (\n                        migration[\"column\"] in [\"source_id\", \"target_id\"]\n                        and current_length == 256\n                    ):\n                        needs_migration = True\n                    elif (\n                        migration[\"column\"] == \"file_path\"\n                        and current_type == \"character varying\"\n                    ):\n                        needs_migration = True\n\n                    if needs_migration:\n                        logger.info(\n                            f\"Migrating {migration['table']}.{migration['column']}: {migration['description']}\"\n                        )\n\n                        # Execute the migration\n                        alter_sql = f\"\"\"\n                        ALTER TABLE {migration[\"table\"]}\n                        ALTER COLUMN {migration[\"column\"]} TYPE {migration[\"new_type\"]}\n                        \"\"\"\n\n                        await self.execute(alter_sql)\n                        logger.info(\n                            f\"Successfully migrated {migration['table']}.{migration['column']}\"\n                        )\n                    else:\n                        logger.debug(\n                            f\"Column {migration['table']}.{migration['column']} already has correct type, no migration needed\"\n                        )\n\n                except Exception as e:\n                    # Log error but don't interrupt the process\n                    logger.warning(\n                        f\"Failed to migrate {migration['table']}.{migration['column']}: {e}\"\n                    )\n        except Exception as e:\n            logger.error(f\"Failed to batch check field lengths: {e}\")\n\n    async def check_tables(self):\n        # Vector tables that should be skipped - they are created by PGVectorStorage.setup_table()\n        # with proper embedding model and dimension suffix for data isolation\n        vector_tables_to_skip = {\n            \"LIGHTRAG_VDB_CHUNKS\",\n            \"LIGHTRAG_VDB_ENTITY\",\n            \"LIGHTRAG_VDB_RELATION\",\n        }\n\n        # First create all tables (except vector tables)\n        for k, v in TABLES.items():\n            # Skip vector tables - they are created by PGVectorStorage.setup_table()\n            if k in vector_tables_to_skip:\n                continue\n\n            try:\n                await self.query(f\"SELECT 1 FROM {k} LIMIT 1\")\n            except Exception:\n                try:\n                    logger.info(f\"PostgreSQL, Try Creating table {k} in database\")\n                    await self.execute(v[\"ddl\"])\n                    logger.info(\n                        f\"PostgreSQL, Creation success table {k} in PostgreSQL database\"\n                    )\n                except Exception as e:\n                    logger.error(\n                        f\"PostgreSQL, Failed to create table {k} in database, Please verify the connection with PostgreSQL database, Got: {e}\"\n                    )\n                    raise e\n\n        # Batch check all indexes at once (optimization: single query instead of N queries)\n        try:\n            # Exclude vector tables from index creation since they are created by PGVectorStorage.setup_table()\n            table_names = [k for k in TABLES.keys() if k not in vector_tables_to_skip]\n            table_names_lower = [t.lower() for t in table_names]\n\n            # Get all existing indexes for our tables in one query\n            check_all_indexes_sql = \"\"\"\n            SELECT indexname, tablename\n            FROM pg_indexes\n            WHERE tablename = ANY($1)\n            \"\"\"\n            existing_indexes_result = await self.query(\n                check_all_indexes_sql, [table_names_lower], multirows=True\n            )\n\n            # Build a set of existing index names for fast lookup\n            existing_indexes = set()\n            if existing_indexes_result:\n                existing_indexes = {row[\"indexname\"] for row in existing_indexes_result}\n\n            # Create missing indexes\n            for k in table_names:\n                # Create index for id column if missing\n                index_name = f\"idx_{k.lower()}_id\"\n                if index_name not in existing_indexes:\n                    try:\n                        create_index_sql = f\"CREATE INDEX {index_name} ON {k}(id)\"\n                        logger.info(\n                            f\"PostgreSQL, Creating index {index_name} on table {k}\"\n                        )\n                        await self.execute(create_index_sql)\n                    except Exception as e:\n                        logger.error(\n                            f\"PostgreSQL, Failed to create index {index_name}, Got: {e}\"\n                        )\n\n                # Create composite index for (workspace, id) if missing\n                composite_index_name = f\"idx_{k.lower()}_workspace_id\"\n                if composite_index_name not in existing_indexes:\n                    try:\n                        create_composite_index_sql = (\n                            f\"CREATE INDEX {composite_index_name} ON {k}(workspace, id)\"\n                        )\n                        logger.info(\n                            f\"PostgreSQL, Creating composite index {composite_index_name} on table {k}\"\n                        )\n                        await self.execute(create_composite_index_sql)\n                    except Exception as e:\n                        logger.error(\n                            f\"PostgreSQL, Failed to create composite index {composite_index_name}, Got: {e}\"\n                        )\n        except Exception as e:\n            logger.error(f\"PostgreSQL, Failed to batch check/create indexes: {e}\")\n\n        # NOTE: Vector index creation moved to PGVectorStorage.setup_table()\n        # Each vector storage instance creates its own index with correct embedding_dim\n\n        # After all tables are created, attempt to migrate timestamp fields\n        try:\n            await self._migrate_timestamp_columns()\n        except Exception as e:\n            logger.error(f\"PostgreSQL, Failed to migrate timestamp columns: {e}\")\n            # Don't throw an exception, allow the initialization process to continue\n\n        # Migrate LLM cache schema: add new columns and remove deprecated mode field\n        try:\n            await self._migrate_llm_cache_schema()\n        except Exception as e:\n            logger.error(f\"PostgreSQL, Failed to migrate LLM cache schema: {e}\")\n            # Don't throw an exception, allow the initialization process to continue\n\n        # Finally, attempt to migrate old doc chunks data if needed\n        try:\n            await self._migrate_doc_chunks_to_vdb_chunks()\n        except Exception as e:\n            logger.error(f\"PostgreSQL, Failed to migrate doc_chunks to vdb_chunks: {e}\")\n\n        # Check and migrate LLM cache to flattened keys if needed\n        try:\n            if await self._check_llm_cache_needs_migration():\n                await self._migrate_llm_cache_to_flattened_keys()\n        except Exception as e:\n            logger.error(f\"PostgreSQL, LLM cache migration failed: {e}\")\n\n        # Migrate doc status to add chunks_list field if needed\n        try:\n            await self._migrate_doc_status_add_chunks_list()\n        except Exception as e:\n            logger.error(\n                f\"PostgreSQL, Failed to migrate doc status chunks_list field: {e}\"\n            )\n\n        # Migrate text chunks to add llm_cache_list field if needed\n        try:\n            await self._migrate_text_chunks_add_llm_cache_list()\n        except Exception as e:\n            logger.error(\n                f\"PostgreSQL, Failed to migrate text chunks llm_cache_list field: {e}\"\n            )\n\n        # Migrate field lengths for entity_name, source_id, target_id, and file_path\n        try:\n            await self._migrate_field_lengths()\n        except Exception as e:\n            logger.error(f\"PostgreSQL, Failed to migrate field lengths: {e}\")\n\n        # Migrate doc status to add track_id field if needed\n        try:\n            await self._migrate_doc_status_add_track_id()\n        except Exception as e:\n            logger.error(\n                f\"PostgreSQL, Failed to migrate doc status track_id field: {e}\"\n            )\n\n        # Migrate doc status to add metadata and error_msg fields if needed\n        try:\n            await self._migrate_doc_status_add_metadata_error_msg()\n        except Exception as e:\n            logger.error(\n                f\"PostgreSQL, Failed to migrate doc status metadata/error_msg fields: {e}\"\n            )\n\n        # Create pagination optimization indexes for LIGHTRAG_DOC_STATUS\n        try:\n            await self._create_pagination_indexes()\n        except Exception as e:\n            logger.error(f\"PostgreSQL, Failed to create pagination indexes: {e}\")\n\n        # Migrate to ensure new tables LIGHTRAG_FULL_ENTITIES and LIGHTRAG_FULL_RELATIONS exist\n        try:\n            await self._migrate_create_full_entities_relations_tables()\n        except Exception as e:\n            logger.error(\n                f\"PostgreSQL, Failed to create full entities/relations tables: {e}\"\n            )\n\n    async def _migrate_create_full_entities_relations_tables(self):\n        \"\"\"Create LIGHTRAG_FULL_ENTITIES and LIGHTRAG_FULL_RELATIONS tables if they don't exist\"\"\"\n        tables_to_check = [\n            {\n                \"name\": \"LIGHTRAG_FULL_ENTITIES\",\n                \"ddl\": TABLES[\"LIGHTRAG_FULL_ENTITIES\"][\"ddl\"],\n                \"description\": \"Full entities storage table\",\n            },\n            {\n                \"name\": \"LIGHTRAG_FULL_RELATIONS\",\n                \"ddl\": TABLES[\"LIGHTRAG_FULL_RELATIONS\"][\"ddl\"],\n                \"description\": \"Full relations storage table\",\n            },\n        ]\n\n        for table_info in tables_to_check:\n            table_name = table_info[\"name\"]\n            try:\n                # Check if table exists\n                check_table_sql = \"\"\"\n                SELECT table_name\n                FROM information_schema.tables\n                WHERE table_name = $1\n                AND table_schema = 'public'\n                \"\"\"\n                params = {\"table_name\": table_name.lower()}\n                table_exists = await self.query(check_table_sql, list(params.values()))\n\n                if not table_exists:\n                    logger.info(f\"Creating table {table_name}\")\n                    await self.execute(table_info[\"ddl\"])\n                    logger.info(\n                        f\"Successfully created {table_info['description']}: {table_name}\"\n                    )\n\n                    # Create basic indexes for the new table\n                    try:\n                        # Create index for id column\n                        index_name = f\"idx_{table_name.lower()}_id\"\n                        create_index_sql = (\n                            f\"CREATE INDEX {index_name} ON {table_name}(id)\"\n                        )\n                        await self.execute(create_index_sql)\n                        logger.info(f\"Created index {index_name} on table {table_name}\")\n\n                        # Create composite index for (workspace, id) columns\n                        composite_index_name = f\"idx_{table_name.lower()}_workspace_id\"\n                        create_composite_index_sql = f\"CREATE INDEX {composite_index_name} ON {table_name}(workspace, id)\"\n                        await self.execute(create_composite_index_sql)\n                        logger.info(\n                            f\"Created composite index {composite_index_name} on table {table_name}\"\n                        )\n\n                    except Exception as e:\n                        logger.warning(\n                            f\"Failed to create indexes for table {table_name}: {e}\"\n                        )\n\n                else:\n                    logger.debug(f\"Table {table_name} already exists\")\n\n            except Exception as e:\n                logger.error(f\"Failed to create table {table_name}: {e}\")\n\n    async def _create_pagination_indexes(self):\n        \"\"\"Create indexes to optimize pagination queries for LIGHTRAG_DOC_STATUS\"\"\"\n        indexes = [\n            {\n                \"name\": \"idx_lightrag_doc_status_workspace_status_updated_at\",\n                \"sql\": \"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_lightrag_doc_status_workspace_status_updated_at ON LIGHTRAG_DOC_STATUS (workspace, status, updated_at DESC)\",\n                \"description\": \"Composite index for workspace + status + updated_at pagination\",\n            },\n            {\n                \"name\": \"idx_lightrag_doc_status_workspace_status_created_at\",\n                \"sql\": \"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_lightrag_doc_status_workspace_status_created_at ON LIGHTRAG_DOC_STATUS (workspace, status, created_at DESC)\",\n                \"description\": \"Composite index for workspace + status + created_at pagination\",\n            },\n            {\n                \"name\": \"idx_lightrag_doc_status_workspace_updated_at\",\n                \"sql\": \"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_lightrag_doc_status_workspace_updated_at ON LIGHTRAG_DOC_STATUS (workspace, updated_at DESC)\",\n                \"description\": \"Index for workspace + updated_at pagination (all statuses)\",\n            },\n            {\n                \"name\": \"idx_lightrag_doc_status_workspace_created_at\",\n                \"sql\": \"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_lightrag_doc_status_workspace_created_at ON LIGHTRAG_DOC_STATUS (workspace, created_at DESC)\",\n                \"description\": \"Index for workspace + created_at pagination (all statuses)\",\n            },\n            {\n                \"name\": \"idx_lightrag_doc_status_workspace_id\",\n                \"sql\": \"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_lightrag_doc_status_workspace_id ON LIGHTRAG_DOC_STATUS (workspace, id)\",\n                \"description\": \"Index for workspace + id sorting\",\n            },\n            {\n                \"name\": \"idx_lightrag_doc_status_workspace_file_path\",\n                \"sql\": \"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_lightrag_doc_status_workspace_file_path ON LIGHTRAG_DOC_STATUS (workspace, file_path)\",\n                \"description\": \"Index for workspace + file_path sorting\",\n            },\n        ]\n\n        for index in indexes:\n            try:\n                # Check if index already exists\n                check_sql = \"\"\"\n                SELECT indexname\n                FROM pg_indexes\n                WHERE tablename = 'lightrag_doc_status'\n                AND indexname = $1\n                \"\"\"\n\n                params = {\"indexname\": index[\"name\"]}\n                existing = await self.query(check_sql, list(params.values()))\n\n                if not existing:\n                    logger.info(f\"Creating pagination index: {index['description']}\")\n                    await self.execute(index[\"sql\"])\n                    logger.info(f\"Successfully created index: {index['name']}\")\n                else:\n                    logger.debug(f\"Index already exists: {index['name']}\")\n\n            except Exception as e:\n                logger.warning(f\"Failed to create index {index['name']}: {e}\")\n\n    async def _create_vector_index(self, table_name: str, embedding_dim: int):\n        \"\"\"\n        Create vector index for a specific table.\n\n        Args:\n            table_name: Name of the table to create index on\n            embedding_dim: Embedding dimension for the vector column\n        \"\"\"\n        if not self.vector_index_type:\n            return\n\n        create_sql = {\n            \"HNSW\": f\"\"\"\n                CREATE INDEX {{vector_index_name}}\n                ON {{table_name}} USING hnsw (content_vector vector_cosine_ops)\n                WITH (m = {self.hnsw_m}, ef_construction = {self.hnsw_ef})\n            \"\"\",\n            \"IVFFLAT\": f\"\"\"\n                CREATE INDEX {{vector_index_name}}\n                ON {{table_name}} USING ivfflat (content_vector vector_cosine_ops)\n                WITH (lists = {self.ivfflat_lists})\n            \"\"\",\n            \"VCHORDRQ\": f\"\"\"\n                CREATE INDEX {{vector_index_name}}\n                ON {{table_name}} USING vchordrq (content_vector vector_cosine_ops)\n                {f\"WITH (options = $${self.vchordrq_build_options}$$)\" if self.vchordrq_build_options else \"\"}\n            \"\"\",\n        }\n\n        if self.vector_index_type not in create_sql:\n            logger.warning(\n                f\"Unsupported vector index type: {self.vector_index_type}. \"\n                \"Supported types: HNSW, IVFFLAT, VCHORDRQ\"\n            )\n            return\n\n        k = table_name\n        # Use _safe_index_name to avoid PostgreSQL's 63-byte identifier truncation\n        index_suffix = f\"{self.vector_index_type.lower()}_cosine\"\n        vector_index_name = _safe_index_name(k, index_suffix)\n        check_vector_index_sql = f\"\"\"\n            SELECT 1 FROM pg_indexes\n            WHERE indexname = '{vector_index_name}' AND tablename = '{k.lower()}'\n        \"\"\"\n        try:\n            vector_index_exists = await self.query(check_vector_index_sql)\n            if not vector_index_exists:\n                # Only set vector dimension when index doesn't exist\n                alter_sql = f\"ALTER TABLE {k} ALTER COLUMN content_vector TYPE VECTOR({embedding_dim})\"\n                await self.execute(alter_sql)\n                logger.debug(f\"Ensured vector dimension for {k}\")\n                logger.info(\n                    f\"Creating {self.vector_index_type} index {vector_index_name} on table {k}\"\n                )\n                await self.execute(\n                    create_sql[self.vector_index_type].format(\n                        vector_index_name=vector_index_name, table_name=k\n                    )\n                )\n                logger.info(\n                    f\"Successfully created vector index {vector_index_name} on table {k}\"\n                )\n            else:\n                logger.info(\n                    f\"{self.vector_index_type} vector index {vector_index_name} already exists on table {k}\"\n                )\n        except Exception as e:\n            logger.error(f\"Failed to create vector index on table {k}, Got: {e}\")\n\n    async def query(\n        self,\n        sql: str,\n        params: list[Any] | None = None,\n        multirows: bool = False,\n        with_age: bool = False,\n        graph_name: str | None = None,\n    ) -> dict[str, Any] | None | list[dict[str, Any]]:\n        async def _operation(connection: asyncpg.Connection) -> Any:\n            prepared_params = tuple(params) if params else ()\n            if prepared_params:\n                rows = await connection.fetch(sql, *prepared_params)\n            else:\n                rows = await connection.fetch(sql)\n\n            if multirows:\n                if rows:\n                    columns = [col for col in rows[0].keys()]\n                    return [dict(zip(columns, row)) for row in rows]\n                return []\n\n            if rows:\n                columns = rows[0].keys()\n                return dict(zip(columns, rows[0]))\n            return None\n\n        try:\n            return await self._run_with_retry(\n                _operation, with_age=with_age, graph_name=graph_name\n            )\n        except Exception as e:\n            logger.error(f\"PostgreSQL database, error:{e}\")\n            raise\n\n    async def check_table_exists(self, table_name: str) -> bool:\n        \"\"\"Check if a table exists in PostgreSQL database\n\n        Args:\n            table_name: Name of the table to check\n\n        Returns:\n            bool: True if table exists, False otherwise\n        \"\"\"\n        query = \"\"\"\n            SELECT EXISTS (\n                SELECT FROM information_schema.tables\n                WHERE table_name = $1\n            )\n        \"\"\"\n        result = await self.query(query, [table_name.lower()])\n        return result.get(\"exists\", False) if result else False\n\n    async def execute(\n        self,\n        sql: str,\n        data: dict[str, Any] | None = None,\n        upsert: bool = False,\n        ignore_if_exists: bool = False,\n        with_age: bool = False,\n        graph_name: str | None = None,\n    ):\n        async def _operation(connection: asyncpg.Connection) -> Any:\n            prepared_values = tuple(data.values()) if data else ()\n            try:\n                if not data:\n                    return await connection.execute(sql)\n                return await connection.execute(sql, *prepared_values)\n            except (\n                asyncpg.exceptions.UniqueViolationError,\n                asyncpg.exceptions.DuplicateTableError,\n                asyncpg.exceptions.DuplicateObjectError,\n                asyncpg.exceptions.InvalidSchemaNameError,\n            ) as e:\n                if ignore_if_exists:\n                    logger.debug(\"PostgreSQL, ignoring duplicate during execute: %r\", e)\n                    return None\n                if upsert:\n                    logger.info(\n                        \"PostgreSQL, duplicate detected but treated as upsert success: %r\",\n                        e,\n                    )\n                    return None\n                raise\n\n        try:\n            await self._run_with_retry(\n                _operation, with_age=with_age, graph_name=graph_name\n            )\n        except Exception as e:\n            logger.error(f\"PostgreSQL database,\\nsql:{sql},\\ndata:{data},\\nerror:{e}\")\n            raise\n\n\nclass ClientManager:\n    _instances: dict[str, Any] = {\"db\": None, \"ref_count\": 0}\n    _lock = asyncio.Lock()\n\n    @staticmethod\n    def get_config() -> dict[str, Any]:\n        config = configparser.ConfigParser()\n        config.read(\"config.ini\", \"utf-8\")\n\n        return {\n            \"host\": os.environ.get(\n                \"POSTGRES_HOST\",\n                config.get(\"postgres\", \"host\", fallback=\"localhost\"),\n            ),\n            \"port\": os.environ.get(\n                \"POSTGRES_PORT\", config.get(\"postgres\", \"port\", fallback=5432)\n            ),\n            \"user\": os.environ.get(\n                \"POSTGRES_USER\", config.get(\"postgres\", \"user\", fallback=\"postgres\")\n            ),\n            \"password\": os.environ.get(\n                \"POSTGRES_PASSWORD\",\n                config.get(\"postgres\", \"password\", fallback=None),\n            ),\n            \"database\": os.environ.get(\n                \"POSTGRES_DATABASE\",\n                config.get(\"postgres\", \"database\", fallback=\"postgres\"),\n            ),\n            \"workspace\": os.environ.get(\n                \"POSTGRES_WORKSPACE\",\n                config.get(\"postgres\", \"workspace\", fallback=None),\n            ),\n            \"max_connections\": os.environ.get(\n                \"POSTGRES_MAX_CONNECTIONS\",\n                config.get(\"postgres\", \"max_connections\", fallback=50),\n            ),\n            # SSL configuration\n            \"ssl_mode\": os.environ.get(\n                \"POSTGRES_SSL_MODE\",\n                config.get(\"postgres\", \"ssl_mode\", fallback=None),\n            ),\n            \"ssl_cert\": os.environ.get(\n                \"POSTGRES_SSL_CERT\",\n                config.get(\"postgres\", \"ssl_cert\", fallback=None),\n            ),\n            \"ssl_key\": os.environ.get(\n                \"POSTGRES_SSL_KEY\",\n                config.get(\"postgres\", \"ssl_key\", fallback=None),\n            ),\n            \"ssl_root_cert\": os.environ.get(\n                \"POSTGRES_SSL_ROOT_CERT\",\n                config.get(\"postgres\", \"ssl_root_cert\", fallback=None),\n            ),\n            \"ssl_crl\": os.environ.get(\n                \"POSTGRES_SSL_CRL\",\n                config.get(\"postgres\", \"ssl_crl\", fallback=None),\n            ),\n            # Vector configuration\n            \"enable_vector\": os.environ.get(\n                \"POSTGRES_ENABLE_VECTOR\",\n                config.get(\"postgres\", \"enable_vector\", fallback=\"true\"),\n            ).lower()\n            in (\"true\", \"1\", \"yes\", \"on\"),\n            \"vector_index_type\": os.environ.get(\n                \"POSTGRES_VECTOR_INDEX_TYPE\",\n                config.get(\"postgres\", \"vector_index_type\", fallback=\"HNSW\"),\n            ),\n            \"hnsw_m\": int(\n                os.environ.get(\n                    \"POSTGRES_HNSW_M\",\n                    config.get(\"postgres\", \"hnsw_m\", fallback=\"16\"),\n                )\n            ),\n            \"hnsw_ef\": int(\n                os.environ.get(\n                    \"POSTGRES_HNSW_EF\",\n                    config.get(\"postgres\", \"hnsw_ef\", fallback=\"64\"),\n                )\n            ),\n            \"ivfflat_lists\": int(\n                os.environ.get(\n                    \"POSTGRES_IVFFLAT_LISTS\",\n                    config.get(\"postgres\", \"ivfflat_lists\", fallback=\"100\"),\n                )\n            ),\n            \"vchordrq_build_options\": os.environ.get(\n                \"POSTGRES_VCHORDRQ_BUILD_OPTIONS\",\n                config.get(\"postgres\", \"vchordrq_build_options\", fallback=\"\"),\n            ),\n            \"vchordrq_probes\": os.environ.get(\n                \"POSTGRES_VCHORDRQ_PROBES\",\n                config.get(\"postgres\", \"vchordrq_probes\", fallback=\"\"),\n            ),\n            \"vchordrq_epsilon\": float(\n                os.environ.get(\n                    \"POSTGRES_VCHORDRQ_EPSILON\",\n                    config.get(\"postgres\", \"vchordrq_epsilon\", fallback=\"1.9\"),\n                )\n            ),\n            # Server settings for Supabase\n            \"server_settings\": os.environ.get(\n                \"POSTGRES_SERVER_SETTINGS\",\n                config.get(\"postgres\", \"server_options\", fallback=None),\n            ),\n            \"statement_cache_size\": os.environ.get(\n                \"POSTGRES_STATEMENT_CACHE_SIZE\",\n                config.get(\"postgres\", \"statement_cache_size\", fallback=None),\n            ),\n            # Connection retry configuration\n            \"connection_retry_attempts\": min(\n                100,  # Increased from 10 to 100 for long-running operations\n                int(\n                    os.environ.get(\n                        \"POSTGRES_CONNECTION_RETRIES\",\n                        config.get(\"postgres\", \"connection_retries\", fallback=10),\n                    )\n                ),\n            ),\n            \"connection_retry_backoff\": min(\n                300.0,  # Increased from 5.0 to 300.0 (5 minutes) for PG switchover scenarios\n                float(\n                    os.environ.get(\n                        \"POSTGRES_CONNECTION_RETRY_BACKOFF\",\n                        config.get(\n                            \"postgres\", \"connection_retry_backoff\", fallback=3.0\n                        ),\n                    )\n                ),\n            ),\n            \"connection_retry_backoff_max\": min(\n                600.0,  # Increased from 60.0 to 600.0 (10 minutes) for PG switchover scenarios\n                float(\n                    os.environ.get(\n                        \"POSTGRES_CONNECTION_RETRY_BACKOFF_MAX\",\n                        config.get(\n                            \"postgres\",\n                            \"connection_retry_backoff_max\",\n                            fallback=30.0,\n                        ),\n                    )\n                ),\n            ),\n            \"pool_close_timeout\": min(\n                30.0,\n                float(\n                    os.environ.get(\n                        \"POSTGRES_POOL_CLOSE_TIMEOUT\",\n                        config.get(\"postgres\", \"pool_close_timeout\", fallback=5.0),\n                    )\n                ),\n            ),\n        }\n\n    @classmethod\n    async def get_client(cls) -> PostgreSQLDB:\n        async with cls._lock:\n            if cls._instances[\"db\"] is None:\n                config = ClientManager.get_config()\n                db = PostgreSQLDB(config)\n                await db.initdb()\n                await db.check_tables()\n                cls._instances[\"db\"] = db\n                cls._instances[\"ref_count\"] = 0\n            cls._instances[\"ref_count\"] += 1\n            return cls._instances[\"db\"]\n\n    @classmethod\n    async def release_client(cls, db: PostgreSQLDB):\n        async with cls._lock:\n            if db is not None:\n                if db is cls._instances[\"db\"]:\n                    cls._instances[\"ref_count\"] -= 1\n                    if cls._instances[\"ref_count\"] == 0:\n                        await db.pool.close()\n                        logger.info(\"Closed PostgreSQL database connection pool\")\n                        cls._instances[\"db\"] = None\n                else:\n                    await db.pool.close()\n\n\n@final\n@dataclass\nclass PGKVStorage(BaseKVStorage):\n    db: PostgreSQLDB = field(default=None)\n\n    def __post_init__(self):\n        self._max_batch_size = 200  # DB batch size, independent of embedding batch size\n\n    async def initialize(self):\n        async with get_data_init_lock():\n            if self.db is None:\n                self.db = await ClientManager.get_client()\n\n            # Implement workspace priority: PostgreSQLDB.workspace > self.workspace > \"default\"\n            if self.db.workspace:\n                # Use PostgreSQLDB's workspace (highest priority)\n                logger.info(\n                    f\"Using PG_WORKSPACE environment variable: '{self.db.workspace}' (overriding '{self.workspace}/{self.namespace}')\"\n                )\n                self.workspace = self.db.workspace\n            elif hasattr(self, \"workspace\") and self.workspace:\n                # Use storage class's workspace (medium priority)\n                pass\n            else:\n                # Use \"default\" for compatibility (lowest priority)\n                self.workspace = \"default\"\n\n    async def finalize(self):\n        if self.db is not None:\n            await ClientManager.release_client(self.db)\n            self.db = None\n\n    ################ QUERY METHODS ################\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        \"\"\"Get data by id.\"\"\"\n        sql = SQL_TEMPLATES[\"get_by_id_\" + self.namespace]\n        params = {\"workspace\": self.workspace, \"id\": id}\n        response = await self.db.query(sql, list(params.values()))\n\n        if response and is_namespace(self.namespace, NameSpace.KV_STORE_TEXT_CHUNKS):\n            # Parse llm_cache_list JSON string back to list\n            llm_cache_list = response.get(\"llm_cache_list\", [])\n            if isinstance(llm_cache_list, str):\n                try:\n                    llm_cache_list = json.loads(llm_cache_list)\n                except json.JSONDecodeError:\n                    llm_cache_list = []\n            response[\"llm_cache_list\"] = llm_cache_list\n            create_time = response.get(\"create_time\", 0)\n            update_time = response.get(\"update_time\", 0)\n            response[\"create_time\"] = create_time\n            response[\"update_time\"] = create_time if update_time == 0 else update_time\n\n        # Special handling for LLM cache to ensure compatibility with _get_cached_extraction_results\n        if response and is_namespace(\n            self.namespace, NameSpace.KV_STORE_LLM_RESPONSE_CACHE\n        ):\n            create_time = response.get(\"create_time\", 0)\n            update_time = response.get(\"update_time\", 0)\n            # Parse queryparam JSON string back to dict\n            queryparam = response.get(\"queryparam\")\n            if isinstance(queryparam, str):\n                try:\n                    queryparam = json.loads(queryparam)\n                except json.JSONDecodeError:\n                    queryparam = None\n            # Map field names for compatibility (mode field removed)\n            response = {\n                **response,\n                \"return\": response.get(\"return_value\", \"\"),\n                \"cache_type\": response.get(\"cache_type\"),\n                \"original_prompt\": response.get(\"original_prompt\", \"\"),\n                \"chunk_id\": response.get(\"chunk_id\"),\n                \"queryparam\": queryparam,\n                \"create_time\": create_time,\n                \"update_time\": create_time if update_time == 0 else update_time,\n            }\n\n        # Special handling for FULL_ENTITIES namespace\n        if response and is_namespace(self.namespace, NameSpace.KV_STORE_FULL_ENTITIES):\n            # Parse entity_names JSON string back to list\n            entity_names = response.get(\"entity_names\", [])\n            if isinstance(entity_names, str):\n                try:\n                    entity_names = json.loads(entity_names)\n                except json.JSONDecodeError:\n                    entity_names = []\n            response[\"entity_names\"] = entity_names\n            create_time = response.get(\"create_time\", 0)\n            update_time = response.get(\"update_time\", 0)\n            response[\"create_time\"] = create_time\n            response[\"update_time\"] = create_time if update_time == 0 else update_time\n\n        # Special handling for FULL_RELATIONS namespace\n        if response and is_namespace(self.namespace, NameSpace.KV_STORE_FULL_RELATIONS):\n            # Parse relation_pairs JSON string back to list\n            relation_pairs = response.get(\"relation_pairs\", [])\n            if isinstance(relation_pairs, str):\n                try:\n                    relation_pairs = json.loads(relation_pairs)\n                except json.JSONDecodeError:\n                    relation_pairs = []\n            response[\"relation_pairs\"] = relation_pairs\n            create_time = response.get(\"create_time\", 0)\n            update_time = response.get(\"update_time\", 0)\n            response[\"create_time\"] = create_time\n            response[\"update_time\"] = create_time if update_time == 0 else update_time\n\n        # Special handling for ENTITY_CHUNKS namespace\n        if response and is_namespace(self.namespace, NameSpace.KV_STORE_ENTITY_CHUNKS):\n            # Parse chunk_ids JSON string back to list\n            chunk_ids = response.get(\"chunk_ids\", [])\n            if isinstance(chunk_ids, str):\n                try:\n                    chunk_ids = json.loads(chunk_ids)\n                except json.JSONDecodeError:\n                    chunk_ids = []\n            response[\"chunk_ids\"] = chunk_ids\n            create_time = response.get(\"create_time\", 0)\n            update_time = response.get(\"update_time\", 0)\n            response[\"create_time\"] = create_time\n            response[\"update_time\"] = create_time if update_time == 0 else update_time\n\n        # Special handling for RELATION_CHUNKS namespace\n        if response and is_namespace(\n            self.namespace, NameSpace.KV_STORE_RELATION_CHUNKS\n        ):\n            # Parse chunk_ids JSON string back to list\n            chunk_ids = response.get(\"chunk_ids\", [])\n            if isinstance(chunk_ids, str):\n                try:\n                    chunk_ids = json.loads(chunk_ids)\n                except json.JSONDecodeError:\n                    chunk_ids = []\n            response[\"chunk_ids\"] = chunk_ids\n            create_time = response.get(\"create_time\", 0)\n            update_time = response.get(\"update_time\", 0)\n            response[\"create_time\"] = create_time\n            response[\"update_time\"] = create_time if update_time == 0 else update_time\n\n        return response if response else None\n\n    # Query by id\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        \"\"\"Get data by ids\"\"\"\n        if not ids:\n            return []\n\n        sql = SQL_TEMPLATES[\"get_by_ids_\" + self.namespace]\n        params = {\"workspace\": self.workspace, \"ids\": ids}\n        results = await self.db.query(sql, list(params.values()), multirows=True)\n\n        def _order_results(\n            rows: list[dict[str, Any]] | None,\n        ) -> list[dict[str, Any] | None]:\n            \"\"\"Preserve the caller requested ordering for bulk id lookups.\"\"\"\n            if not rows:\n                return [None for _ in ids]\n\n            id_map: dict[str, dict[str, Any]] = {}\n            for row in rows:\n                if row is None:\n                    continue\n                row_id = row.get(\"id\")\n                if row_id is not None:\n                    id_map[str(row_id)] = row\n\n            ordered: list[dict[str, Any] | None] = []\n            for requested_id in ids:\n                ordered.append(id_map.get(str(requested_id)))\n            return ordered\n\n        if results and is_namespace(self.namespace, NameSpace.KV_STORE_TEXT_CHUNKS):\n            # Parse llm_cache_list JSON string back to list for each result\n            for result in results:\n                llm_cache_list = result.get(\"llm_cache_list\", [])\n                if isinstance(llm_cache_list, str):\n                    try:\n                        llm_cache_list = json.loads(llm_cache_list)\n                    except json.JSONDecodeError:\n                        llm_cache_list = []\n                result[\"llm_cache_list\"] = llm_cache_list\n                create_time = result.get(\"create_time\", 0)\n                update_time = result.get(\"update_time\", 0)\n                result[\"create_time\"] = create_time\n                result[\"update_time\"] = create_time if update_time == 0 else update_time\n\n        # Special handling for LLM cache to ensure compatibility with _get_cached_extraction_results\n        if results and is_namespace(\n            self.namespace, NameSpace.KV_STORE_LLM_RESPONSE_CACHE\n        ):\n            processed_results = []\n            for row in results:\n                create_time = row.get(\"create_time\", 0)\n                update_time = row.get(\"update_time\", 0)\n                # Parse queryparam JSON string back to dict\n                queryparam = row.get(\"queryparam\")\n                if isinstance(queryparam, str):\n                    try:\n                        queryparam = json.loads(queryparam)\n                    except json.JSONDecodeError:\n                        queryparam = None\n                # Map field names for compatibility (mode field removed)\n                processed_row = {\n                    **row,\n                    \"return\": row.get(\"return_value\", \"\"),\n                    \"cache_type\": row.get(\"cache_type\"),\n                    \"original_prompt\": row.get(\"original_prompt\", \"\"),\n                    \"chunk_id\": row.get(\"chunk_id\"),\n                    \"queryparam\": queryparam,\n                    \"create_time\": create_time,\n                    \"update_time\": create_time if update_time == 0 else update_time,\n                }\n                processed_results.append(processed_row)\n            return _order_results(processed_results)\n\n        # Special handling for FULL_ENTITIES namespace\n        if results and is_namespace(self.namespace, NameSpace.KV_STORE_FULL_ENTITIES):\n            for result in results:\n                # Parse entity_names JSON string back to list\n                entity_names = result.get(\"entity_names\", [])\n                if isinstance(entity_names, str):\n                    try:\n                        entity_names = json.loads(entity_names)\n                    except json.JSONDecodeError:\n                        entity_names = []\n                result[\"entity_names\"] = entity_names\n                create_time = result.get(\"create_time\", 0)\n                update_time = result.get(\"update_time\", 0)\n                result[\"create_time\"] = create_time\n                result[\"update_time\"] = create_time if update_time == 0 else update_time\n\n        # Special handling for FULL_RELATIONS namespace\n        if results and is_namespace(self.namespace, NameSpace.KV_STORE_FULL_RELATIONS):\n            for result in results:\n                # Parse relation_pairs JSON string back to list\n                relation_pairs = result.get(\"relation_pairs\", [])\n                if isinstance(relation_pairs, str):\n                    try:\n                        relation_pairs = json.loads(relation_pairs)\n                    except json.JSONDecodeError:\n                        relation_pairs = []\n                result[\"relation_pairs\"] = relation_pairs\n                create_time = result.get(\"create_time\", 0)\n                update_time = result.get(\"update_time\", 0)\n                result[\"create_time\"] = create_time\n                result[\"update_time\"] = create_time if update_time == 0 else update_time\n\n        # Special handling for ENTITY_CHUNKS namespace\n        if results and is_namespace(self.namespace, NameSpace.KV_STORE_ENTITY_CHUNKS):\n            for result in results:\n                # Parse chunk_ids JSON string back to list\n                chunk_ids = result.get(\"chunk_ids\", [])\n                if isinstance(chunk_ids, str):\n                    try:\n                        chunk_ids = json.loads(chunk_ids)\n                    except json.JSONDecodeError:\n                        chunk_ids = []\n                result[\"chunk_ids\"] = chunk_ids\n                create_time = result.get(\"create_time\", 0)\n                update_time = result.get(\"update_time\", 0)\n                result[\"create_time\"] = create_time\n                result[\"update_time\"] = create_time if update_time == 0 else update_time\n\n        # Special handling for RELATION_CHUNKS namespace\n        if results and is_namespace(self.namespace, NameSpace.KV_STORE_RELATION_CHUNKS):\n            for result in results:\n                # Parse chunk_ids JSON string back to list\n                chunk_ids = result.get(\"chunk_ids\", [])\n                if isinstance(chunk_ids, str):\n                    try:\n                        chunk_ids = json.loads(chunk_ids)\n                    except json.JSONDecodeError:\n                        chunk_ids = []\n                result[\"chunk_ids\"] = chunk_ids\n                create_time = result.get(\"create_time\", 0)\n                update_time = result.get(\"update_time\", 0)\n                result[\"create_time\"] = create_time\n                result[\"update_time\"] = create_time if update_time == 0 else update_time\n\n        return _order_results(results)\n\n    async def filter_keys(self, keys: set[str]) -> set[str]:\n        \"\"\"Filter out duplicated content\"\"\"\n        if not keys:\n            return set()\n\n        table_name = namespace_to_table_name(self.namespace)\n        sql = f\"SELECT id FROM {table_name} WHERE workspace=$1 AND id = ANY($2)\"\n        params = {\"workspace\": self.workspace, \"ids\": list(keys)}\n        try:\n            res = await self.db.query(sql, list(params.values()), multirows=True)\n            if res:\n                exist_keys = [key[\"id\"] for key in res]\n            else:\n                exist_keys = []\n            new_keys = set([s for s in keys if s not in exist_keys])\n            return new_keys\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] PostgreSQL database,\\nsql:{sql},\\nparams:{params},\\nerror:{e}\"\n            )\n            raise\n\n    ################ INSERT METHODS ################\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        logger.debug(f\"[{self.workspace}] Inserting {len(data)} to {self.namespace}\")\n        if not data:\n            return\n\n        batch_values: list[tuple] = []\n        upsert_sql = \"\"\n\n        if is_namespace(self.namespace, NameSpace.KV_STORE_TEXT_CHUNKS):\n            upsert_sql = SQL_TEMPLATES[\"upsert_text_chunk\"]\n            # Get current UTC time and convert to naive datetime for database storage\n            current_time = datetime.datetime.now(timezone.utc).replace(tzinfo=None)\n            for k, v in data.items():\n                # Tuple order must match SQL: (workspace, id, tokens, chunk_order_index,\n                #   full_doc_id, content, file_path, llm_cache_list, create_time, update_time)\n                batch_values.append(\n                    (\n                        self.workspace,\n                        k,\n                        v[\"tokens\"],\n                        v[\"chunk_order_index\"],\n                        v[\"full_doc_id\"],\n                        v[\"content\"],\n                        v[\"file_path\"],\n                        json.dumps(v.get(\"llm_cache_list\", [])),\n                        current_time,\n                        current_time,\n                    )\n                )\n        elif is_namespace(self.namespace, NameSpace.KV_STORE_FULL_DOCS):\n            upsert_sql = SQL_TEMPLATES[\"upsert_doc_full\"]\n            for k, v in data.items():\n                # Tuple order must match SQL: (id, content, doc_name, workspace)\n                batch_values.append(\n                    (k, v[\"content\"], v.get(\"file_path\", \"\"), self.workspace)\n                )\n        elif is_namespace(self.namespace, NameSpace.KV_STORE_LLM_RESPONSE_CACHE):\n            upsert_sql = SQL_TEMPLATES[\"upsert_llm_response_cache\"]\n            for k, v in data.items():\n                # Tuple order must match SQL: (workspace, id, original_prompt, return_value,\n                #   chunk_id, cache_type, queryparam)\n                batch_values.append(\n                    (\n                        self.workspace,\n                        k,\n                        v[\"original_prompt\"],\n                        v[\"return\"],\n                        v.get(\"chunk_id\"),\n                        v.get(\"cache_type\", \"extract\"),\n                        json.dumps(v.get(\"queryparam\"))\n                        if v.get(\"queryparam\")\n                        else None,\n                    )\n                )\n        elif is_namespace(self.namespace, NameSpace.KV_STORE_FULL_ENTITIES):\n            upsert_sql = SQL_TEMPLATES[\"upsert_full_entities\"]\n            # Get current UTC time and convert to naive datetime for database storage\n            current_time = datetime.datetime.now(timezone.utc).replace(tzinfo=None)\n            for k, v in data.items():\n                # Tuple order must match SQL: (workspace, id, entity_names, count,\n                #   create_time, update_time)\n                batch_values.append(\n                    (\n                        self.workspace,\n                        k,\n                        json.dumps(v[\"entity_names\"]),\n                        v[\"count\"],\n                        current_time,\n                        current_time,\n                    )\n                )\n        elif is_namespace(self.namespace, NameSpace.KV_STORE_FULL_RELATIONS):\n            upsert_sql = SQL_TEMPLATES[\"upsert_full_relations\"]\n            # Get current UTC time and convert to naive datetime for database storage\n            current_time = datetime.datetime.now(timezone.utc).replace(tzinfo=None)\n            for k, v in data.items():\n                # Tuple order must match SQL: (workspace, id, relation_pairs, count,\n                #   create_time, update_time)\n                batch_values.append(\n                    (\n                        self.workspace,\n                        k,\n                        json.dumps(v[\"relation_pairs\"]),\n                        v[\"count\"],\n                        current_time,\n                        current_time,\n                    )\n                )\n        elif is_namespace(self.namespace, NameSpace.KV_STORE_ENTITY_CHUNKS):\n            upsert_sql = SQL_TEMPLATES[\"upsert_entity_chunks\"]\n            # Get current UTC time and convert to naive datetime for database storage\n            current_time = datetime.datetime.now(timezone.utc).replace(tzinfo=None)\n            for k, v in data.items():\n                # Tuple order must match SQL: (workspace, id, chunk_ids, count,\n                #   create_time, update_time)\n                batch_values.append(\n                    (\n                        self.workspace,\n                        k,\n                        json.dumps(v[\"chunk_ids\"]),\n                        v[\"count\"],\n                        current_time,\n                        current_time,\n                    )\n                )\n        elif is_namespace(self.namespace, NameSpace.KV_STORE_RELATION_CHUNKS):\n            upsert_sql = SQL_TEMPLATES[\"upsert_relation_chunks\"]\n            # Get current UTC time and convert to naive datetime for database storage\n            current_time = datetime.datetime.now(timezone.utc).replace(tzinfo=None)\n            for k, v in data.items():\n                # Tuple order must match SQL: (workspace, id, chunk_ids, count,\n                #   create_time, update_time)\n                batch_values.append(\n                    (\n                        self.workspace,\n                        k,\n                        json.dumps(v[\"chunk_ids\"]),\n                        v[\"count\"],\n                        current_time,\n                        current_time,\n                    )\n                )\n        else:\n            logger.error(f\"Unknown namespace: {self.namespace}\")\n            raise ValueError(f\"Unknown namespace: {self.namespace}\")\n\n        # upsert_sql is always set here; unknown namespace raises ValueError above\n        if batch_values:\n            # Split into sub-batches to prevent database overload\n            for i in range(0, len(batch_values), self._max_batch_size):\n                sub_batch = batch_values[i : i + self._max_batch_size]\n\n                async def _batch_upsert(\n                    connection: asyncpg.Connection,\n                    _sql: str = upsert_sql,\n                    _data: list[tuple] = sub_batch,\n                ) -> None:\n                    await connection.executemany(_sql, _data)\n\n                await self.db._run_with_retry(_batch_upsert)\n\n            num_batches = (\n                len(batch_values) + self._max_batch_size - 1\n            ) // self._max_batch_size\n            logger.debug(\n                f\"[{self.workspace}] Batch upserted {len(batch_values)} records to {self.namespace} \"\n                f\"in {num_batches} sub-batches\"\n            )\n\n    async def index_done_callback(self) -> None:\n        # PG handles persistence automatically\n        pass\n\n    async def is_empty(self) -> bool:\n        \"\"\"Check if the storage is empty for the current workspace and namespace\n\n        Returns:\n            bool: True if storage is empty, False otherwise\n        \"\"\"\n        table_name = namespace_to_table_name(self.namespace)\n        if not table_name:\n            logger.error(\n                f\"[{self.workspace}] Unknown namespace for is_empty check: {self.namespace}\"\n            )\n            return True\n\n        sql = f\"SELECT EXISTS(SELECT 1 FROM {table_name} WHERE workspace=$1 LIMIT 1) as has_data\"\n\n        try:\n            result = await self.db.query(sql, [self.workspace])\n            return not result.get(\"has_data\", False) if result else True\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error checking if storage is empty: {e}\")\n            return True\n\n    async def delete(self, ids: list[str]) -> None:\n        \"\"\"Delete specific records from storage by their IDs\n\n        Args:\n            ids (list[str]): List of document IDs to be deleted from storage\n\n        Returns:\n            None\n        \"\"\"\n        if not ids:\n            return\n\n        table_name = namespace_to_table_name(self.namespace)\n        if not table_name:\n            logger.error(\n                f\"[{self.workspace}] Unknown namespace for deletion: {self.namespace}\"\n            )\n            return\n\n        delete_sql = f\"DELETE FROM {table_name} WHERE workspace=$1 AND id = ANY($2)\"\n\n        try:\n            await self.db.execute(delete_sql, {\"workspace\": self.workspace, \"ids\": ids})\n            logger.debug(\n                f\"[{self.workspace}] Successfully deleted {len(ids)} records from {self.namespace}\"\n            )\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error while deleting records from {self.namespace}: {e}\"\n            )\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop the storage\"\"\"\n        try:\n            table_name = namespace_to_table_name(self.namespace)\n            if not table_name:\n                return {\n                    \"status\": \"error\",\n                    \"message\": f\"Unknown namespace: {self.namespace}\",\n                }\n\n            drop_sql = SQL_TEMPLATES[\"drop_specifiy_table_workspace\"].format(\n                table_name=table_name\n            )\n            await self.db.execute(drop_sql, {\"workspace\": self.workspace})\n            return {\"status\": \"success\", \"message\": \"data dropped\"}\n        except Exception as e:\n            return {\"status\": \"error\", \"message\": str(e)}\n\n\n@final\n@dataclass\nclass PGVectorStorage(BaseVectorStorage):\n    db: PostgreSQLDB | None = field(default=None)\n\n    def __post_init__(self):\n        self._validate_embedding_func()\n        self._max_batch_size = self.global_config[\"embedding_batch_num\"]\n        config = self.global_config.get(\"vector_db_storage_cls_kwargs\", {})\n        cosine_threshold = config.get(\"cosine_better_than_threshold\")\n        if cosine_threshold is None:\n            raise ValueError(\n                \"cosine_better_than_threshold must be specified in vector_db_storage_cls_kwargs\"\n            )\n        self.cosine_better_than_threshold = cosine_threshold\n\n        # Generate model suffix for table isolation\n        self.model_suffix = self._generate_collection_suffix()\n\n        # Get base table name\n        base_table = namespace_to_table_name(self.namespace)\n        if not base_table:\n            raise ValueError(f\"Unknown namespace: {self.namespace}\")\n\n        # New table name (with suffix)\n        # Ensure model_suffix is not empty before appending\n        if self.model_suffix:\n            self.table_name = f\"{base_table}_{self.model_suffix}\"\n            logger.info(f\"PostgreSQL table: {self.table_name}\")\n        else:\n            # Fallback: use base table name if model_suffix is unavailable\n            self.table_name = base_table\n            logger.warning(\n                f\"PostgreSQL table: {self.table_name} missing suffix. Pls add model_name to embedding_func for proper workspace data isolation.\"\n            )\n\n        # Legacy table name (without suffix, for migration)\n        self.legacy_table_name = base_table\n\n        # Validate table name length (PostgreSQL identifier limit is 63 characters)\n        if len(self.table_name) > PG_MAX_IDENTIFIER_LENGTH:\n            raise ValueError(\n                f\"PostgreSQL table name exceeds {PG_MAX_IDENTIFIER_LENGTH} character limit: '{self.table_name}' \"\n                f\"(length: {len(self.table_name)}). \"\n                f\"Consider using a shorter embedding model name or workspace name.\"\n            )\n\n    @staticmethod\n    async def _pg_create_table(\n        db: PostgreSQLDB, table_name: str, base_table: str, embedding_dim: int\n    ) -> None:\n        \"\"\"Create a new vector table by replacing the table name in DDL template,\n        and create indexes on id and (workspace, id) columns.\n\n        Args:\n            db: PostgreSQLDB instance\n            table_name: Name of the new table to create\n            base_table: Base table name for DDL template lookup\n            embedding_dim: Embedding dimension for vector column\n        \"\"\"\n        if base_table not in TABLES:\n            raise ValueError(f\"No DDL template found for table: {base_table}\")\n\n        ddl_template = TABLES[base_table][\"ddl\"]\n\n        # Replace embedding dimension placeholder if exists\n        ddl = ddl_template.replace(\"VECTOR(dimension)\", f\"VECTOR({embedding_dim})\")\n\n        # Replace table name\n        ddl = ddl.replace(base_table, table_name)\n\n        # Make creation idempotent to handle restarts and race conditions\n        ddl = ddl.replace(\"CREATE TABLE \", \"CREATE TABLE IF NOT EXISTS \", 1)\n        await db.execute(ddl)\n\n        # Create indexes similar to check_tables() but with safe index names\n        # Create index for id column\n        id_index_name = _safe_index_name(table_name, \"id\")\n        try:\n            create_id_index_sql = (\n                f\"CREATE INDEX IF NOT EXISTS {id_index_name} ON {table_name}(id)\"\n            )\n            logger.info(\n                f\"PostgreSQL, Creating index {id_index_name} on table {table_name}\"\n            )\n            await db.execute(create_id_index_sql)\n        except Exception as e:\n            logger.error(\n                f\"PostgreSQL, Failed to create index {id_index_name}, Got: {e}\"\n            )\n\n        # Create composite index for (workspace, id)\n        workspace_id_index_name = _safe_index_name(table_name, \"workspace_id\")\n        try:\n            create_composite_index_sql = f\"CREATE INDEX IF NOT EXISTS {workspace_id_index_name} ON {table_name}(workspace, id)\"\n            logger.info(\n                f\"PostgreSQL, Creating composite index {workspace_id_index_name} on table {table_name}\"\n            )\n            await db.execute(create_composite_index_sql)\n        except Exception as e:\n            logger.error(\n                f\"PostgreSQL, Failed to create composite index {workspace_id_index_name}, Got: {e}\"\n            )\n\n    @staticmethod\n    async def _pg_migrate_workspace_data(\n        db: PostgreSQLDB,\n        legacy_table_name: str,\n        new_table_name: str,\n        workspace: str,\n        expected_count: int,\n        embedding_dim: int,\n    ) -> int:\n        \"\"\"Migrate workspace data from legacy table to new table using batch insert.\n\n        This function uses asyncpg's executemany for efficient batch insertion,\n        reducing database round-trips from N to 1 per batch.\n\n        Uses keyset pagination (cursor-based) with ORDER BY id for stable ordering.\n        This ensures every legacy row is migrated exactly once, avoiding the\n        non-deterministic row ordering issues with OFFSET/LIMIT without ORDER BY.\n\n        Args:\n            db: PostgreSQLDB instance\n            legacy_table_name: Name of the legacy table to migrate from\n            new_table_name: Name of the new table to migrate to\n            workspace: Workspace to filter records for migration\n            expected_count: Expected number of records to migrate\n            embedding_dim: Embedding dimension for vector column\n\n        Returns:\n            Number of records migrated\n        \"\"\"\n        migrated_count = 0\n        last_id: str | None = None\n        batch_size = 500\n\n        while True:\n            # Use keyset pagination with ORDER BY id for deterministic ordering\n            # This avoids OFFSET/LIMIT without ORDER BY which can skip or duplicate rows\n            if workspace:\n                if last_id is not None:\n                    select_query = f\"SELECT * FROM {legacy_table_name} WHERE workspace = $1 AND id > $2 ORDER BY id LIMIT $3\"\n                    rows = await db.query(\n                        select_query, [workspace, last_id, batch_size], multirows=True\n                    )\n                else:\n                    select_query = f\"SELECT * FROM {legacy_table_name} WHERE workspace = $1 ORDER BY id LIMIT $2\"\n                    rows = await db.query(\n                        select_query, [workspace, batch_size], multirows=True\n                    )\n            else:\n                if last_id is not None:\n                    select_query = f\"SELECT * FROM {legacy_table_name} WHERE id > $1 ORDER BY id LIMIT $2\"\n                    rows = await db.query(\n                        select_query, [last_id, batch_size], multirows=True\n                    )\n                else:\n                    select_query = (\n                        f\"SELECT * FROM {legacy_table_name} ORDER BY id LIMIT $1\"\n                    )\n                    rows = await db.query(select_query, [batch_size], multirows=True)\n\n            if not rows:\n                break\n\n            # Track the last ID for keyset pagination cursor\n            last_id = rows[-1][\"id\"]\n\n            # Batch insert optimization: use executemany instead of individual inserts\n            # Get column names from the first row\n            first_row = dict(rows[0])\n            columns = list(first_row.keys())\n            columns_str = \", \".join(columns)\n            placeholders = \", \".join([f\"${i + 1}\" for i in range(len(columns))])\n\n            insert_query = f\"\"\"\n                INSERT INTO {new_table_name} ({columns_str})\n                VALUES ({placeholders})\n                ON CONFLICT (workspace, id) DO NOTHING\n            \"\"\"\n\n            # Prepare batch data: convert rows to list of tuples\n            batch_values = []\n            for row in rows:\n                row_dict = dict(row)\n\n                # FIX: Parse vector strings from connections without register_vector codec.\n                # When pgvector codec is not registered on the read connection, vector\n                # columns are returned as text strings like \"[0.1,0.2,...]\" instead of\n                # lists/arrays. We need to convert these to numpy arrays before passing\n                # to executemany, which uses a connection WITH register_vector codec\n                # that expects list/tuple/ndarray types.\n                if \"content_vector\" in row_dict:\n                    vec = row_dict[\"content_vector\"]\n                    if isinstance(vec, str):\n                        # pgvector text format: \"[0.1,0.2,0.3,...]\"\n                        vec = vec.strip(\"[]\")\n                        if vec:\n                            row_dict[\"content_vector\"] = np.array(\n                                [float(x) for x in vec.split(\",\")], dtype=np.float32\n                            )\n                        else:\n                            row_dict[\"content_vector\"] = None\n\n                # Extract values in column order to match placeholders\n                values_tuple = tuple(row_dict[col] for col in columns)\n                batch_values.append(values_tuple)\n\n            # Use executemany for batch execution - significantly reduces DB round-trips\n            # Note: register_vector is already called on pool init, no need to call it again\n            async def _batch_insert(connection: asyncpg.Connection) -> None:\n                await connection.executemany(insert_query, batch_values)\n\n            await db._run_with_retry(_batch_insert)\n\n            migrated_count += len(rows)\n            workspace_info = f\" for workspace '{workspace}'\" if workspace else \"\"\n            logger.info(\n                f\"PostgreSQL: {migrated_count}/{expected_count} records migrated{workspace_info}\"\n            )\n\n        return migrated_count\n\n    @staticmethod\n    async def setup_table(\n        db: PostgreSQLDB,\n        table_name: str,\n        workspace: str,\n        embedding_dim: int,\n        legacy_table_name: str,\n        base_table: str,\n    ):\n        \"\"\"\n        Setup PostgreSQL table with migration support from legacy tables.\n\n        Ensure final table has workspace isolation index.\n        Check vector dimension compatibility before new table creation.\n        Drop legacy table if it exists and is empty.\n        Only migrate data from legacy table to new table when new table first created and legacy table is not empty.\n        This function must be call ClientManager.get_client() to legacy table is migrated to latest schema.\n\n        Args:\n            db: PostgreSQLDB instance\n            table_name: Name of the new table\n            workspace: Workspace to filter records for migration\n            legacy_table_name: Name of the legacy table to check for migration\n            base_table: Base table name for DDL template lookup\n            embedding_dim: Embedding dimension for vector column\n        \"\"\"\n        if not workspace:\n            raise ValueError(\"workspace must be provided\")\n\n        new_table_exists = await db.check_table_exists(table_name)\n        legacy_exists = legacy_table_name and await db.check_table_exists(\n            legacy_table_name\n        )\n\n        # Case 1: Only new table exists or new table is the same as legacy table\n        #         No data migration needed, ensuring index is created then return\n        if (new_table_exists and not legacy_exists) or (\n            new_table_exists and (table_name.lower() == legacy_table_name.lower())\n        ):\n            await db._create_vector_index(table_name, embedding_dim)\n\n            workspace_count_query = (\n                f\"SELECT COUNT(*) as count FROM {table_name} WHERE workspace = $1\"\n            )\n            workspace_count_result = await db.query(workspace_count_query, [workspace])\n            workspace_count = (\n                workspace_count_result.get(\"count\", 0) if workspace_count_result else 0\n            )\n            if workspace_count == 0 and not (\n                table_name.lower() == legacy_table_name.lower()\n            ):\n                logger.warning(\n                    f\"PostgreSQL: workspace data in table '{table_name}' is empty. \"\n                    f\"Ensure it is caused by new workspace setup and not an unexpected embedding model change.\"\n                )\n\n            return\n\n        legacy_count = None\n        if not new_table_exists:\n            # Check vector dimension compatibility before creating new table\n            if legacy_exists:\n                count_query = f\"SELECT COUNT(*) as count FROM {legacy_table_name} WHERE workspace = $1\"\n                count_result = await db.query(count_query, [workspace])\n                legacy_count = count_result.get(\"count\", 0) if count_result else 0\n\n                if legacy_count > 0:\n                    legacy_dim = None\n                    try:\n                        sample_query = f\"SELECT content_vector FROM {legacy_table_name} WHERE workspace = $1 LIMIT 1\"\n                        sample_result = await db.query(sample_query, [workspace])\n                        # Fix: Use 'is not None' instead of truthiness check to avoid\n                        # NumPy array boolean ambiguity error\n                        if (\n                            sample_result\n                            and sample_result.get(\"content_vector\") is not None\n                        ):\n                            vector_data = sample_result[\"content_vector\"]\n                            # pgvector returns list directly, but may also return NumPy arrays\n                            # when register_vector codec is active on the connection\n                            if isinstance(vector_data, (list, tuple)):\n                                legacy_dim = len(vector_data)\n                            elif hasattr(vector_data, \"__len__\") and not isinstance(\n                                vector_data, str\n                            ):\n                                # Handle NumPy arrays and other array-like objects\n                                legacy_dim = len(vector_data)\n                            elif isinstance(vector_data, str):\n                                import json\n\n                                vector_list = json.loads(vector_data)\n                                legacy_dim = len(vector_list)\n\n                        if legacy_dim and legacy_dim != embedding_dim:\n                            logger.error(\n                                f\"PostgreSQL: Dimension mismatch detected! \"\n                                f\"Legacy table '{legacy_table_name}' has {legacy_dim}d vectors, \"\n                                f\"but new embedding model expects {embedding_dim}d.\"\n                            )\n                            raise DataMigrationError(\n                                f\"Dimension mismatch between legacy table '{legacy_table_name}' \"\n                                f\"and new embedding model. Expected {embedding_dim}d but got {legacy_dim}d.\"\n                            )\n\n                    except DataMigrationError:\n                        # Re-raise DataMigrationError as-is to preserve specific error messages\n                        raise\n                    except Exception as e:\n                        raise DataMigrationError(\n                            f\"Could not verify legacy table vector dimension: {e}. \"\n                            f\"Proceeding with caution...\"\n                        )\n\n            await PGVectorStorage._pg_create_table(\n                db, table_name, base_table, embedding_dim\n            )\n            logger.info(f\"PostgreSQL: New table '{table_name}' created successfully\")\n\n            if not legacy_exists:\n                await db._create_vector_index(table_name, embedding_dim)\n                logger.info(\n                    \"Ensure this new table creation is caused by new workspace setup and not an unexpected embedding model change.\"\n                )\n                return\n\n        # Ensure vector index is created\n        await db._create_vector_index(table_name, embedding_dim)\n\n        # Case 2: Legacy table exist\n        if legacy_exists:\n            workspace_info = f\" for workspace '{workspace}'\"\n\n            # Only drop legacy table if entire table is empty\n            total_count_query = f\"SELECT COUNT(*) as count FROM {legacy_table_name}\"\n            total_count_result = await db.query(total_count_query, [])\n            total_count = (\n                total_count_result.get(\"count\", 0) if total_count_result else 0\n            )\n            if total_count == 0:\n                logger.info(\n                    f\"PostgreSQL: Empty legacy table '{legacy_table_name}' deleted successfully\"\n                )\n                drop_query = f\"DROP TABLE {legacy_table_name}\"\n                await db.execute(drop_query, None)\n                return\n\n            # No data migration needed if legacy workspace is empty\n            if legacy_count is None:\n                count_query = f\"SELECT COUNT(*) as count FROM {legacy_table_name} WHERE workspace = $1\"\n                count_result = await db.query(count_query, [workspace])\n                legacy_count = count_result.get(\"count\", 0) if count_result else 0\n\n            if legacy_count == 0:\n                logger.info(\n                    f\"PostgreSQL: No records{workspace_info} found in legacy table. \"\n                    f\"No data migration needed.\"\n                )\n                return\n\n            new_count_query = (\n                f\"SELECT COUNT(*) as count FROM {table_name} WHERE workspace = $1\"\n            )\n            new_count_result = await db.query(new_count_query, [workspace])\n            new_table_workspace_count = (\n                new_count_result.get(\"count\", 0) if new_count_result else 0\n            )\n\n            if new_table_workspace_count > 0:\n                logger.warning(\n                    f\"PostgreSQL: Both new and legacy collection have data. \"\n                    f\"{legacy_count} records in {legacy_table_name} require manual deletion after migration verification.\"\n                )\n                return\n\n            # Case 3: Legacy has workspace data and new table is empty for workspace\n            logger.info(\n                f\"PostgreSQL: Found legacy table '{legacy_table_name}' with {legacy_count} records{workspace_info}.\"\n            )\n            logger.info(\n                f\"PostgreSQL: Migrating data from legacy table '{legacy_table_name}' to new table '{table_name}'\"\n            )\n\n            try:\n                migrated_count = await PGVectorStorage._pg_migrate_workspace_data(\n                    db,\n                    legacy_table_name,\n                    table_name,\n                    workspace,\n                    legacy_count,\n                    embedding_dim,\n                )\n                if migrated_count != legacy_count:\n                    logger.warning(\n                        \"PostgreSQL: Read %s legacy records%s during migration, expected %s.\",\n                        migrated_count,\n                        workspace_info,\n                        legacy_count,\n                    )\n\n                new_count_result = await db.query(new_count_query, [workspace])\n                new_table_count_after = (\n                    new_count_result.get(\"count\", 0) if new_count_result else 0\n                )\n                inserted_count = new_table_count_after - new_table_workspace_count\n\n                if inserted_count != legacy_count:\n                    error_msg = (\n                        \"PostgreSQL: Migration verification failed, \"\n                        f\"expected {legacy_count} inserted records, got {inserted_count}.\"\n                    )\n                    logger.error(error_msg)\n                    raise DataMigrationError(error_msg)\n\n            except DataMigrationError:\n                # Re-raise DataMigrationError as-is to preserve specific error messages\n                raise\n            except Exception as e:\n                logger.error(\n                    f\"PostgreSQL: Failed to migrate data from legacy table '{legacy_table_name}' to new table '{table_name}': {e}\"\n                )\n                raise DataMigrationError(\n                    f\"Failed to migrate data from legacy table '{legacy_table_name}' to new table '{table_name}'\"\n                ) from e\n\n            logger.info(\n                f\"PostgreSQL: Migration from '{legacy_table_name}' to '{table_name}' completed successfully\"\n            )\n            logger.warning(\n                \"PostgreSQL: Manual deletion is required after data migration verification.\"\n            )\n\n    async def initialize(self):\n        async with get_data_init_lock():\n            if self.db is None:\n                self.db = await ClientManager.get_client()\n\n            # Implement workspace priority: PostgreSQLDB.workspace > self.workspace > \"default\"\n            if self.db.workspace:\n                # Use PostgreSQLDB's workspace (highest priority)\n                logger.info(\n                    f\"Using PG_WORKSPACE environment variable: '{self.db.workspace}' (overriding '{self.workspace}/{self.namespace}')\"\n                )\n                self.workspace = self.db.workspace\n            elif hasattr(self, \"workspace\") and self.workspace:\n                # Use storage class's workspace (medium priority)\n                pass\n            else:\n                # Use \"default\" for compatibility (lowest priority)\n                self.workspace = \"default\"\n\n            if not self.db.enable_vector:\n                raise ValueError(\n                    \"Cannot use PGVectorStorage when POSTGRES_ENABLE_VECTOR=false. Configure an alternative vector backend.\"\n                )\n\n            # Setup table (create if not exists and handle migration)\n            await PGVectorStorage.setup_table(\n                self.db,\n                self.table_name,\n                self.workspace,  # CRITICAL: Filter migration by workspace\n                embedding_dim=self.embedding_func.embedding_dim,\n                legacy_table_name=self.legacy_table_name,\n                base_table=self.legacy_table_name,  # base_table for DDL template lookup\n            )\n\n    async def finalize(self):\n        if self.db is not None:\n            await ClientManager.release_client(self.db)\n            self.db = None\n\n    def _upsert_chunks(\n        self, item: dict[str, Any], current_time: datetime.datetime\n    ) -> tuple[str, tuple[Any, ...]]:\n        \"\"\"Prepare upsert data for chunks.\n\n        Returns:\n            Tuple of (SQL template, values tuple for executemany)\n        \"\"\"\n        try:\n            upsert_sql = SQL_TEMPLATES[\"upsert_chunk\"].format(\n                table_name=self.table_name\n            )\n            # Return tuple in the exact order of SQL parameters ($1, $2, ...)\n            values: tuple[Any, ...] = (\n                self.workspace,  # $1\n                item[\"__id__\"],  # $2\n                item[\"tokens\"],  # $3\n                item[\"chunk_order_index\"],  # $4\n                item[\"full_doc_id\"],  # $5\n                item[\"content\"],  # $6\n                item[\"__vector__\"],  # $7 - numpy array, handled by pgvector codec\n                item[\"file_path\"],  # $8\n                current_time,  # $9\n                current_time,  # $10\n            )\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error to prepare upsert,\\nerror: {e}\\nitem: {item}\"\n            )\n            raise\n\n        return upsert_sql, values\n\n    def _upsert_entities(\n        self, item: dict[str, Any], current_time: datetime.datetime\n    ) -> tuple[str, tuple[Any, ...]]:\n        \"\"\"Prepare upsert data for entities.\n\n        Returns:\n            Tuple of (SQL template, values tuple for executemany)\n        \"\"\"\n        upsert_sql = SQL_TEMPLATES[\"upsert_entity\"].format(table_name=self.table_name)\n        source_id = item[\"source_id\"]\n        if isinstance(source_id, str) and \"<SEP>\" in source_id:\n            chunk_ids = source_id.split(\"<SEP>\")\n        else:\n            chunk_ids = [source_id]\n\n        # Return tuple in the exact order of SQL parameters ($1, $2, ...)\n        values: tuple[Any, ...] = (\n            self.workspace,  # $1\n            item[\"__id__\"],  # $2\n            item[\"entity_name\"],  # $3\n            item[\"content\"],  # $4\n            item[\"__vector__\"],  # $5 - numpy array, handled by pgvector codec\n            chunk_ids,  # $6\n            item.get(\"file_path\", None),  # $7\n            current_time,  # $8\n            current_time,  # $9\n        )\n        return upsert_sql, values\n\n    def _upsert_relationships(\n        self, item: dict[str, Any], current_time: datetime.datetime\n    ) -> tuple[str, tuple[Any, ...]]:\n        \"\"\"Prepare upsert data for relationships.\n\n        Returns:\n            Tuple of (SQL template, values tuple for executemany)\n        \"\"\"\n        upsert_sql = SQL_TEMPLATES[\"upsert_relationship\"].format(\n            table_name=self.table_name\n        )\n        source_id = item[\"source_id\"]\n        if isinstance(source_id, str) and \"<SEP>\" in source_id:\n            chunk_ids = source_id.split(\"<SEP>\")\n        else:\n            chunk_ids = [source_id]\n\n        # Return tuple in the exact order of SQL parameters ($1, $2, ...)\n        values: tuple[Any, ...] = (\n            self.workspace,  # $1\n            item[\"__id__\"],  # $2\n            item[\"src_id\"],  # $3\n            item[\"tgt_id\"],  # $4\n            item[\"content\"],  # $5\n            item[\"__vector__\"],  # $6 - numpy array, handled by pgvector codec\n            chunk_ids,  # $7\n            item.get(\"file_path\", None),  # $8\n            current_time,  # $9\n            current_time,  # $10\n        )\n        return upsert_sql, values\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        logger.debug(f\"[{self.workspace}] Inserting {len(data)} to {self.namespace}\")\n        if not data:\n            return\n\n        # Get current UTC time and convert to naive datetime for database storage\n        current_time = datetime.datetime.now(timezone.utc).replace(tzinfo=None)\n        list_data = [\n            {\n                \"__id__\": k,\n                **{k1: v1 for k1, v1 in v.items()},\n            }\n            for k, v in data.items()\n        ]\n        contents = [v[\"content\"] for v in data.values()]\n        batches = [\n            contents[i : i + self._max_batch_size]\n            for i in range(0, len(contents), self._max_batch_size)\n        ]\n\n        embedding_tasks = [self.embedding_func(batch) for batch in batches]\n        embeddings_list = await asyncio.gather(*embedding_tasks)\n\n        embeddings = np.concatenate(embeddings_list)\n        for i, d in enumerate(list_data):\n            d[\"__vector__\"] = embeddings[i]\n\n        # Prepare batch values for executemany\n        batch_values: list[tuple[Any, ...]] = []\n        upsert_sql = None\n\n        for item in list_data:\n            if is_namespace(self.namespace, NameSpace.VECTOR_STORE_CHUNKS):\n                upsert_sql, values = self._upsert_chunks(item, current_time)\n            elif is_namespace(self.namespace, NameSpace.VECTOR_STORE_ENTITIES):\n                upsert_sql, values = self._upsert_entities(item, current_time)\n            elif is_namespace(self.namespace, NameSpace.VECTOR_STORE_RELATIONSHIPS):\n                upsert_sql, values = self._upsert_relationships(item, current_time)\n            else:\n                raise ValueError(f\"{self.namespace} is not supported\")\n\n            batch_values.append(values)\n\n        # Use executemany for batch execution - significantly reduces DB round-trips\n        # Note: register_vector is already called on pool init, no need to call it again\n        if batch_values and upsert_sql:\n\n            async def _batch_upsert(connection: asyncpg.Connection) -> None:\n                await connection.executemany(upsert_sql, batch_values)\n\n            await self.db._run_with_retry(_batch_upsert)\n            logger.debug(\n                f\"[{self.workspace}] Batch upserted {len(batch_values)} records to {self.namespace}\"\n            )\n\n    #################### query method ###############\n    async def query(\n        self, query: str, top_k: int, query_embedding: list[float] = None\n    ) -> list[dict[str, Any]]:\n        if query_embedding is not None:\n            embedding = query_embedding\n        else:\n            embeddings = await self.embedding_func(\n                [query], _priority=5\n            )  # higher priority for query\n            embedding = embeddings[0]\n\n        embedding_string = \",\".join(map(str, embedding))\n\n        sql = SQL_TEMPLATES[self.namespace].format(\n            embedding_string=embedding_string, table_name=self.table_name\n        )\n        params = {\n            \"workspace\": self.workspace,\n            \"closer_than_threshold\": 1 - self.cosine_better_than_threshold,\n            \"top_k\": top_k,\n        }\n        results = await self.db.query(sql, params=list(params.values()), multirows=True)\n        return results\n\n    async def index_done_callback(self) -> None:\n        # PG handles persistence automatically\n        pass\n\n    async def delete(self, ids: list[str]) -> None:\n        \"\"\"Delete vectors with specified IDs from the storage.\n\n        Args:\n            ids: List of vector IDs to be deleted\n        \"\"\"\n        if not ids:\n            return\n\n        delete_sql = (\n            f\"DELETE FROM {self.table_name} WHERE workspace=$1 AND id = ANY($2)\"\n        )\n\n        try:\n            await self.db.execute(delete_sql, {\"workspace\": self.workspace, \"ids\": ids})\n            logger.debug(\n                f\"[{self.workspace}] Successfully deleted {len(ids)} vectors from {self.namespace}\"\n            )\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error while deleting vectors from {self.namespace}: {e}\"\n            )\n\n    async def delete_entity(self, entity_name: str) -> None:\n        \"\"\"Delete an entity by its name from the vector storage.\n\n        Args:\n            entity_name: The name of the entity to delete\n        \"\"\"\n        try:\n            # Construct SQL to delete the entity using dynamic table name\n            delete_sql = f\"\"\"DELETE FROM {self.table_name}\n                            WHERE workspace=$1 AND entity_name=$2\"\"\"\n\n            await self.db.execute(\n                delete_sql, {\"workspace\": self.workspace, \"entity_name\": entity_name}\n            )\n            logger.debug(\n                f\"[{self.workspace}] Successfully deleted entity {entity_name}\"\n            )\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error deleting entity {entity_name}: {e}\")\n\n    async def delete_entity_relation(self, entity_name: str) -> None:\n        \"\"\"Delete all relations associated with an entity.\n\n        Args:\n            entity_name: The name of the entity whose relations should be deleted\n        \"\"\"\n        try:\n            # Delete relations where the entity is either the source or target\n            delete_sql = f\"\"\"DELETE FROM {self.table_name}\n                            WHERE workspace=$1 AND (source_id=$2 OR target_id=$2)\"\"\"\n\n            await self.db.execute(\n                delete_sql, {\"workspace\": self.workspace, \"entity_name\": entity_name}\n            )\n            logger.debug(\n                f\"[{self.workspace}] Successfully deleted relations for entity {entity_name}\"\n            )\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error deleting relations for entity {entity_name}: {e}\"\n            )\n\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        \"\"\"Get vector data by its ID\n\n        Args:\n            id: The unique identifier of the vector\n\n        Returns:\n            The vector data if found, or None if not found\n        \"\"\"\n        query = f\"SELECT *, EXTRACT(EPOCH FROM create_time)::BIGINT as created_at FROM {self.table_name} WHERE workspace=$1 AND id=$2\"\n        params = {\"workspace\": self.workspace, \"id\": id}\n\n        try:\n            result = await self.db.query(query, list(params.values()))\n            if result:\n                return dict(result)\n            return None\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error retrieving vector data for ID {id}: {e}\"\n            )\n            return None\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        \"\"\"Get multiple vector data by their IDs\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            List of vector data objects that were found\n        \"\"\"\n        if not ids:\n            return []\n\n        ids_str = \",\".join([f\"'{id}'\" for id in ids])\n        query = f\"SELECT *, EXTRACT(EPOCH FROM create_time)::BIGINT as created_at FROM {self.table_name} WHERE workspace=$1 AND id IN ({ids_str})\"\n        params = {\"workspace\": self.workspace}\n\n        try:\n            results = await self.db.query(query, list(params.values()), multirows=True)\n            if not results:\n                return []\n\n            # Preserve caller requested ordering while normalizing asyncpg rows to dicts.\n            id_map: dict[str, dict[str, Any]] = {}\n            for record in results:\n                if record is None:\n                    continue\n                record_dict = dict(record)\n                row_id = record_dict.get(\"id\")\n                if row_id is not None:\n                    id_map[str(row_id)] = record_dict\n\n            ordered_results: list[dict[str, Any] | None] = []\n            for requested_id in ids:\n                ordered_results.append(id_map.get(str(requested_id)))\n            return ordered_results\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error retrieving vector data for IDs {ids}: {e}\"\n            )\n            return []\n\n    async def get_vectors_by_ids(self, ids: list[str]) -> dict[str, list[float]]:\n        \"\"\"Get vectors by their IDs, returning only ID and vector data for efficiency\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            Dictionary mapping IDs to their vector embeddings\n            Format: {id: [vector_values], ...}\n        \"\"\"\n        if not ids:\n            return {}\n\n        ids_str = \",\".join([f\"'{id}'\" for id in ids])\n        query = f\"SELECT id, content_vector FROM {self.table_name} WHERE workspace=$1 AND id IN ({ids_str})\"\n        params = {\"workspace\": self.workspace}\n\n        try:\n            results = await self.db.query(query, list(params.values()), multirows=True)\n            vectors_dict = {}\n\n            for result in results:\n                if result and \"content_vector\" in result and \"id\" in result:\n                    try:\n                        vector_data = result[\"content_vector\"]\n                        # Handle both pgvector-registered connections (returns list/tuple)\n                        # and non-registered connections (returns JSON string)\n                        if isinstance(vector_data, (list, tuple)):\n                            vectors_dict[result[\"id\"]] = list(vector_data)\n                        elif isinstance(vector_data, str):\n                            parsed = json.loads(vector_data)\n                            if isinstance(parsed, list):\n                                vectors_dict[result[\"id\"]] = parsed\n                        # Handle numpy arrays from pgvector\n                        elif hasattr(vector_data, \"tolist\"):\n                            vectors_dict[result[\"id\"]] = vector_data.tolist()\n                    except (json.JSONDecodeError, TypeError) as e:\n                        logger.warning(\n                            f\"[{self.workspace}] Failed to parse vector data for ID {result['id']}: {e}\"\n                        )\n\n            return vectors_dict\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error retrieving vectors by IDs from {self.namespace}: {e}\"\n            )\n            return {}\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop the storage\"\"\"\n        try:\n            drop_sql = SQL_TEMPLATES[\"drop_specifiy_table_workspace\"].format(\n                table_name=self.table_name\n            )\n            await self.db.execute(drop_sql, {\"workspace\": self.workspace})\n            return {\"status\": \"success\", \"message\": \"data dropped\"}\n        except Exception as e:\n            return {\"status\": \"error\", \"message\": str(e)}\n\n\n@final\n@dataclass\nclass PGDocStatusStorage(DocStatusStorage):\n    db: PostgreSQLDB = field(default=None)\n\n    def _format_datetime_with_timezone(self, dt):\n        \"\"\"Convert datetime to ISO format string with timezone info\"\"\"\n        if dt is None:\n            return None\n        # If no timezone info, assume it's UTC time (as stored in database)\n        if dt.tzinfo is None:\n            dt = dt.replace(tzinfo=timezone.utc)\n        # If datetime already has timezone info, keep it as is\n        return dt.isoformat()\n\n    async def initialize(self):\n        async with get_data_init_lock():\n            if self.db is None:\n                self.db = await ClientManager.get_client()\n\n            # Implement workspace priority: PostgreSQLDB.workspace > self.workspace > \"default\"\n            if self.db.workspace:\n                # Use PostgreSQLDB's workspace (highest priority)\n                logger.info(\n                    f\"Using PG_WORKSPACE environment variable: '{self.db.workspace}' (overriding '{self.workspace}/{self.namespace}')\"\n                )\n                self.workspace = self.db.workspace\n            elif hasattr(self, \"workspace\") and self.workspace:\n                # Use storage class's workspace (medium priority)\n                pass\n            else:\n                # Use \"default\" for compatibility (lowest priority)\n                self.workspace = \"default\"\n\n            # NOTE: Table creation is handled by PostgreSQLDB.initdb() during initialization\n            # No need to create table here as it's already created in the TABLES dict\n\n    async def finalize(self):\n        if self.db is not None:\n            await ClientManager.release_client(self.db)\n            self.db = None\n\n    async def filter_keys(self, keys: set[str]) -> set[str]:\n        \"\"\"Filter out duplicated content\"\"\"\n        if not keys:\n            return set()\n\n        table_name = namespace_to_table_name(self.namespace)\n        sql = f\"SELECT id FROM {table_name} WHERE workspace=$1 AND id = ANY($2)\"\n        params = {\"workspace\": self.workspace, \"ids\": list(keys)}\n        try:\n            res = await self.db.query(sql, list(params.values()), multirows=True)\n            if res:\n                exist_keys = [key[\"id\"] for key in res]\n            else:\n                exist_keys = []\n            new_keys = set([s for s in keys if s not in exist_keys])\n            # print(f\"keys: {keys}\")\n            # print(f\"new_keys: {new_keys}\")\n            return new_keys\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] PostgreSQL database,\\nsql:{sql},\\nparams:{params},\\nerror:{e}\"\n            )\n            raise\n\n    async def get_by_id(self, id: str) -> Union[dict[str, Any], None]:\n        sql = \"select * from LIGHTRAG_DOC_STATUS where workspace=$1 and id=$2\"\n        params = {\"workspace\": self.workspace, \"id\": id}\n        result = await self.db.query(sql, list(params.values()), True)\n        if result is None or result == []:\n            return None\n        else:\n            # Parse chunks_list JSON string back to list\n            chunks_list = result[0].get(\"chunks_list\", [])\n            if isinstance(chunks_list, str):\n                try:\n                    chunks_list = json.loads(chunks_list)\n                except json.JSONDecodeError:\n                    chunks_list = []\n\n            # Parse metadata JSON string back to dict\n            metadata = result[0].get(\"metadata\", {})\n            if isinstance(metadata, str):\n                try:\n                    metadata = json.loads(metadata)\n                except json.JSONDecodeError:\n                    metadata = {}\n\n            # Convert datetime objects to ISO format strings with timezone info\n            created_at = self._format_datetime_with_timezone(result[0][\"created_at\"])\n            updated_at = self._format_datetime_with_timezone(result[0][\"updated_at\"])\n\n            return dict(\n                content_length=result[0][\"content_length\"],\n                content_summary=result[0][\"content_summary\"],\n                status=result[0][\"status\"],\n                chunks_count=result[0][\"chunks_count\"],\n                created_at=created_at,\n                updated_at=updated_at,\n                file_path=result[0][\"file_path\"],\n                chunks_list=chunks_list,\n                metadata=metadata,\n                error_msg=result[0].get(\"error_msg\"),\n                track_id=result[0].get(\"track_id\"),\n            )\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        \"\"\"Get doc_chunks data by multiple IDs.\"\"\"\n        if not ids:\n            return []\n\n        sql = \"SELECT * FROM LIGHTRAG_DOC_STATUS WHERE workspace=$1 AND id = ANY($2)\"\n        params = {\"workspace\": self.workspace, \"ids\": ids}\n\n        results = await self.db.query(sql, list(params.values()), True)\n\n        if not results:\n            return []\n\n        processed_map: dict[str, dict[str, Any]] = {}\n        for row in results:\n            # Parse chunks_list JSON string back to list\n            chunks_list = row.get(\"chunks_list\", [])\n            if isinstance(chunks_list, str):\n                try:\n                    chunks_list = json.loads(chunks_list)\n                except json.JSONDecodeError:\n                    chunks_list = []\n\n            # Parse metadata JSON string back to dict\n            metadata = row.get(\"metadata\", {})\n            if isinstance(metadata, str):\n                try:\n                    metadata = json.loads(metadata)\n                except json.JSONDecodeError:\n                    metadata = {}\n\n            # Convert datetime objects to ISO format strings with timezone info\n            created_at = self._format_datetime_with_timezone(row[\"created_at\"])\n            updated_at = self._format_datetime_with_timezone(row[\"updated_at\"])\n\n            processed_map[str(row.get(\"id\"))] = {\n                \"content_length\": row[\"content_length\"],\n                \"content_summary\": row[\"content_summary\"],\n                \"status\": row[\"status\"],\n                \"chunks_count\": row[\"chunks_count\"],\n                \"created_at\": created_at,\n                \"updated_at\": updated_at,\n                \"file_path\": row[\"file_path\"],\n                \"chunks_list\": chunks_list,\n                \"metadata\": metadata,\n                \"error_msg\": row.get(\"error_msg\"),\n                \"track_id\": row.get(\"track_id\"),\n            }\n\n        ordered_results: list[dict[str, Any] | None] = []\n        for requested_id in ids:\n            ordered_results.append(processed_map.get(str(requested_id)))\n\n        return ordered_results\n\n    async def get_doc_by_file_path(self, file_path: str) -> Union[dict[str, Any], None]:\n        \"\"\"Get document by file path\n\n        Args:\n            file_path: The file path to search for\n\n        Returns:\n            Union[dict[str, Any], None]: Document data if found, None otherwise\n            Returns the same format as get_by_id method\n        \"\"\"\n        sql = \"select * from LIGHTRAG_DOC_STATUS where workspace=$1 and file_path=$2\"\n        params = {\"workspace\": self.workspace, \"file_path\": file_path}\n        result = await self.db.query(sql, list(params.values()), True)\n\n        if result is None or result == []:\n            return None\n        else:\n            # Parse chunks_list JSON string back to list\n            chunks_list = result[0].get(\"chunks_list\", [])\n            if isinstance(chunks_list, str):\n                try:\n                    chunks_list = json.loads(chunks_list)\n                except json.JSONDecodeError:\n                    chunks_list = []\n\n            # Parse metadata JSON string back to dict\n            metadata = result[0].get(\"metadata\", {})\n            if isinstance(metadata, str):\n                try:\n                    metadata = json.loads(metadata)\n                except json.JSONDecodeError:\n                    metadata = {}\n\n            # Convert datetime objects to ISO format strings with timezone info\n            created_at = self._format_datetime_with_timezone(result[0][\"created_at\"])\n            updated_at = self._format_datetime_with_timezone(result[0][\"updated_at\"])\n\n            return dict(\n                content_length=result[0][\"content_length\"],\n                content_summary=result[0][\"content_summary\"],\n                status=result[0][\"status\"],\n                chunks_count=result[0][\"chunks_count\"],\n                created_at=created_at,\n                updated_at=updated_at,\n                file_path=result[0][\"file_path\"],\n                chunks_list=chunks_list,\n                metadata=metadata,\n                error_msg=result[0].get(\"error_msg\"),\n                track_id=result[0].get(\"track_id\"),\n            )\n\n    async def get_status_counts(self) -> dict[str, int]:\n        \"\"\"Get counts of documents in each status\"\"\"\n        sql = \"\"\"SELECT status as \"status\", COUNT(1) as \"count\"\n                   FROM LIGHTRAG_DOC_STATUS\n                  where workspace=$1 GROUP BY STATUS\n                 \"\"\"\n        params = {\"workspace\": self.workspace}\n        result = await self.db.query(sql, list(params.values()), True)\n        counts = {}\n        for doc in result:\n            counts[doc[\"status\"]] = doc[\"count\"]\n        return counts\n\n    async def get_docs_by_status(\n        self, status: DocStatus\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"all documents with a specific status\"\"\"\n        sql = \"select * from LIGHTRAG_DOC_STATUS where workspace=$1 and status=$2\"\n        params = {\"workspace\": self.workspace, \"status\": status.value}\n        result = await self.db.query(sql, list(params.values()), True)\n\n        docs_by_status = {}\n        for element in result:\n            # Parse chunks_list JSON string back to list\n            chunks_list = element.get(\"chunks_list\", [])\n            if isinstance(chunks_list, str):\n                try:\n                    chunks_list = json.loads(chunks_list)\n                except json.JSONDecodeError:\n                    chunks_list = []\n\n            # Parse metadata JSON string back to dict\n            metadata = element.get(\"metadata\", {})\n            if isinstance(metadata, str):\n                try:\n                    metadata = json.loads(metadata)\n                except json.JSONDecodeError:\n                    metadata = {}\n            # Ensure metadata is a dict\n            if not isinstance(metadata, dict):\n                metadata = {}\n\n            # Safe handling for file_path\n            file_path = element.get(\"file_path\")\n            if file_path is None:\n                file_path = \"no-file-path\"\n\n            # Convert datetime objects to ISO format strings with timezone info\n            created_at = self._format_datetime_with_timezone(element[\"created_at\"])\n            updated_at = self._format_datetime_with_timezone(element[\"updated_at\"])\n\n            docs_by_status[element[\"id\"]] = DocProcessingStatus(\n                content_summary=element[\"content_summary\"],\n                content_length=element[\"content_length\"],\n                status=element[\"status\"],\n                created_at=created_at,\n                updated_at=updated_at,\n                chunks_count=element[\"chunks_count\"],\n                file_path=file_path,\n                chunks_list=chunks_list,\n                metadata=metadata,\n                error_msg=element.get(\"error_msg\"),\n                track_id=element.get(\"track_id\"),\n            )\n\n        return docs_by_status\n\n    async def get_docs_by_track_id(\n        self, track_id: str\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Get all documents with a specific track_id\"\"\"\n        sql = \"select * from LIGHTRAG_DOC_STATUS where workspace=$1 and track_id=$2\"\n        params = {\"workspace\": self.workspace, \"track_id\": track_id}\n        result = await self.db.query(sql, list(params.values()), True)\n\n        docs_by_track_id = {}\n        for element in result:\n            # Parse chunks_list JSON string back to list\n            chunks_list = element.get(\"chunks_list\", [])\n            if isinstance(chunks_list, str):\n                try:\n                    chunks_list = json.loads(chunks_list)\n                except json.JSONDecodeError:\n                    chunks_list = []\n\n            # Parse metadata JSON string back to dict\n            metadata = element.get(\"metadata\", {})\n            if isinstance(metadata, str):\n                try:\n                    metadata = json.loads(metadata)\n                except json.JSONDecodeError:\n                    metadata = {}\n            # Ensure metadata is a dict\n            if not isinstance(metadata, dict):\n                metadata = {}\n\n            # Safe handling for file_path\n            file_path = element.get(\"file_path\")\n            if file_path is None:\n                file_path = \"no-file-path\"\n\n            # Convert datetime objects to ISO format strings with timezone info\n            created_at = self._format_datetime_with_timezone(element[\"created_at\"])\n            updated_at = self._format_datetime_with_timezone(element[\"updated_at\"])\n\n            docs_by_track_id[element[\"id\"]] = DocProcessingStatus(\n                content_summary=element[\"content_summary\"],\n                content_length=element[\"content_length\"],\n                status=element[\"status\"],\n                created_at=created_at,\n                updated_at=updated_at,\n                chunks_count=element[\"chunks_count\"],\n                file_path=file_path,\n                chunks_list=chunks_list,\n                track_id=element.get(\"track_id\"),\n                metadata=metadata,\n                error_msg=element.get(\"error_msg\"),\n            )\n\n        return docs_by_track_id\n\n    async def get_docs_paginated(\n        self,\n        status_filter: DocStatus | None = None,\n        page: int = 1,\n        page_size: int = 50,\n        sort_field: str = \"updated_at\",\n        sort_direction: str = \"desc\",\n    ) -> tuple[list[tuple[str, DocProcessingStatus]], int]:\n        \"\"\"Get documents with pagination support\n\n        Args:\n            status_filter: Filter by document status, None for all statuses\n            page: Page number (1-based)\n            page_size: Number of documents per page (10-200)\n            sort_field: Field to sort by ('created_at', 'updated_at', 'id')\n            sort_direction: Sort direction ('asc' or 'desc')\n\n        Returns:\n            Tuple of (list of (doc_id, DocProcessingStatus) tuples, total_count)\n        \"\"\"\n        # Validate parameters\n        if page < 1:\n            page = 1\n        if page_size < 10:\n            page_size = 10\n        elif page_size > 200:\n            page_size = 200\n\n        # Whitelist validation for sort_field to prevent SQL injection\n        allowed_sort_fields = {\"created_at\", \"updated_at\", \"id\", \"file_path\"}\n        if sort_field not in allowed_sort_fields:\n            sort_field = \"updated_at\"\n\n        # Whitelist validation for sort_direction to prevent SQL injection\n        if sort_direction.lower() not in [\"asc\", \"desc\"]:\n            sort_direction = \"desc\"\n        else:\n            sort_direction = sort_direction.lower()\n\n        # Calculate offset\n        offset = (page - 1) * page_size\n\n        # Build parameterized query components\n        params = {\"workspace\": self.workspace}\n        param_count = 1\n\n        # Build WHERE clause with parameterized query\n        if status_filter is not None:\n            param_count += 1\n            where_clause = \"WHERE workspace=$1 AND status=$2\"\n            params[\"status\"] = status_filter.value\n        else:\n            where_clause = \"WHERE workspace=$1\"\n\n        # Build ORDER BY clause using validated whitelist values\n        order_clause = f\"ORDER BY {sort_field} {sort_direction.upper()}\"\n\n        # Query for total count\n        count_sql = f\"SELECT COUNT(*) as total FROM LIGHTRAG_DOC_STATUS {where_clause}\"\n        count_result = await self.db.query(count_sql, list(params.values()))\n        total_count = count_result[\"total\"] if count_result else 0\n\n        # Query for paginated data with parameterized LIMIT and OFFSET\n        data_sql = f\"\"\"\n            SELECT * FROM LIGHTRAG_DOC_STATUS\n            {where_clause}\n            {order_clause}\n            LIMIT ${param_count + 1} OFFSET ${param_count + 2}\n        \"\"\"\n        params[\"limit\"] = page_size\n        params[\"offset\"] = offset\n\n        result = await self.db.query(data_sql, list(params.values()), True)\n\n        # Convert to (doc_id, DocProcessingStatus) tuples\n        documents = []\n        for element in result:\n            doc_id = element[\"id\"]\n\n            # Parse chunks_list JSON string back to list\n            chunks_list = element.get(\"chunks_list\", [])\n            if isinstance(chunks_list, str):\n                try:\n                    chunks_list = json.loads(chunks_list)\n                except json.JSONDecodeError:\n                    chunks_list = []\n\n            # Parse metadata JSON string back to dict\n            metadata = element.get(\"metadata\", {})\n            if isinstance(metadata, str):\n                try:\n                    metadata = json.loads(metadata)\n                except json.JSONDecodeError:\n                    metadata = {}\n\n            # Convert datetime objects to ISO format strings with timezone info\n            created_at = self._format_datetime_with_timezone(element[\"created_at\"])\n            updated_at = self._format_datetime_with_timezone(element[\"updated_at\"])\n\n            doc_status = DocProcessingStatus(\n                content_summary=element[\"content_summary\"],\n                content_length=element[\"content_length\"],\n                status=element[\"status\"],\n                created_at=created_at,\n                updated_at=updated_at,\n                chunks_count=element[\"chunks_count\"],\n                file_path=element[\"file_path\"],\n                chunks_list=chunks_list,\n                track_id=element.get(\"track_id\"),\n                metadata=metadata,\n                error_msg=element.get(\"error_msg\"),\n            )\n            documents.append((doc_id, doc_status))\n\n        return documents, total_count\n\n    async def get_all_status_counts(self) -> dict[str, int]:\n        \"\"\"Get counts of documents in each status for all documents\n\n        Returns:\n            Dictionary mapping status names to counts, including 'all' field\n        \"\"\"\n        sql = \"\"\"\n            SELECT status, COUNT(*) as count\n            FROM LIGHTRAG_DOC_STATUS\n            WHERE workspace=$1\n            GROUP BY status\n        \"\"\"\n        params = {\"workspace\": self.workspace}\n        result = await self.db.query(sql, list(params.values()), True)\n\n        counts = {}\n        total_count = 0\n        for row in result:\n            counts[row[\"status\"]] = row[\"count\"]\n            total_count += row[\"count\"]\n\n        # Add 'all' field with total count\n        counts[\"all\"] = total_count\n\n        return counts\n\n    async def index_done_callback(self) -> None:\n        # PG handles persistence automatically\n        pass\n\n    async def is_empty(self) -> bool:\n        \"\"\"Check if the storage is empty for the current workspace and namespace\n\n        Returns:\n            bool: True if storage is empty, False otherwise\n        \"\"\"\n        table_name = namespace_to_table_name(self.namespace)\n        if not table_name:\n            logger.error(\n                f\"[{self.workspace}] Unknown namespace for is_empty check: {self.namespace}\"\n            )\n            return True\n\n        sql = f\"SELECT EXISTS(SELECT 1 FROM {table_name} WHERE workspace=$1 LIMIT 1) as has_data\"\n\n        try:\n            result = await self.db.query(sql, [self.workspace])\n            return not result.get(\"has_data\", False) if result else True\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error checking if storage is empty: {e}\")\n            return True\n\n    async def delete(self, ids: list[str]) -> None:\n        \"\"\"Delete specific records from storage by their IDs\n\n        Args:\n            ids (list[str]): List of document IDs to be deleted from storage\n\n        Returns:\n            None\n        \"\"\"\n        if not ids:\n            return\n\n        table_name = namespace_to_table_name(self.namespace)\n        if not table_name:\n            logger.error(\n                f\"[{self.workspace}] Unknown namespace for deletion: {self.namespace}\"\n            )\n            return\n\n        delete_sql = f\"DELETE FROM {table_name} WHERE workspace=$1 AND id = ANY($2)\"\n\n        try:\n            await self.db.execute(delete_sql, {\"workspace\": self.workspace, \"ids\": ids})\n            logger.debug(\n                f\"[{self.workspace}] Successfully deleted {len(ids)} records from {self.namespace}\"\n            )\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error while deleting records from {self.namespace}: {e}\"\n            )\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        \"\"\"Update or insert document status\n\n        Args:\n            data: dictionary of document IDs and their status data\n        \"\"\"\n        logger.debug(f\"[{self.workspace}] Inserting {len(data)} to {self.namespace}\")\n        if not data:\n            return\n\n        def parse_datetime(dt_str):\n            \"\"\"Parse datetime and ensure it's stored as UTC time in database\"\"\"\n            if dt_str is None:\n                return None\n            if isinstance(dt_str, (datetime.date, datetime.datetime)):\n                # If it's a datetime object\n                if isinstance(dt_str, datetime.datetime):\n                    # If no timezone info, assume it's UTC\n                    if dt_str.tzinfo is None:\n                        dt_str = dt_str.replace(tzinfo=timezone.utc)\n                    # Convert to UTC and remove timezone info for storage\n                    return dt_str.astimezone(timezone.utc).replace(tzinfo=None)\n                return dt_str\n            try:\n                # Process ISO format string with timezone\n                dt = datetime.datetime.fromisoformat(dt_str)\n                # If no timezone info, assume it's UTC\n                if dt.tzinfo is None:\n                    dt = dt.replace(tzinfo=timezone.utc)\n                # Convert to UTC and remove timezone info for storage\n                return dt.astimezone(timezone.utc).replace(tzinfo=None)\n            except (ValueError, TypeError):\n                logger.warning(\n                    f\"[{self.workspace}] Unable to parse datetime string: {dt_str}\"\n                )\n                return None\n\n        # Modified SQL to include created_at, updated_at, chunks_list, track_id, metadata, and error_msg in both INSERT and UPDATE operations\n        # All fields are updated from the input data in both INSERT and UPDATE cases\n        sql = \"\"\"insert into LIGHTRAG_DOC_STATUS(workspace,id,content_summary,content_length,chunks_count,status,file_path,chunks_list,track_id,metadata,error_msg,created_at,updated_at)\n                 values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)\n                  on conflict(id,workspace) do update set\n                  content_summary = EXCLUDED.content_summary,\n                  content_length = EXCLUDED.content_length,\n                  chunks_count = EXCLUDED.chunks_count,\n                  status = EXCLUDED.status,\n                  file_path = EXCLUDED.file_path,\n                  chunks_list = EXCLUDED.chunks_list,\n                  track_id = EXCLUDED.track_id,\n                  metadata = EXCLUDED.metadata,\n                  error_msg = EXCLUDED.error_msg,\n                  created_at = EXCLUDED.created_at,\n                  updated_at = EXCLUDED.updated_at\"\"\"\n        for k, v in data.items():\n            # Remove timezone information, store utc time in db\n            created_at = parse_datetime(v.get(\"created_at\"))\n            updated_at = parse_datetime(v.get(\"updated_at\"))\n\n            # chunks_count, chunks_list, track_id, metadata, and error_msg are optional\n            await self.db.execute(\n                sql,\n                {\n                    \"workspace\": self.workspace,\n                    \"id\": k,\n                    \"content_summary\": v[\"content_summary\"],\n                    \"content_length\": v[\"content_length\"],\n                    \"chunks_count\": v[\"chunks_count\"] if \"chunks_count\" in v else -1,\n                    \"status\": v[\"status\"],\n                    \"file_path\": v[\"file_path\"],\n                    \"chunks_list\": json.dumps(v.get(\"chunks_list\", [])),\n                    \"track_id\": v.get(\"track_id\"),  # Add track_id support\n                    \"metadata\": json.dumps(\n                        v.get(\"metadata\", {})\n                    ),  # Add metadata support\n                    \"error_msg\": v.get(\"error_msg\"),  # Add error_msg support\n                    \"created_at\": created_at,  # Use the converted datetime object\n                    \"updated_at\": updated_at,  # Use the converted datetime object\n                },\n            )\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop the storage\"\"\"\n        try:\n            table_name = namespace_to_table_name(self.namespace)\n            if not table_name:\n                return {\n                    \"status\": \"error\",\n                    \"message\": f\"Unknown namespace: {self.namespace}\",\n                }\n\n            drop_sql = SQL_TEMPLATES[\"drop_specifiy_table_workspace\"].format(\n                table_name=table_name\n            )\n            await self.db.execute(drop_sql, {\"workspace\": self.workspace})\n            return {\"status\": \"success\", \"message\": \"data dropped\"}\n        except Exception as e:\n            return {\"status\": \"error\", \"message\": str(e)}\n\n\nclass PGGraphQueryException(Exception):\n    \"\"\"Exception for the AGE queries.\"\"\"\n\n    def __init__(self, exception: Union[str, dict[str, Any]]) -> None:\n        if isinstance(exception, dict):\n            self.message = exception[\"message\"] if \"message\" in exception else \"unknown\"\n            self.details = exception[\"details\"] if \"details\" in exception else \"unknown\"\n        else:\n            self.message = exception\n            self.details = \"unknown\"\n\n    def get_message(self) -> str:\n        return self.message\n\n    def get_details(self) -> Any:\n        return self.details\n\n\n@final\n@dataclass\nclass PGGraphStorage(BaseGraphStorage):\n    def __post_init__(self):\n        # Graph name will be dynamically generated in initialize() based on workspace\n        self.db: PostgreSQLDB | None = None\n\n    def _get_workspace_graph_name(self) -> str:\n        \"\"\"\n        Generate graph name based on workspace and namespace for data isolation.\n        Rules:\n        - If workspace is empty or \"default\": graph_name = namespace\n        - If workspace has other value: graph_name = workspace_namespace\n\n        Args:\n            None\n\n        Returns:\n            str: The graph name for the current workspace\n        \"\"\"\n        workspace = self.workspace\n        namespace = self.namespace\n\n        if workspace and workspace.strip() and workspace.strip().lower() != \"default\":\n            # Ensure names comply with PostgreSQL identifier specifications\n            safe_workspace = re.sub(r\"[^a-zA-Z0-9_]\", \"_\", workspace.strip())\n            safe_namespace = re.sub(r\"[^a-zA-Z0-9_]\", \"_\", namespace)\n            return f\"{safe_workspace}_{safe_namespace}\"\n        else:\n            # When the workspace is \"default\", use the namespace directly (for backward compatibility with legacy implementations)\n            return re.sub(r\"[^a-zA-Z0-9_]\", \"_\", namespace)\n\n    @staticmethod\n    def _normalize_node_id(node_id: str) -> str:\n        \"\"\"\n        Normalize node ID to ensure special characters are properly handled in Cypher queries.\n\n        Args:\n            node_id: The original node ID\n\n        Returns:\n            Normalized node ID suitable for Cypher queries\n        \"\"\"\n        # Escape backslashes\n        normalized_id = node_id\n        normalized_id = normalized_id.replace(\"\\\\\", \"\\\\\\\\\")\n        normalized_id = normalized_id.replace('\"', '\\\\\"')\n        return normalized_id\n\n    async def initialize(self):\n        async with get_data_init_lock():\n            if self.db is None:\n                self.db = await ClientManager.get_client()\n\n            # Implement workspace priority: PostgreSQLDB.workspace > self.workspace > \"default\"\n            if self.db.workspace:\n                # Use PostgreSQLDB's workspace (highest priority)\n                logger.info(\n                    f\"Using PG_WORKSPACE environment variable: '{self.db.workspace}' (overriding '{self.workspace}/{self.namespace}')\"\n                )\n                self.workspace = self.db.workspace\n            elif hasattr(self, \"workspace\") and self.workspace:\n                # Use storage class's workspace (medium priority)\n                pass\n            else:\n                # Use \"default\" for compatibility (lowest priority)\n                self.workspace = \"default\"\n\n            # Dynamically generate graph name based on workspace\n            self.graph_name = self._get_workspace_graph_name()\n\n            # Log the graph initialization for debugging\n            logger.info(\n                f\"[{self.workspace}] PostgreSQL Graph initialized: graph_name='{self.graph_name}'\"\n            )\n\n            # Create AGE extension and configure graph environment once at initialization\n            async with self.db.pool.acquire() as connection:\n                # First ensure AGE extension is created\n                await PostgreSQLDB.configure_age_extension(connection)\n\n            # Execute each statement separately and ignore errors\n            queries = [\n                f\"SELECT create_graph('{self.graph_name}')\",\n                f\"SELECT create_vlabel('{self.graph_name}', 'base');\",\n                f\"SELECT create_elabel('{self.graph_name}', 'DIRECTED');\",\n                # f'CREATE INDEX CONCURRENTLY vertex_p_idx ON {self.graph_name}.\"_ag_label_vertex\" (id)',\n                f'CREATE INDEX CONCURRENTLY vertex_idx_node_id ON {self.graph_name}.\"_ag_label_vertex\" (ag_catalog.agtype_access_operator(properties, \\'\"entity_id\"\\'::agtype))',\n                # f'CREATE INDEX CONCURRENTLY edge_p_idx ON {self.graph_name}.\"_ag_label_edge\" (id)',\n                f'CREATE INDEX CONCURRENTLY edge_sid_idx ON {self.graph_name}.\"_ag_label_edge\" (start_id)',\n                f'CREATE INDEX CONCURRENTLY edge_eid_idx ON {self.graph_name}.\"_ag_label_edge\" (end_id)',\n                f'CREATE INDEX CONCURRENTLY edge_seid_idx ON {self.graph_name}.\"_ag_label_edge\" (start_id,end_id)',\n                f'CREATE INDEX CONCURRENTLY directed_p_idx ON {self.graph_name}.\"DIRECTED\" (id)',\n                f'CREATE INDEX CONCURRENTLY directed_eid_idx ON {self.graph_name}.\"DIRECTED\" (end_id)',\n                f'CREATE INDEX CONCURRENTLY directed_sid_idx ON {self.graph_name}.\"DIRECTED\" (start_id)',\n                f'CREATE INDEX CONCURRENTLY directed_seid_idx ON {self.graph_name}.\"DIRECTED\" (start_id,end_id)',\n                f'CREATE INDEX CONCURRENTLY entity_p_idx ON {self.graph_name}.\"base\" (id)',\n                f'CREATE INDEX CONCURRENTLY entity_idx_node_id ON {self.graph_name}.\"base\" (ag_catalog.agtype_access_operator(properties, \\'\"entity_id\"\\'::agtype))',\n                f'CREATE INDEX CONCURRENTLY entity_node_id_gin_idx ON {self.graph_name}.\"base\" using gin(properties)',\n                f'ALTER TABLE {self.graph_name}.\"DIRECTED\" CLUSTER ON directed_sid_idx',\n            ]\n\n            for query in queries:\n                # Use the new flag to silently ignore \"already exists\" errors\n                # at the source, preventing log spam.\n                await self.db.execute(\n                    query,\n                    upsert=True,\n                    ignore_if_exists=True,  # Pass the new flag\n                    with_age=True,\n                    graph_name=self.graph_name,\n                )\n\n    async def finalize(self):\n        if self.db is not None:\n            await ClientManager.release_client(self.db)\n            self.db = None\n\n    async def index_done_callback(self) -> None:\n        # PG handles persistence automatically\n        pass\n\n    @staticmethod\n    def _record_to_dict(record: asyncpg.Record) -> dict[str, Any]:\n        \"\"\"\n        Convert a record returned from an age query to a dictionary\n\n        Args:\n            record (): a record from an age query result\n\n        Returns:\n            dict[str, Any]: a dictionary representation of the record where\n                the dictionary key is the field name and the value is the\n                value converted to a python type\n        \"\"\"\n\n        @staticmethod\n        def parse_agtype_string(agtype_str: str) -> tuple[str, str]:\n            \"\"\"\n            Parse agtype string precisely, separating JSON content and type identifier\n\n            Args:\n                agtype_str: String like '{\"json\": \"content\"}::vertex'\n\n            Returns:\n                (json_content, type_identifier)\n            \"\"\"\n            if not isinstance(agtype_str, str) or \"::\" not in agtype_str:\n                return agtype_str, \"\"\n\n            # Find the last :: from the right, which is the start of type identifier\n            last_double_colon = agtype_str.rfind(\"::\")\n\n            if last_double_colon == -1:\n                return agtype_str, \"\"\n\n            # Separate JSON content and type identifier\n            json_content = agtype_str[:last_double_colon]\n            type_identifier = agtype_str[last_double_colon + 2 :]\n\n            return json_content, type_identifier\n\n        @staticmethod\n        def safe_json_parse(json_str: str, context: str = \"\") -> dict:\n            \"\"\"\n            Safe JSON parsing with simplified error logging\n            \"\"\"\n            try:\n                return json.loads(json_str)\n            except json.JSONDecodeError as e:\n                logger.error(f\"JSON parsing failed ({context}): {e}\")\n                logger.error(f\"Raw data (first 100 chars): {repr(json_str[:100])}\")\n                logger.error(f\"Error position: line {e.lineno}, column {e.colno}\")\n                return None\n\n        # result holder\n        d = {}\n\n        # prebuild a mapping of vertex_id to vertex mappings to be used\n        # later to build edges\n        vertices = {}\n\n        # First pass: preprocess vertices\n        for k in record.keys():\n            v = record[k]\n            if isinstance(v, str) and \"::\" in v:\n                if v.startswith(\"[\") and v.endswith(\"]\"):\n                    # Handle vertex arrays\n                    json_content, type_id = parse_agtype_string(v)\n                    if type_id == \"vertex\":\n                        vertexes = safe_json_parse(\n                            json_content, f\"vertices array for {k}\"\n                        )\n                        if vertexes:\n                            for vertex in vertexes:\n                                vertices[vertex[\"id\"]] = vertex.get(\"properties\")\n                else:\n                    # Handle single vertex\n                    json_content, type_id = parse_agtype_string(v)\n                    if type_id == \"vertex\":\n                        vertex = safe_json_parse(json_content, f\"single vertex for {k}\")\n                        if vertex:\n                            vertices[vertex[\"id\"]] = vertex.get(\"properties\")\n\n        # Second pass: process all fields\n        for k in record.keys():\n            v = record[k]\n            if isinstance(v, str) and \"::\" in v:\n                if v.startswith(\"[\") and v.endswith(\"]\"):\n                    # Handle array types\n                    json_content, type_id = parse_agtype_string(v)\n                    if type_id in [\"vertex\", \"edge\"]:\n                        parsed_data = safe_json_parse(\n                            json_content, f\"array {type_id} for field {k}\"\n                        )\n                        d[k] = parsed_data if parsed_data is not None else None\n                    else:\n                        logger.warning(f\"Unknown array type: {type_id}\")\n                        d[k] = None\n                else:\n                    # Handle single objects\n                    json_content, type_id = parse_agtype_string(v)\n                    if type_id in [\"vertex\", \"edge\"]:\n                        parsed_data = safe_json_parse(\n                            json_content, f\"single {type_id} for field {k}\"\n                        )\n                        d[k] = parsed_data if parsed_data is not None else None\n                    else:\n                        # May be other types of agtype data, keep as is\n                        d[k] = v\n            else:\n                d[k] = v  # Keep as string\n\n        return d\n\n    @staticmethod\n    def _format_properties(\n        properties: dict[str, Any], _id: Union[str, None] = None\n    ) -> str:\n        \"\"\"\n        Convert a dictionary of properties to a string representation that\n        can be used in a cypher query insert/merge statement.\n\n        Args:\n            properties (dict[str,str]): a dictionary containing node/edge properties\n            _id (Union[str, None]): the id of the node or None if none exists\n\n        Returns:\n            str: the properties dictionary as a properly formatted string\n        \"\"\"\n        props = []\n        # wrap property key in backticks to escape\n        for k, v in properties.items():\n            prop = f\"`{k}`: {json.dumps(v)}\"\n            props.append(prop)\n        if _id is not None and \"id\" not in properties:\n            props.append(\n                f\"id: {json.dumps(_id)}\" if isinstance(_id, str) else f\"id: {_id}\"\n            )\n        return \"{\" + \", \".join(props) + \"}\"\n\n    async def _query(\n        self,\n        query: str,\n        readonly: bool = True,\n        upsert: bool = False,\n        params: dict[str, Any] | None = None,\n    ) -> list[dict[str, Any]]:\n        \"\"\"\n        Query the graph by taking a cypher query, converting it to an\n        age compatible query, executing it and converting the result\n\n        Args:\n            query (str): a cypher query to be executed\n\n        Returns:\n            list[dict[str, Any]]: a list of dictionaries containing the result set\n        \"\"\"\n        try:\n            if readonly:\n                data = await self.db.query(\n                    query,\n                    list(params.values()) if params else None,\n                    multirows=True,\n                    with_age=True,\n                    graph_name=self.graph_name,\n                )\n            else:\n                data = await self.db.execute(\n                    query,\n                    upsert=upsert,\n                    with_age=True,\n                    graph_name=self.graph_name,\n                )\n\n        except Exception as e:\n            raise PGGraphQueryException(\n                {\n                    \"message\": f\"Error executing graph query: {query}\",\n                    \"wrapped\": query,\n                    \"detail\": repr(e),\n                    \"error_type\": e.__class__.__name__,\n                }\n            ) from e\n\n        if data is None:\n            result = []\n        # decode records\n        else:\n            result = [self._record_to_dict(d) for d in data]\n\n        return result\n\n    async def has_node(self, node_id: str) -> bool:\n        query = f\"\"\"\n            SELECT EXISTS (\n              SELECT 1\n              FROM {self.graph_name}.base\n              WHERE ag_catalog.agtype_access_operator(\n                      VARIADIC ARRAY[properties, '\"entity_id\"'::agtype]\n                    ) = (to_json($1::text)::text)::agtype\n              LIMIT 1\n            ) AS node_exists;\n        \"\"\"\n\n        params = {\"node_id\": node_id}\n        row = (await self._query(query, params=params))[0]\n        return bool(row[\"node_exists\"])\n\n    async def has_edge(self, source_node_id: str, target_node_id: str) -> bool:\n        query = f\"\"\"\n            WITH a AS (\n              SELECT id AS vid\n              FROM {self.graph_name}.base\n              WHERE ag_catalog.agtype_access_operator(\n                      VARIADIC ARRAY[properties, '\"entity_id\"'::agtype]\n                    ) = (to_json($1::text)::text)::agtype\n            ),\n            b AS (\n              SELECT id AS vid\n              FROM {self.graph_name}.base\n              WHERE ag_catalog.agtype_access_operator(\n                      VARIADIC ARRAY[properties, '\"entity_id\"'::agtype]\n                    ) = (to_json($2::text)::text)::agtype\n            )\n            SELECT EXISTS (\n              SELECT 1\n              FROM {self.graph_name}.\"DIRECTED\" d\n              JOIN a ON d.start_id = a.vid\n              JOIN b ON d.end_id   = b.vid\n              LIMIT 1\n            )\n            OR EXISTS (\n              SELECT 1\n              FROM {self.graph_name}.\"DIRECTED\" d\n              JOIN a ON d.end_id   = a.vid\n              JOIN b ON d.start_id = b.vid\n              LIMIT 1\n            ) AS edge_exists;\n        \"\"\"\n        params = {\n            \"source_node_id\": source_node_id,\n            \"target_node_id\": target_node_id,\n        }\n        row = (await self._query(query, params=params))[0]\n        return bool(row[\"edge_exists\"])\n\n    async def get_node(self, node_id: str) -> dict[str, str] | None:\n        \"\"\"Get node by its label identifier, return only node properties\"\"\"\n\n        result = await self.get_nodes_batch(node_ids=[node_id])\n        if result and node_id in result:\n            return result[node_id]\n        return None\n\n    async def node_degree(self, node_id: str) -> int:\n        result = await self.node_degrees_batch(node_ids=[node_id])\n        if result and node_id in result:\n            return result[node_id]\n\n    async def edge_degree(self, src_id: str, tgt_id: str) -> int:\n        result = await self.edge_degrees_batch(edges=[(src_id, tgt_id)])\n        if result and (src_id, tgt_id) in result:\n            return result[(src_id, tgt_id)]\n\n    async def get_edge(\n        self, source_node_id: str, target_node_id: str\n    ) -> dict[str, str] | None:\n        \"\"\"Get edge properties between two nodes\"\"\"\n        result = await self.get_edges_batch(\n            [{\"src\": source_node_id, \"tgt\": target_node_id}]\n        )\n        if result and (source_node_id, target_node_id) in result:\n            return result[(source_node_id, target_node_id)]\n        return None\n\n    async def get_node_edges(self, source_node_id: str) -> list[tuple[str, str]] | None:\n        \"\"\"\n        Retrieves all edges (relationships) for a particular node identified by its label.\n        :return: list of dictionaries containing edge information\n        \"\"\"\n        label = self._normalize_node_id(source_node_id)\n\n        # Build Cypher query with dynamic dollar-quoting to handle entity_id containing $ sequences\n        cypher_query = f\"\"\"MATCH (n:base {{entity_id: \"{label}\"}})\n                      OPTIONAL MATCH (n)-[]-(connected:base)\n                      RETURN n.entity_id AS source_id, connected.entity_id AS connected_id\"\"\"\n\n        query = f\"SELECT * FROM cypher({_dollar_quote(self.graph_name)}, {_dollar_quote(cypher_query)}) AS (source_id text, connected_id text)\"\n\n        results = await self._query(query)\n        edges = []\n        for record in results:\n            source_id = record[\"source_id\"]\n            connected_id = record[\"connected_id\"]\n\n            if source_id and connected_id:\n                edges.append((source_id, connected_id))\n\n        return edges\n\n    @retry(\n        stop=stop_after_attempt(3),\n        wait=wait_exponential(multiplier=1, min=4, max=10),\n        retry=retry_if_exception_type((PGGraphQueryException,)),\n    )\n    async def upsert_node(self, node_id: str, node_data: dict[str, str]) -> None:\n        \"\"\"\n        Upsert a node in the Neo4j database.\n\n        Args:\n            node_id: The unique identifier for the node (used as label)\n            node_data: Dictionary of node properties\n        \"\"\"\n        if \"entity_id\" not in node_data:\n            raise ValueError(\n                \"PostgreSQL: node properties must contain an 'entity_id' field\"\n            )\n\n        label = self._normalize_node_id(node_id)\n        properties = self._format_properties(node_data)\n\n        # Build Cypher query with dynamic dollar-quoting to handle content containing $$\n        # This prevents syntax errors when LLM-extracted descriptions contain $ sequences\n        cypher_query = f\"\"\"MERGE (n:base {{entity_id: \"{label}\"}})\n                     SET n += {properties}\n                     RETURN n\"\"\"\n\n        query = f\"SELECT * FROM cypher({_dollar_quote(self.graph_name)}, {_dollar_quote(cypher_query)}) AS (n agtype)\"\n\n        try:\n            await self._query(query, readonly=False, upsert=True)\n\n        except Exception:\n            logger.error(\n                f\"[{self.workspace}] POSTGRES, upsert_node error on node_id: `{node_id}`\"\n            )\n            raise\n\n    @retry(\n        stop=stop_after_attempt(3),\n        wait=wait_exponential(multiplier=1, min=4, max=10),\n        retry=retry_if_exception_type((PGGraphQueryException,)),\n    )\n    async def upsert_edge(\n        self, source_node_id: str, target_node_id: str, edge_data: dict[str, str]\n    ) -> None:\n        \"\"\"\n        Upsert an edge and its properties between two nodes identified by their labels.\n\n        Args:\n            source_node_id (str): Label of the source node (used as identifier)\n            target_node_id (str): Label of the target node (used as identifier)\n            edge_data (dict): dictionary of properties to set on the edge\n        \"\"\"\n        src_label = self._normalize_node_id(source_node_id)\n        tgt_label = self._normalize_node_id(target_node_id)\n        edge_properties = self._format_properties(edge_data)\n\n        # Build Cypher query with dynamic dollar-quoting to handle content containing $$\n        # This prevents syntax errors when LLM-extracted descriptions contain $ sequences\n        # See: https://github.com/HKUDS/LightRAG/issues/1438#issuecomment-2826000195\n        cypher_query = f\"\"\"MATCH (source:base {{entity_id: \"{src_label}\"}})\n                     WITH source\n                     MATCH (target:base {{entity_id: \"{tgt_label}\"}})\n                     MERGE (source)-[r:DIRECTED]-(target)\n                     SET r += {edge_properties}\n                     SET r += {edge_properties}\n                     RETURN r\"\"\"\n\n        query = f\"SELECT * FROM cypher({_dollar_quote(self.graph_name)}, {_dollar_quote(cypher_query)}) AS (r agtype)\"\n\n        try:\n            await self._query(query, readonly=False, upsert=True)\n\n        except Exception:\n            logger.error(\n                f\"[{self.workspace}] POSTGRES, upsert_edge error on edge: `{source_node_id}`-`{target_node_id}`\"\n            )\n            raise\n\n    async def delete_node(self, node_id: str) -> None:\n        \"\"\"\n        Delete a node from the graph.\n\n        Args:\n            node_id (str): The ID of the node to delete.\n        \"\"\"\n        label = self._normalize_node_id(node_id)\n\n        # Build Cypher query with dynamic dollar-quoting to handle entity_id containing $ sequences\n        cypher_query = f\"\"\"MATCH (n:base {{entity_id: \"{label}\"}})\n                     DETACH DELETE n\"\"\"\n\n        query = f\"SELECT * FROM cypher({_dollar_quote(self.graph_name)}, {_dollar_quote(cypher_query)}) AS (n agtype)\"\n\n        try:\n            await self._query(query, readonly=False)\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error during node deletion: {e}\")\n            raise\n\n    async def remove_nodes(self, node_ids: list[str]) -> None:\n        \"\"\"\n        Remove multiple nodes from the graph.\n\n        Args:\n            node_ids (list[str]): A list of node IDs to remove.\n        \"\"\"\n        node_ids_normalized = [self._normalize_node_id(node_id) for node_id in node_ids]\n        node_id_list = \", \".join([f'\"{node_id}\"' for node_id in node_ids_normalized])\n\n        # Build Cypher query with dynamic dollar-quoting to handle entity_id containing $ sequences\n        cypher_query = f\"\"\"MATCH (n:base)\n                     WHERE n.entity_id IN [{node_id_list}]\n                     DETACH DELETE n\"\"\"\n\n        query = f\"SELECT * FROM cypher({_dollar_quote(self.graph_name)}, {_dollar_quote(cypher_query)}) AS (n agtype)\"\n\n        try:\n            await self._query(query, readonly=False)\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error during node removal: {e}\")\n            raise\n\n    async def remove_edges(self, edges: list[tuple[str, str]]) -> None:\n        \"\"\"\n        Remove multiple edges from the graph.\n\n        Args:\n            edges (list[tuple[str, str]]): A list of edges to remove, where each edge is a tuple of (source_node_id, target_node_id).\n        \"\"\"\n        for source, target in edges:\n            src_label = self._normalize_node_id(source)\n            tgt_label = self._normalize_node_id(target)\n\n            # Build Cypher query with dynamic dollar-quoting to handle entity_id containing $ sequences\n            cypher_query = f\"\"\"MATCH (a:base {{entity_id: \"{src_label}\"}})-[r]-(b:base {{entity_id: \"{tgt_label}\"}})\n                         DELETE r\"\"\"\n\n            query = f\"SELECT * FROM cypher({_dollar_quote(self.graph_name)}, {_dollar_quote(cypher_query)}) AS (r agtype)\"\n\n            try:\n                await self._query(query, readonly=False)\n                logger.debug(\n                    f\"[{self.workspace}] Deleted edge from '{source}' to '{target}'\"\n                )\n            except Exception as e:\n                logger.error(f\"[{self.workspace}] Error during edge deletion: {str(e)}\")\n                raise\n\n    async def get_nodes_batch(\n        self, node_ids: list[str], batch_size: int = 1000\n    ) -> dict[str, dict]:\n        \"\"\"\n        Retrieve multiple nodes in one query using UNWIND.\n\n        Args:\n            node_ids: List of node entity IDs to fetch.\n            batch_size: Batch size for the query\n\n        Returns:\n            A dictionary mapping each node_id to its node data (or None if not found).\n        \"\"\"\n        if not node_ids:\n            return {}\n\n        seen: set[str] = set()\n        unique_ids: list[str] = []\n        lookup: dict[str, str] = {}\n        requested: set[str] = set()\n        for nid in node_ids:\n            if nid not in seen:\n                seen.add(nid)\n                unique_ids.append(nid)\n            requested.add(nid)\n            lookup[nid] = nid\n            lookup[self._normalize_node_id(nid)] = nid\n\n        # Build result dictionary\n        nodes_dict = {}\n\n        for i in range(0, len(unique_ids), batch_size):\n            batch = unique_ids[i : i + batch_size]\n\n            query = f\"\"\"\n                WITH input(v, ord) AS (\n                  SELECT v, ord\n                  FROM unnest($1::text[]) WITH ORDINALITY AS t(v, ord)\n                ),\n                ids(node_id, ord) AS (\n                  SELECT (to_json(v)::text)::agtype AS node_id, ord\n                  FROM input\n                )\n                SELECT i.node_id::text AS node_id,\n                       b.properties\n                FROM {self.graph_name}.base AS b\n                JOIN ids i\n                  ON ag_catalog.agtype_access_operator(\n                       VARIADIC ARRAY[b.properties, '\"entity_id\"'::agtype]\n                     ) = i.node_id\n                ORDER BY i.ord;\n            \"\"\"\n\n            results = await self._query(query, params={\"ids\": batch})\n\n            for result in results:\n                if result[\"node_id\"] and result[\"properties\"]:\n                    node_dict = result[\"properties\"]\n\n                    # Process string result, parse it to JSON dictionary\n                    if isinstance(node_dict, str):\n                        try:\n                            node_dict = json.loads(node_dict)\n                        except json.JSONDecodeError:\n                            logger.warning(\n                                f\"[{self.workspace}] Failed to parse node string in batch: {node_dict}\"\n                            )\n\n                    node_key = result[\"node_id\"]\n                    original_key = lookup.get(node_key)\n                    if original_key is None:\n                        logger.warning(\n                            f\"[{self.workspace}] Node {node_key} not found in lookup map\"\n                        )\n                        original_key = node_key\n                    if original_key in requested:\n                        nodes_dict[original_key] = node_dict\n\n        return nodes_dict\n\n    async def node_degrees_batch(\n        self, node_ids: list[str], batch_size: int = 500\n    ) -> dict[str, int]:\n        \"\"\"\n        Retrieve the degree for multiple nodes in a single query using UNWIND.\n        Calculates the total degree by counting distinct relationships.\n        Uses separate queries for outgoing and incoming edges.\n\n        Args:\n            node_ids: List of node labels (entity_id values) to look up.\n            batch_size: Batch size for the query\n\n        Returns:\n            A dictionary mapping each node_id to its degree (total number of relationships).\n            If a node is not found, its degree will be set to 0.\n        \"\"\"\n        if not node_ids:\n            return {}\n\n        seen: set[str] = set()\n        unique_ids: list[str] = []\n        lookup: dict[str, str] = {}\n        requested: set[str] = set()\n        for nid in node_ids:\n            if nid not in seen:\n                seen.add(nid)\n                unique_ids.append(nid)\n            requested.add(nid)\n            lookup[nid] = nid\n            lookup[self._normalize_node_id(nid)] = nid\n\n        out_degrees = {}\n        in_degrees = {}\n\n        for i in range(0, len(unique_ids), batch_size):\n            batch = unique_ids[i : i + batch_size]\n\n            query = f\"\"\"\n                    WITH input(v, ord) AS (\n                      SELECT v, ord\n                      FROM unnest($1::text[]) WITH ORDINALITY AS t(v, ord)\n                    ),\n                    ids(node_id, ord) AS (\n                      SELECT (to_json(v)::text)::agtype AS node_id, ord\n                      FROM input\n                    ),\n                    vids AS (\n                      SELECT b.id AS vid, i.node_id, i.ord\n                      FROM {self.graph_name}.base AS b\n                      JOIN ids i\n                        ON ag_catalog.agtype_access_operator(\n                             VARIADIC ARRAY[b.properties, '\"entity_id\"'::agtype]\n                           ) = i.node_id\n                    ),\n                    deg_out AS (\n                      SELECT d.start_id AS vid, COUNT(*)::bigint AS out_degree\n                      FROM {self.graph_name}.\"DIRECTED\" AS d\n                      JOIN vids v ON v.vid = d.start_id\n                      GROUP BY d.start_id\n                    ),\n                    deg_in AS (\n                      SELECT d.end_id AS vid, COUNT(*)::bigint AS in_degree\n                      FROM {self.graph_name}.\"DIRECTED\" AS d\n                      JOIN vids v ON v.vid = d.end_id\n                      GROUP BY d.end_id\n                    )\n                    SELECT v.node_id::text AS node_id,\n                           COALESCE(o.out_degree, 0) AS out_degree,\n                           COALESCE(n.in_degree, 0)  AS in_degree\n                    FROM vids v\n                    LEFT JOIN deg_out o ON o.vid = v.vid\n                    LEFT JOIN deg_in  n ON n.vid = v.vid\n                    ORDER BY v.ord;\n                \"\"\"\n\n            combined_results = await self._query(query, params={\"ids\": batch})\n\n            for row in combined_results:\n                node_id = row[\"node_id\"]\n                if not node_id:\n                    continue\n                node_key = node_id\n                original_key = lookup.get(node_key)\n                if original_key is None:\n                    logger.warning(\n                        f\"[{self.workspace}] Node {node_key} not found in lookup map\"\n                    )\n                    original_key = node_key\n                if original_key in requested:\n                    out_degrees[original_key] = int(row.get(\"out_degree\", 0) or 0)\n                    in_degrees[original_key] = int(row.get(\"in_degree\", 0) or 0)\n\n        degrees_dict = {}\n        for node_id in node_ids:\n            out_degree = out_degrees.get(node_id, 0)\n            in_degree = in_degrees.get(node_id, 0)\n            degrees_dict[node_id] = out_degree + in_degree\n\n        return degrees_dict\n\n    async def edge_degrees_batch(\n        self, edges: list[tuple[str, str]]\n    ) -> dict[tuple[str, str], int]:\n        \"\"\"\n        Calculate the combined degree for each edge (sum of the source and target node degrees)\n        in batch using the already implemented node_degrees_batch.\n\n        Args:\n            edges: List of (source_node_id, target_node_id) tuples\n\n        Returns:\n            Dictionary mapping edge tuples to their combined degrees\n        \"\"\"\n        if not edges:\n            return {}\n\n        # Use node_degrees_batch to get all node degrees efficiently\n        all_nodes = set()\n        for src, tgt in edges:\n            all_nodes.add(src)\n            all_nodes.add(tgt)\n\n        node_degrees = await self.node_degrees_batch(list(all_nodes))\n\n        # Calculate edge degrees\n        edge_degrees_dict = {}\n        for src, tgt in edges:\n            src_degree = node_degrees.get(src, 0)\n            tgt_degree = node_degrees.get(tgt, 0)\n            edge_degrees_dict[(src, tgt)] = src_degree + tgt_degree\n\n        return edge_degrees_dict\n\n    async def get_edges_batch(\n        self, pairs: list[dict[str, str]], batch_size: int = 500\n    ) -> dict[tuple[str, str], dict]:\n        \"\"\"\n        Retrieve edge properties for multiple (src, tgt) pairs in one query.\n        Get forward and backward edges separately and merge them before return\n\n        Args:\n            pairs: List of dictionaries, e.g. [{\"src\": \"node1\", \"tgt\": \"node2\"}, ...]\n            batch_size: Batch size for the query\n\n        Returns:\n            A dictionary mapping (src, tgt) tuples to their edge properties.\n        \"\"\"\n        if not pairs:\n            return {}\n\n        seen = set()\n        uniq_pairs: list[dict[str, str]] = []\n        for p in pairs:\n            s = self._normalize_node_id(p[\"src\"])\n            t = self._normalize_node_id(p[\"tgt\"])\n            key = (s, t)\n            if s and t and key not in seen:\n                seen.add(key)\n                uniq_pairs.append(p)\n\n        edges_dict: dict[tuple[str, str], dict] = {}\n\n        for i in range(0, len(uniq_pairs), batch_size):\n            batch = uniq_pairs[i : i + batch_size]\n\n            pairs = [{\"src\": p[\"src\"], \"tgt\": p[\"tgt\"]} for p in batch]\n\n            forward_cypher = \"\"\"\n                         UNWIND $pairs AS p\n                         WITH p.src AS src_eid, p.tgt AS tgt_eid\n                         MATCH (a:base {entity_id: src_eid})\n                         MATCH (b:base {entity_id: tgt_eid})\n                         MATCH (a)-[r]->(b)\n                         RETURN src_eid AS source, tgt_eid AS target, properties(r) AS edge_properties\"\"\"\n            backward_cypher = \"\"\"\n                         UNWIND $pairs AS p\n                         WITH p.src AS src_eid, p.tgt AS tgt_eid\n                         MATCH (a:base {entity_id: src_eid})\n                         MATCH (b:base {entity_id: tgt_eid})\n                         MATCH (a)<-[r]-(b)\n                         RETURN src_eid AS source, tgt_eid AS target, properties(r) AS edge_properties\"\"\"\n\n            sql_fwd = f\"\"\"\n            SELECT * FROM cypher({_dollar_quote(self.graph_name)}::name,\n                                 {_dollar_quote(forward_cypher)}::cstring,\n                                 $1::agtype)\n              AS (source text, target text, edge_properties agtype)\n            \"\"\"\n\n            sql_bwd = f\"\"\"\n            SELECT * FROM cypher({_dollar_quote(self.graph_name)}::name,\n                                 {_dollar_quote(backward_cypher)}::cstring,\n                                 $1::agtype)\n              AS (source text, target text, edge_properties agtype)\n            \"\"\"\n\n            pg_params = {\"params\": json.dumps({\"pairs\": pairs}, ensure_ascii=False)}\n\n            forward_results = await self._query(sql_fwd, params=pg_params)\n            backward_results = await self._query(sql_bwd, params=pg_params)\n\n            for result in forward_results:\n                if result[\"source\"] and result[\"target\"] and result[\"edge_properties\"]:\n                    edge_props = result[\"edge_properties\"]\n\n                    # Process string result, parse it to JSON dictionary\n                    if isinstance(edge_props, str):\n                        try:\n                            edge_props = json.loads(edge_props)\n                        except json.JSONDecodeError:\n                            logger.warning(\n                                f\"[{self.workspace}]Failed to parse edge properties string: {edge_props}\"\n                            )\n                            continue\n\n                    edges_dict[(result[\"source\"], result[\"target\"])] = edge_props\n\n            for result in backward_results:\n                if result[\"source\"] and result[\"target\"] and result[\"edge_properties\"]:\n                    edge_props = result[\"edge_properties\"]\n\n                    # Process string result, parse it to JSON dictionary\n                    if isinstance(edge_props, str):\n                        try:\n                            edge_props = json.loads(edge_props)\n                        except json.JSONDecodeError:\n                            logger.warning(\n                                f\"[{self.workspace}] Failed to parse edge properties string: {edge_props}\"\n                            )\n                            continue\n\n                    edges_dict[(result[\"source\"], result[\"target\"])] = edge_props\n\n        return edges_dict\n\n    async def get_nodes_edges_batch(\n        self, node_ids: list[str], batch_size: int = 500\n    ) -> dict[str, list[tuple[str, str]]]:\n        \"\"\"\n        Get all edges (both outgoing and incoming) for multiple nodes in a single batch operation.\n\n        Args:\n            node_ids: List of node IDs to get edges for\n            batch_size: Batch size for the query\n\n        Returns:\n            Dictionary mapping node IDs to lists of (source, target) edge tuples\n        \"\"\"\n        if not node_ids:\n            return {}\n\n        seen = set()\n        unique_ids: list[str] = []\n        for nid in node_ids:\n            n = self._normalize_node_id(nid)\n            if n and n not in seen:\n                seen.add(n)\n                unique_ids.append(n)\n\n        edges_norm: dict[str, list[tuple[str, str]]] = {n: [] for n in unique_ids}\n\n        for i in range(0, len(unique_ids), batch_size):\n            batch = unique_ids[i : i + batch_size]\n            # Format node IDs for the query\n            formatted_ids = \", \".join([f'\"{n}\"' for n in batch])\n\n            # Build Cypher queries with dynamic dollar-quoting to handle entity_id containing $ sequences\n            outgoing_cypher = f\"\"\"UNWIND [{formatted_ids}] AS node_id\n                         MATCH (n:base {{entity_id: node_id}})\n                         OPTIONAL MATCH (n:base)-[]->(connected:base)\n                         RETURN node_id, connected.entity_id AS connected_id\"\"\"\n\n            incoming_cypher = f\"\"\"UNWIND [{formatted_ids}] AS node_id\n                         MATCH (n:base {{entity_id: node_id}})\n                         OPTIONAL MATCH (n:base)<-[]-(connected:base)\n                         RETURN node_id, connected.entity_id AS connected_id\"\"\"\n\n            outgoing_query = f\"SELECT * FROM cypher({_dollar_quote(self.graph_name)}, {_dollar_quote(outgoing_cypher)}) AS (node_id text, connected_id text)\"\n            incoming_query = f\"SELECT * FROM cypher({_dollar_quote(self.graph_name)}, {_dollar_quote(incoming_cypher)}) AS (node_id text, connected_id text)\"\n\n            outgoing_results = await self._query(outgoing_query)\n            incoming_results = await self._query(incoming_query)\n\n            for result in outgoing_results:\n                if result[\"node_id\"] and result[\"connected_id\"]:\n                    edges_norm[result[\"node_id\"]].append(\n                        (result[\"node_id\"], result[\"connected_id\"])\n                    )\n\n            for result in incoming_results:\n                if result[\"node_id\"] and result[\"connected_id\"]:\n                    edges_norm[result[\"node_id\"]].append(\n                        (result[\"connected_id\"], result[\"node_id\"])\n                    )\n\n        out: dict[str, list[tuple[str, str]]] = {}\n        for orig in node_ids:\n            n = self._normalize_node_id(orig)\n            out[orig] = edges_norm.get(n, [])\n\n        return out\n\n    async def get_all_labels(self) -> list[str]:\n        \"\"\"\n        Get all labels(node IDs, entity names) in the graph.\n\n        Returns:\n            list[str]: A list of all labels in the graph.\n        \"\"\"\n        query = (\n            \"\"\"SELECT * FROM cypher('%s', $$\n                     MATCH (n:base)\n                     WHERE n.entity_id IS NOT NULL\n                     RETURN DISTINCT n.entity_id AS label\n                     ORDER BY n.entity_id\n                   $$) AS (label text)\"\"\"\n            % self.graph_name\n        )\n\n        results = await self._query(query)\n        labels = []\n        for result in results:\n            if result and isinstance(result, dict) and \"label\" in result:\n                labels.append(result[\"label\"])\n        return labels\n\n    async def _bfs_subgraph(\n        self, node_label: str, max_depth: int, max_nodes: int\n    ) -> KnowledgeGraph:\n        \"\"\"\n        Implements a true breadth-first search algorithm for subgraph retrieval.\n        This method is used as a fallback when the standard Cypher query is too slow\n        or when we need to guarantee BFS ordering.\n\n        Args:\n            node_label: Label of the starting node\n            max_depth: Maximum depth of the subgraph\n            max_nodes: Maximum number of nodes to return\n\n        Returns:\n            KnowledgeGraph object containing nodes and edges\n        \"\"\"\n        from collections import deque\n\n        result = KnowledgeGraph()\n        visited_nodes = set()\n        visited_node_ids = set()\n        visited_edges = set()\n        visited_edge_pairs = set()\n\n        # Get starting node data\n        label = self._normalize_node_id(node_label)\n\n        # Build Cypher query with dynamic dollar-quoting to handle entity_id containing $ sequences\n        cypher_query = f\"\"\"MATCH (n:base {{entity_id: \"{label}\"}})\n                    RETURN id(n) as node_id, n\"\"\"\n\n        query = f\"SELECT * FROM cypher({_dollar_quote(self.graph_name)}, {_dollar_quote(cypher_query)}) AS (node_id bigint, n agtype)\"\n\n        node_result = await self._query(query)\n        if not node_result or not node_result[0].get(\"n\"):\n            return result\n\n        # Create initial KnowledgeGraphNode\n        start_node_data = node_result[0][\"n\"]\n        entity_id = start_node_data[\"properties\"][\"entity_id\"]\n        internal_id = str(start_node_data[\"id\"])\n\n        start_node = KnowledgeGraphNode(\n            id=internal_id,\n            labels=[entity_id],\n            properties=start_node_data[\"properties\"],\n        )\n\n        # Initialize BFS queue, each element is a tuple of (node, depth)\n        queue = deque([(start_node, 0)])\n\n        visited_nodes.add(entity_id)\n        visited_node_ids.add(internal_id)\n        result.nodes.append(start_node)\n\n        result.is_truncated = False\n\n        # BFS search main loop\n        while queue:\n            # Get all nodes at the current depth\n            current_level_nodes = []\n            current_depth = None\n\n            # Determine current depth\n            if queue:\n                current_depth = queue[0][1]\n\n            # Extract all nodes at current depth from the queue\n            while queue and queue[0][1] == current_depth:\n                node, depth = queue.popleft()\n                if depth > max_depth:\n                    continue\n                current_level_nodes.append(node)\n\n            if not current_level_nodes:\n                continue\n\n            # Check depth limit\n            if current_depth > max_depth:\n                continue\n\n            # Prepare node IDs list\n            node_ids = [node.labels[0] for node in current_level_nodes]\n            formatted_ids = \", \".join(\n                [f'\"{self._normalize_node_id(node_id)}\"' for node_id in node_ids]\n            )\n\n            # Build Cypher queries with dynamic dollar-quoting to handle entity_id containing $ sequences\n            outgoing_cypher = f\"\"\"UNWIND [{formatted_ids}] AS node_id\n                MATCH (n:base {{entity_id: node_id}})\n                OPTIONAL MATCH (n)-[r]->(neighbor:base)\n                RETURN node_id AS current_id,\n                       id(n) AS current_internal_id,\n                       id(neighbor) AS neighbor_internal_id,\n                       neighbor.entity_id AS neighbor_id,\n                       id(r) AS edge_id,\n                       r,\n                       neighbor,\n                       true AS is_outgoing\"\"\"\n\n            incoming_cypher = f\"\"\"UNWIND [{formatted_ids}] AS node_id\n                MATCH (n:base {{entity_id: node_id}})\n                OPTIONAL MATCH (n)<-[r]-(neighbor:base)\n                RETURN node_id AS current_id,\n                       id(n) AS current_internal_id,\n                       id(neighbor) AS neighbor_internal_id,\n                       neighbor.entity_id AS neighbor_id,\n                       id(r) AS edge_id,\n                       r,\n                       neighbor,\n                       false AS is_outgoing\"\"\"\n\n            outgoing_query = f\"SELECT * FROM cypher({_dollar_quote(self.graph_name)}, {_dollar_quote(outgoing_cypher)}) AS (current_id text, current_internal_id bigint, neighbor_internal_id bigint, neighbor_id text, edge_id bigint, r agtype, neighbor agtype, is_outgoing bool)\"\n\n            incoming_query = f\"SELECT * FROM cypher({_dollar_quote(self.graph_name)}, {_dollar_quote(incoming_cypher)}) AS (current_id text, current_internal_id bigint, neighbor_internal_id bigint, neighbor_id text, edge_id bigint, r agtype, neighbor agtype, is_outgoing bool)\"\n\n            # Execute queries\n            outgoing_results = await self._query(outgoing_query)\n            incoming_results = await self._query(incoming_query)\n\n            # Combine results\n            neighbors = outgoing_results + incoming_results\n\n            # Create mapping from node ID to node object\n            node_map = {node.labels[0]: node for node in current_level_nodes}\n\n            # Process all results in a single loop\n            for record in neighbors:\n                if not record.get(\"neighbor\") or not record.get(\"r\"):\n                    continue\n\n                # Get current node information\n                current_entity_id = record[\"current_id\"]\n                current_node = node_map[current_entity_id]\n\n                # Get neighbor node information\n                neighbor_entity_id = record[\"neighbor_id\"]\n                neighbor_internal_id = str(record[\"neighbor_internal_id\"])\n                is_outgoing = record[\"is_outgoing\"]\n\n                # Determine edge direction\n                if is_outgoing:\n                    source_id = current_node.id\n                    target_id = neighbor_internal_id\n                else:\n                    source_id = neighbor_internal_id\n                    target_id = current_node.id\n\n                if not neighbor_entity_id:\n                    continue\n\n                # Get edge and node information\n                b_node = record[\"neighbor\"]\n                rel = record[\"r\"]\n                edge_id = str(record[\"edge_id\"])\n\n                # Create neighbor node object\n                neighbor_node = KnowledgeGraphNode(\n                    id=neighbor_internal_id,\n                    labels=[neighbor_entity_id],\n                    properties=b_node[\"properties\"],\n                )\n\n                # Sort entity_ids to ensure (A,B) and (B,A) are treated as the same edge\n                sorted_pair = tuple(sorted([current_entity_id, neighbor_entity_id]))\n\n                # Create edge object\n                edge = KnowledgeGraphEdge(\n                    id=edge_id,\n                    type=rel[\"label\"],\n                    source=source_id,\n                    target=target_id,\n                    properties=rel[\"properties\"],\n                )\n\n                if neighbor_internal_id in visited_node_ids:\n                    # Add backward edge if neighbor node is already visited\n                    if (\n                        edge_id not in visited_edges\n                        and sorted_pair not in visited_edge_pairs\n                    ):\n                        result.edges.append(edge)\n                        visited_edges.add(edge_id)\n                        visited_edge_pairs.add(sorted_pair)\n                else:\n                    if len(visited_node_ids) < max_nodes and current_depth < max_depth:\n                        # Add new node to result and queue\n                        result.nodes.append(neighbor_node)\n                        visited_nodes.add(neighbor_entity_id)\n                        visited_node_ids.add(neighbor_internal_id)\n\n                        # Add node to queue with incremented depth\n                        queue.append((neighbor_node, current_depth + 1))\n\n                        # Add forward edge\n                        if (\n                            edge_id not in visited_edges\n                            and sorted_pair not in visited_edge_pairs\n                        ):\n                            result.edges.append(edge)\n                            visited_edges.add(edge_id)\n                            visited_edge_pairs.add(sorted_pair)\n                    else:\n                        if current_depth < max_depth:\n                            result.is_truncated = True\n\n        return result\n\n    async def get_knowledge_graph(\n        self,\n        node_label: str,\n        max_depth: int = 3,\n        max_nodes: int = None,\n    ) -> KnowledgeGraph:\n        \"\"\"\n        Retrieve a connected subgraph of nodes where the label includes the specified `node_label`.\n\n        Args:\n            node_label: Label of the starting node, * means all nodes\n            max_depth: Maximum depth of the subgraph, Defaults to 3\n            max_nodes: Maximum nodes to return, Defaults to global_config max_graph_nodes\n\n        Returns:\n            KnowledgeGraph object containing nodes and edges, with an is_truncated flag\n            indicating whether the graph was truncated due to max_nodes limit\n        \"\"\"\n        # Use global_config max_graph_nodes as default if max_nodes is None\n        if max_nodes is None:\n            max_nodes = self.global_config.get(\"max_graph_nodes\", 1000)\n        else:\n            # Limit max_nodes to not exceed global_config max_graph_nodes\n            max_nodes = min(max_nodes, self.global_config.get(\"max_graph_nodes\", 1000))\n        kg = KnowledgeGraph()\n\n        # Handle wildcard query - get all nodes\n        if node_label == \"*\":\n            # First check total node count to determine if graph should be truncated\n            count_query = f\"\"\"SELECT * FROM cypher('{self.graph_name}', $$\n                    MATCH (n:base)\n                    RETURN count(distinct n) AS total_nodes\n                    $$) AS (total_nodes bigint)\"\"\"\n\n            count_result = await self._query(count_query)\n            total_nodes = count_result[0][\"total_nodes\"] if count_result else 0\n            is_truncated = total_nodes > max_nodes\n\n            # Get max_nodes with highest degrees\n            query_nodes = f\"\"\"SELECT * FROM cypher('{self.graph_name}', $$\n                    MATCH (n:base)\n                    OPTIONAL MATCH (n)-[r]->()\n                    RETURN id(n) as node_id, count(r) as degree\n                $$) AS (node_id BIGINT, degree BIGINT)\n                ORDER BY degree DESC\n                LIMIT {max_nodes}\"\"\"\n            node_results = await self._query(query_nodes)\n\n            node_ids = [str(result[\"node_id\"]) for result in node_results]\n\n            logger.info(\n                f\"[{self.workspace}] Total nodes: {total_nodes}, Selected nodes: {len(node_ids)}\"\n            )\n\n            if node_ids:\n                formatted_ids = \", \".join(node_ids)\n                # Construct batch query for subgraph within max_nodes\n                query = f\"\"\"SELECT * FROM cypher('{self.graph_name}', $$\n                        WITH [{formatted_ids}] AS node_ids\n                        MATCH (a)\n                        WHERE id(a) IN node_ids\n                        OPTIONAL MATCH (a)-[r]->(b)\n                            WHERE id(b) IN node_ids\n                        RETURN a, r, b\n                    $$) AS (a AGTYPE, r AGTYPE, b AGTYPE)\"\"\"\n                results = await self._query(query)\n\n                # Process query results, deduplicate nodes and edges\n                nodes_dict = {}\n                edges_dict = {}\n                for result in results:\n                    # Process node a\n                    if result.get(\"a\") and isinstance(result[\"a\"], dict):\n                        node_a = result[\"a\"]\n                        node_id = str(node_a[\"id\"])\n                        if node_id not in nodes_dict and \"properties\" in node_a:\n                            nodes_dict[node_id] = KnowledgeGraphNode(\n                                id=node_id,\n                                labels=[node_a[\"properties\"][\"entity_id\"]],\n                                properties=node_a[\"properties\"],\n                            )\n\n                    # Process node b\n                    if result.get(\"b\") and isinstance(result[\"b\"], dict):\n                        node_b = result[\"b\"]\n                        node_id = str(node_b[\"id\"])\n                        if node_id not in nodes_dict and \"properties\" in node_b:\n                            nodes_dict[node_id] = KnowledgeGraphNode(\n                                id=node_id,\n                                labels=[node_b[\"properties\"][\"entity_id\"]],\n                                properties=node_b[\"properties\"],\n                            )\n\n                    # Process edge r\n                    if result.get(\"r\") and isinstance(result[\"r\"], dict):\n                        edge = result[\"r\"]\n                        edge_id = str(edge[\"id\"])\n                        if edge_id not in edges_dict:\n                            edges_dict[edge_id] = KnowledgeGraphEdge(\n                                id=edge_id,\n                                type=edge[\"label\"],\n                                source=str(edge[\"start_id\"]),\n                                target=str(edge[\"end_id\"]),\n                                properties=edge[\"properties\"],\n                            )\n\n                kg = KnowledgeGraph(\n                    nodes=list(nodes_dict.values()),\n                    edges=list(edges_dict.values()),\n                    is_truncated=is_truncated,\n                )\n            else:\n                # For single node query, use BFS algorithm\n                kg = await self._bfs_subgraph(node_label, max_depth, max_nodes)\n\n            logger.info(\n                f\"[{self.workspace}] Subgraph query successful | Node count: {len(kg.nodes)} | Edge count: {len(kg.edges)}\"\n            )\n        else:\n            # For non-wildcard queries, use the BFS algorithm\n            kg = await self._bfs_subgraph(node_label, max_depth, max_nodes)\n            logger.info(\n                f\"[{self.workspace}] Subgraph query for '{node_label}' successful | Node count: {len(kg.nodes)} | Edge count: {len(kg.edges)}\"\n            )\n\n        return kg\n\n    async def get_all_nodes(self) -> list[dict]:\n        \"\"\"Get all nodes in the graph.\n\n        Returns:\n            A list of all nodes, where each node is a dictionary of its properties\n        \"\"\"\n        # Use native SQL to avoid Cypher wrapper overhead\n        # Original: SELECT * FROM cypher(...) with MATCH (n:base)\n        # Optimized: Direct table access for better performance\n        query = f\"\"\"\n            SELECT properties\n            FROM {self.graph_name}.base\n        \"\"\"\n\n        results = await self._query(query)\n        nodes = []\n        for result in results:\n            if result.get(\"properties\"):\n                node_dict = result[\"properties\"]\n\n                # Process string result, parse it to JSON dictionary\n                if isinstance(node_dict, str):\n                    try:\n                        node_dict = json.loads(node_dict)\n                    except json.JSONDecodeError:\n                        logger.warning(\n                            f\"[{self.workspace}] Failed to parse node string: {node_dict}\"\n                        )\n                        continue\n\n                # Add node id (entity_id) to the dictionary for easier access\n                node_dict[\"id\"] = node_dict.get(\"entity_id\")\n                nodes.append(node_dict)\n        return nodes\n\n    async def get_all_edges(self) -> list[dict]:\n        \"\"\"Get all edges in the graph.\n\n        Returns:\n            A list of all edges, where each edge is a dictionary of its properties\n            (If 2 directional edges exist between the same pair of nodes, deduplication must be handled by the caller)\n        \"\"\"\n        # Use native SQL to avoid Cartesian product (N×N) in Cypher MATCH\n        # Original Cypher: MATCH (a:base)-[r]-(b:base) creates ~50 billion row combinations\n        # Optimized: Start from edges table, join to nodes only to get entity_id\n        # Performance: O(E) instead of O(N²), ~50,000x faster for large graphs\n        query = f\"\"\"\n            SELECT DISTINCT\n                (ag_catalog.agtype_access_operator(VARIADIC ARRAY[a.properties, '\"entity_id\"'::agtype]))::text AS source,\n                (ag_catalog.agtype_access_operator(VARIADIC ARRAY[b.properties, '\"entity_id\"'::agtype]))::text AS target,\n                r.properties\n            FROM {self.graph_name}.\"DIRECTED\" r\n            JOIN {self.graph_name}.base a ON r.start_id = a.id\n            JOIN {self.graph_name}.base b ON r.end_id = b.id\n        \"\"\"\n\n        results = await self._query(query)\n        edges = []\n        for result in results:\n            edge_properties = result[\"properties\"]\n\n            # Process string result, parse it to JSON dictionary\n            if isinstance(edge_properties, str):\n                try:\n                    edge_properties = json.loads(edge_properties)\n                except json.JSONDecodeError:\n                    logger.warning(\n                        f\"[{self.workspace}] Failed to parse edge properties string: {edge_properties}\"\n                    )\n                    edge_properties = {}\n\n            edge_properties[\"source\"] = result[\"source\"]\n            edge_properties[\"target\"] = result[\"target\"]\n            edges.append(edge_properties)\n        return edges\n\n    async def get_popular_labels(self, limit: int = 300) -> list[str]:\n        \"\"\"Get popular labels by node degree (most connected entities) using native SQL for performance.\"\"\"\n        try:\n            # Native SQL query to calculate node degrees directly from AGE's underlying tables\n            # This is significantly faster than using the cypher() function wrapper\n            query = f\"\"\"\n            WITH node_degrees AS (\n                SELECT\n                    node_id,\n                    COUNT(*) AS degree\n                FROM (\n                    SELECT start_id AS node_id FROM {self.graph_name}._ag_label_edge\n                    UNION ALL\n                    SELECT end_id AS node_id FROM {self.graph_name}._ag_label_edge\n                ) AS all_edges\n                GROUP BY node_id\n            )\n            SELECT\n                (ag_catalog.agtype_access_operator(VARIADIC ARRAY[v.properties, '\"entity_id\"'::agtype]))::text AS label\n            FROM\n                node_degrees d\n            JOIN\n                {self.graph_name}._ag_label_vertex v ON d.node_id = v.id\n            WHERE\n                ag_catalog.agtype_access_operator(VARIADIC ARRAY[v.properties, '\"entity_id\"'::agtype]) IS NOT NULL\n            ORDER BY\n                d.degree DESC,\n                label ASC\n            LIMIT $1;\n            \"\"\"\n            results = await self._query(query, params={\"limit\": limit})\n            labels = [\n                result[\"label\"] for result in results if result and \"label\" in result\n            ]\n\n            logger.debug(\n                f\"[{self.workspace}] Retrieved {len(labels)} popular labels (limit: {limit})\"\n            )\n            return labels\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error getting popular labels: {str(e)}\")\n            return []\n\n    async def search_labels(self, query: str, limit: int = 50) -> list[str]:\n        \"\"\"Search labels with fuzzy matching using native, parameterized SQL for performance and security.\"\"\"\n        query_lower = query.lower().strip()\n        if not query_lower:\n            return []\n\n        try:\n            # Re-implementing with the correct agtype access operator and full scoring logic.\n            sql_query = f\"\"\"\n            WITH ranked_labels AS (\n                SELECT\n                    (ag_catalog.agtype_access_operator(VARIADIC ARRAY[properties, '\"entity_id\"'::agtype]))::text AS label,\n                    LOWER((ag_catalog.agtype_access_operator(VARIADIC ARRAY[properties, '\"entity_id\"'::agtype]))::text) AS label_lower\n                FROM\n                    {self.graph_name}._ag_label_vertex\n                WHERE\n                    ag_catalog.agtype_access_operator(VARIADIC ARRAY[properties, '\"entity_id\"'::agtype]) IS NOT NULL\n                    AND LOWER((ag_catalog.agtype_access_operator(VARIADIC ARRAY[properties, '\"entity_id\"'::agtype]))::text) ILIKE $1\n            )\n            SELECT\n                label\n            FROM (\n                SELECT\n                    label,\n                    CASE\n                        WHEN label_lower = $2 THEN 1000\n                        WHEN label_lower LIKE $3 THEN 500\n                        ELSE (100 - LENGTH(label))\n                    END +\n                    CASE\n                        WHEN label_lower LIKE $4 OR label_lower LIKE $5 THEN 50\n                        ELSE 0\n                    END AS score\n                FROM\n                    ranked_labels\n            ) AS scored_labels\n            ORDER BY\n                score DESC,\n                label ASC\n            LIMIT $6;\n            \"\"\"\n            params = (\n                f\"%{query_lower}%\",  # For the main ILIKE clause ($1)\n                query_lower,  # For exact match ($2)\n                f\"{query_lower}%\",  # For prefix match ($3)\n                f\"% {query_lower}%\",  # For word boundary (space) ($4)\n                f\"%_{query_lower}%\",  # For word boundary (underscore) ($5)\n                limit,  # For LIMIT ($6)\n            )\n            results = await self._query(sql_query, params=dict(enumerate(params, 1)))\n            labels = [\n                result[\"label\"] for result in results if result and \"label\" in result\n            ]\n\n            logger.debug(\n                f\"[{self.workspace}] Search query '{query}' returned {len(labels)} results (limit: {limit})\"\n            )\n            return labels\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error searching labels with query '{query}': {str(e)}\"\n            )\n            return []\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop the storage\"\"\"\n        try:\n            drop_query = f\"\"\"SELECT * FROM cypher('{self.graph_name}', $$\n                            MATCH (n)\n                            DETACH DELETE n\n                            $$) AS (result agtype)\"\"\"\n\n            await self._query(drop_query, readonly=False)\n            return {\n                \"status\": \"success\",\n                \"message\": f\"workspace '{self.workspace}' graph data dropped\",\n            }\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error dropping graph: {e}\")\n            return {\"status\": \"error\", \"message\": str(e)}\n\n\n# Note: Order matters! More specific namespaces (e.g., \"full_entities\") must come before\n# more general ones (e.g., \"entities\") because is_namespace() uses endswith() matching\nNAMESPACE_TABLE_MAP = {\n    NameSpace.KV_STORE_FULL_DOCS: \"LIGHTRAG_DOC_FULL\",\n    NameSpace.KV_STORE_TEXT_CHUNKS: \"LIGHTRAG_DOC_CHUNKS\",\n    NameSpace.KV_STORE_FULL_ENTITIES: \"LIGHTRAG_FULL_ENTITIES\",\n    NameSpace.KV_STORE_FULL_RELATIONS: \"LIGHTRAG_FULL_RELATIONS\",\n    NameSpace.KV_STORE_ENTITY_CHUNKS: \"LIGHTRAG_ENTITY_CHUNKS\",\n    NameSpace.KV_STORE_RELATION_CHUNKS: \"LIGHTRAG_RELATION_CHUNKS\",\n    NameSpace.KV_STORE_LLM_RESPONSE_CACHE: \"LIGHTRAG_LLM_CACHE\",\n    NameSpace.VECTOR_STORE_CHUNKS: \"LIGHTRAG_VDB_CHUNKS\",\n    NameSpace.VECTOR_STORE_ENTITIES: \"LIGHTRAG_VDB_ENTITY\",\n    NameSpace.VECTOR_STORE_RELATIONSHIPS: \"LIGHTRAG_VDB_RELATION\",\n    NameSpace.DOC_STATUS: \"LIGHTRAG_DOC_STATUS\",\n}\n\n\ndef namespace_to_table_name(namespace: str) -> str:\n    for k, v in NAMESPACE_TABLE_MAP.items():\n        if is_namespace(namespace, k):\n            return v\n\n\nTABLES = {\n    \"LIGHTRAG_DOC_FULL\": {\n        \"ddl\": \"\"\"CREATE TABLE LIGHTRAG_DOC_FULL (\n                    id VARCHAR(255),\n                    workspace VARCHAR(255),\n                    doc_name VARCHAR(1024),\n                    content TEXT,\n                    meta JSONB,\n                    create_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    update_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n\t                CONSTRAINT LIGHTRAG_DOC_FULL_PK PRIMARY KEY (workspace, id)\n                    )\"\"\"\n    },\n    \"LIGHTRAG_DOC_CHUNKS\": {\n        \"ddl\": \"\"\"CREATE TABLE LIGHTRAG_DOC_CHUNKS (\n                    id VARCHAR(255),\n                    workspace VARCHAR(255),\n                    full_doc_id VARCHAR(256),\n                    chunk_order_index INTEGER,\n                    tokens INTEGER,\n                    content TEXT,\n                    file_path TEXT NULL,\n                    llm_cache_list JSONB NULL DEFAULT '[]'::jsonb,\n                    create_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    update_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n\t                CONSTRAINT LIGHTRAG_DOC_CHUNKS_PK PRIMARY KEY (workspace, id)\n                    )\"\"\"\n    },\n    \"LIGHTRAG_VDB_CHUNKS\": {\n        \"ddl\": \"\"\"CREATE TABLE LIGHTRAG_VDB_CHUNKS (\n                    id VARCHAR(255),\n                    workspace VARCHAR(255),\n                    full_doc_id VARCHAR(256),\n                    chunk_order_index INTEGER,\n                    tokens INTEGER,\n                    content TEXT,\n                    content_vector VECTOR(dimension),\n                    file_path TEXT NULL,\n                    create_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    update_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n\t                CONSTRAINT LIGHTRAG_VDB_CHUNKS_PK PRIMARY KEY (workspace, id)\n                    )\"\"\"\n    },\n    \"LIGHTRAG_VDB_ENTITY\": {\n        \"ddl\": \"\"\"CREATE TABLE LIGHTRAG_VDB_ENTITY (\n                    id VARCHAR(255),\n                    workspace VARCHAR(255),\n                    entity_name VARCHAR(512),\n                    content TEXT,\n                    content_vector VECTOR(dimension),\n                    create_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    update_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    chunk_ids VARCHAR(255)[] NULL,\n                    file_path TEXT NULL,\n\t                CONSTRAINT LIGHTRAG_VDB_ENTITY_PK PRIMARY KEY (workspace, id)\n                    )\"\"\"\n    },\n    \"LIGHTRAG_VDB_RELATION\": {\n        \"ddl\": \"\"\"CREATE TABLE LIGHTRAG_VDB_RELATION (\n                    id VARCHAR(255),\n                    workspace VARCHAR(255),\n                    source_id VARCHAR(512),\n                    target_id VARCHAR(512),\n                    content TEXT,\n                    content_vector VECTOR(dimension),\n                    create_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    update_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    chunk_ids VARCHAR(255)[] NULL,\n                    file_path TEXT NULL,\n\t                CONSTRAINT LIGHTRAG_VDB_RELATION_PK PRIMARY KEY (workspace, id)\n                    )\"\"\"\n    },\n    \"LIGHTRAG_LLM_CACHE\": {\n        \"ddl\": \"\"\"CREATE TABLE LIGHTRAG_LLM_CACHE (\n\t                workspace varchar(255) NOT NULL,\n\t                id varchar(255) NOT NULL,\n                    original_prompt TEXT,\n                    return_value TEXT,\n                    chunk_id VARCHAR(255) NULL,\n                    cache_type VARCHAR(32),\n                    queryparam JSONB NULL,\n                    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n                    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n\t                CONSTRAINT LIGHTRAG_LLM_CACHE_PK PRIMARY KEY (workspace, id)\n                    )\"\"\"\n    },\n    \"LIGHTRAG_DOC_STATUS\": {\n        \"ddl\": \"\"\"CREATE TABLE LIGHTRAG_DOC_STATUS (\n\t               workspace varchar(255) NOT NULL,\n\t               id varchar(255) NOT NULL,\n\t               content_summary varchar(255) NULL,\n\t               content_length int4 NULL,\n\t               chunks_count int4 NULL,\n\t               status varchar(64) NULL,\n\t               file_path TEXT NULL,\n\t               chunks_list JSONB NULL DEFAULT '[]'::jsonb,\n\t               track_id varchar(255) NULL,\n\t               metadata JSONB NULL DEFAULT '{}'::jsonb,\n\t               error_msg TEXT NULL,\n\t               created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n\t               updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n\t               CONSTRAINT LIGHTRAG_DOC_STATUS_PK PRIMARY KEY (workspace, id)\n\t              )\"\"\"\n    },\n    \"LIGHTRAG_FULL_ENTITIES\": {\n        \"ddl\": \"\"\"CREATE TABLE LIGHTRAG_FULL_ENTITIES (\n                    id VARCHAR(255),\n                    workspace VARCHAR(255),\n                    entity_names JSONB,\n                    count INTEGER,\n                    create_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    update_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    CONSTRAINT LIGHTRAG_FULL_ENTITIES_PK PRIMARY KEY (workspace, id)\n                    )\"\"\"\n    },\n    \"LIGHTRAG_FULL_RELATIONS\": {\n        \"ddl\": \"\"\"CREATE TABLE LIGHTRAG_FULL_RELATIONS (\n                    id VARCHAR(255),\n                    workspace VARCHAR(255),\n                    relation_pairs JSONB,\n                    count INTEGER,\n                    create_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    update_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    CONSTRAINT LIGHTRAG_FULL_RELATIONS_PK PRIMARY KEY (workspace, id)\n                    )\"\"\"\n    },\n    \"LIGHTRAG_ENTITY_CHUNKS\": {\n        \"ddl\": \"\"\"CREATE TABLE LIGHTRAG_ENTITY_CHUNKS (\n                    id VARCHAR(512),\n                    workspace VARCHAR(255),\n                    chunk_ids JSONB,\n                    count INTEGER,\n                    create_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    update_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    CONSTRAINT LIGHTRAG_ENTITY_CHUNKS_PK PRIMARY KEY (workspace, id)\n                    )\"\"\"\n    },\n    \"LIGHTRAG_RELATION_CHUNKS\": {\n        \"ddl\": \"\"\"CREATE TABLE LIGHTRAG_RELATION_CHUNKS (\n                    id VARCHAR(512),\n                    workspace VARCHAR(255),\n                    chunk_ids JSONB,\n                    count INTEGER,\n                    create_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    update_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP,\n                    CONSTRAINT LIGHTRAG_RELATION_CHUNKS_PK PRIMARY KEY (workspace, id)\n                    )\"\"\"\n    },\n}\n\n\nSQL_TEMPLATES = {\n    # SQL for KVStorage\n    \"get_by_id_full_docs\": \"\"\"SELECT id, COALESCE(content, '') as content,\n                                COALESCE(doc_name, '') as file_path\n                                FROM LIGHTRAG_DOC_FULL WHERE workspace=$1 AND id=$2\n                            \"\"\",\n    \"get_by_id_text_chunks\": \"\"\"SELECT id, tokens, COALESCE(content, '') as content,\n                                chunk_order_index, full_doc_id, file_path,\n                                COALESCE(llm_cache_list, '[]'::jsonb) as llm_cache_list,\n                                EXTRACT(EPOCH FROM create_time)::BIGINT as create_time,\n                                EXTRACT(EPOCH FROM update_time)::BIGINT as update_time\n                                FROM LIGHTRAG_DOC_CHUNKS WHERE workspace=$1 AND id=$2\n                            \"\"\",\n    \"get_by_id_llm_response_cache\": \"\"\"SELECT id, original_prompt, return_value, chunk_id, cache_type, queryparam,\n                                EXTRACT(EPOCH FROM create_time)::BIGINT as create_time,\n                                EXTRACT(EPOCH FROM update_time)::BIGINT as update_time\n                                FROM LIGHTRAG_LLM_CACHE WHERE workspace=$1 AND id=$2\n                               \"\"\",\n    \"get_by_ids_full_docs\": \"\"\"SELECT id, COALESCE(content, '') as content,\n                                 COALESCE(doc_name, '') as file_path\n                                 FROM LIGHTRAG_DOC_FULL WHERE workspace=$1 AND id = ANY($2)\n                            \"\"\",\n    \"get_by_ids_text_chunks\": \"\"\"SELECT id, tokens, COALESCE(content, '') as content,\n                                  chunk_order_index, full_doc_id, file_path,\n                                  COALESCE(llm_cache_list, '[]'::jsonb) as llm_cache_list,\n                                  EXTRACT(EPOCH FROM create_time)::BIGINT as create_time,\n                                  EXTRACT(EPOCH FROM update_time)::BIGINT as update_time\n                                   FROM LIGHTRAG_DOC_CHUNKS WHERE workspace=$1 AND id = ANY($2)\n                                \"\"\",\n    \"get_by_ids_llm_response_cache\": \"\"\"SELECT id, original_prompt, return_value, chunk_id, cache_type, queryparam,\n                                 EXTRACT(EPOCH FROM create_time)::BIGINT as create_time,\n                                 EXTRACT(EPOCH FROM update_time)::BIGINT as update_time\n                                 FROM LIGHTRAG_LLM_CACHE WHERE workspace=$1 AND id = ANY($2)\n                                \"\"\",\n    \"get_by_id_full_entities\": \"\"\"SELECT id, entity_names, count,\n                                EXTRACT(EPOCH FROM create_time)::BIGINT as create_time,\n                                EXTRACT(EPOCH FROM update_time)::BIGINT as update_time\n                                FROM LIGHTRAG_FULL_ENTITIES WHERE workspace=$1 AND id=$2\n                               \"\"\",\n    \"get_by_id_full_relations\": \"\"\"SELECT id, relation_pairs, count,\n                                EXTRACT(EPOCH FROM create_time)::BIGINT as create_time,\n                                EXTRACT(EPOCH FROM update_time)::BIGINT as update_time\n                                FROM LIGHTRAG_FULL_RELATIONS WHERE workspace=$1 AND id=$2\n                               \"\"\",\n    \"get_by_ids_full_entities\": \"\"\"SELECT id, entity_names, count,\n                                 EXTRACT(EPOCH FROM create_time)::BIGINT as create_time,\n                                 EXTRACT(EPOCH FROM update_time)::BIGINT as update_time\n                                 FROM LIGHTRAG_FULL_ENTITIES WHERE workspace=$1 AND id = ANY($2)\n                                \"\"\",\n    \"get_by_ids_full_relations\": \"\"\"SELECT id, relation_pairs, count,\n                                 EXTRACT(EPOCH FROM create_time)::BIGINT as create_time,\n                                 EXTRACT(EPOCH FROM update_time)::BIGINT as update_time\n                                 FROM LIGHTRAG_FULL_RELATIONS WHERE workspace=$1 AND id = ANY($2)\n                                \"\"\",\n    \"get_by_id_entity_chunks\": \"\"\"SELECT id, chunk_ids, count,\n                                EXTRACT(EPOCH FROM create_time)::BIGINT as create_time,\n                                EXTRACT(EPOCH FROM update_time)::BIGINT as update_time\n                                FROM LIGHTRAG_ENTITY_CHUNKS WHERE workspace=$1 AND id=$2\n                               \"\"\",\n    \"get_by_id_relation_chunks\": \"\"\"SELECT id, chunk_ids, count,\n                                EXTRACT(EPOCH FROM create_time)::BIGINT as create_time,\n                                EXTRACT(EPOCH FROM update_time)::BIGINT as update_time\n                                FROM LIGHTRAG_RELATION_CHUNKS WHERE workspace=$1 AND id=$2\n                               \"\"\",\n    \"get_by_ids_entity_chunks\": \"\"\"SELECT id, chunk_ids, count,\n                                 EXTRACT(EPOCH FROM create_time)::BIGINT as create_time,\n                                 EXTRACT(EPOCH FROM update_time)::BIGINT as update_time\n                                 FROM LIGHTRAG_ENTITY_CHUNKS WHERE workspace=$1 AND id = ANY($2)\n                                \"\"\",\n    \"get_by_ids_relation_chunks\": \"\"\"SELECT id, chunk_ids, count,\n                                 EXTRACT(EPOCH FROM create_time)::BIGINT as create_time,\n                                 EXTRACT(EPOCH FROM update_time)::BIGINT as update_time\n                                 FROM LIGHTRAG_RELATION_CHUNKS WHERE workspace=$1 AND id = ANY($2)\n                                \"\"\",\n    \"filter_keys\": \"SELECT id FROM {table_name} WHERE workspace=$1 AND id IN ({ids})\",\n    \"upsert_doc_full\": \"\"\"INSERT INTO LIGHTRAG_DOC_FULL (id, content, doc_name, workspace)\n                        VALUES ($1, $2, $3, $4)\n                        ON CONFLICT (workspace,id) DO UPDATE\n                           SET content = $2,\n                               doc_name = $3,\n                               update_time = CURRENT_TIMESTAMP\n                       \"\"\",\n    \"upsert_llm_response_cache\": \"\"\"INSERT INTO LIGHTRAG_LLM_CACHE(workspace,id,original_prompt,return_value,chunk_id,cache_type,queryparam)\n                                      VALUES ($1, $2, $3, $4, $5, $6, $7)\n                                      ON CONFLICT (workspace,id) DO UPDATE\n                                      SET original_prompt = EXCLUDED.original_prompt,\n                                      return_value=EXCLUDED.return_value,\n                                      chunk_id=EXCLUDED.chunk_id,\n                                      cache_type=EXCLUDED.cache_type,\n                                      queryparam=EXCLUDED.queryparam,\n                                      update_time = CURRENT_TIMESTAMP\n                                     \"\"\",\n    \"upsert_text_chunk\": \"\"\"INSERT INTO LIGHTRAG_DOC_CHUNKS (workspace, id, tokens,\n                      chunk_order_index, full_doc_id, content, file_path, llm_cache_list,\n                      create_time, update_time)\n                      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n                      ON CONFLICT (workspace,id) DO UPDATE\n                      SET tokens=EXCLUDED.tokens,\n                      chunk_order_index=EXCLUDED.chunk_order_index,\n                      full_doc_id=EXCLUDED.full_doc_id,\n                      content = EXCLUDED.content,\n                      file_path=EXCLUDED.file_path,\n                      llm_cache_list=EXCLUDED.llm_cache_list,\n                      update_time = EXCLUDED.update_time\n                     \"\"\",\n    \"upsert_full_entities\": \"\"\"INSERT INTO LIGHTRAG_FULL_ENTITIES (workspace, id, entity_names, count,\n                      create_time, update_time)\n                      VALUES ($1, $2, $3, $4, $5, $6)\n                      ON CONFLICT (workspace,id) DO UPDATE\n                      SET entity_names=EXCLUDED.entity_names,\n                      count=EXCLUDED.count,\n                      update_time = EXCLUDED.update_time\n                     \"\"\",\n    \"upsert_full_relations\": \"\"\"INSERT INTO LIGHTRAG_FULL_RELATIONS (workspace, id, relation_pairs, count,\n                      create_time, update_time)\n                      VALUES ($1, $2, $3, $4, $5, $6)\n                      ON CONFLICT (workspace,id) DO UPDATE\n                      SET relation_pairs=EXCLUDED.relation_pairs,\n                      count=EXCLUDED.count,\n                      update_time = EXCLUDED.update_time\n                     \"\"\",\n    \"upsert_entity_chunks\": \"\"\"INSERT INTO LIGHTRAG_ENTITY_CHUNKS (workspace, id, chunk_ids, count,\n                      create_time, update_time)\n                      VALUES ($1, $2, $3, $4, $5, $6)\n                      ON CONFLICT (workspace,id) DO UPDATE\n                      SET chunk_ids=EXCLUDED.chunk_ids,\n                      count=EXCLUDED.count,\n                      update_time = EXCLUDED.update_time\n                     \"\"\",\n    \"upsert_relation_chunks\": \"\"\"INSERT INTO LIGHTRAG_RELATION_CHUNKS (workspace, id, chunk_ids, count,\n                      create_time, update_time)\n                      VALUES ($1, $2, $3, $4, $5, $6)\n                      ON CONFLICT (workspace,id) DO UPDATE\n                      SET chunk_ids=EXCLUDED.chunk_ids,\n                      count=EXCLUDED.count,\n                      update_time = EXCLUDED.update_time\n                     \"\"\",\n    # SQL for VectorStorage\n    \"upsert_chunk\": \"\"\"INSERT INTO {table_name} (workspace, id, tokens,\n                      chunk_order_index, full_doc_id, content, content_vector, file_path,\n                      create_time, update_time)\n                      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n                      ON CONFLICT (workspace,id) DO UPDATE\n                      SET tokens=EXCLUDED.tokens,\n                      chunk_order_index=EXCLUDED.chunk_order_index,\n                      full_doc_id=EXCLUDED.full_doc_id,\n                      content = EXCLUDED.content,\n                      content_vector=EXCLUDED.content_vector,\n                      file_path=EXCLUDED.file_path,\n                      update_time = EXCLUDED.update_time\n                     \"\"\",\n    \"upsert_entity\": \"\"\"INSERT INTO {table_name} (workspace, id, entity_name, content,\n                      content_vector, chunk_ids, file_path, create_time, update_time)\n                      VALUES ($1, $2, $3, $4, $5, $6::varchar[], $7, $8, $9)\n                      ON CONFLICT (workspace,id) DO UPDATE\n                      SET entity_name=EXCLUDED.entity_name,\n                      content=EXCLUDED.content,\n                      content_vector=EXCLUDED.content_vector,\n                      chunk_ids=EXCLUDED.chunk_ids,\n                      file_path=EXCLUDED.file_path,\n                      update_time=EXCLUDED.update_time\n                     \"\"\",\n    \"upsert_relationship\": \"\"\"INSERT INTO {table_name} (workspace, id, source_id,\n                      target_id, content, content_vector, chunk_ids, file_path, create_time, update_time)\n                      VALUES ($1, $2, $3, $4, $5, $6, $7::varchar[], $8, $9, $10)\n                      ON CONFLICT (workspace,id) DO UPDATE\n                      SET source_id=EXCLUDED.source_id,\n                      target_id=EXCLUDED.target_id,\n                      content=EXCLUDED.content,\n                      content_vector=EXCLUDED.content_vector,\n                      chunk_ids=EXCLUDED.chunk_ids,\n                      file_path=EXCLUDED.file_path,\n                      update_time = EXCLUDED.update_time\n                     \"\"\",\n    \"relationships\": \"\"\"\n                     SELECT r.source_id AS src_id,\n                            r.target_id AS tgt_id,\n                            EXTRACT(EPOCH FROM r.create_time)::BIGINT AS created_at\n                     FROM {table_name} r\n                     WHERE r.workspace = $1\n                       AND r.content_vector <=> '[{embedding_string}]'::vector < $2\n                     ORDER BY r.content_vector <=> '[{embedding_string}]'::vector\n                     LIMIT $3;\n                     \"\"\",\n    \"entities\": \"\"\"\n                SELECT e.entity_name,\n                       EXTRACT(EPOCH FROM e.create_time)::BIGINT AS created_at\n                FROM {table_name} e\n                WHERE e.workspace = $1\n                  AND e.content_vector <=> '[{embedding_string}]'::vector < $2\n                ORDER BY e.content_vector <=> '[{embedding_string}]'::vector\n                LIMIT $3;\n                \"\"\",\n    \"chunks\": \"\"\"\n              SELECT c.id,\n                     c.content,\n                     c.file_path,\n                     EXTRACT(EPOCH FROM c.create_time)::BIGINT AS created_at\n              FROM {table_name} c\n              WHERE c.workspace = $1\n                AND c.content_vector <=> '[{embedding_string}]'::vector < $2\n              ORDER BY c.content_vector <=> '[{embedding_string}]'::vector\n              LIMIT $3;\n              \"\"\",\n    # DROP tables\n    \"drop_specifiy_table_workspace\": \"\"\"\n        DELETE FROM {table_name} WHERE workspace=$1\n       \"\"\",\n}\n"
  },
  {
    "path": "lightrag/kg/qdrant_impl.py",
    "content": "import asyncio\nimport configparser\nimport hashlib\nimport json\nimport os\nimport uuid\nfrom dataclasses import dataclass\nfrom typing import Any, List, final\n\nimport numpy as np\nimport pipmaster as pm\n\nfrom ..base import BaseVectorStorage\nfrom ..exceptions import DataMigrationError\nfrom ..kg.shared_storage import get_data_init_lock\nfrom ..utils import compute_mdhash_id, logger\n\nif not pm.is_installed(\"qdrant-client\"):\n    pm.install(\"qdrant-client\")\n\nfrom qdrant_client import QdrantClient, models  # type: ignore\n\nDEFAULT_WORKSPACE = \"_\"\nWORKSPACE_ID_FIELD = \"workspace_id\"\nENTITY_PREFIX = \"ent-\"\nCREATED_AT_FIELD = \"created_at\"\nID_FIELD = \"id\"\nDEFAULT_QDRANT_UPSERT_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024  # 16MB\nDEFAULT_QDRANT_UPSERT_MAX_POINTS_PER_BATCH = 128\n\nconfig = configparser.ConfigParser()\nconfig.read(\"config.ini\", \"utf-8\")\n\n\ndef compute_mdhash_id_for_qdrant(\n    content: str, prefix: str = \"\", style: str = \"simple\"\n) -> str:\n    \"\"\"\n    Generate a UUID based on the content and support multiple formats.\n\n    :param content: The content used to generate the UUID.\n    :param style: The format of the UUID, optional values are \"simple\", \"hyphenated\", \"urn\".\n    :return: A UUID that meets the requirements of Qdrant.\n    \"\"\"\n    if not content:\n        raise ValueError(\"Content must not be empty.\")\n\n    # Use the hash value of the content to create a UUID.\n    hashed_content = hashlib.sha256((prefix + content).encode(\"utf-8\")).digest()\n    generated_uuid = uuid.UUID(bytes=hashed_content[:16], version=4)\n\n    # Return the UUID according to the specified format.\n    if style == \"simple\":\n        return generated_uuid.hex\n    elif style == \"hyphenated\":\n        return str(generated_uuid)\n    elif style == \"urn\":\n        return f\"urn:uuid:{generated_uuid}\"\n    else:\n        raise ValueError(\"Invalid style. Choose from 'simple', 'hyphenated', or 'urn'.\")\n\n\ndef workspace_filter_condition(workspace: str) -> models.FieldCondition:\n    \"\"\"\n    Create a workspace filter condition for Qdrant queries.\n    \"\"\"\n    return models.FieldCondition(\n        key=WORKSPACE_ID_FIELD, match=models.MatchValue(value=workspace)\n    )\n\n\ndef _find_legacy_collection(\n    client: QdrantClient,\n    namespace: str,\n    workspace: str = None,\n    model_suffix: str = None,\n) -> str | None:\n    \"\"\"\n    Find legacy collection with backward compatibility support.\n\n    This function tries multiple naming patterns to locate legacy collections\n    created by older versions of LightRAG:\n\n    1. lightrag_vdb_{namespace} - if model_suffix is provided (HIGHEST PRIORITY)\n    2. {workspace}_{namespace} or {namespace} - no matter if model_suffix is provided or not\n    3. lightrag_vdb_{namespace} - fall back value no matter if model_suffix is provided or not (LOWEST PRIORITY)\n\n    Args:\n        client: QdrantClient instance\n        namespace: Base namespace (e.g., \"chunks\", \"entities\")\n        workspace: Optional workspace identifier\n        model_suffix: Optional model suffix for new collection\n\n    Returns:\n        Collection name if found, None otherwise\n    \"\"\"\n    # Try multiple naming patterns for backward compatibility\n    # More specific names (with workspace) have higher priority\n    candidates = [\n        f\"lightrag_vdb_{namespace}\" if model_suffix else None,\n        f\"{workspace}_{namespace}\" if workspace else None,\n        f\"lightrag_vdb_{namespace}\",\n        namespace,\n    ]\n\n    for candidate in candidates:\n        if candidate and client.collection_exists(candidate):\n            logger.info(\n                f\"Qdrant: Found legacy collection '{candidate}' \"\n                f\"(namespace={namespace}, workspace={workspace or 'none'})\"\n            )\n            return candidate\n\n    return None\n\n\n@final\n@dataclass\nclass QdrantVectorDBStorage(BaseVectorStorage):\n    def __init__(\n        self, namespace, global_config, embedding_func, workspace=None, meta_fields=None\n    ):\n        super().__init__(\n            namespace=namespace,\n            workspace=workspace or \"\",\n            global_config=global_config,\n            embedding_func=embedding_func,\n            meta_fields=meta_fields or set(),\n        )\n        self.__post_init__()\n\n    @staticmethod\n    def setup_collection(\n        client: QdrantClient,\n        collection_name: str,\n        namespace: str,\n        workspace: str,\n        vectors_config: models.VectorParams,\n        hnsw_config: models.HnswConfigDiff,\n        model_suffix: str,\n    ):\n        \"\"\"\n        Setup Qdrant collection with migration support from legacy collections.\n\n        Ensure final collection has workspace isolation index.\n        Check vector dimension compatibility before new collection creation.\n        Drop legacy collection if it exists and is empty.\n        Only migrate data from legacy collection to new collection when new collection first created and legacy collection is not empty.\n\n        Args:\n            client: QdrantClient instance\n            collection_name: Name of the final collection\n            namespace: Base namespace (e.g., \"chunks\", \"entities\")\n            workspace: Workspace identifier for data isolation\n            vectors_config: Vector configuration parameters for the collection\n            hnsw_config: HNSW index configuration diff for the collection\n        \"\"\"\n        if not namespace or not workspace:\n            raise ValueError(\"namespace and workspace must be provided\")\n\n        workspace_count_filter = models.Filter(\n            must=[workspace_filter_condition(workspace)]\n        )\n\n        new_collection_exists = client.collection_exists(collection_name)\n        legacy_collection = _find_legacy_collection(\n            client, namespace, workspace, model_suffix\n        )\n\n        # Case 1: Only new collection exists or  new collection is the same as legacy collection\n        #         No data migration needed,  and ensuring index is created then return\n        if (new_collection_exists and not legacy_collection) or (\n            collection_name == legacy_collection\n        ):\n            # create_payload_index return without error if index already exists\n            client.create_payload_index(\n                collection_name=collection_name,\n                field_name=WORKSPACE_ID_FIELD,\n                field_schema=models.KeywordIndexParams(\n                    type=models.KeywordIndexType.KEYWORD,\n                    is_tenant=True,\n                ),\n            )\n            new_workspace_count = client.count(\n                collection_name=collection_name,\n                count_filter=workspace_count_filter,\n                exact=True,\n            ).count\n\n            # Skip data migration if new collection already has workspace data\n            if new_workspace_count == 0 and not (collection_name == legacy_collection):\n                logger.warning(\n                    f\"Qdrant: workspace data in collection '{collection_name}' is empty. \"\n                    f\"Ensure it is caused by new workspace setup and not an unexpected embedding model change.\"\n                )\n\n            return\n\n        legacy_count = None\n        if not new_collection_exists:\n            # Check vector dimension compatibility before creating new collection\n            if legacy_collection:\n                legacy_count = client.count(\n                    collection_name=legacy_collection, exact=True\n                ).count\n                if legacy_count > 0:\n                    legacy_info = client.get_collection(legacy_collection)\n                    legacy_dim = legacy_info.config.params.vectors.size\n\n                    if vectors_config.size and legacy_dim != vectors_config.size:\n                        logger.error(\n                            f\"Qdrant: Dimension mismatch detected! \"\n                            f\"Legacy collection '{legacy_collection}' has {legacy_dim}d vectors, \"\n                            f\"but new embedding model expects {vectors_config.size}d.\"\n                        )\n\n                        raise DataMigrationError(\n                            f\"Dimension mismatch between legacy collection '{legacy_collection}' \"\n                            f\"and new collection. Expected {vectors_config.size}d but got {legacy_dim}d.\"\n                        )\n\n            client.create_collection(\n                collection_name, vectors_config=vectors_config, hnsw_config=hnsw_config\n            )\n            logger.info(f\"Qdrant: Collection '{collection_name}' created successfully\")\n            if not legacy_collection:\n                logger.warning(\n                    \"Qdrant: Ensure this new collection creation is caused by new workspace setup and not an unexpected embedding model change.\"\n                )\n\n        # create_payload_index return without error if index already exists\n        client.create_payload_index(\n            collection_name=collection_name,\n            field_name=WORKSPACE_ID_FIELD,\n            field_schema=models.KeywordIndexParams(\n                type=models.KeywordIndexType.KEYWORD,\n                is_tenant=True,\n            ),\n        )\n\n        # Case 2: Legacy collection exist\n        if legacy_collection:\n            # Only drop legacy collection if it's empty\n            if legacy_count is None:\n                legacy_count = client.count(\n                    collection_name=legacy_collection, exact=True\n                ).count\n            if legacy_count == 0:\n                client.delete_collection(collection_name=legacy_collection)\n                logger.info(\n                    f\"Qdrant: Empty legacy collection '{legacy_collection}' deleted successfully\"\n                )\n                return\n\n            new_workspace_count = client.count(\n                collection_name=collection_name,\n                count_filter=workspace_count_filter,\n                exact=True,\n            ).count\n\n            # Skip data migration if new collection already has workspace data\n            if new_workspace_count > 0:\n                logger.warning(\n                    f\"Qdrant: Both new and legacy collection have data. \"\n                    f\"{legacy_count} records in {legacy_collection} require manual deletion after migration verification.\"\n                )\n                return\n\n            # Case 3: Only legacy exists - migrate data from legacy collection to new collection\n            # Check if legacy collection has workspace_id to determine migration strategy\n            # Note: payload_schema only reflects INDEXED fields, so we also sample\n            # actual payloads to detect unindexed workspace_id fields\n            legacy_info = client.get_collection(legacy_collection)\n            has_workspace_index = WORKSPACE_ID_FIELD in (\n                legacy_info.payload_schema or {}\n            )\n\n            # Detect workspace_id field presence by sampling payloads if not indexed\n            # This prevents cross-workspace data leakage when workspace_id exists but isn't indexed\n            has_workspace_field = has_workspace_index\n            if not has_workspace_index:\n                # Sample a small batch of points to check for workspace_id in payloads\n                # All points must have workspace_id if any point has it\n                sample_result = client.scroll(\n                    collection_name=legacy_collection,\n                    limit=10,  # Small sample is sufficient for detection\n                    with_payload=True,\n                    with_vectors=False,\n                )\n                sample_points, _ = sample_result\n                for point in sample_points:\n                    if point.payload and WORKSPACE_ID_FIELD in point.payload:\n                        has_workspace_field = True\n                        logger.info(\n                            f\"Qdrant: Detected unindexed {WORKSPACE_ID_FIELD} field \"\n                            f\"in legacy collection '{legacy_collection}' via payload sampling\"\n                        )\n                        break\n\n            # Build workspace filter if legacy collection has workspace support\n            # This prevents cross-workspace data leakage during migration\n            legacy_scroll_filter = None\n            if has_workspace_field:\n                legacy_scroll_filter = models.Filter(\n                    must=[workspace_filter_condition(workspace)]\n                )\n                # Recount with workspace filter for accurate migration tracking\n                legacy_count = client.count(\n                    collection_name=legacy_collection,\n                    count_filter=legacy_scroll_filter,\n                    exact=True,\n                ).count\n                logger.info(\n                    f\"Qdrant: Legacy collection has workspace support, \"\n                    f\"filtering to {legacy_count} records for workspace '{workspace}'\"\n                )\n\n            logger.info(\n                f\"Qdrant: Found legacy collection '{legacy_collection}' with {legacy_count} records to migrate.\"\n            )\n            logger.info(\n                f\"Qdrant: Migrating data from legacy collection '{legacy_collection}' to new collection '{collection_name}'\"\n            )\n\n            try:\n                # Batch migration (500 records per batch)\n                migrated_count = 0\n                offset = None\n                batch_size = 500\n\n                while True:\n                    # Scroll through legacy data with optional workspace filter\n                    result = client.scroll(\n                        collection_name=legacy_collection,\n                        scroll_filter=legacy_scroll_filter,\n                        limit=batch_size,\n                        offset=offset,\n                        with_vectors=True,\n                        with_payload=True,\n                    )\n                    points, next_offset = result\n\n                    if not points:\n                        break\n\n                    # Transform points for new collection\n                    new_points = []\n                    for point in points:\n                        # Set workspace_id in payload\n                        new_payload = dict(point.payload or {})\n                        new_payload[WORKSPACE_ID_FIELD] = workspace\n\n                        # Create new point with workspace-prefixed ID\n                        original_id = new_payload.get(ID_FIELD)\n                        if original_id:\n                            new_point_id = compute_mdhash_id_for_qdrant(\n                                original_id, prefix=workspace\n                            )\n                        else:\n                            # Fallback: use original point ID\n                            new_point_id = str(point.id)\n\n                        new_points.append(\n                            models.PointStruct(\n                                id=new_point_id,\n                                vector=point.vector,\n                                payload=new_payload,\n                            )\n                        )\n\n                    # Upsert to new collection\n                    client.upsert(\n                        collection_name=collection_name, points=new_points, wait=True\n                    )\n\n                    migrated_count += len(points)\n                    logger.info(\n                        f\"Qdrant: {migrated_count}/{legacy_count} records migrated\"\n                    )\n\n                    # Check if we've reached the end\n                    if next_offset is None:\n                        break\n                    offset = next_offset\n\n                new_count_after = client.count(\n                    collection_name=collection_name,\n                    count_filter=workspace_count_filter,\n                    exact=True,\n                ).count\n                inserted_count = new_count_after - new_workspace_count\n                if inserted_count != legacy_count:\n                    error_msg = (\n                        \"Qdrant: Migration verification failed, expected \"\n                        f\"{legacy_count} inserted records, got {inserted_count}.\"\n                    )\n                    logger.error(error_msg)\n                    raise DataMigrationError(error_msg)\n\n            except DataMigrationError:\n                # Re-raise DataMigrationError as-is to preserve specific error messages\n                raise\n            except Exception as e:\n                logger.error(\n                    f\"Qdrant: Failed to migrate data from legacy collection '{legacy_collection}' to new collection '{collection_name}': {e}\"\n                )\n                raise DataMigrationError(\n                    f\"Failed to migrate data from legacy collection '{legacy_collection}' to new collection '{collection_name}'\"\n                ) from e\n\n            logger.info(\n                f\"Qdrant: Migration from '{legacy_collection}' to '{collection_name}' completed successfully\"\n            )\n            logger.warning(\n                \"Qdrant: Manual deletion is required after data migration verification.\"\n            )\n\n    def __post_init__(self):\n        self._validate_embedding_func()\n        # Check for QDRANT_WORKSPACE environment variable first (higher priority)\n        # This allows administrators to force a specific workspace for all Qdrant storage instances\n        qdrant_workspace = os.environ.get(\"QDRANT_WORKSPACE\")\n        if qdrant_workspace and qdrant_workspace.strip():\n            # Use environment variable value, overriding the passed workspace parameter\n            effective_workspace = qdrant_workspace.strip()\n            logger.info(\n                f\"Using QDRANT_WORKSPACE environment variable: '{effective_workspace}' (overriding '{self.workspace}/{self.namespace}')\"\n            )\n        else:\n            # Use the workspace parameter passed during initialization\n            effective_workspace = self.workspace\n            if effective_workspace:\n                logger.debug(\n                    f\"Using passed workspace parameter: '{effective_workspace}'\"\n                )\n\n        self.effective_workspace = effective_workspace or DEFAULT_WORKSPACE\n\n        # Generate model suffix\n        self.model_suffix = self._generate_collection_suffix()\n\n        # New naming scheme with model isolation\n        # Example: \"lightrag_vdb_chunks_text_embedding_ada_002_1536d\"\n        # Ensure model_suffix is not empty before appending\n        if self.model_suffix:\n            self.final_namespace = f\"lightrag_vdb_{self.namespace}_{self.model_suffix}\"\n            logger.info(f\"Qdrant collection: {self.final_namespace}\")\n        else:\n            # Fallback: use legacy namespace if model_suffix is unavailable\n            self.final_namespace = f\"lightrag_vdb_{self.namespace}\"\n            logger.warning(\n                f\"Qdrant collection: {self.final_namespace} missing suffix. Pls add model_name to embedding_func for proper workspace data isolation.\"\n            )\n\n        kwargs = self.global_config.get(\"vector_db_storage_cls_kwargs\", {})\n        cosine_threshold = kwargs.get(\"cosine_better_than_threshold\")\n        if cosine_threshold is None:\n            raise ValueError(\n                \"cosine_better_than_threshold must be specified in vector_db_storage_cls_kwargs\"\n            )\n        self.cosine_better_than_threshold = cosine_threshold\n\n        # Initialize client as None - will be created in initialize() method\n        self._client = None\n        self._max_batch_size = self.global_config[\"embedding_batch_num\"]\n        self._max_upsert_payload_bytes = int(\n            os.getenv(\n                \"QDRANT_UPSERT_MAX_PAYLOAD_BYTES\",\n                str(DEFAULT_QDRANT_UPSERT_MAX_PAYLOAD_BYTES),\n            )\n        )\n        self._max_upsert_points_per_batch = int(\n            os.getenv(\n                \"QDRANT_UPSERT_MAX_POINTS_PER_BATCH\",\n                str(DEFAULT_QDRANT_UPSERT_MAX_POINTS_PER_BATCH),\n            )\n        )\n        if self._max_upsert_payload_bytes <= 0:\n            logger.warning(\n                f\"QDRANT_UPSERT_MAX_PAYLOAD_BYTES={self._max_upsert_payload_bytes} is non-positive, disable payload-size splitting\"\n            )\n        if self._max_upsert_points_per_batch <= 0:\n            logger.warning(\n                f\"QDRANT_UPSERT_MAX_POINTS_PER_BATCH={self._max_upsert_points_per_batch} is non-positive, disable point-count splitting\"\n            )\n        self._initialized = False\n\n    @staticmethod\n    def _to_json_serializable(value: Any) -> Any:\n        \"\"\"Convert nested values to JSON-serializable types for payload size estimation.\"\"\"\n        if isinstance(value, np.ndarray):\n            return value.tolist()\n        if isinstance(value, np.integer):\n            return int(value)\n        if isinstance(value, np.floating):\n            return float(value)\n        if isinstance(value, dict):\n            return {\n                str(k): QdrantVectorDBStorage._to_json_serializable(v)\n                for k, v in value.items()\n            }\n        if isinstance(value, (list, tuple)):\n            return [QdrantVectorDBStorage._to_json_serializable(v) for v in value]\n        return value\n\n    @staticmethod\n    def _estimate_point_payload_bytes(point: models.PointStruct) -> int:\n        \"\"\"Estimate serialized JSON byte size of a single Qdrant point.\"\"\"\n        point_obj = {\n            \"id\": point.id,\n            \"vector\": QdrantVectorDBStorage._to_json_serializable(point.vector),\n            \"payload\": QdrantVectorDBStorage._to_json_serializable(point.payload or {}),\n        }\n        return len(\n            json.dumps(\n                point_obj,\n                ensure_ascii=False,\n                separators=(\",\", \":\"),\n            ).encode(\"utf-8\")\n        )\n\n    @staticmethod\n    def _build_upsert_batches(\n        points: list[models.PointStruct],\n        max_payload_bytes: int,\n        max_points_per_batch: int,\n    ) -> list[tuple[list[models.PointStruct], int]]:\n        \"\"\"Split points into batches using payload size and point count limits.\"\"\"\n        if not points:\n            return []\n\n        payload_limit = max_payload_bytes if max_payload_bytes > 0 else float(\"inf\")\n        points_limit = (\n            max_points_per_batch if max_points_per_batch > 0 else float(\"inf\")\n        )\n\n        batches: list[tuple[list[models.PointStruct], int]] = []\n        current_batch: list[models.PointStruct] = []\n        # JSON array overhead (\"[]\")\n        current_estimated_bytes = 2\n\n        for point in points:\n            point_size = QdrantVectorDBStorage._estimate_point_payload_bytes(point)\n            point_with_array_overhead = point_size + 2\n            point_id = str(point.id)\n\n            if point_with_array_overhead > payload_limit:\n                raise ValueError(\n                    f\"Single Qdrant point exceeds payload limit: id={point_id}, \"\n                    f\"estimated_bytes={point_with_array_overhead}, \"\n                    f\"limit={int(payload_limit)}\"\n                )\n\n            # If current batch not empty, a comma is needed before next element.\n            separator_overhead = 1 if current_batch else 0\n            next_batch_size = current_estimated_bytes + separator_overhead + point_size\n\n            if current_batch and (\n                len(current_batch) >= points_limit or next_batch_size > payload_limit\n            ):\n                batches.append((current_batch, current_estimated_bytes))\n                current_batch = []\n                current_estimated_bytes = 2\n                next_batch_size = current_estimated_bytes + point_size\n\n            current_batch.append(point)\n            current_estimated_bytes = next_batch_size\n\n        if current_batch:\n            batches.append((current_batch, current_estimated_bytes))\n\n        return batches\n\n    async def initialize(self):\n        \"\"\"Initialize Qdrant collection\"\"\"\n        async with get_data_init_lock():\n            if self._initialized:\n                return\n\n            try:\n                # Create QdrantClient if not already created\n                if self._client is None:\n                    self._client = QdrantClient(\n                        url=os.environ.get(\n                            \"QDRANT_URL\", config.get(\"qdrant\", \"uri\", fallback=None)\n                        ),\n                        api_key=os.environ.get(\n                            \"QDRANT_API_KEY\",\n                            config.get(\"qdrant\", \"apikey\", fallback=None),\n                        ),\n                    )\n                    logger.debug(\n                        f\"[{self.workspace}] QdrantClient created successfully\"\n                    )\n\n                # Setup collection (create if not exists and configure indexes)\n                # Pass namespace and workspace for backward-compatible migration support\n                QdrantVectorDBStorage.setup_collection(\n                    self._client,\n                    self.final_namespace,\n                    namespace=self.namespace,\n                    workspace=self.effective_workspace,\n                    vectors_config=models.VectorParams(\n                        size=self.embedding_func.embedding_dim,\n                        distance=models.Distance.COSINE,\n                    ),\n                    hnsw_config=models.HnswConfigDiff(\n                        payload_m=16,\n                        m=0,\n                    ),\n                    model_suffix=self.model_suffix,\n                )\n\n                # Removed duplicate max batch size initialization\n\n                self._initialized = True\n                logger.info(\n                    f\"[{self.workspace}] Qdrant collection '{self.namespace}' initialized successfully\"\n                )\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Failed to initialize Qdrant collection '{self.namespace}': {e}\"\n                )\n                raise\n\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        logger.debug(f\"[{self.workspace}] Inserting {len(data)} to {self.namespace}\")\n        if not data:\n            return\n\n        import time\n\n        current_time = int(time.time())\n\n        list_data = [\n            {\n                ID_FIELD: k,\n                WORKSPACE_ID_FIELD: self.effective_workspace,\n                CREATED_AT_FIELD: current_time,\n                **{k1: v1 for k1, v1 in v.items() if k1 in self.meta_fields},\n            }\n            for k, v in data.items()\n        ]\n        contents = [v[\"content\"] for v in data.values()]\n        batches = [\n            contents[i : i + self._max_batch_size]\n            for i in range(0, len(contents), self._max_batch_size)\n        ]\n\n        embedding_tasks = [self.embedding_func(batch) for batch in batches]\n        embeddings_list = await asyncio.gather(*embedding_tasks)\n\n        embeddings = np.concatenate(embeddings_list)\n\n        list_points = []\n        for i, d in enumerate(list_data):\n            list_points.append(\n                models.PointStruct(\n                    id=compute_mdhash_id_for_qdrant(\n                        d[ID_FIELD], prefix=self.effective_workspace\n                    ),\n                    vector=embeddings[i],\n                    payload=d,\n                )\n            )\n\n        point_batches = self._build_upsert_batches(\n            list_points,\n            max_payload_bytes=self._max_upsert_payload_bytes,\n            max_points_per_batch=self._max_upsert_points_per_batch,\n        )\n\n        if len(point_batches) > 1:\n            logger.info(\n                f\"[{self.workspace}] Qdrant upsert split into {len(point_batches)} batches \"\n                f\"for {len(list_points)} points (max_payload_bytes={self._max_upsert_payload_bytes}, \"\n                f\"max_points_per_batch={self._max_upsert_points_per_batch})\"\n            )\n\n        results = None\n        for batch_index, (points_batch, estimated_bytes) in enumerate(point_batches, 1):\n            logger.debug(\n                f\"[{self.workspace}] Qdrant upsert batch {batch_index}/{len(point_batches)}: \"\n                f\"points={len(points_batch)}, estimated_payload_bytes={estimated_bytes}\"\n            )\n            # Fail-fast: any batch failure raises immediately and stops subsequent batches.\n            results = self._client.upsert(\n                collection_name=self.final_namespace,\n                points=points_batch,\n                wait=True,\n            )\n\n        return results\n\n    async def query(\n        self, query: str, top_k: int, query_embedding: list[float] = None\n    ) -> list[dict[str, Any]]:\n        if query_embedding is not None:\n            embedding = query_embedding\n        else:\n            embedding_result = await self.embedding_func(\n                [query], _priority=5\n            )  # higher priority for query\n            embedding = embedding_result[0]\n\n        results = self._client.query_points(\n            collection_name=self.final_namespace,\n            query=embedding,\n            limit=top_k,\n            with_payload=True,\n            score_threshold=self.cosine_better_than_threshold,\n            query_filter=models.Filter(\n                must=[workspace_filter_condition(self.effective_workspace)]\n            ),\n        ).points\n\n        return [\n            {\n                **dp.payload,\n                \"distance\": dp.score,\n                CREATED_AT_FIELD: dp.payload.get(CREATED_AT_FIELD),\n            }\n            for dp in results\n        ]\n\n    async def index_done_callback(self) -> None:\n        # Qdrant handles persistence automatically\n        pass\n\n    async def delete(self, ids: List[str]) -> None:\n        \"\"\"Delete vectors with specified IDs\n\n        Args:\n            ids: List of vector IDs to be deleted\n        \"\"\"\n        try:\n            if not ids:\n                return\n\n            # Convert regular ids to Qdrant compatible ids\n            qdrant_ids = [\n                compute_mdhash_id_for_qdrant(id, prefix=self.effective_workspace)\n                for id in ids\n            ]\n            # Delete points from the collection with workspace filtering\n            self._client.delete(\n                collection_name=self.final_namespace,\n                points_selector=models.PointIdsList(points=qdrant_ids),\n                wait=True,\n            )\n            logger.debug(\n                f\"[{self.workspace}] Successfully deleted {len(ids)} vectors from {self.namespace}\"\n            )\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error while deleting vectors from {self.namespace}: {e}\"\n            )\n\n    async def delete_entity(self, entity_name: str) -> None:\n        \"\"\"Delete an entity by name\n\n        Args:\n            entity_name: Name of the entity to delete\n        \"\"\"\n        try:\n            # Compute entity ID from name (same as Milvus)\n            entity_id = compute_mdhash_id(entity_name, prefix=ENTITY_PREFIX)\n            logger.debug(\n                f\"[{self.workspace}] Attempting to delete entity {entity_name} with ID {entity_id}\"\n            )\n\n            # Scroll to find the entity by its ID field in payload with workspace filtering\n            # This is safer than reconstructing the Qdrant point ID\n            results = self._client.scroll(\n                collection_name=self.final_namespace,\n                scroll_filter=models.Filter(\n                    must=[\n                        workspace_filter_condition(self.effective_workspace),\n                        models.FieldCondition(\n                            key=ID_FIELD, match=models.MatchValue(value=entity_id)\n                        ),\n                    ]\n                ),\n                with_payload=False,\n                limit=1,\n            )\n\n            # Extract point IDs to delete\n            points = results[0]\n            if points:\n                ids_to_delete = [point.id for point in points]\n                self._client.delete(\n                    collection_name=self.final_namespace,\n                    points_selector=models.PointIdsList(points=ids_to_delete),\n                    wait=True,\n                )\n                logger.debug(\n                    f\"[{self.workspace}] Successfully deleted entity {entity_name}\"\n                )\n            else:\n                logger.debug(\n                    f\"[{self.workspace}] Entity {entity_name} not found in storage\"\n                )\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error deleting entity {entity_name}: {e}\")\n\n    async def delete_entity_relation(self, entity_name: str) -> None:\n        \"\"\"Delete all relations associated with an entity\n\n        Args:\n            entity_name: Name of the entity whose relations should be deleted\n        \"\"\"\n        try:\n            # Build the filter to find relations where entity is either source or target\n            # must + should = workspace_id matches AND (src_id matches OR tgt_id matches)\n            relation_filter = models.Filter(\n                must=[workspace_filter_condition(self.effective_workspace)],\n                should=[\n                    models.FieldCondition(\n                        key=\"src_id\", match=models.MatchValue(value=entity_name)\n                    ),\n                    models.FieldCondition(\n                        key=\"tgt_id\", match=models.MatchValue(value=entity_name)\n                    ),\n                ],\n            )\n\n            # Paginate through all matching relations to handle large datasets\n            total_deleted = 0\n            offset = None\n            batch_size = 1000\n\n            while True:\n                # Scroll to find relations, using with_payload=False for efficiency\n                # since we only need point IDs for deletion\n                results = self._client.scroll(\n                    collection_name=self.final_namespace,\n                    scroll_filter=relation_filter,\n                    with_payload=False,\n                    with_vectors=False,\n                    limit=batch_size,\n                    offset=offset,\n                )\n\n                points, next_offset = results\n                if not points:\n                    break\n\n                # Extract point IDs to delete\n                ids_to_delete = [point.id for point in points]\n\n                # Delete the batch of relations\n                self._client.delete(\n                    collection_name=self.final_namespace,\n                    points_selector=models.PointIdsList(points=ids_to_delete),\n                    wait=True,\n                )\n                total_deleted += len(ids_to_delete)\n\n                # Check if we've reached the end\n                if next_offset is None:\n                    break\n                offset = next_offset\n\n            if total_deleted > 0:\n                logger.debug(\n                    f\"[{self.workspace}] Deleted {total_deleted} relations for {entity_name}\"\n                )\n            else:\n                logger.debug(\n                    f\"[{self.workspace}] No relations found for entity {entity_name}\"\n                )\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error deleting relations for {entity_name}: {e}\"\n            )\n\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        \"\"\"Get vector data by its ID\n\n        Args:\n            id: The unique identifier of the vector\n\n        Returns:\n            The vector data if found, or None if not found\n        \"\"\"\n        try:\n            # Convert to Qdrant compatible ID\n            qdrant_id = compute_mdhash_id_for_qdrant(\n                id, prefix=self.effective_workspace\n            )\n\n            # Retrieve the point by ID with workspace filtering\n            result = self._client.retrieve(\n                collection_name=self.final_namespace,\n                ids=[qdrant_id],\n                with_payload=True,\n            )\n\n            if not result:\n                return None\n\n            payload = result[0].payload\n            if CREATED_AT_FIELD not in payload:\n                payload[CREATED_AT_FIELD] = None\n\n            return payload\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error retrieving vector data for ID {id}: {e}\"\n            )\n            return None\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        \"\"\"Get multiple vector data by their IDs\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            List of vector data objects that were found\n        \"\"\"\n        if not ids:\n            return []\n\n        try:\n            # Convert to Qdrant compatible IDs\n            qdrant_ids = [\n                compute_mdhash_id_for_qdrant(id, prefix=self.effective_workspace)\n                for id in ids\n            ]\n\n            # Retrieve the points by IDs\n            results = self._client.retrieve(\n                collection_name=self.final_namespace,\n                ids=qdrant_ids,\n                with_payload=True,\n            )\n\n            # Ensure each result contains created_at field and preserve caller ordering\n            payload_by_original_id: dict[str, dict[str, Any]] = {}\n            payload_by_qdrant_id: dict[str, dict[str, Any]] = {}\n\n            for point in results:\n                payload = dict(point.payload or {})\n                if CREATED_AT_FIELD not in payload:\n                    payload[CREATED_AT_FIELD] = None\n\n                qdrant_point_id = str(point.id) if point.id is not None else \"\"\n                if qdrant_point_id:\n                    payload_by_qdrant_id[qdrant_point_id] = payload\n\n                original_id = payload.get(ID_FIELD)\n                if original_id is not None:\n                    payload_by_original_id[str(original_id)] = payload\n\n            ordered_payloads: list[dict[str, Any] | None] = []\n            for requested_id, qdrant_id in zip(ids, qdrant_ids):\n                payload = payload_by_original_id.get(str(requested_id))\n                if payload is None:\n                    payload = payload_by_qdrant_id.get(str(qdrant_id))\n                ordered_payloads.append(payload)\n\n            return ordered_payloads\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error retrieving vector data for IDs {ids}: {e}\"\n            )\n            return []\n\n    async def get_vectors_by_ids(self, ids: list[str]) -> dict[str, list[float]]:\n        \"\"\"Get vectors by their IDs, returning only ID and vector data for efficiency\n\n        Args:\n            ids: List of unique identifiers\n\n        Returns:\n            Dictionary mapping IDs to their vector embeddings\n            Format: {id: [vector_values], ...}\n        \"\"\"\n        if not ids:\n            return {}\n\n        try:\n            # Convert to Qdrant compatible IDs\n            qdrant_ids = [\n                compute_mdhash_id_for_qdrant(id, prefix=self.effective_workspace)\n                for id in ids\n            ]\n\n            # Retrieve the points by IDs with vectors\n            results = self._client.retrieve(\n                collection_name=self.final_namespace,\n                ids=qdrant_ids,\n                with_vectors=True,  # Important: request vectors\n                with_payload=True,\n            )\n\n            vectors_dict = {}\n            for point in results:\n                if point and point.vector is not None and point.payload:\n                    # Get original ID from payload\n                    original_id = point.payload.get(ID_FIELD)\n                    if original_id:\n                        # Convert numpy array to list if needed\n                        vector_data = point.vector\n                        if isinstance(vector_data, np.ndarray):\n                            vector_data = vector_data.tolist()\n                        vectors_dict[original_id] = vector_data\n\n            return vectors_dict\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error retrieving vectors by IDs from {self.namespace}: {e}\"\n            )\n            return {}\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop all vector data from storage and clean up resources\n\n        This method will delete all data for the current workspace from the Qdrant collection.\n\n        Returns:\n            dict[str, str]: Operation status and message\n            - On success: {\"status\": \"success\", \"message\": \"data dropped\"}\n            - On failure: {\"status\": \"error\", \"message\": \"<error details>\"}\n        \"\"\"\n        # No need to lock: data integrity is ensured by allowing only one process to hold pipeline at a time\n        try:\n            # Delete all points for the current workspace\n            self._client.delete(\n                collection_name=self.final_namespace,\n                points_selector=models.FilterSelector(\n                    filter=models.Filter(\n                        must=[workspace_filter_condition(self.effective_workspace)]\n                    )\n                ),\n                wait=True,\n            )\n\n            logger.info(\n                f\"[{self.workspace}] Process {os.getpid()} dropped workspace data from Qdrant collection {self.namespace}\"\n            )\n            return {\"status\": \"success\", \"message\": \"data dropped\"}\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error dropping workspace data from Qdrant collection {self.namespace}: {e}\"\n            )\n            return {\"status\": \"error\", \"message\": str(e)}\n"
  },
  {
    "path": "lightrag/kg/redis_impl.py",
    "content": "import os\nimport logging\nfrom typing import Any, final, Union\nfrom dataclasses import dataclass\nimport pipmaster as pm\nimport configparser\nfrom contextlib import asynccontextmanager\nimport threading\n\nif not pm.is_installed(\"redis\"):\n    pm.install(\"redis\")\n\n# aioredis is a depricated library, replaced with redis\nfrom redis.asyncio import Redis, ConnectionPool  # type: ignore\nfrom redis.exceptions import RedisError, ConnectionError, TimeoutError  # type: ignore\nfrom lightrag.utils import logger, get_pinyin_sort_key\n\nfrom lightrag.base import (\n    BaseKVStorage,\n    DocStatusStorage,\n    DocStatus,\n    DocProcessingStatus,\n)\nfrom ..kg.shared_storage import get_data_init_lock\nimport json\n\n# Import tenacity for retry logic\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n    before_sleep_log,\n)\n\nconfig = configparser.ConfigParser()\nconfig.read(\"config.ini\", \"utf-8\")\n\n# Constants for Redis connection pool with environment variable support\nMAX_CONNECTIONS = int(os.getenv(\"REDIS_MAX_CONNECTIONS\", \"200\"))\nSOCKET_TIMEOUT = float(os.getenv(\"REDIS_SOCKET_TIMEOUT\", \"30.0\"))\nSOCKET_CONNECT_TIMEOUT = float(os.getenv(\"REDIS_CONNECT_TIMEOUT\", \"10.0\"))\nRETRY_ATTEMPTS = int(os.getenv(\"REDIS_RETRY_ATTEMPTS\", \"3\"))\n\n# Tenacity retry decorator for Redis operations\nredis_retry = retry(\n    stop=stop_after_attempt(RETRY_ATTEMPTS),\n    wait=wait_exponential(multiplier=1, min=1, max=8),\n    retry=(\n        retry_if_exception_type(ConnectionError)\n        | retry_if_exception_type(TimeoutError)\n        | retry_if_exception_type(RedisError)\n    ),\n    before_sleep=before_sleep_log(logger, logging.WARNING),\n)\n\n\nclass RedisConnectionManager:\n    \"\"\"Shared Redis connection pool manager to avoid creating multiple pools for the same Redis URI\"\"\"\n\n    _pools = {}\n    _pool_refs = {}  # Track reference count for each pool\n    _lock = threading.Lock()\n\n    @classmethod\n    def get_pool(cls, redis_url: str) -> ConnectionPool:\n        \"\"\"Get or create a connection pool for the given Redis URL\"\"\"\n        with cls._lock:\n            if redis_url not in cls._pools:\n                cls._pools[redis_url] = ConnectionPool.from_url(\n                    redis_url,\n                    max_connections=MAX_CONNECTIONS,\n                    decode_responses=True,\n                    socket_timeout=SOCKET_TIMEOUT,\n                    socket_connect_timeout=SOCKET_CONNECT_TIMEOUT,\n                )\n                cls._pool_refs[redis_url] = 0\n                logger.info(f\"Created shared Redis connection pool for {redis_url}\")\n\n            # Increment reference count\n            cls._pool_refs[redis_url] += 1\n            logger.debug(\n                f\"Redis pool {redis_url} reference count: {cls._pool_refs[redis_url]}\"\n            )\n\n        return cls._pools[redis_url]\n\n    @classmethod\n    def release_pool(cls, redis_url: str):\n        \"\"\"Release a reference to the connection pool\"\"\"\n        with cls._lock:\n            if redis_url in cls._pool_refs:\n                cls._pool_refs[redis_url] -= 1\n                logger.debug(\n                    f\"Redis pool {redis_url} reference count: {cls._pool_refs[redis_url]}\"\n                )\n\n                # If no more references, close the pool\n                if cls._pool_refs[redis_url] <= 0:\n                    try:\n                        cls._pools[redis_url].disconnect()\n                        logger.info(\n                            f\"Closed Redis connection pool for {redis_url} (no more references)\"\n                        )\n                    except Exception as e:\n                        logger.error(f\"Error closing Redis pool for {redis_url}: {e}\")\n                    finally:\n                        del cls._pools[redis_url]\n                        del cls._pool_refs[redis_url]\n\n    @classmethod\n    def close_all_pools(cls):\n        \"\"\"Close all connection pools (for cleanup)\"\"\"\n        with cls._lock:\n            for url, pool in cls._pools.items():\n                try:\n                    pool.disconnect()\n                    logger.info(f\"Closed Redis connection pool for {url}\")\n                except Exception as e:\n                    logger.error(f\"Error closing Redis pool for {url}: {e}\")\n            cls._pools.clear()\n            cls._pool_refs.clear()\n\n\n@final\n@dataclass\nclass RedisKVStorage(BaseKVStorage):\n    def __post_init__(self):\n        # Check for REDIS_WORKSPACE environment variable first (higher priority)\n        # This allows administrators to force a specific workspace for all Redis storage instances\n        redis_workspace = os.environ.get(\"REDIS_WORKSPACE\")\n        if redis_workspace and redis_workspace.strip():\n            # Use environment variable value, overriding the passed workspace parameter\n            effective_workspace = redis_workspace.strip()\n            logger.info(\n                f\"Using REDIS_WORKSPACE environment variable: '{effective_workspace}' (overriding '{self.workspace}/{self.namespace}')\"\n            )\n        else:\n            # Use the workspace parameter passed during initialization\n            effective_workspace = self.workspace\n            if effective_workspace:\n                logger.debug(\n                    f\"Using passed workspace parameter: '{effective_workspace}'\"\n                )\n\n        # Build final_namespace with workspace prefix for data isolation\n        # Keep original namespace unchanged for type detection logic\n        if effective_workspace:\n            self.final_namespace = f\"{effective_workspace}_{self.namespace}\"\n            logger.debug(\n                f\"Final namespace with workspace prefix: '{self.final_namespace}'\"\n            )\n        else:\n            # When workspace is empty, final_namespace equals original namespace\n            self.final_namespace = self.namespace\n            self.workspace = \"\"\n            logger.debug(f\"Final namespace (no workspace): '{self.final_namespace}'\")\n\n        self._redis_url = os.environ.get(\n            \"REDIS_URI\", config.get(\"redis\", \"uri\", fallback=\"redis://localhost:6379\")\n        )\n        self._pool = None\n        self._redis = None\n        self._initialized = False\n\n        try:\n            # Use shared connection pool\n            self._pool = RedisConnectionManager.get_pool(self._redis_url)\n            self._redis = Redis(connection_pool=self._pool)\n            logger.info(\n                f\"[{self.workspace}] Initialized Redis KV storage for {self.namespace} using shared connection pool\"\n            )\n        except Exception as e:\n            # Clean up on initialization failure\n            if self._redis_url:\n                RedisConnectionManager.release_pool(self._redis_url)\n            logger.error(\n                f\"[{self.workspace}] Failed to initialize Redis KV storage: {e}\"\n            )\n            raise\n\n    async def initialize(self):\n        \"\"\"Initialize Redis connection and migrate legacy cache structure if needed\"\"\"\n        async with get_data_init_lock():\n            if self._initialized:\n                return\n\n            # Test connection\n            try:\n                async with self._get_redis_connection() as redis:\n                    await redis.ping()\n                    logger.info(\n                        f\"[{self.workspace}] Connected to Redis for namespace {self.namespace}\"\n                    )\n                    self._initialized = True\n            except Exception as e:\n                logger.error(f\"[{self.workspace}] Failed to connect to Redis: {e}\")\n                # Clean up on connection failure\n                await self.close()\n                raise\n\n            # Migrate legacy cache structure if this is a cache namespace\n            if self.namespace.endswith(\"_cache\"):\n                try:\n                    await self._migrate_legacy_cache_structure()\n                except Exception as e:\n                    logger.error(\n                        f\"[{self.workspace}] Failed to migrate legacy cache structure: {e}\"\n                    )\n                    # Don't fail initialization for migration errors, just log them\n\n    @asynccontextmanager\n    async def _get_redis_connection(self):\n        \"\"\"Safe context manager for Redis operations.\"\"\"\n        if not self._redis:\n            raise ConnectionError(\"Redis connection not initialized\")\n\n        try:\n            # Use the existing Redis instance with shared pool\n            yield self._redis\n        except ConnectionError as e:\n            logger.error(\n                f\"[{self.workspace}] Redis connection error in {self.namespace}: {e}\"\n            )\n            raise\n        except RedisError as e:\n            logger.error(\n                f\"[{self.workspace}] Redis operation error in {self.namespace}: {e}\"\n            )\n            raise\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Unexpected error in Redis operation for {self.namespace}: {e}\"\n            )\n            raise\n\n    async def close(self):\n        \"\"\"Close the Redis connection and release pool reference to prevent resource leaks.\"\"\"\n        if hasattr(self, \"_redis\") and self._redis:\n            try:\n                await self._redis.close()\n                logger.debug(\n                    f\"[{self.workspace}] Closed Redis connection for {self.namespace}\"\n                )\n            except Exception as e:\n                logger.error(f\"[{self.workspace}] Error closing Redis connection: {e}\")\n            finally:\n                self._redis = None\n\n        # Release the pool reference (will auto-close pool if no more references)\n        if hasattr(self, \"_redis_url\") and self._redis_url:\n            RedisConnectionManager.release_pool(self._redis_url)\n            self._pool = None\n            logger.debug(\n                f\"[{self.workspace}] Released Redis connection pool reference for {self.namespace}\"\n            )\n\n    async def __aenter__(self):\n        \"\"\"Support for async context manager.\"\"\"\n        return self\n\n    async def __aexit__(self, exc_type, exc_val, exc_tb):\n        \"\"\"Ensure Redis resources are cleaned up when exiting context.\"\"\"\n        await self.close()\n\n    @redis_retry\n    async def get_by_id(self, id: str) -> dict[str, Any] | None:\n        async with self._get_redis_connection() as redis:\n            try:\n                data = await redis.get(f\"{self.final_namespace}:{id}\")\n                if data:\n                    result = json.loads(data)\n                    # Ensure time fields are present, provide default values for old data\n                    result.setdefault(\"create_time\", 0)\n                    result.setdefault(\"update_time\", 0)\n                    return result\n                return None\n            except json.JSONDecodeError as e:\n                logger.error(f\"[{self.workspace}] JSON decode error for id {id}: {e}\")\n                return None\n\n    @redis_retry\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        async with self._get_redis_connection() as redis:\n            try:\n                pipe = redis.pipeline()\n                for id in ids:\n                    pipe.get(f\"{self.final_namespace}:{id}\")\n                results = await pipe.execute()\n\n                processed_results = []\n                for result in results:\n                    if result:\n                        data = json.loads(result)\n                        # Ensure time fields are present for all documents\n                        data.setdefault(\"create_time\", 0)\n                        data.setdefault(\"update_time\", 0)\n                        processed_results.append(data)\n                    else:\n                        processed_results.append(None)\n\n                return processed_results\n            except json.JSONDecodeError as e:\n                logger.error(f\"[{self.workspace}] JSON decode error in batch get: {e}\")\n                return [None] * len(ids)\n\n    async def filter_keys(self, keys: set[str]) -> set[str]:\n        async with self._get_redis_connection() as redis:\n            pipe = redis.pipeline()\n            keys_list = list(keys)  # Convert set to list for indexing\n            for key in keys_list:\n                pipe.exists(f\"{self.final_namespace}:{key}\")\n            results = await pipe.execute()\n\n            existing_ids = {keys_list[i] for i, exists in enumerate(results) if exists}\n            return set(keys) - existing_ids\n\n    @redis_retry\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        if not data:\n            return\n\n        import time\n\n        current_time = int(time.time())  # Get current Unix timestamp\n\n        async with self._get_redis_connection() as redis:\n            try:\n                # Check which keys already exist to determine create vs update\n                pipe = redis.pipeline()\n                for k in data.keys():\n                    pipe.exists(f\"{self.final_namespace}:{k}\")\n                exists_results = await pipe.execute()\n\n                # Add timestamps to data\n                for i, (k, v) in enumerate(data.items()):\n                    # For text_chunks namespace, ensure llm_cache_list field exists\n                    if self.namespace.endswith(\"text_chunks\"):\n                        if \"llm_cache_list\" not in v:\n                            v[\"llm_cache_list\"] = []\n\n                    # Add timestamps based on whether key exists\n                    if exists_results[i]:  # Key exists, only update update_time\n                        v[\"update_time\"] = current_time\n                    else:  # New key, set both create_time and update_time\n                        v[\"create_time\"] = current_time\n                        v[\"update_time\"] = current_time\n\n                    v[\"_id\"] = k\n\n                # Store the data\n                pipe = redis.pipeline()\n                for k, v in data.items():\n                    pipe.set(f\"{self.final_namespace}:{k}\", json.dumps(v))\n                await pipe.execute()\n\n            except json.JSONDecodeError as e:\n                logger.error(f\"[{self.workspace}] JSON decode error during upsert: {e}\")\n                raise\n\n    async def index_done_callback(self) -> None:\n        # Redis handles persistence automatically\n        pass\n\n    async def is_empty(self) -> bool:\n        \"\"\"Check if the storage is empty for the current workspace and namespace\n\n        Returns:\n            bool: True if storage is empty, False otherwise\n        \"\"\"\n        pattern = f\"{self.final_namespace}:*\"\n        try:\n            async with self._get_redis_connection() as redis:\n                # Use scan to check if any keys exist\n                async for key in redis.scan_iter(match=pattern, count=1):\n                    return False  # Found at least one key\n                return True  # No keys found\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error checking if storage is empty: {e}\")\n            return True\n\n    async def delete(self, ids: list[str]) -> None:\n        \"\"\"Delete specific records from storage by their IDs\"\"\"\n        if not ids:\n            return\n\n        async with self._get_redis_connection() as redis:\n            pipe = redis.pipeline()\n            for id in ids:\n                pipe.delete(f\"{self.final_namespace}:{id}\")\n\n            results = await pipe.execute()\n            deleted_count = sum(results)\n            logger.info(\n                f\"[{self.workspace}] Deleted {deleted_count} of {len(ids)} entries from {self.namespace}\"\n            )\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop the storage by removing all keys under the current namespace.\n\n        Returns:\n            dict[str, str]: Status of the operation with keys 'status' and 'message'\n        \"\"\"\n        async with self._get_redis_connection() as redis:\n            try:\n                # Use SCAN to find all keys with the namespace prefix\n                pattern = f\"{self.final_namespace}:*\"\n                cursor = 0\n                deleted_count = 0\n\n                while True:\n                    cursor, keys = await redis.scan(cursor, match=pattern, count=1000)\n                    if keys:\n                        # Delete keys in batches\n                        pipe = redis.pipeline()\n                        for key in keys:\n                            pipe.delete(key)\n                        results = await pipe.execute()\n                        deleted_count += sum(results)\n\n                    if cursor == 0:\n                        break\n\n                logger.info(\n                    f\"[{self.workspace}] Dropped {deleted_count} keys from {self.namespace}\"\n                )\n                return {\n                    \"status\": \"success\",\n                    \"message\": f\"{deleted_count} keys dropped\",\n                }\n\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Error dropping keys from {self.namespace}: {e}\"\n                )\n                return {\"status\": \"error\", \"message\": str(e)}\n\n    async def _migrate_legacy_cache_structure(self):\n        \"\"\"Migrate legacy nested cache structure to flattened structure for Redis\n\n        Redis already stores data in a flattened way, but we need to check for\n        legacy keys that might contain nested JSON structures and migrate them.\n\n        Early exit if any flattened key is found (indicating migration already done).\n        \"\"\"\n        from lightrag.utils import generate_cache_key\n\n        async with self._get_redis_connection() as redis:\n            # Get all keys for this namespace\n            keys = await redis.keys(f\"{self.final_namespace}:*\")\n\n            if not keys:\n                return\n\n            # Check if we have any flattened keys already - if so, skip migration\n            has_flattened_keys = False\n            keys_to_migrate = []\n\n            for key in keys:\n                # Extract the ID part (after namespace:)\n                key_id = key.split(\":\", 1)[1]\n\n                # Check if already in flattened format (contains exactly 2 colons for mode:cache_type:hash)\n                if \":\" in key_id and len(key_id.split(\":\")) == 3:\n                    has_flattened_keys = True\n                    break  # Early exit - migration already done\n\n                # Get the data to check if it's a legacy nested structure\n                data = await redis.get(key)\n                if data:\n                    try:\n                        parsed_data = json.loads(data)\n                        # Check if this looks like a legacy cache mode with nested structure\n                        if isinstance(parsed_data, dict) and all(\n                            isinstance(v, dict) and \"return\" in v\n                            for v in parsed_data.values()\n                        ):\n                            keys_to_migrate.append((key, key_id, parsed_data))\n                    except json.JSONDecodeError:\n                        continue\n\n            # If we found any flattened keys, assume migration is already done\n            if has_flattened_keys:\n                logger.debug(\n                    f\"[{self.workspace}] Found flattened cache keys in {self.namespace}, skipping migration\"\n                )\n                return\n\n            if not keys_to_migrate:\n                return\n\n            # Perform migration\n            pipe = redis.pipeline()\n            migration_count = 0\n\n            for old_key, mode, nested_data in keys_to_migrate:\n                # Delete the old key\n                pipe.delete(old_key)\n\n                # Create new flattened keys\n                for cache_hash, cache_entry in nested_data.items():\n                    cache_type = cache_entry.get(\"cache_type\", \"extract\")\n                    flattened_key = generate_cache_key(mode, cache_type, cache_hash)\n                    full_key = f\"{self.final_namespace}:{flattened_key}\"\n                    pipe.set(full_key, json.dumps(cache_entry))\n                    migration_count += 1\n\n            await pipe.execute()\n\n            if migration_count > 0:\n                logger.info(\n                    f\"[{self.workspace}] Migrated {migration_count} legacy cache entries to flattened structure in Redis\"\n                )\n\n\n@final\n@dataclass\nclass RedisDocStatusStorage(DocStatusStorage):\n    \"\"\"Redis implementation of document status storage\"\"\"\n\n    def __post_init__(self):\n        # Check for REDIS_WORKSPACE environment variable first (higher priority)\n        # This allows administrators to force a specific workspace for all Redis storage instances\n        redis_workspace = os.environ.get(\"REDIS_WORKSPACE\")\n        if redis_workspace and redis_workspace.strip():\n            # Use environment variable value, overriding the passed workspace parameter\n            effective_workspace = redis_workspace.strip()\n            logger.info(\n                f\"Using REDIS_WORKSPACE environment variable: '{effective_workspace}' (overriding '{self.workspace}/{self.namespace}')\"\n            )\n        else:\n            # Use the workspace parameter passed during initialization\n            effective_workspace = self.workspace\n            if effective_workspace:\n                logger.debug(\n                    f\"Using passed workspace parameter: '{effective_workspace}'\"\n                )\n\n        # Build final_namespace with workspace prefix for data isolation\n        # Keep original namespace unchanged for type detection logic\n        if effective_workspace:\n            self.final_namespace = f\"{effective_workspace}_{self.namespace}\"\n            logger.debug(\n                f\"[{self.workspace}] Final namespace with workspace prefix: '{self.namespace}'\"\n            )\n        else:\n            # When workspace is empty, final_namespace equals original namespace\n            self.final_namespace = self.namespace\n            self.workspace = \"_\"\n            logger.debug(\n                f\"[{self.workspace}] Final namespace (no workspace): '{self.namespace}'\"\n            )\n\n        self._redis_url = os.environ.get(\n            \"REDIS_URI\", config.get(\"redis\", \"uri\", fallback=\"redis://localhost:6379\")\n        )\n        self._pool = None\n        self._redis = None\n        self._initialized = False\n\n        try:\n            # Use shared connection pool\n            self._pool = RedisConnectionManager.get_pool(self._redis_url)\n            self._redis = Redis(connection_pool=self._pool)\n            logger.info(\n                f\"[{self.workspace}] Initialized Redis doc status storage for {self.namespace} using shared connection pool\"\n            )\n        except Exception as e:\n            # Clean up on initialization failure\n            if self._redis_url:\n                RedisConnectionManager.release_pool(self._redis_url)\n            logger.error(\n                f\"[{self.workspace}] Failed to initialize Redis doc status storage: {e}\"\n            )\n            raise\n\n    async def initialize(self):\n        \"\"\"Initialize Redis connection\"\"\"\n        async with get_data_init_lock():\n            if self._initialized:\n                return\n\n            try:\n                async with self._get_redis_connection() as redis:\n                    await redis.ping()\n                    logger.info(\n                        f\"[{self.workspace}] Connected to Redis for doc status namespace {self.namespace}\"\n                    )\n                    self._initialized = True\n            except Exception as e:\n                logger.error(\n                    f\"[{self.workspace}] Failed to connect to Redis for doc status: {e}\"\n                )\n                # Clean up on connection failure\n                await self.close()\n                raise\n\n    @asynccontextmanager\n    async def _get_redis_connection(self):\n        \"\"\"Safe context manager for Redis operations.\"\"\"\n        if not self._redis:\n            raise ConnectionError(\"Redis connection not initialized\")\n\n        try:\n            # Use the existing Redis instance with shared pool\n            yield self._redis\n        except ConnectionError as e:\n            logger.error(\n                f\"[{self.workspace}] Redis connection error in doc status {self.namespace}: {e}\"\n            )\n            raise\n        except RedisError as e:\n            logger.error(\n                f\"[{self.workspace}] Redis operation error in doc status {self.namespace}: {e}\"\n            )\n            raise\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Unexpected error in Redis doc status operation for {self.namespace}: {e}\"\n            )\n            raise\n\n    async def close(self):\n        \"\"\"Close the Redis connection and release pool reference to prevent resource leaks.\"\"\"\n        if hasattr(self, \"_redis\") and self._redis:\n            try:\n                await self._redis.close()\n                logger.debug(\n                    f\"[{self.workspace}] Closed Redis connection for doc status {self.namespace}\"\n                )\n            except Exception as e:\n                logger.error(f\"[{self.workspace}] Error closing Redis connection: {e}\")\n            finally:\n                self._redis = None\n\n        # Release the pool reference (will auto-close pool if no more references)\n        if hasattr(self, \"_redis_url\") and self._redis_url:\n            RedisConnectionManager.release_pool(self._redis_url)\n            self._pool = None\n            logger.debug(\n                f\"[{self.workspace}] Released Redis connection pool reference for doc status {self.namespace}\"\n            )\n\n    async def __aenter__(self):\n        \"\"\"Support for async context manager.\"\"\"\n        return self\n\n    async def __aexit__(self, exc_type, exc_val, exc_tb):\n        \"\"\"Ensure Redis resources are cleaned up when exiting context.\"\"\"\n        await self.close()\n\n    async def filter_keys(self, keys: set[str]) -> set[str]:\n        \"\"\"Return keys that should be processed (not in storage or not successfully processed)\"\"\"\n        async with self._get_redis_connection() as redis:\n            pipe = redis.pipeline()\n            keys_list = list(keys)\n            for key in keys_list:\n                pipe.exists(f\"{self.final_namespace}:{key}\")\n            results = await pipe.execute()\n\n            existing_ids = {keys_list[i] for i, exists in enumerate(results) if exists}\n            return set(keys) - existing_ids\n\n    async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:\n        ordered_results: list[dict[str, Any] | None] = []\n        async with self._get_redis_connection() as redis:\n            try:\n                pipe = redis.pipeline()\n                for id in ids:\n                    pipe.get(f\"{self.final_namespace}:{id}\")\n                results = await pipe.execute()\n\n                for result_data in results:\n                    if result_data:\n                        try:\n                            ordered_results.append(json.loads(result_data))\n                        except json.JSONDecodeError as e:\n                            logger.error(\n                                f\"[{self.workspace}] JSON decode error in get_by_ids: {e}\"\n                            )\n                            ordered_results.append(None)\n                    else:\n                        ordered_results.append(None)\n            except Exception as e:\n                logger.error(f\"[{self.workspace}] Error in get_by_ids: {e}\")\n        return ordered_results\n\n    async def get_status_counts(self) -> dict[str, int]:\n        \"\"\"Get counts of documents in each status\"\"\"\n        counts = {status.value: 0 for status in DocStatus}\n        async with self._get_redis_connection() as redis:\n            try:\n                # Use SCAN to iterate through all keys in the namespace\n                cursor = 0\n                while True:\n                    cursor, keys = await redis.scan(\n                        cursor, match=f\"{self.final_namespace}:*\", count=1000\n                    )\n                    if keys:\n                        # Get all values in batch\n                        pipe = redis.pipeline()\n                        for key in keys:\n                            pipe.get(key)\n                        values = await pipe.execute()\n\n                        # Count statuses\n                        for value in values:\n                            if value:\n                                try:\n                                    doc_data = json.loads(value)\n                                    status = doc_data.get(\"status\")\n                                    if status in counts:\n                                        counts[status] += 1\n                                except json.JSONDecodeError:\n                                    continue\n\n                    if cursor == 0:\n                        break\n            except Exception as e:\n                logger.error(f\"[{self.workspace}] Error getting status counts: {e}\")\n\n        return counts\n\n    async def get_docs_by_status(\n        self, status: DocStatus\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Get all documents with a specific status\"\"\"\n        result = {}\n        async with self._get_redis_connection() as redis:\n            try:\n                # Use SCAN to iterate through all keys in the namespace\n                cursor = 0\n                while True:\n                    cursor, keys = await redis.scan(\n                        cursor, match=f\"{self.final_namespace}:*\", count=1000\n                    )\n                    if keys:\n                        # Get all values in batch\n                        pipe = redis.pipeline()\n                        for key in keys:\n                            pipe.get(key)\n                        values = await pipe.execute()\n\n                        # Filter by status and create DocProcessingStatus objects\n                        for key, value in zip(keys, values):\n                            if value:\n                                try:\n                                    doc_data = json.loads(value)\n                                    if doc_data.get(\"status\") == status.value:\n                                        # Extract document ID from key\n                                        doc_id = key.split(\":\", 1)[1]\n\n                                        # Make a copy of the data to avoid modifying the original\n                                        data = doc_data.copy()\n                                        # Remove deprecated content field if it exists\n                                        data.pop(\"content\", None)\n                                        # If file_path is not in data, use document id as file path\n                                        if \"file_path\" not in data:\n                                            data[\"file_path\"] = \"no-file-path\"\n                                        # Ensure new fields exist with default values\n                                        if \"metadata\" not in data:\n                                            data[\"metadata\"] = {}\n                                        if \"error_msg\" not in data:\n                                            data[\"error_msg\"] = None\n\n                                        result[doc_id] = DocProcessingStatus(**data)\n                                except (json.JSONDecodeError, KeyError) as e:\n                                    logger.error(\n                                        f\"[{self.workspace}] Error processing document {key}: {e}\"\n                                    )\n                                    continue\n\n                    if cursor == 0:\n                        break\n            except Exception as e:\n                logger.error(f\"[{self.workspace}] Error getting docs by status: {e}\")\n\n        return result\n\n    async def get_docs_by_track_id(\n        self, track_id: str\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Get all documents with a specific track_id\"\"\"\n        result = {}\n        async with self._get_redis_connection() as redis:\n            try:\n                # Use SCAN to iterate through all keys in the namespace\n                cursor = 0\n                while True:\n                    cursor, keys = await redis.scan(\n                        cursor, match=f\"{self.final_namespace}:*\", count=1000\n                    )\n                    if keys:\n                        # Get all values in batch\n                        pipe = redis.pipeline()\n                        for key in keys:\n                            pipe.get(key)\n                        values = await pipe.execute()\n\n                        # Filter by track_id and create DocProcessingStatus objects\n                        for key, value in zip(keys, values):\n                            if value:\n                                try:\n                                    doc_data = json.loads(value)\n                                    if doc_data.get(\"track_id\") == track_id:\n                                        # Extract document ID from key\n                                        doc_id = key.split(\":\", 1)[1]\n\n                                        # Make a copy of the data to avoid modifying the original\n                                        data = doc_data.copy()\n                                        # Remove deprecated content field if it exists\n                                        data.pop(\"content\", None)\n                                        # If file_path is not in data, use document id as file path\n                                        if \"file_path\" not in data:\n                                            data[\"file_path\"] = \"no-file-path\"\n                                        # Ensure new fields exist with default values\n                                        if \"metadata\" not in data:\n                                            data[\"metadata\"] = {}\n                                        if \"error_msg\" not in data:\n                                            data[\"error_msg\"] = None\n\n                                        result[doc_id] = DocProcessingStatus(**data)\n                                except (json.JSONDecodeError, KeyError) as e:\n                                    logger.error(\n                                        f\"[{self.workspace}] Error processing document {key}: {e}\"\n                                    )\n                                    continue\n\n                    if cursor == 0:\n                        break\n            except Exception as e:\n                logger.error(f\"[{self.workspace}] Error getting docs by track_id: {e}\")\n\n        return result\n\n    async def index_done_callback(self) -> None:\n        \"\"\"Redis handles persistence automatically\"\"\"\n        pass\n\n    async def is_empty(self) -> bool:\n        \"\"\"Check if the storage is empty for the current workspace and namespace\n\n        Returns:\n            bool: True if storage is empty, False otherwise\n        \"\"\"\n        pattern = f\"{self.final_namespace}:*\"\n        try:\n            async with self._get_redis_connection() as redis:\n                # Use scan to check if any keys exist\n                async for key in redis.scan_iter(match=pattern, count=1):\n                    return False  # Found at least one key\n                return True  # No keys found\n        except Exception as e:\n            logger.error(f\"[{self.workspace}] Error checking if storage is empty: {e}\")\n            return True\n\n    @redis_retry\n    async def upsert(self, data: dict[str, dict[str, Any]]) -> None:\n        \"\"\"Insert or update document status data\"\"\"\n        if not data:\n            return\n\n        logger.debug(\n            f\"[{self.workspace}] Inserting {len(data)} records to {self.namespace}\"\n        )\n        async with self._get_redis_connection() as redis:\n            try:\n                # Ensure chunks_list field exists for new documents\n                for doc_id, doc_data in data.items():\n                    if \"chunks_list\" not in doc_data:\n                        doc_data[\"chunks_list\"] = []\n\n                pipe = redis.pipeline()\n                for k, v in data.items():\n                    pipe.set(f\"{self.final_namespace}:{k}\", json.dumps(v))\n                await pipe.execute()\n            except json.JSONDecodeError as e:\n                logger.error(f\"[{self.workspace}] JSON decode error during upsert: {e}\")\n                raise\n\n    @redis_retry\n    async def get_by_id(self, id: str) -> Union[dict[str, Any], None]:\n        async with self._get_redis_connection() as redis:\n            try:\n                data = await redis.get(f\"{self.final_namespace}:{id}\")\n                return json.loads(data) if data else None\n            except json.JSONDecodeError as e:\n                logger.error(f\"[{self.workspace}] JSON decode error for id {id}: {e}\")\n                return None\n\n    async def delete(self, doc_ids: list[str]) -> None:\n        \"\"\"Delete specific records from storage by their IDs\"\"\"\n        if not doc_ids:\n            return\n\n        async with self._get_redis_connection() as redis:\n            pipe = redis.pipeline()\n            for doc_id in doc_ids:\n                pipe.delete(f\"{self.final_namespace}:{doc_id}\")\n\n            results = await pipe.execute()\n            deleted_count = sum(results)\n            logger.info(\n                f\"[{self.workspace}] Deleted {deleted_count} of {len(doc_ids)} doc status entries from {self.namespace}\"\n            )\n\n    async def get_docs_paginated(\n        self,\n        status_filter: DocStatus | None = None,\n        page: int = 1,\n        page_size: int = 50,\n        sort_field: str = \"updated_at\",\n        sort_direction: str = \"desc\",\n    ) -> tuple[list[tuple[str, DocProcessingStatus]], int]:\n        \"\"\"Get documents with pagination support\n\n        Args:\n            status_filter: Filter by document status, None for all statuses\n            page: Page number (1-based)\n            page_size: Number of documents per page (10-200)\n            sort_field: Field to sort by ('created_at', 'updated_at', 'id')\n            sort_direction: Sort direction ('asc' or 'desc')\n\n        Returns:\n            Tuple of (list of (doc_id, DocProcessingStatus) tuples, total_count)\n        \"\"\"\n        # Validate parameters\n        if page < 1:\n            page = 1\n        if page_size < 10:\n            page_size = 10\n        elif page_size > 200:\n            page_size = 200\n\n        if sort_field not in [\"created_at\", \"updated_at\", \"id\", \"file_path\"]:\n            sort_field = \"updated_at\"\n\n        if sort_direction.lower() not in [\"asc\", \"desc\"]:\n            sort_direction = \"desc\"\n\n        # For Redis, we need to load all data and sort/filter in memory\n        all_docs = []\n        total_count = 0\n\n        async with self._get_redis_connection() as redis:\n            try:\n                # Use SCAN to iterate through all keys in the namespace\n                cursor = 0\n                while True:\n                    cursor, keys = await redis.scan(\n                        cursor, match=f\"{self.final_namespace}:*\", count=1000\n                    )\n                    if keys:\n                        # Get all values in batch\n                        pipe = redis.pipeline()\n                        for key in keys:\n                            pipe.get(key)\n                        values = await pipe.execute()\n\n                        # Process documents\n                        for key, value in zip(keys, values):\n                            if value:\n                                try:\n                                    doc_data = json.loads(value)\n\n                                    # Apply status filter\n                                    if (\n                                        status_filter is not None\n                                        and doc_data.get(\"status\")\n                                        != status_filter.value\n                                    ):\n                                        continue\n\n                                    # Extract document ID from key\n                                    doc_id = key.split(\":\", 1)[1]\n\n                                    # Prepare document data\n                                    data = doc_data.copy()\n                                    data.pop(\"content\", None)\n                                    if \"file_path\" not in data:\n                                        data[\"file_path\"] = \"no-file-path\"\n                                    if \"metadata\" not in data:\n                                        data[\"metadata\"] = {}\n                                    if \"error_msg\" not in data:\n                                        data[\"error_msg\"] = None\n\n                                    # Calculate sort key for sorting (but don't add to data)\n                                    if sort_field == \"id\":\n                                        sort_key = doc_id\n                                    elif sort_field == \"file_path\":\n                                        # Use pinyin sorting for file_path field to support Chinese characters\n                                        file_path_value = data.get(sort_field, \"\")\n                                        sort_key = get_pinyin_sort_key(file_path_value)\n                                    else:\n                                        sort_key = data.get(sort_field, \"\")\n\n                                    doc_status = DocProcessingStatus(**data)\n                                    all_docs.append((doc_id, doc_status, sort_key))\n\n                                except (json.JSONDecodeError, KeyError) as e:\n                                    logger.error(\n                                        f\"[{self.workspace}] Error processing document {key}: {e}\"\n                                    )\n                                    continue\n\n                    if cursor == 0:\n                        break\n\n            except Exception as e:\n                logger.error(f\"[{self.workspace}] Error getting paginated docs: {e}\")\n                return [], 0\n\n        # Sort documents using the separate sort key\n        reverse_sort = sort_direction.lower() == \"desc\"\n        all_docs.sort(key=lambda x: x[2], reverse=reverse_sort)\n\n        # Remove sort key from tuples and keep only (doc_id, doc_status)\n        all_docs = [(doc_id, doc_status) for doc_id, doc_status, _ in all_docs]\n\n        total_count = len(all_docs)\n\n        # Apply pagination\n        start_idx = (page - 1) * page_size\n        end_idx = start_idx + page_size\n        paginated_docs = all_docs[start_idx:end_idx]\n\n        return paginated_docs, total_count\n\n    async def get_all_status_counts(self) -> dict[str, int]:\n        \"\"\"Get counts of documents in each status for all documents\n\n        Returns:\n            Dictionary mapping status names to counts, including 'all' field\n        \"\"\"\n        counts = await self.get_status_counts()\n\n        # Add 'all' field with total count\n        total_count = sum(counts.values())\n        counts[\"all\"] = total_count\n\n        return counts\n\n    async def get_doc_by_file_path(self, file_path: str) -> Union[dict[str, Any], None]:\n        \"\"\"Get document by file path\n\n        Args:\n            file_path: The file path to search for\n\n        Returns:\n            Union[dict[str, Any], None]: Document data if found, None otherwise\n            Returns the same format as get_by_id method\n        \"\"\"\n        async with self._get_redis_connection() as redis:\n            try:\n                # Use SCAN to iterate through all keys in the namespace\n                cursor = 0\n                while True:\n                    cursor, keys = await redis.scan(\n                        cursor, match=f\"{self.final_namespace}:*\", count=1000\n                    )\n                    if keys:\n                        # Get all values in batch\n                        pipe = redis.pipeline()\n                        for key in keys:\n                            pipe.get(key)\n                        values = await pipe.execute()\n\n                        # Check each document for matching file_path\n                        for value in values:\n                            if value:\n                                try:\n                                    doc_data = json.loads(value)\n                                    if doc_data.get(\"file_path\") == file_path:\n                                        return doc_data\n                                except json.JSONDecodeError as e:\n                                    logger.error(\n                                        f\"[{self.workspace}] JSON decode error in get_doc_by_file_path: {e}\"\n                                    )\n                                    continue\n\n                    if cursor == 0:\n                        break\n\n                return None\n            except Exception as e:\n                logger.error(f\"[{self.workspace}] Error in get_doc_by_file_path: {e}\")\n                return None\n\n    async def drop(self) -> dict[str, str]:\n        \"\"\"Drop all document status data from storage and clean up resources\"\"\"\n        try:\n            async with self._get_redis_connection() as redis:\n                # Use SCAN to find all keys with the namespace prefix\n                pattern = f\"{self.final_namespace}:*\"\n                cursor = 0\n                deleted_count = 0\n\n                while True:\n                    cursor, keys = await redis.scan(cursor, match=pattern, count=1000)\n                    if keys:\n                        # Delete keys in batches\n                        pipe = redis.pipeline()\n                        for key in keys:\n                            pipe.delete(key)\n                        results = await pipe.execute()\n                        deleted_count += sum(results)\n\n                    if cursor == 0:\n                        break\n\n                logger.info(\n                    f\"[{self.workspace}] Dropped {deleted_count} doc status keys from {self.namespace}\"\n                )\n                return {\"status\": \"success\", \"message\": \"data dropped\"}\n        except Exception as e:\n            logger.error(\n                f\"[{self.workspace}] Error dropping doc status {self.namespace}: {e}\"\n            )\n            return {\"status\": \"error\", \"message\": str(e)}\n"
  },
  {
    "path": "lightrag/kg/shared_storage.py",
    "content": "import os\nimport sys\nimport asyncio\nimport multiprocessing as mp\nfrom multiprocessing.synchronize import Lock as ProcessLock\nfrom multiprocessing import Manager\nimport time\nimport logging\nfrom contextvars import ContextVar\nfrom typing import Any, Dict, List, Optional, Union, TypeVar, Generic\n\nfrom lightrag.exceptions import PipelineNotInitializedError\n\nDEBUG_LOCKS = False\n\n\n# Define a direct print function for critical logs that must be visible in all processes\ndef direct_log(message, enable_output: bool = True, level: str = \"DEBUG\"):\n    \"\"\"\n    Log a message directly to stderr to ensure visibility in all processes,\n    including the Gunicorn master process.\n\n    Args:\n        message: The message to log\n        level: Log level for message (control the visibility of the message by comparing with the current logger level)\n        enable_output: Enable or disable log message (Force to turn off the message,)\n    \"\"\"\n    if not enable_output:\n        return\n\n    # Get the current logger level from the lightrag logger\n    try:\n        from lightrag.utils import logger\n\n        current_level = logger.getEffectiveLevel()\n    except ImportError:\n        # Fallback if lightrag.utils is not available\n        current_level = 20  # INFO\n\n    # Convert string level to numeric level for comparison\n    level_mapping = {\n        \"DEBUG\": 10,  # DEBUG\n        \"INFO\": 20,  # INFO\n        \"WARNING\": 30,  # WARNING\n        \"ERROR\": 40,  # ERROR\n        \"CRITICAL\": 50,  # CRITICAL\n    }\n    message_level = level_mapping.get(level.upper(), logging.DEBUG)\n\n    if message_level >= current_level:\n        print(f\"{level}: {message}\", file=sys.stderr, flush=True)\n\n\nT = TypeVar(\"T\")\nLockType = Union[ProcessLock, asyncio.Lock]\n\n_is_multiprocess = None\n_workers = None\n_manager = None\n\n# Global singleton data for multi-process keyed locks\n_lock_registry: Optional[Dict[str, mp.synchronize.Lock]] = None\n_lock_registry_count: Optional[Dict[str, int]] = None\n_lock_cleanup_data: Optional[Dict[str, time.time]] = None\n_registry_guard = None\n# Timeout for keyed locks in seconds (Default 300)\nCLEANUP_KEYED_LOCKS_AFTER_SECONDS = 300\n# Cleanup pending list threshold for triggering cleanup (Default 500)\nCLEANUP_THRESHOLD = 500\n# Minimum interval between cleanup operations in seconds (Default 30)\nMIN_CLEANUP_INTERVAL_SECONDS = 30\n# Track the earliest cleanup time for efficient cleanup triggering (multiprocess locks only)\n_earliest_mp_cleanup_time: Optional[float] = None\n# Track the last cleanup time to enforce minimum interval (multiprocess locks only)\n_last_mp_cleanup_time: Optional[float] = None\n\n_initialized = None\n\n# Default workspace for backward compatibility\n_default_workspace: Optional[str] = None\n\n# shared data for storage across processes\n_shared_dicts: Optional[Dict[str, Any]] = None\n_init_flags: Optional[Dict[str, bool]] = None  # namespace -> initialized\n_update_flags: Optional[Dict[str, bool]] = None  # namespace -> updated\n\n# locks for mutex access\n_internal_lock: Optional[LockType] = None\n_data_init_lock: Optional[LockType] = None\n# Manager for all keyed locks\n_storage_keyed_lock: Optional[\"KeyedUnifiedLock\"] = None\n\n# async locks for coroutine synchronization in multiprocess mode\n_async_locks: Optional[Dict[str, asyncio.Lock]] = None\n\n_debug_n_locks_acquired: int = 0\n\n\ndef get_final_namespace(namespace: str, workspace: str | None = None):\n    global _default_workspace\n    if workspace is None:\n        workspace = _default_workspace\n\n    if workspace is None:\n        direct_log(\n            f\"Error: Invoke namespace operation without workspace, pid={os.getpid()}\",\n            level=\"ERROR\",\n        )\n        raise ValueError(\"Invoke namespace operation without workspace\")\n\n    final_namespace = f\"{workspace}:{namespace}\" if workspace else f\"{namespace}\"\n    return final_namespace\n\n\ndef inc_debug_n_locks_acquired():\n    global _debug_n_locks_acquired\n    if DEBUG_LOCKS:\n        _debug_n_locks_acquired += 1\n        print(f\"DEBUG: Keyed Lock acquired, total: {_debug_n_locks_acquired:>5}\")\n\n\ndef dec_debug_n_locks_acquired():\n    global _debug_n_locks_acquired\n    if DEBUG_LOCKS:\n        if _debug_n_locks_acquired > 0:\n            _debug_n_locks_acquired -= 1\n            print(f\"DEBUG: Keyed Lock released, total: {_debug_n_locks_acquired:>5}\")\n        else:\n            raise RuntimeError(\"Attempting to release lock when no locks are acquired\")\n\n\ndef get_debug_n_locks_acquired():\n    global _debug_n_locks_acquired\n    return _debug_n_locks_acquired\n\n\nclass UnifiedLock(Generic[T]):\n    \"\"\"Provide a unified lock interface type for asyncio.Lock and multiprocessing.Lock\"\"\"\n\n    def __init__(\n        self,\n        lock: Union[ProcessLock, asyncio.Lock],\n        is_async: bool,\n        name: str = \"unnamed\",\n        enable_logging: bool = True,\n        async_lock: Optional[asyncio.Lock] = None,\n    ):\n        self._lock = lock\n        self._is_async = is_async\n        self._pid = os.getpid()  # for debug only\n        self._name = name  # for debug only\n        self._enable_logging = enable_logging  # for debug only\n        self._async_lock = async_lock  # auxiliary lock for coroutine synchronization\n\n    async def __aenter__(self) -> \"UnifiedLock[T]\":\n        try:\n            # If in multiprocess mode and async lock exists, acquire it first\n            if not self._is_async and self._async_lock is not None:\n                await self._async_lock.acquire()\n                direct_log(\n                    f\"== Lock == Process {self._pid}: Acquired async lock '{self._name}\",\n                    level=\"DEBUG\",\n                    enable_output=self._enable_logging,\n                )\n\n            # Acquire the main lock\n            # Note: self._lock should never be None here as the check has been moved\n            # to get_internal_lock() and get_data_init_lock() functions\n            if self._is_async:\n                await self._lock.acquire()\n            else:\n                self._lock.acquire()\n\n            direct_log(\n                f\"== Lock == Process {self._pid}: Acquired lock {self._name} (async={self._is_async})\",\n                level=\"INFO\",\n                enable_output=self._enable_logging,\n            )\n            return self\n        except Exception as e:\n            # If main lock acquisition fails, release the async lock if it was acquired\n            if (\n                not self._is_async\n                and self._async_lock is not None\n                and self._async_lock.locked()\n            ):\n                self._async_lock.release()\n\n            direct_log(\n                f\"== Lock == Process {self._pid}: Failed to acquire lock '{self._name}': {e}\",\n                level=\"ERROR\",\n                enable_output=True,\n            )\n            raise\n\n    async def __aexit__(self, exc_type, exc_val, exc_tb):\n        main_lock_released = False\n        async_lock_released = False\n        try:\n            # Release main lock first\n            if self._lock is not None:\n                if self._is_async:\n                    self._lock.release()\n                else:\n                    self._lock.release()\n\n                direct_log(\n                    f\"== Lock == Process {self._pid}: Released lock {self._name} (async={self._is_async})\",\n                    level=\"INFO\",\n                    enable_output=self._enable_logging,\n                )\n                main_lock_released = True\n\n            # Then release async lock if in multiprocess mode\n            if not self._is_async and self._async_lock is not None:\n                self._async_lock.release()\n                direct_log(\n                    f\"== Lock == Process {self._pid}: Released async lock {self._name}\",\n                    level=\"DEBUG\",\n                    enable_output=self._enable_logging,\n                )\n                async_lock_released = True\n\n        except Exception as e:\n            direct_log(\n                f\"== Lock == Process {self._pid}: Failed to release lock '{self._name}': {e}\",\n                level=\"ERROR\",\n                enable_output=True,\n            )\n\n            # If main lock release failed but async lock hasn't been attempted yet, try to release it\n            if (\n                not main_lock_released\n                and not async_lock_released\n                and not self._is_async\n                and self._async_lock is not None\n            ):\n                try:\n                    direct_log(\n                        f\"== Lock == Process {self._pid}: Attempting to release async lock after main lock failure\",\n                        level=\"DEBUG\",\n                        enable_output=self._enable_logging,\n                    )\n                    self._async_lock.release()\n                    direct_log(\n                        f\"== Lock == Process {self._pid}: Successfully released async lock after main lock failure\",\n                        level=\"INFO\",\n                        enable_output=self._enable_logging,\n                    )\n                except Exception as inner_e:\n                    direct_log(\n                        f\"== Lock == Process {self._pid}: Failed to release async lock after main lock failure: {inner_e}\",\n                        level=\"ERROR\",\n                        enable_output=True,\n                    )\n\n            raise\n\n    def __enter__(self) -> \"UnifiedLock[T]\":\n        \"\"\"For backward compatibility\"\"\"\n        try:\n            if self._is_async:\n                raise RuntimeError(\"Use 'async with' for shared_storage lock\")\n\n            # Acquire the main lock\n            # Note: self._lock should never be None here as the check has been moved\n            # to get_internal_lock() and get_data_init_lock() functions\n            direct_log(\n                f\"== Lock == Process {self._pid}: Acquiring lock {self._name} (sync)\",\n                level=\"DEBUG\",\n                enable_output=self._enable_logging,\n            )\n            self._lock.acquire()\n            direct_log(\n                f\"== Lock == Process {self._pid}: Acquired lock {self._name} (sync)\",\n                level=\"INFO\",\n                enable_output=self._enable_logging,\n            )\n            return self\n        except Exception as e:\n            direct_log(\n                f\"== Lock == Process {self._pid}: Failed to acquire lock '{self._name}' (sync): {e}\",\n                level=\"ERROR\",\n                enable_output=True,\n            )\n            raise\n\n    def __exit__(self, exc_type, exc_val, exc_tb):\n        \"\"\"For backward compatibility\"\"\"\n        try:\n            if self._is_async:\n                raise RuntimeError(\"Use 'async with' for shared_storage lock\")\n            direct_log(\n                f\"== Lock == Process {self._pid}: Releasing lock '{self._name}' (sync)\",\n                level=\"DEBUG\",\n                enable_output=self._enable_logging,\n            )\n            self._lock.release()\n            direct_log(\n                f\"== Lock == Process {self._pid}: Released lock {self._name} (sync)\",\n                level=\"INFO\",\n                enable_output=self._enable_logging,\n            )\n        except Exception as e:\n            direct_log(\n                f\"== Lock == Process {self._pid}: Failed to release lock '{self._name}' (sync): {e}\",\n                level=\"ERROR\",\n                enable_output=True,\n            )\n            raise\n\n    def locked(self) -> bool:\n        if self._is_async:\n            return self._lock.locked()\n        else:\n            return self._lock.locked()\n\n\ndef _get_combined_key(factory_name: str, key: str) -> str:\n    \"\"\"Return the combined key for the factory and key.\"\"\"\n    return f\"{factory_name}:{key}\"\n\n\ndef _perform_lock_cleanup(\n    lock_type: str,\n    cleanup_data: Dict[str, float],\n    lock_registry: Optional[Dict[str, Any]],\n    lock_count: Optional[Dict[str, int]],\n    earliest_cleanup_time: Optional[float],\n    last_cleanup_time: Optional[float],\n    current_time: float,\n    threshold_check: bool = True,\n) -> tuple[int, Optional[float], Optional[float]]:\n    \"\"\"\n    Generic lock cleanup function to unify cleanup logic for both multiprocess and async locks.\n\n    Args:\n        lock_type: Lock type identifier (\"mp\" or \"async\")\n        cleanup_data: Cleanup data dictionary\n        lock_registry: Lock registry dictionary (can be None for async locks)\n        lock_count: Lock count dictionary (can be None for async locks)\n        earliest_cleanup_time: Earliest cleanup time\n        last_cleanup_time: Last cleanup time\n        current_time: Current time\n        threshold_check: Whether to check threshold condition (default True, set to False in cleanup_expired_locks)\n\n    Returns:\n        tuple: (cleaned_count, new_earliest_time, new_last_cleanup_time)\n    \"\"\"\n    if len(cleanup_data) == 0:\n        return 0, earliest_cleanup_time, last_cleanup_time\n\n    # If threshold check is needed and threshold not reached, return directly\n    if threshold_check and len(cleanup_data) < CLEANUP_THRESHOLD:\n        return 0, earliest_cleanup_time, last_cleanup_time\n\n    # Time rollback detection\n    if last_cleanup_time is not None and current_time < last_cleanup_time:\n        direct_log(\n            f\"== {lock_type} Lock == Time rollback detected, resetting cleanup time\",\n            level=\"WARNING\",\n            enable_output=False,\n        )\n        last_cleanup_time = None\n\n    # Check cleanup conditions\n    has_expired_locks = (\n        earliest_cleanup_time is not None\n        and current_time - earliest_cleanup_time > CLEANUP_KEYED_LOCKS_AFTER_SECONDS\n    )\n\n    interval_satisfied = (\n        last_cleanup_time is None\n        or current_time - last_cleanup_time > MIN_CLEANUP_INTERVAL_SECONDS\n    )\n\n    if not (has_expired_locks and interval_satisfied):\n        return 0, earliest_cleanup_time, last_cleanup_time\n\n    try:\n        cleaned_count = 0\n        new_earliest_time = None\n\n        # Calculate total count before cleanup\n        total_cleanup_len = len(cleanup_data)\n\n        # Perform cleanup operation\n        for cleanup_key, cleanup_time in list(cleanup_data.items()):\n            if current_time - cleanup_time > CLEANUP_KEYED_LOCKS_AFTER_SECONDS:\n                # Remove from cleanup data\n                cleanup_data.pop(cleanup_key, None)\n\n                # Remove from lock registry if exists\n                if lock_registry is not None:\n                    lock_registry.pop(cleanup_key, None)\n                if lock_count is not None:\n                    lock_count.pop(cleanup_key, None)\n\n                cleaned_count += 1\n            else:\n                # Track the earliest time among remaining locks\n                if new_earliest_time is None or cleanup_time < new_earliest_time:\n                    new_earliest_time = cleanup_time\n\n        # Update state only after successful cleanup\n        if cleaned_count > 0:\n            new_last_cleanup_time = current_time\n\n            # Log cleanup results\n            next_cleanup_in = max(\n                (new_earliest_time + CLEANUP_KEYED_LOCKS_AFTER_SECONDS - current_time)\n                if new_earliest_time\n                else float(\"inf\"),\n                MIN_CLEANUP_INTERVAL_SECONDS,\n            )\n\n            if lock_type == \"async\":\n                direct_log(\n                    f\"== {lock_type} Lock == Cleaned up {cleaned_count}/{total_cleanup_len} expired {lock_type} locks, \"\n                    f\"next cleanup in {next_cleanup_in:.1f}s\",\n                    enable_output=False,\n                    level=\"INFO\",\n                )\n            else:\n                direct_log(\n                    f\"== {lock_type} Lock == Cleaned up {cleaned_count}/{total_cleanup_len} expired locks, \"\n                    f\"next cleanup in {next_cleanup_in:.1f}s\",\n                    enable_output=False,\n                    level=\"INFO\",\n                )\n\n            return cleaned_count, new_earliest_time, new_last_cleanup_time\n        else:\n            return 0, earliest_cleanup_time, last_cleanup_time\n\n    except Exception as e:\n        direct_log(\n            f\"== {lock_type} Lock == Cleanup failed: {e}\",\n            level=\"ERROR\",\n            enable_output=True,\n        )\n        return 0, earliest_cleanup_time, last_cleanup_time\n\n\ndef _get_or_create_shared_raw_mp_lock(\n    factory_name: str, key: str\n) -> Optional[mp.synchronize.Lock]:\n    \"\"\"Return the *singleton* manager.Lock() proxy for keyed lock, creating if needed.\"\"\"\n    if not _is_multiprocess:\n        return None\n\n    with _registry_guard:\n        combined_key = _get_combined_key(factory_name, key)\n        raw = _lock_registry.get(combined_key)\n        count = _lock_registry_count.get(combined_key)\n        if raw is None:\n            raw = _manager.Lock()\n            _lock_registry[combined_key] = raw\n            count = 0\n        else:\n            if count is None:\n                raise RuntimeError(\n                    f\"Shared-Data lock registry for {factory_name} is corrupted for key {key}\"\n                )\n            if (\n                count == 0 and combined_key in _lock_cleanup_data\n            ):  # Reusing an key waiting for cleanup, remove it from cleanup list\n                _lock_cleanup_data.pop(combined_key)\n        count += 1\n        _lock_registry_count[combined_key] = count\n        return raw\n\n\ndef _release_shared_raw_mp_lock(factory_name: str, key: str):\n    \"\"\"Release the *singleton* manager.Lock() proxy for *key*.\"\"\"\n    if not _is_multiprocess:\n        return\n\n    global _earliest_mp_cleanup_time, _last_mp_cleanup_time\n\n    with _registry_guard:\n        combined_key = _get_combined_key(factory_name, key)\n        raw = _lock_registry.get(combined_key)\n        count = _lock_registry_count.get(combined_key)\n        if raw is None and count is None:\n            return\n        elif raw is None or count is None:\n            raise RuntimeError(\n                f\"Shared-Data lock registry for {factory_name} is corrupted for key {key}\"\n            )\n\n        count -= 1\n        if count < 0:\n            raise RuntimeError(\n                f\"Attempting to release lock for {key} more times than it was acquired\"\n            )\n\n        _lock_registry_count[combined_key] = count\n\n        current_time = time.time()\n        if count == 0:\n            _lock_cleanup_data[combined_key] = current_time\n\n            # Update earliest multiprocess cleanup time (only when earlier)\n            if (\n                _earliest_mp_cleanup_time is None\n                or current_time < _earliest_mp_cleanup_time\n            ):\n                _earliest_mp_cleanup_time = current_time\n\n        # Use generic cleanup function\n        cleaned_count, new_earliest_time, new_last_cleanup_time = _perform_lock_cleanup(\n            lock_type=\"mp\",\n            cleanup_data=_lock_cleanup_data,\n            lock_registry=_lock_registry,\n            lock_count=_lock_registry_count,\n            earliest_cleanup_time=_earliest_mp_cleanup_time,\n            last_cleanup_time=_last_mp_cleanup_time,\n            current_time=current_time,\n            threshold_check=True,\n        )\n\n        # Update global state if cleanup was performed\n        if cleaned_count > 0:\n            _earliest_mp_cleanup_time = new_earliest_time\n            _last_mp_cleanup_time = new_last_cleanup_time\n\n\nclass KeyedUnifiedLock:\n    \"\"\"\n    Manager for unified keyed locks, supporting both single and multi-process\n\n    • Keeps only a table of async keyed locks locally\n    • Fetches the multi-process keyed lock on every acquire\n    • Builds a fresh `UnifiedLock` each time, so `enable_logging`\n      (or future options) can vary per call.\n    • Supports dynamic namespaces specified at lock usage time\n    \"\"\"\n\n    def __init__(self, *, default_enable_logging: bool = True) -> None:\n        self._default_enable_logging = default_enable_logging\n        self._async_lock: Dict[str, asyncio.Lock] = {}  # local keyed locks\n        self._async_lock_count: Dict[\n            str, int\n        ] = {}  # local keyed locks referenced count\n        self._async_lock_cleanup_data: Dict[\n            str, time.time\n        ] = {}  # local keyed locks timeout\n        self._mp_locks: Dict[\n            str, mp.synchronize.Lock\n        ] = {}  # multi-process lock proxies\n        self._earliest_async_cleanup_time: Optional[float] = (\n            None  # track earliest async cleanup time\n        )\n        self._last_async_cleanup_time: Optional[float] = (\n            None  # track last async cleanup time for minimum interval\n        )\n\n    def __call__(\n        self, namespace: str, keys: list[str], *, enable_logging: Optional[bool] = None\n    ):\n        \"\"\"\n        Ergonomic helper so you can write:\n\n            async with storage_keyed_lock(\"namespace\", [\"key1\", \"key2\"]):\n                ...\n        \"\"\"\n        if enable_logging is None:\n            enable_logging = self._default_enable_logging\n        return _KeyedLockContext(\n            self,\n            namespace=namespace,\n            keys=keys,\n            enable_logging=enable_logging,\n        )\n\n    def _get_or_create_async_lock(self, combined_key: str) -> asyncio.Lock:\n        async_lock = self._async_lock.get(combined_key)\n        count = self._async_lock_count.get(combined_key, 0)\n        if async_lock is None:\n            async_lock = asyncio.Lock()\n            self._async_lock[combined_key] = async_lock\n        elif count == 0 and combined_key in self._async_lock_cleanup_data:\n            self._async_lock_cleanup_data.pop(combined_key)\n        count += 1\n        self._async_lock_count[combined_key] = count\n        return async_lock\n\n    def _release_async_lock(self, combined_key: str):\n        count = self._async_lock_count.get(combined_key, 0)\n        count -= 1\n\n        current_time = time.time()\n        if count == 0:\n            self._async_lock_cleanup_data[combined_key] = current_time\n\n            # Update earliest async cleanup time (only when earlier)\n            if (\n                self._earliest_async_cleanup_time is None\n                or current_time < self._earliest_async_cleanup_time\n            ):\n                self._earliest_async_cleanup_time = current_time\n        self._async_lock_count[combined_key] = count\n\n        # Use generic cleanup function\n        cleaned_count, new_earliest_time, new_last_cleanup_time = _perform_lock_cleanup(\n            lock_type=\"async\",\n            cleanup_data=self._async_lock_cleanup_data,\n            lock_registry=self._async_lock,\n            lock_count=self._async_lock_count,\n            earliest_cleanup_time=self._earliest_async_cleanup_time,\n            last_cleanup_time=self._last_async_cleanup_time,\n            current_time=current_time,\n            threshold_check=True,\n        )\n\n        # Update instance state if cleanup was performed\n        if cleaned_count > 0:\n            self._earliest_async_cleanup_time = new_earliest_time\n            self._last_async_cleanup_time = new_last_cleanup_time\n\n    def _get_lock_for_key(\n        self, namespace: str, key: str, enable_logging: bool = False\n    ) -> UnifiedLock:\n        # 1. Create combined key for this namespace:key combination\n        combined_key = _get_combined_key(namespace, key)\n\n        # 2. get (or create) the per‑process async gate for this combined key\n        # Is synchronous, so no need to acquire a lock\n        async_lock = self._get_or_create_async_lock(combined_key)\n\n        # 3. fetch the shared raw lock\n        raw_lock = _get_or_create_shared_raw_mp_lock(namespace, key)\n        is_multiprocess = raw_lock is not None\n        if not is_multiprocess:\n            raw_lock = async_lock\n\n        # 4. build a *fresh* UnifiedLock with the chosen logging flag\n        if is_multiprocess:\n            return UnifiedLock(\n                lock=raw_lock,\n                is_async=False,  # manager.Lock is synchronous\n                name=combined_key,\n                enable_logging=enable_logging,\n                async_lock=async_lock,  # prevents event‑loop blocking\n            )\n        else:\n            return UnifiedLock(\n                lock=raw_lock,\n                is_async=True,\n                name=combined_key,\n                enable_logging=enable_logging,\n                async_lock=None,  # No need for async lock in single process mode\n            )\n\n    def _release_lock_for_key(self, namespace: str, key: str):\n        combined_key = _get_combined_key(namespace, key)\n        self._release_async_lock(combined_key)\n        _release_shared_raw_mp_lock(namespace, key)\n\n    def cleanup_expired_locks(self) -> Dict[str, Any]:\n        \"\"\"\n        Cleanup expired locks for both async and multiprocess locks following the same\n        conditions as _release_shared_raw_mp_lock and _release_async_lock functions.\n\n        Only performs cleanup when both has_expired_locks and interval_satisfied conditions are met\n        to avoid too frequent cleanup operations.\n\n        Since async and multiprocess locks work together, this method cleans up\n        both types of expired locks and returns comprehensive statistics.\n\n        Returns:\n            Dict containing cleanup statistics and current status:\n            {\n                \"process_id\": 12345,\n                \"cleanup_performed\": {\n                    \"mp_cleaned\": 5,\n                    \"async_cleaned\": 3\n                },\n                \"current_status\": {\n                    \"total_mp_locks\": 10,\n                    \"pending_mp_cleanup\": 2,\n                    \"total_async_locks\": 8,\n                    \"pending_async_cleanup\": 1\n                }\n            }\n        \"\"\"\n        global _lock_registry, _lock_registry_count, _lock_cleanup_data\n        global _registry_guard, _earliest_mp_cleanup_time, _last_mp_cleanup_time\n\n        cleanup_stats = {\"mp_cleaned\": 0, \"async_cleaned\": 0}\n\n        current_time = time.time()\n\n        # 1. Cleanup multiprocess locks using generic function\n        if (\n            _is_multiprocess\n            and _lock_registry is not None\n            and _registry_guard is not None\n        ):\n            try:\n                with _registry_guard:\n                    if _lock_cleanup_data is not None:\n                        # Use generic cleanup function without threshold check\n                        cleaned_count, new_earliest_time, new_last_cleanup_time = (\n                            _perform_lock_cleanup(\n                                lock_type=\"mp\",\n                                cleanup_data=_lock_cleanup_data,\n                                lock_registry=_lock_registry,\n                                lock_count=_lock_registry_count,\n                                earliest_cleanup_time=_earliest_mp_cleanup_time,\n                                last_cleanup_time=_last_mp_cleanup_time,\n                                current_time=current_time,\n                                threshold_check=False,  # Force cleanup in cleanup_expired_locks\n                            )\n                        )\n\n                        # Update global state if cleanup was performed\n                        if cleaned_count > 0:\n                            _earliest_mp_cleanup_time = new_earliest_time\n                            _last_mp_cleanup_time = new_last_cleanup_time\n                            cleanup_stats[\"mp_cleaned\"] = cleaned_count\n\n            except Exception as e:\n                direct_log(\n                    f\"Error during multiprocess lock cleanup: {e}\",\n                    level=\"ERROR\",\n                    enable_output=True,\n                )\n\n        # 2. Cleanup async locks using generic function\n        try:\n            # Use generic cleanup function without threshold check\n            cleaned_count, new_earliest_time, new_last_cleanup_time = (\n                _perform_lock_cleanup(\n                    lock_type=\"async\",\n                    cleanup_data=self._async_lock_cleanup_data,\n                    lock_registry=self._async_lock,\n                    lock_count=self._async_lock_count,\n                    earliest_cleanup_time=self._earliest_async_cleanup_time,\n                    last_cleanup_time=self._last_async_cleanup_time,\n                    current_time=current_time,\n                    threshold_check=False,  # Force cleanup in cleanup_expired_locks\n                )\n            )\n\n            # Update instance state if cleanup was performed\n            if cleaned_count > 0:\n                self._earliest_async_cleanup_time = new_earliest_time\n                self._last_async_cleanup_time = new_last_cleanup_time\n                cleanup_stats[\"async_cleaned\"] = cleaned_count\n\n        except Exception as e:\n            direct_log(\n                f\"Error during async lock cleanup: {e}\",\n                level=\"ERROR\",\n                enable_output=True,\n            )\n\n        # 3. Get current status after cleanup\n        current_status = self.get_lock_status()\n\n        return {\n            \"process_id\": os.getpid(),\n            \"cleanup_performed\": cleanup_stats,\n            \"current_status\": current_status,\n        }\n\n    def get_lock_status(self) -> Dict[str, int]:\n        \"\"\"\n        Get current status of both async and multiprocess locks.\n\n        Returns comprehensive lock counts for both types of locks since\n        they work together in the keyed lock system.\n\n        Returns:\n            Dict containing lock counts:\n            {\n                \"total_mp_locks\": 10,\n                \"pending_mp_cleanup\": 2,\n                \"total_async_locks\": 8,\n                \"pending_async_cleanup\": 1\n            }\n        \"\"\"\n        global _lock_registry_count, _lock_cleanup_data, _registry_guard\n\n        status = {\n            \"total_mp_locks\": 0,\n            \"pending_mp_cleanup\": 0,\n            \"total_async_locks\": 0,\n            \"pending_async_cleanup\": 0,\n        }\n\n        try:\n            # Count multiprocess locks\n            if _is_multiprocess and _lock_registry_count is not None:\n                if _registry_guard is not None:\n                    with _registry_guard:\n                        status[\"total_mp_locks\"] = len(_lock_registry_count)\n                        if _lock_cleanup_data is not None:\n                            status[\"pending_mp_cleanup\"] = len(_lock_cleanup_data)\n\n            # Count async locks\n            status[\"total_async_locks\"] = len(self._async_lock_count)\n            status[\"pending_async_cleanup\"] = len(self._async_lock_cleanup_data)\n\n        except Exception as e:\n            direct_log(\n                f\"Error getting keyed lock status: {e}\",\n                level=\"ERROR\",\n                enable_output=True,\n            )\n\n        return status\n\n\nclass _KeyedLockContext:\n    def __init__(\n        self,\n        parent: KeyedUnifiedLock,\n        namespace: str,\n        keys: list[str],\n        enable_logging: bool,\n    ) -> None:\n        self._parent = parent\n        self._namespace = namespace\n\n        # The sorting is critical to ensure proper lock and release order\n        # to avoid deadlocks\n        self._keys = sorted(keys)\n        self._enable_logging = (\n            enable_logging\n            if enable_logging is not None\n            else parent._default_enable_logging\n        )\n        self._ul: Optional[List[Dict[str, Any]]] = None  # set in __aenter__\n\n    # ----- enter -----\n    async def __aenter__(self):\n        if self._ul is not None:\n            raise RuntimeError(\"KeyedUnifiedLock already acquired in current context\")\n\n        self._ul = []\n\n        try:\n            # Acquire locks for all keys in the namespace\n            for key in self._keys:\n                lock = None\n                entry = None\n\n                try:\n                    # 1. Get lock object (reference count is incremented here)\n                    lock = self._parent._get_lock_for_key(\n                        self._namespace, key, enable_logging=self._enable_logging\n                    )\n\n                    # 2. Immediately create and add entry to list (critical for rollback to work)\n                    entry = {\n                        \"key\": key,\n                        \"lock\": lock,\n                        \"entered\": False,\n                        \"debug_inc\": False,\n                        \"ref_incremented\": True,  # Mark that reference count has been incremented\n                    }\n                    self._ul.append(\n                        entry\n                    )  # Add immediately after _get_lock_for_key for rollback to work\n\n                    # 3. Try to acquire the lock\n                    # Use try-finally to ensure state is updated atomically\n                    lock_acquired = False\n                    try:\n                        await lock.__aenter__()\n                        lock_acquired = True  # Lock successfully acquired\n                    finally:\n                        if lock_acquired:\n                            entry[\"entered\"] = True\n                            inc_debug_n_locks_acquired()\n                            entry[\"debug_inc\"] = True\n\n                except asyncio.CancelledError:\n                    # Lock acquisition was cancelled\n                    # The finally block above ensures entry[\"entered\"] is correct\n                    direct_log(\n                        f\"Lock acquisition cancelled for key {key}\",\n                        level=\"WARNING\",\n                        enable_output=self._enable_logging,\n                    )\n                    raise\n                except Exception as e:\n                    # Other exceptions, log and re-raise\n                    direct_log(\n                        f\"Lock acquisition failed for key {key}: {e}\",\n                        level=\"ERROR\",\n                        enable_output=True,\n                    )\n                    raise\n\n            return self\n\n        except BaseException:\n            # Critical: if any exception occurs (including CancelledError) during lock acquisition,\n            # we must rollback all already acquired locks to prevent lock leaks\n            # Use shield to ensure rollback completes\n            await asyncio.shield(self._rollback_acquired_locks())\n            raise\n\n    async def _rollback_acquired_locks(self):\n        \"\"\"Rollback all acquired locks in case of exception during __aenter__\"\"\"\n        if not self._ul:\n            return\n\n        async def rollback_single_entry(entry):\n            \"\"\"Rollback a single lock acquisition\"\"\"\n            key = entry[\"key\"]\n            lock = entry[\"lock\"]\n            debug_inc = entry[\"debug_inc\"]\n            entered = entry[\"entered\"]\n            ref_incremented = entry.get(\n                \"ref_incremented\", True\n            )  # Default to True for safety\n\n            errors = []\n\n            # 1. If lock was acquired, release it\n            if entered:\n                try:\n                    await lock.__aexit__(None, None, None)\n                except Exception as e:\n                    errors.append((\"lock_exit\", e))\n                    direct_log(\n                        f\"Lock rollback error for key {key}: {e}\",\n                        level=\"ERROR\",\n                        enable_output=True,\n                    )\n\n            # 2. Release reference count (if it was incremented)\n            if ref_incremented:\n                try:\n                    self._parent._release_lock_for_key(self._namespace, key)\n                except Exception as e:\n                    errors.append((\"ref_release\", e))\n                    direct_log(\n                        f\"Lock rollback reference release error for key {key}: {e}\",\n                        level=\"ERROR\",\n                        enable_output=True,\n                    )\n\n            # 3. Decrement debug counter\n            if debug_inc:\n                try:\n                    dec_debug_n_locks_acquired()\n                except Exception as e:\n                    errors.append((\"debug_dec\", e))\n                    direct_log(\n                        f\"Lock rollback counter decrementing error for key {key}: {e}\",\n                        level=\"ERROR\",\n                        enable_output=True,\n                    )\n\n            return errors\n\n        # Release already acquired locks in reverse order\n        for entry in reversed(self._ul):\n            # Use shield to protect each lock's rollback\n            try:\n                await asyncio.shield(rollback_single_entry(entry))\n            except Exception as e:\n                # Log but continue rolling back other locks\n                direct_log(\n                    f\"Lock rollback unexpected error for {entry['key']}: {e}\",\n                    level=\"ERROR\",\n                    enable_output=True,\n                )\n\n        self._ul = None\n\n    # ----- exit -----\n    async def __aexit__(self, exc_type, exc, tb):\n        if self._ul is None:\n            return\n\n        async def release_all_locks():\n            \"\"\"Release all locks with comprehensive error handling, protected from cancellation\"\"\"\n\n            async def release_single_entry(entry, exc_type, exc, tb):\n                \"\"\"Release a single lock with full protection\"\"\"\n                key = entry[\"key\"]\n                lock = entry[\"lock\"]\n                debug_inc = entry[\"debug_inc\"]\n                entered = entry[\"entered\"]\n\n                errors = []\n\n                # 1. Release the lock\n                if entered:\n                    try:\n                        await lock.__aexit__(exc_type, exc, tb)\n                    except Exception as e:\n                        errors.append((\"lock_exit\", e))\n                        direct_log(\n                            f\"Lock release error for key {key}: {e}\",\n                            level=\"ERROR\",\n                            enable_output=True,\n                        )\n\n                # 2. Release reference count\n                try:\n                    self._parent._release_lock_for_key(self._namespace, key)\n                except Exception as e:\n                    errors.append((\"ref_release\", e))\n                    direct_log(\n                        f\"Lock release reference error for key {key}: {e}\",\n                        level=\"ERROR\",\n                        enable_output=True,\n                    )\n\n                # 3. Decrement debug counter\n                if debug_inc:\n                    try:\n                        dec_debug_n_locks_acquired()\n                    except Exception as e:\n                        errors.append((\"debug_dec\", e))\n                        direct_log(\n                            f\"Lock release counter decrementing error for key {key}: {e}\",\n                            level=\"ERROR\",\n                            enable_output=True,\n                        )\n\n                return errors\n\n            all_errors = []\n\n            # Release locks in reverse order\n            # This entire loop is protected by the outer shield\n            for entry in reversed(self._ul):\n                try:\n                    errors = await release_single_entry(entry, exc_type, exc, tb)\n                    for error_type, error in errors:\n                        all_errors.append((entry[\"key\"], error_type, error))\n                except Exception as e:\n                    all_errors.append((entry[\"key\"], \"unexpected\", e))\n                    direct_log(\n                        f\"Lock release unexpected error for {entry['key']}: {e}\",\n                        level=\"ERROR\",\n                        enable_output=True,\n                    )\n\n            return all_errors\n\n        # CRITICAL: Protect the entire release process with shield\n        # This ensures that even if cancellation occurs, all locks are released\n        try:\n            all_errors = await asyncio.shield(release_all_locks())\n        except Exception as e:\n            direct_log(\n                f\"Critical error during __aexit__ cleanup: {e}\",\n                level=\"ERROR\",\n                enable_output=True,\n            )\n            all_errors = []\n        finally:\n            # Always clear the lock list, even if shield was cancelled\n            self._ul = None\n\n        # If there were release errors and no other exception, raise the first release error\n        if all_errors and exc_type is None:\n            raise all_errors[0][2]  # (key, error_type, error)\n\n\ndef get_internal_lock(enable_logging: bool = False) -> UnifiedLock:\n    \"\"\"return unified storage lock for data consistency\"\"\"\n    if _internal_lock is None:\n        raise RuntimeError(\n            \"Shared data not initialized. Call initialize_share_data() before using locks!\"\n        )\n    async_lock = _async_locks.get(\"internal_lock\") if _is_multiprocess else None\n    return UnifiedLock(\n        lock=_internal_lock,\n        is_async=not _is_multiprocess,\n        name=\"internal_lock\",\n        enable_logging=enable_logging,\n        async_lock=async_lock,\n    )\n\n\n# Workspace based storage_lock is implemented by get_storage_keyed_lock instead.\n# Workspace based pipeline_status_lock is implemented by get_storage_keyed_lock instead.\n# No need to implement graph_db_lock:\n#    data integrity is ensured by entity level keyed-lock and allowing only one process to hold pipeline at a time.\n\n\ndef get_storage_keyed_lock(\n    keys: str | list[str], namespace: str = \"default\", enable_logging: bool = False\n) -> _KeyedLockContext:\n    \"\"\"Return unified storage keyed lock for ensuring atomic operations across different namespaces\"\"\"\n    global _storage_keyed_lock\n    if _storage_keyed_lock is None:\n        raise RuntimeError(\"Shared-Data is not initialized\")\n    if isinstance(keys, str):\n        keys = [keys]\n    return _storage_keyed_lock(namespace, keys, enable_logging=enable_logging)\n\n\ndef get_data_init_lock(enable_logging: bool = False) -> UnifiedLock:\n    \"\"\"return unified data initialization lock for ensuring atomic data initialization\"\"\"\n    if _data_init_lock is None:\n        raise RuntimeError(\n            \"Shared data not initialized. Call initialize_share_data() before using locks!\"\n        )\n    async_lock = _async_locks.get(\"data_init_lock\") if _is_multiprocess else None\n    return UnifiedLock(\n        lock=_data_init_lock,\n        is_async=not _is_multiprocess,\n        name=\"data_init_lock\",\n        enable_logging=enable_logging,\n        async_lock=async_lock,\n    )\n\n\ndef cleanup_keyed_lock() -> Dict[str, Any]:\n    \"\"\"\n    Force cleanup of expired keyed locks and return comprehensive status information.\n\n    This function actively cleans up expired locks for both async and multiprocess locks,\n    then returns detailed statistics about the cleanup operation and current lock status.\n\n    Returns:\n        Same as cleanup_expired_locks in KeyedUnifiedLock\n    \"\"\"\n    global _storage_keyed_lock\n\n    # Check if shared storage is initialized\n    if not _initialized or _storage_keyed_lock is None:\n        return {\n            \"process_id\": os.getpid(),\n            \"cleanup_performed\": {\"mp_cleaned\": 0, \"async_cleaned\": 0},\n            \"current_status\": {\n                \"total_mp_locks\": 0,\n                \"pending_mp_cleanup\": 0,\n                \"total_async_locks\": 0,\n                \"pending_async_cleanup\": 0,\n            },\n        }\n\n    return _storage_keyed_lock.cleanup_expired_locks()\n\n\ndef get_keyed_lock_status() -> Dict[str, Any]:\n    \"\"\"\n    Get current status of keyed locks without performing cleanup.\n\n    This function provides a read-only view of the current lock counts\n    for both multiprocess and async locks, including pending cleanup counts.\n\n    Returns:\n        Same as get_lock_status in KeyedUnifiedLock\n    \"\"\"\n    global _storage_keyed_lock\n\n    # Check if shared storage is initialized\n    if not _initialized or _storage_keyed_lock is None:\n        return {\n            \"process_id\": os.getpid(),\n            \"total_mp_locks\": 0,\n            \"pending_mp_cleanup\": 0,\n            \"total_async_locks\": 0,\n            \"pending_async_cleanup\": 0,\n        }\n\n    status = _storage_keyed_lock.get_lock_status()\n    status[\"process_id\"] = os.getpid()\n    return status\n\n\ndef initialize_share_data(workers: int = 1):\n    \"\"\"\n    Initialize shared storage data for single or multi-process mode.\n\n    When used with Gunicorn's preload feature, this function is called once in the\n    master process before forking worker processes, allowing all workers to share\n    the same initialized data.\n\n    In single-process mode, this function is called in FASTAPI lifespan function.\n\n    The function determines whether to use cross-process shared variables for data storage\n    based on the number of workers. If workers=1, it uses thread locks and local dictionaries.\n    If workers>1, it uses process locks and shared dictionaries managed by multiprocessing.Manager.\n\n    Args:\n        workers (int): Number of worker processes. If 1, single-process mode is used.\n                      If > 1, multi-process mode with shared memory is used.\n    \"\"\"\n    global \\\n        _manager, \\\n        _workers, \\\n        _is_multiprocess, \\\n        _lock_registry, \\\n        _lock_registry_count, \\\n        _lock_cleanup_data, \\\n        _registry_guard, \\\n        _internal_lock, \\\n        _data_init_lock, \\\n        _shared_dicts, \\\n        _init_flags, \\\n        _initialized, \\\n        _update_flags, \\\n        _async_locks, \\\n        _storage_keyed_lock, \\\n        _earliest_mp_cleanup_time, \\\n        _last_mp_cleanup_time\n\n    # Check if already initialized\n    if _initialized:\n        direct_log(\n            f\"Process {os.getpid()} Shared-Data already initialized (multiprocess={_is_multiprocess})\"\n        )\n        return\n\n    _workers = workers\n\n    if workers > 1:\n        _is_multiprocess = True\n        _manager = Manager()\n        _lock_registry = _manager.dict()\n        _lock_registry_count = _manager.dict()\n        _lock_cleanup_data = _manager.dict()\n        _registry_guard = _manager.RLock()\n        _internal_lock = _manager.Lock()\n        _data_init_lock = _manager.Lock()\n        _shared_dicts = _manager.dict()\n        _init_flags = _manager.dict()\n        _update_flags = _manager.dict()\n\n        _storage_keyed_lock = KeyedUnifiedLock()\n\n        # Initialize async locks for multiprocess mode\n        _async_locks = {\n            \"internal_lock\": asyncio.Lock(),\n            \"graph_db_lock\": asyncio.Lock(),\n            \"data_init_lock\": asyncio.Lock(),\n        }\n\n        direct_log(\n            f\"Process {os.getpid()} Shared-Data created for Multiple Process (workers={workers})\"\n        )\n    else:\n        _is_multiprocess = False\n        _internal_lock = asyncio.Lock()\n        _data_init_lock = asyncio.Lock()\n        _shared_dicts = {}\n        _init_flags = {}\n        _update_flags = {}\n        _async_locks = None  # No need for async locks in single process mode\n\n        _storage_keyed_lock = KeyedUnifiedLock()\n        direct_log(f\"Process {os.getpid()} Shared-Data created for Single Process\")\n\n    # Initialize multiprocess cleanup times\n    _earliest_mp_cleanup_time = None\n    _last_mp_cleanup_time = None\n\n    # Mark as initialized\n    _initialized = True\n\n\nasync def initialize_pipeline_status(workspace: str | None = None):\n    \"\"\"\n    Initialize pipeline_status share data with default values.\n    This function could be called before during FASTAPI lifespan for each worker.\n\n    Args:\n        workspace: Optional workspace identifier for pipeline_status of specific workspace.\n                   If None or empty string, uses the default workspace set by\n                   set_default_workspace().\n    \"\"\"\n    pipeline_namespace = await get_namespace_data(\n        \"pipeline_status\", first_init=True, workspace=workspace\n    )\n\n    async with get_internal_lock():\n        # Check if already initialized by checking for required fields\n        if \"busy\" in pipeline_namespace:\n            return\n\n        # Create a shared list object for history_messages\n        history_messages = _manager.list() if _is_multiprocess else []\n        pipeline_namespace.update(\n            {\n                \"autoscanned\": False,  # Auto-scan started\n                \"busy\": False,  # Control concurrent processes\n                \"job_name\": \"-\",  # Current job name (indexing files/indexing texts)\n                \"job_start\": None,  # Job start time\n                \"docs\": 0,  # Total number of documents to be indexed\n                \"batchs\": 0,  # Number of batches for processing documents\n                \"cur_batch\": 0,  # Current processing batch\n                \"request_pending\": False,  # Flag for pending request for processing\n                \"latest_message\": \"\",  # Latest message from pipeline processing\n                \"history_messages\": history_messages,  # 使用共享列表对象\n            }\n        )\n\n        final_namespace = get_final_namespace(\"pipeline_status\", workspace)\n        direct_log(\n            f\"Process {os.getpid()} Pipeline namespace '{final_namespace}' initialized\"\n        )\n\n\nasync def get_update_flag(namespace: str, workspace: str | None = None):\n    \"\"\"\n    Create a namespace's update flag for a workers.\n    Returen the update flag to caller for referencing or reset.\n    \"\"\"\n    global _update_flags\n    if _update_flags is None:\n        raise ValueError(\"Try to create namespace before Shared-Data is initialized\")\n\n    final_namespace = get_final_namespace(namespace, workspace)\n\n    async with get_internal_lock():\n        if final_namespace not in _update_flags:\n            if _is_multiprocess and _manager is not None:\n                _update_flags[final_namespace] = _manager.list()\n            else:\n                _update_flags[final_namespace] = []\n            direct_log(\n                f\"Process {os.getpid()} initialized updated flags for namespace: [{final_namespace}]\"\n            )\n\n        if _is_multiprocess and _manager is not None:\n            new_update_flag = _manager.Value(\"b\", False)\n        else:\n            # Create a simple mutable object to store boolean value for compatibility with mutiprocess\n            class MutableBoolean:\n                def __init__(self, initial_value=False):\n                    self.value = initial_value\n\n            new_update_flag = MutableBoolean(False)\n\n        _update_flags[final_namespace].append(new_update_flag)\n        return new_update_flag\n\n\nasync def set_all_update_flags(namespace: str, workspace: str | None = None):\n    \"\"\"Set all update flag of namespace indicating all workers need to reload data from files\"\"\"\n    global _update_flags\n    if _update_flags is None:\n        raise ValueError(\"Try to create namespace before Shared-Data is initialized\")\n\n    final_namespace = get_final_namespace(namespace, workspace)\n\n    async with get_internal_lock():\n        if final_namespace not in _update_flags:\n            raise ValueError(f\"Namespace {final_namespace} not found in update flags\")\n        # Update flags for both modes\n        for i in range(len(_update_flags[final_namespace])):\n            _update_flags[final_namespace][i].value = True\n\n\nasync def clear_all_update_flags(namespace: str, workspace: str | None = None):\n    \"\"\"Clear all update flag of namespace indicating all workers need to reload data from files\"\"\"\n    global _update_flags\n    if _update_flags is None:\n        raise ValueError(\"Try to create namespace before Shared-Data is initialized\")\n\n    final_namespace = get_final_namespace(namespace, workspace)\n\n    async with get_internal_lock():\n        if final_namespace not in _update_flags:\n            raise ValueError(f\"Namespace {final_namespace} not found in update flags\")\n        # Update flags for both modes\n        for i in range(len(_update_flags[final_namespace])):\n            _update_flags[final_namespace][i].value = False\n\n\nasync def get_all_update_flags_status(workspace: str | None = None) -> Dict[str, list]:\n    \"\"\"\n    Get update flags status for all namespaces.\n\n    Returns:\n        Dict[str, list]: A dictionary mapping namespace names to lists of update flag statuses\n    \"\"\"\n    if _update_flags is None:\n        return {}\n\n    if workspace is None:\n        workspace = get_default_workspace()\n\n    result = {}\n    async with get_internal_lock():\n        for namespace, flags in _update_flags.items():\n            # Check if namespace has a workspace prefix (contains ':')\n            if \":\" in namespace:\n                # Namespace has workspace prefix like \"space1:pipeline_status\"\n                # Only include if workspace matches the prefix\n                # Use rsplit to split from the right since workspace can contain colons\n                namespace_split = namespace.rsplit(\":\", 1)\n                if not workspace or namespace_split[0] != workspace:\n                    continue\n            else:\n                # Namespace has no workspace prefix like \"pipeline_status\"\n                # Only include if we're querying the default (empty) workspace\n                if workspace:\n                    continue\n\n            worker_statuses = []\n            for flag in flags:\n                if _is_multiprocess:\n                    worker_statuses.append(flag.value)\n                else:\n                    worker_statuses.append(flag)\n            result[namespace] = worker_statuses\n\n    return result\n\n\nasync def try_initialize_namespace(\n    namespace: str, workspace: str | None = None\n) -> bool:\n    \"\"\"\n    Returns True if the current worker(process) gets initialization permission for loading data later.\n    The worker does not get the permission is prohibited to load data from files.\n    \"\"\"\n    global _init_flags, _manager\n\n    if _init_flags is None:\n        raise ValueError(\"Try to create nanmespace before Shared-Data is initialized\")\n\n    final_namespace = get_final_namespace(namespace, workspace)\n\n    async with get_internal_lock():\n        if final_namespace not in _init_flags:\n            _init_flags[final_namespace] = True\n            direct_log(\n                f\"Process {os.getpid()} ready to initialize storage namespace: [{final_namespace}]\"\n            )\n            return True\n        direct_log(\n            f\"Process {os.getpid()} storage namespace already initialized: [{final_namespace}]\"\n        )\n\n    return False\n\n\nasync def get_namespace_data(\n    namespace: str, first_init: bool = False, workspace: str | None = None\n) -> Dict[str, Any]:\n    \"\"\"get the shared data reference for specific namespace\n\n    Args:\n        namespace: The namespace to retrieve\n        first_init: If True, allows pipeline_status namespace to create namespace if it doesn't exist.\n                    Prevent getting pipeline_status namespace without initialize_pipeline_status().\n                    This parameter is used internally by initialize_pipeline_status().\n        workspace: Workspace identifier (may be empty string for global namespace)\n    \"\"\"\n    if _shared_dicts is None:\n        direct_log(\n            f\"Error: Try to getnanmespace before it is initialized, pid={os.getpid()}\",\n            level=\"ERROR\",\n        )\n        raise ValueError(\"Shared dictionaries not initialized\")\n\n    final_namespace = get_final_namespace(namespace, workspace)\n\n    async with get_internal_lock():\n        if final_namespace not in _shared_dicts:\n            # Special handling for pipeline_status namespace\n            if (\n                final_namespace.endswith(\":pipeline_status\")\n                or final_namespace == \"pipeline_status\"\n            ) and not first_init:\n                # Check if pipeline_status should have been initialized but wasn't\n                # This helps users to call initialize_pipeline_status() before get_namespace_data()\n                raise PipelineNotInitializedError(final_namespace)\n\n            # For other namespaces or when allow_create=True, create them dynamically\n            if _is_multiprocess and _manager is not None:\n                _shared_dicts[final_namespace] = _manager.dict()\n            else:\n                _shared_dicts[final_namespace] = {}\n\n    return _shared_dicts[final_namespace]\n\n\nclass NamespaceLock:\n    \"\"\"\n    Reusable namespace lock wrapper that creates a fresh context on each use.\n\n    This class solves the lock re-entrance and concurrent coroutine issues by using\n    contextvars.ContextVar to provide per-coroutine storage. Each coroutine gets its\n    own independent lock context, preventing state interference between concurrent\n    coroutines using the same NamespaceLock instance.\n\n    Example:\n        lock = NamespaceLock(\"my_namespace\", \"workspace1\")\n\n        # Can be used multiple times safely\n        async with lock:\n            await do_something()\n\n        # Can even be used concurrently without deadlock\n        await asyncio.gather(\n            coroutine_1(lock),  # Each gets its own context\n            coroutine_2(lock)   # No state interference\n        )\n    \"\"\"\n\n    def __init__(\n        self, namespace: str, workspace: str | None = None, enable_logging: bool = False\n    ):\n        self._namespace = namespace\n        self._workspace = workspace\n        self._enable_logging = enable_logging\n        # Use ContextVar to provide per-coroutine storage for lock context\n        # This ensures each coroutine has its own independent context\n        self._ctx_var: ContextVar[Optional[_KeyedLockContext]] = ContextVar(\n            \"lock_ctx\", default=None\n        )\n\n    async def __aenter__(self):\n        \"\"\"Create a fresh context each time we enter\"\"\"\n        # Check if this coroutine already has an active lock context\n        if self._ctx_var.get() is not None:\n            raise RuntimeError(\n                \"NamespaceLock already acquired in current coroutine context\"\n            )\n\n        final_namespace = get_final_namespace(self._namespace, self._workspace)\n        ctx = get_storage_keyed_lock(\n            [\"default_key\"],\n            namespace=final_namespace,\n            enable_logging=self._enable_logging,\n        )\n\n        # Acquire the lock first, then store context only after successful acquisition\n        # This prevents the ContextVar from being set if acquisition fails (e.g., due to cancellation),\n        # which would permanently brick the lock\n        result = await ctx.__aenter__()\n        self._ctx_var.set(ctx)\n        return result\n\n    async def __aexit__(self, exc_type, exc_val, exc_tb):\n        \"\"\"Exit the current context and clean up\"\"\"\n        # Retrieve this coroutine's context\n        ctx = self._ctx_var.get()\n        if ctx is None:\n            raise RuntimeError(\"NamespaceLock exited without being entered\")\n\n        result = await ctx.__aexit__(exc_type, exc_val, exc_tb)\n        # Clear this coroutine's context\n        self._ctx_var.set(None)\n        return result\n\n\ndef get_namespace_lock(\n    namespace: str, workspace: str | None = None, enable_logging: bool = False\n) -> NamespaceLock:\n    \"\"\"Get a reusable namespace lock wrapper.\n\n    This function returns a NamespaceLock instance that can be used multiple times\n    safely, even in concurrent scenarios. Each use creates a fresh lock context\n    internally, preventing lock re-entrance errors.\n\n    Args:\n        namespace: The namespace to get the lock for.\n        workspace: Workspace identifier (may be empty string for global namespace)\n        enable_logging: Whether to enable lock operation logging\n\n    Returns:\n        NamespaceLock: A reusable lock wrapper that can be used with 'async with'\n\n    Example:\n        lock = get_namespace_lock(\"pipeline_status\", workspace=\"space1\")\n\n        # Can be used multiple times\n        async with lock:\n            await do_something()\n\n        async with lock:\n            await do_something_else()\n    \"\"\"\n    return NamespaceLock(namespace, workspace, enable_logging)\n\n\ndef finalize_share_data():\n    \"\"\"\n    Release shared resources and clean up.\n\n    This function should be called when the application is shutting down\n    to properly release shared resources and avoid memory leaks.\n\n    In multi-process mode, it shuts down the Manager and releases all shared objects.\n    In single-process mode, it simply resets the global variables.\n    \"\"\"\n    global \\\n        _manager, \\\n        _is_multiprocess, \\\n        _internal_lock, \\\n        _data_init_lock, \\\n        _shared_dicts, \\\n        _init_flags, \\\n        _initialized, \\\n        _update_flags, \\\n        _async_locks, \\\n        _default_workspace\n\n    # Check if already initialized\n    if not _initialized:\n        direct_log(\n            f\"Process {os.getpid()} storage data not initialized, nothing to finalize\"\n        )\n        return\n\n    direct_log(\n        f\"Process {os.getpid()} finalizing storage data (multiprocess={_is_multiprocess})\"\n    )\n\n    # In multi-process mode, shut down the Manager\n    if _is_multiprocess and _manager is not None:\n        try:\n            # Clear shared resources before shutting down Manager\n            if _shared_dicts is not None:\n                # Clear pipeline status history messages first if exists\n                try:\n                    pipeline_status = _shared_dicts.get(\"pipeline_status\", {})\n                    if \"history_messages\" in pipeline_status:\n                        pipeline_status[\"history_messages\"].clear()\n                except Exception:\n                    pass  # Ignore any errors during history messages cleanup\n                _shared_dicts.clear()\n            if _init_flags is not None:\n                _init_flags.clear()\n            if _update_flags is not None:\n                # Clear each namespace's update flags list and Value objects\n                try:\n                    for namespace in _update_flags:\n                        flags_list = _update_flags[namespace]\n                        if isinstance(flags_list, list):\n                            # Clear Value objects in the list\n                            for flag in flags_list:\n                                if hasattr(\n                                    flag, \"value\"\n                                ):  # Check if it's a Value object\n                                    flag.value = False\n                            flags_list.clear()\n                except Exception:\n                    pass  # Ignore any errors during update flags cleanup\n                _update_flags.clear()\n\n            # Shut down the Manager - this will automatically clean up all shared resources\n            _manager.shutdown()\n            direct_log(f\"Process {os.getpid()} Manager shutdown complete\")\n        except Exception as e:\n            direct_log(\n                f\"Process {os.getpid()} Error shutting down Manager: {e}\", level=\"ERROR\"\n            )\n\n    # Reset global variables\n    _manager = None\n    _initialized = None\n    _is_multiprocess = None\n    _shared_dicts = None\n    _init_flags = None\n    _internal_lock = None\n    _data_init_lock = None\n    _update_flags = None\n    _async_locks = None\n    _default_workspace = None\n\n    direct_log(f\"Process {os.getpid()} storage data finalization complete\")\n\n\ndef set_default_workspace(workspace: str | None = None):\n    \"\"\"\n    Set default workspace for namespace operations for backward compatibility.\n\n    This allows get_namespace_data(),get_namespace_lock() or initialize_pipeline_status() to\n    automatically use the correct workspace when called without workspace parameters,\n    maintaining compatibility with legacy code that doesn't pass workspace explicitly.\n\n    Args:\n        workspace: Workspace identifier (may be empty string for global namespace)\n    \"\"\"\n    global _default_workspace\n    if workspace is None:\n        workspace = \"\"\n    _default_workspace = workspace\n    direct_log(\n        f\"Default workspace set to: '{_default_workspace}' (empty means global)\",\n        level=\"DEBUG\",\n    )\n\n\ndef get_default_workspace() -> str:\n    \"\"\"\n    Get default workspace for backward compatibility.\n\n    Returns:\n        The default workspace string. Empty string means global namespace. None means not set.\n    \"\"\"\n    global _default_workspace\n    return _default_workspace\n\n\ndef get_pipeline_status_lock(\n    enable_logging: bool = False, workspace: str = None\n) -> NamespaceLock:\n    \"\"\"Return unified storage lock for pipeline status data consistency.\n\n    This function is for compatibility with legacy code only.\n    \"\"\"\n    global _default_workspace\n    actual_workspace = workspace if workspace else _default_workspace\n    return get_namespace_lock(\n        \"pipeline_status\", workspace=actual_workspace, enable_logging=enable_logging\n    )\n"
  },
  {
    "path": "lightrag/lightrag.py",
    "content": "from __future__ import annotations\n\nimport traceback\nimport asyncio\nimport configparser\nimport inspect\nimport os\nimport time\nimport warnings\nfrom dataclasses import asdict, dataclass, field, replace\nfrom datetime import datetime, timezone\nfrom functools import partial\nfrom typing import (\n    Any,\n    AsyncIterator,\n    Awaitable,\n    Callable,\n    Iterator,\n    cast,\n    final,\n    Literal,\n    Optional,\n    List,\n    Dict,\n    Union,\n)\nfrom lightrag.prompt import PROMPTS\nfrom lightrag.exceptions import PipelineCancelledException\nfrom lightrag.constants import (\n    DEFAULT_MAX_GLEANING,\n    DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,\n    DEFAULT_TOP_K,\n    DEFAULT_CHUNK_TOP_K,\n    DEFAULT_MAX_ENTITY_TOKENS,\n    DEFAULT_MAX_RELATION_TOKENS,\n    DEFAULT_MAX_TOTAL_TOKENS,\n    DEFAULT_COSINE_THRESHOLD,\n    DEFAULT_RELATED_CHUNK_NUMBER,\n    DEFAULT_KG_CHUNK_PICK_METHOD,\n    DEFAULT_MIN_RERANK_SCORE,\n    DEFAULT_SUMMARY_MAX_TOKENS,\n    DEFAULT_SUMMARY_CONTEXT_SIZE,\n    DEFAULT_SUMMARY_LENGTH_RECOMMENDED,\n    DEFAULT_MAX_EXTRACT_INPUT_TOKENS,\n    DEFAULT_MAX_ASYNC,\n    DEFAULT_MAX_PARALLEL_INSERT,\n    DEFAULT_MAX_GRAPH_NODES,\n    DEFAULT_MAX_SOURCE_IDS_PER_ENTITY,\n    DEFAULT_MAX_SOURCE_IDS_PER_RELATION,\n    DEFAULT_ENTITY_TYPES,\n    DEFAULT_SUMMARY_LANGUAGE,\n    DEFAULT_LLM_TIMEOUT,\n    DEFAULT_EMBEDDING_TIMEOUT,\n    DEFAULT_SOURCE_IDS_LIMIT_METHOD,\n    DEFAULT_MAX_FILE_PATHS,\n    DEFAULT_FILE_PATH_MORE_PLACEHOLDER,\n)\nfrom lightrag.utils import get_env_value\n\nfrom lightrag.kg import (\n    STORAGES,\n    verify_storage_implementation,\n)\n\n\nfrom lightrag.kg.shared_storage import (\n    get_namespace_data,\n    get_data_init_lock,\n    get_default_workspace,\n    set_default_workspace,\n    get_namespace_lock,\n)\n\nfrom lightrag.base import (\n    BaseGraphStorage,\n    BaseKVStorage,\n    BaseVectorStorage,\n    DocProcessingStatus,\n    DocStatus,\n    DocStatusStorage,\n    QueryParam,\n    StorageNameSpace,\n    StoragesStatus,\n    DeletionResult,\n    OllamaServerInfos,\n    QueryResult,\n)\nfrom lightrag.namespace import NameSpace\nfrom lightrag.operate import (\n    chunking_by_token_size,\n    extract_entities,\n    merge_nodes_and_edges,\n    kg_query,\n    naive_query,\n    rebuild_knowledge_from_chunks,\n)\nfrom lightrag.constants import GRAPH_FIELD_SEP\nfrom lightrag.utils import (\n    Tokenizer,\n    TiktokenTokenizer,\n    EmbeddingFunc,\n    always_get_an_event_loop,\n    compute_mdhash_id,\n    lazy_external_import,\n    priority_limit_async_func_call,\n    get_content_summary,\n    sanitize_text_for_encoding,\n    check_storage_env_vars,\n    generate_track_id,\n    convert_to_user_format,\n    logger,\n    subtract_source_ids,\n    make_relation_chunk_key,\n    normalize_source_ids_limit_method,\n)\nfrom lightrag.types import KnowledgeGraph\nfrom dotenv import load_dotenv\n\n# use the .env that is inside the current folder\n# allows to use different .env file for each lightrag instance\n# the OS environment variables take precedence over the .env file\nload_dotenv(dotenv_path=\".env\", override=False)\n\n# TODO: TO REMOVE @Yannick\nconfig = configparser.ConfigParser()\nconfig.read(\"config.ini\", \"utf-8\")\n\n\ndef _chunk_fields_from_status_doc(\n    status_doc: \"DocProcessingStatus\",\n) -> tuple[list[str], int]:\n    \"\"\"Return (chunks_list, chunks_count) preserved from a status document.\n\n    Filters out any non-string or empty chunk IDs.  When chunks_count is\n    absent or invalid, it is inferred from the length of chunks_list.\n    \"\"\"\n    chunks_list: list[str] = []\n    if isinstance(status_doc.chunks_list, list):\n        chunks_list = [\n            chunk_id\n            for chunk_id in status_doc.chunks_list\n            if isinstance(chunk_id, str) and chunk_id\n        ]\n\n    if isinstance(status_doc.chunks_count, int) and status_doc.chunks_count >= 0:\n        return chunks_list, status_doc.chunks_count\n\n    return chunks_list, len(chunks_list)\n\n\n@final\n@dataclass\nclass LightRAG:\n    \"\"\"LightRAG: Simple and Fast Retrieval-Augmented Generation.\"\"\"\n\n    # Directory\n    # ---\n\n    working_dir: str = field(default=\"./rag_storage\")\n    \"\"\"Directory where cache and temporary files are stored.\"\"\"\n\n    # Storage\n    # ---\n\n    kv_storage: str = field(default=\"JsonKVStorage\")\n    \"\"\"Storage backend for key-value data.\"\"\"\n\n    vector_storage: str = field(default=\"NanoVectorDBStorage\")\n    \"\"\"Storage backend for vector embeddings.\"\"\"\n\n    graph_storage: str = field(default=\"NetworkXStorage\")\n    \"\"\"Storage backend for knowledge graphs.\"\"\"\n\n    doc_status_storage: str = field(default=\"JsonDocStatusStorage\")\n    \"\"\"Storage type for tracking document processing statuses.\"\"\"\n\n    # Workspace\n    # ---\n\n    workspace: str = field(default_factory=lambda: os.getenv(\"WORKSPACE\", \"\"))\n    \"\"\"Workspace for data isolation. Defaults to empty string if WORKSPACE environment variable is not set.\"\"\"\n\n    # ---\n    # TODO: Deprecated, use setup_logger in utils.py instead\n    log_level: int | None = field(default=None)\n    log_file_path: str | None = field(default=None)\n\n    # Query parameters\n    # ---\n\n    top_k: int = field(default=get_env_value(\"TOP_K\", DEFAULT_TOP_K, int))\n    \"\"\"Number of entities/relations to retrieve for each query.\"\"\"\n\n    chunk_top_k: int = field(\n        default=get_env_value(\"CHUNK_TOP_K\", DEFAULT_CHUNK_TOP_K, int)\n    )\n    \"\"\"Maximum number of chunks in context.\"\"\"\n\n    max_entity_tokens: int = field(\n        default=get_env_value(\"MAX_ENTITY_TOKENS\", DEFAULT_MAX_ENTITY_TOKENS, int)\n    )\n    \"\"\"Maximum number of tokens for entity in context.\"\"\"\n\n    max_relation_tokens: int = field(\n        default=get_env_value(\"MAX_RELATION_TOKENS\", DEFAULT_MAX_RELATION_TOKENS, int)\n    )\n    \"\"\"Maximum number of tokens for relation in context.\"\"\"\n\n    max_total_tokens: int = field(\n        default=get_env_value(\"MAX_TOTAL_TOKENS\", DEFAULT_MAX_TOTAL_TOKENS, int)\n    )\n    \"\"\"Maximum total tokens in context (including system prompt, entities, relations and chunks).\"\"\"\n\n    cosine_threshold: int = field(\n        default=get_env_value(\"COSINE_THRESHOLD\", DEFAULT_COSINE_THRESHOLD, int)\n    )\n    \"\"\"Cosine threshold of vector DB retrieval for entities, relations and chunks.\"\"\"\n\n    related_chunk_number: int = field(\n        default=get_env_value(\"RELATED_CHUNK_NUMBER\", DEFAULT_RELATED_CHUNK_NUMBER, int)\n    )\n    \"\"\"Number of related chunks to grab from single entity or relation.\"\"\"\n\n    kg_chunk_pick_method: str = field(\n        default=get_env_value(\"KG_CHUNK_PICK_METHOD\", DEFAULT_KG_CHUNK_PICK_METHOD, str)\n    )\n    \"\"\"Method for selecting text chunks: 'WEIGHT' for weight-based selection, 'VECTOR' for embedding similarity-based selection.\"\"\"\n\n    # Entity extraction\n    # ---\n\n    entity_extract_max_gleaning: int = field(\n        default=get_env_value(\"MAX_GLEANING\", DEFAULT_MAX_GLEANING, int)\n    )\n    \"\"\"Maximum number of entity extraction attempts for ambiguous content.\"\"\"\n\n    max_extract_input_tokens: int = field(\n        default=get_env_value(\n            \"MAX_EXTRACT_INPUT_TOKENS\", DEFAULT_MAX_EXTRACT_INPUT_TOKENS, int\n        )\n    )\n    \"\"\"Maximum tokens allowed for entity extraction input context.\"\"\"\n\n    force_llm_summary_on_merge: int = field(\n        default=get_env_value(\n            \"FORCE_LLM_SUMMARY_ON_MERGE\", DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE, int\n        )\n    )\n\n    # Text chunking\n    # ---\n\n    chunk_token_size: int = field(default=int(os.getenv(\"CHUNK_SIZE\", 1200)))\n    \"\"\"Maximum number of tokens per text chunk when splitting documents.\"\"\"\n\n    chunk_overlap_token_size: int = field(\n        default=int(os.getenv(\"CHUNK_OVERLAP_SIZE\", 100))\n    )\n    \"\"\"Number of overlapping tokens between consecutive text chunks to preserve context.\"\"\"\n\n    tokenizer: Optional[Tokenizer] = field(default=None)\n    \"\"\"\n    A function that returns a Tokenizer instance.\n    If None, and a `tiktoken_model_name` is provided, a TiktokenTokenizer will be created.\n    If both are None, the default TiktokenTokenizer is used.\n    \"\"\"\n\n    tiktoken_model_name: str = field(default=\"gpt-4o-mini\")\n    \"\"\"Model name used for tokenization when chunking text with tiktoken. Defaults to `gpt-4o-mini`.\"\"\"\n\n    chunking_func: Callable[\n        [\n            Tokenizer,\n            str,\n            Optional[str],\n            bool,\n            int,\n            int,\n        ],\n        Union[List[Dict[str, Any]], Awaitable[List[Dict[str, Any]]]],\n    ] = field(default_factory=lambda: chunking_by_token_size)\n    \"\"\"\n    Custom chunking function for splitting text into chunks before processing.\n\n    The function can be either synchronous or asynchronous.\n\n    The function should take the following parameters:\n\n        - `tokenizer`: A Tokenizer instance to use for tokenization.\n        - `content`: The text to be split into chunks.\n        - `split_by_character`: The character to split the text on. If None, the text is split into chunks of `chunk_token_size` tokens.\n        - `split_by_character_only`: If True, the text is split only on the specified character.\n        - `chunk_overlap_token_size`: The number of overlapping tokens between consecutive chunks.\n        - `chunk_token_size`: The maximum number of tokens per chunk.\n\n\n    The function should return a list of dictionaries (or an awaitable that resolves to a list),\n    where each dictionary contains the following keys:\n        - `tokens` (int): The number of tokens in the chunk.\n        - `content` (str): The text content of the chunk.\n        - `chunk_order_index` (int): Zero-based index indicating the chunk's order in the document.\n\n    Defaults to `chunking_by_token_size` if not specified.\n    \"\"\"\n\n    # Embedding\n    # ---\n\n    embedding_func: EmbeddingFunc | None = field(default=None)\n    \"\"\"Function for computing text embeddings. Must be set before use.\"\"\"\n\n    embedding_token_limit: int | None = field(default=None, init=False)\n    \"\"\"Token limit for embedding model. Set automatically from embedding_func.max_token_size in __post_init__.\"\"\"\n\n    embedding_batch_num: int = field(default=int(os.getenv(\"EMBEDDING_BATCH_NUM\", 10)))\n    \"\"\"Batch size for embedding computations.\"\"\"\n\n    embedding_func_max_async: int = field(\n        default=int(os.getenv(\"EMBEDDING_FUNC_MAX_ASYNC\", 8))\n    )\n    \"\"\"Maximum number of concurrent embedding function calls.\"\"\"\n\n    embedding_cache_config: dict[str, Any] = field(\n        default_factory=lambda: {\n            \"enabled\": False,\n            \"similarity_threshold\": 0.95,\n            \"use_llm_check\": False,\n        }\n    )\n    \"\"\"Configuration for embedding cache.\n    - enabled: If True, enables caching to avoid redundant computations.\n    - similarity_threshold: Minimum similarity score to use cached embeddings.\n    - use_llm_check: If True, validates cached embeddings using an LLM.\n    \"\"\"\n\n    default_embedding_timeout: int = field(\n        default=int(os.getenv(\"EMBEDDING_TIMEOUT\", DEFAULT_EMBEDDING_TIMEOUT))\n    )\n\n    # LLM Configuration\n    # ---\n\n    llm_model_func: Callable[..., object] | None = field(default=None)\n    \"\"\"Function for interacting with the large language model (LLM). Must be set before use.\"\"\"\n\n    llm_model_name: str = field(default=\"gpt-4o-mini\")\n    \"\"\"Name of the LLM model used for generating responses.\"\"\"\n\n    summary_max_tokens: int = field(\n        default=int(os.getenv(\"SUMMARY_MAX_TOKENS\", DEFAULT_SUMMARY_MAX_TOKENS))\n    )\n    \"\"\"Maximum tokens allowed for entity/relation description.\"\"\"\n\n    summary_context_size: int = field(\n        default=int(os.getenv(\"SUMMARY_CONTEXT_SIZE\", DEFAULT_SUMMARY_CONTEXT_SIZE))\n    )\n    \"\"\"Maximum number of tokens allowed per LLM response.\"\"\"\n\n    summary_length_recommended: int = field(\n        default=int(\n            os.getenv(\"SUMMARY_LENGTH_RECOMMENDED\", DEFAULT_SUMMARY_LENGTH_RECOMMENDED)\n        )\n    )\n    \"\"\"Recommended length of LLM summary output.\"\"\"\n\n    llm_model_max_async: int = field(\n        default=int(os.getenv(\"MAX_ASYNC\", DEFAULT_MAX_ASYNC))\n    )\n    \"\"\"Maximum number of concurrent LLM calls.\"\"\"\n\n    llm_model_kwargs: dict[str, Any] = field(default_factory=dict)\n    \"\"\"Additional keyword arguments passed to the LLM model function.\"\"\"\n\n    default_llm_timeout: int = field(\n        default=int(os.getenv(\"LLM_TIMEOUT\", DEFAULT_LLM_TIMEOUT))\n    )\n\n    # Rerank Configuration\n    # ---\n\n    rerank_model_func: Callable[..., object] | None = field(default=None)\n    \"\"\"Function for reranking retrieved documents. All rerank configurations (model name, API keys, top_k, etc.) should be included in this function. Optional.\"\"\"\n\n    min_rerank_score: float = field(\n        default=get_env_value(\"MIN_RERANK_SCORE\", DEFAULT_MIN_RERANK_SCORE, float)\n    )\n    \"\"\"Minimum rerank score threshold for filtering chunks after reranking.\"\"\"\n\n    # Storage\n    # ---\n\n    vector_db_storage_cls_kwargs: dict[str, Any] = field(default_factory=dict)\n    \"\"\"Additional parameters for vector database storage.\"\"\"\n\n    enable_llm_cache: bool = field(default=True)\n    \"\"\"Enables caching for LLM responses to avoid redundant computations.\"\"\"\n\n    enable_llm_cache_for_entity_extract: bool = field(default=True)\n    \"\"\"If True, enables caching for entity extraction steps to reduce LLM costs.\"\"\"\n\n    # Extensions\n    # ---\n\n    max_parallel_insert: int = field(\n        default=int(os.getenv(\"MAX_PARALLEL_INSERT\", DEFAULT_MAX_PARALLEL_INSERT))\n    )\n    \"\"\"Maximum number of parallel insert operations.\"\"\"\n\n    max_graph_nodes: int = field(\n        default=get_env_value(\"MAX_GRAPH_NODES\", DEFAULT_MAX_GRAPH_NODES, int)\n    )\n    \"\"\"Maximum number of graph nodes to return in knowledge graph queries.\"\"\"\n\n    max_source_ids_per_entity: int = field(\n        default=get_env_value(\n            \"MAX_SOURCE_IDS_PER_ENTITY\", DEFAULT_MAX_SOURCE_IDS_PER_ENTITY, int\n        )\n    )\n    \"\"\"Maximum number of source (chunk) ids in entity Grpah + VDB.\"\"\"\n\n    max_source_ids_per_relation: int = field(\n        default=get_env_value(\n            \"MAX_SOURCE_IDS_PER_RELATION\",\n            DEFAULT_MAX_SOURCE_IDS_PER_RELATION,\n            int,\n        )\n    )\n    \"\"\"Maximum number of source (chunk) ids in relation Graph + VDB.\"\"\"\n\n    source_ids_limit_method: str = field(\n        default_factory=lambda: normalize_source_ids_limit_method(\n            get_env_value(\n                \"SOURCE_IDS_LIMIT_METHOD\",\n                DEFAULT_SOURCE_IDS_LIMIT_METHOD,\n                str,\n            )\n        )\n    )\n    \"\"\"Strategy for enforcing source_id limits: IGNORE_NEW or FIFO.\"\"\"\n\n    max_file_paths: int = field(\n        default=get_env_value(\"MAX_FILE_PATHS\", DEFAULT_MAX_FILE_PATHS, int)\n    )\n    \"\"\"Maximum number of file paths to store in entity/relation file_path field.\"\"\"\n\n    file_path_more_placeholder: str = field(default=DEFAULT_FILE_PATH_MORE_PLACEHOLDER)\n    \"\"\"Placeholder text when file paths exceed max_file_paths limit.\"\"\"\n\n    addon_params: dict[str, Any] = field(\n        default_factory=lambda: {\n            \"language\": get_env_value(\n                \"SUMMARY_LANGUAGE\", DEFAULT_SUMMARY_LANGUAGE, str\n            ),\n            \"entity_types\": get_env_value(\"ENTITY_TYPES\", DEFAULT_ENTITY_TYPES, list),\n        }\n    )\n\n    # Storages Management\n    # ---\n\n    # TODO: Deprecated (LightRAG will never initialize storage automatically on creation，and finalize should be call before destroying)\n    auto_manage_storages_states: bool = field(default=False)\n    \"\"\"If True, lightrag will automatically calls initialize_storages and finalize_storages at the appropriate times.\"\"\"\n\n    cosine_better_than_threshold: float = field(\n        default=float(os.getenv(\"COSINE_THRESHOLD\", 0.2))\n    )\n\n    ollama_server_infos: Optional[OllamaServerInfos] = field(default=None)\n    \"\"\"Configuration for Ollama server information.\"\"\"\n\n    _storages_status: StoragesStatus = field(default=StoragesStatus.NOT_CREATED)\n\n    def __post_init__(self):\n        from lightrag.kg.shared_storage import (\n            initialize_share_data,\n        )\n\n        # Handle deprecated parameters\n        if self.log_level is not None:\n            warnings.warn(\n                \"WARNING: log_level parameter is deprecated, use setup_logger in utils.py instead\",\n                UserWarning,\n                stacklevel=2,\n            )\n        if self.log_file_path is not None:\n            warnings.warn(\n                \"WARNING: log_file_path parameter is deprecated, use setup_logger in utils.py instead\",\n                UserWarning,\n                stacklevel=2,\n            )\n\n        # Remove these attributes to prevent their use\n        if hasattr(self, \"log_level\"):\n            delattr(self, \"log_level\")\n        if hasattr(self, \"log_file_path\"):\n            delattr(self, \"log_file_path\")\n\n        initialize_share_data()\n\n        if not os.path.exists(self.working_dir):\n            logger.info(f\"Creating working directory {self.working_dir}\")\n            os.makedirs(self.working_dir)\n\n        # Verify storage implementation compatibility and environment variables\n        storage_configs = [\n            (\"KV_STORAGE\", self.kv_storage),\n            (\"VECTOR_STORAGE\", self.vector_storage),\n            (\"GRAPH_STORAGE\", self.graph_storage),\n            (\"DOC_STATUS_STORAGE\", self.doc_status_storage),\n        ]\n\n        for storage_type, storage_name in storage_configs:\n            # Verify storage implementation compatibility\n            verify_storage_implementation(storage_type, storage_name)\n            # Check environment variables\n            check_storage_env_vars(storage_name)\n\n        # Ensure vector_db_storage_cls_kwargs has required fields\n        self.vector_db_storage_cls_kwargs = {\n            \"cosine_better_than_threshold\": self.cosine_better_than_threshold,\n            **self.vector_db_storage_cls_kwargs,\n        }\n\n        # Init Tokenizer\n        # Post-initialization hook to handle backward compatabile tokenizer initialization based on provided parameters\n        if self.tokenizer is None:\n            if self.tiktoken_model_name:\n                self.tokenizer = TiktokenTokenizer(self.tiktoken_model_name)\n            else:\n                self.tokenizer = TiktokenTokenizer()\n\n        # Initialize ollama_server_infos if not provided\n        if self.ollama_server_infos is None:\n            self.ollama_server_infos = OllamaServerInfos()\n\n        # Validate config\n        if self.force_llm_summary_on_merge < 3:\n            logger.warning(\n                f\"force_llm_summary_on_merge should be at least 3, got {self.force_llm_summary_on_merge}\"\n            )\n        if self.summary_context_size > self.max_total_tokens:\n            logger.warning(\n                f\"summary_context_size({self.summary_context_size}) should no greater than max_total_tokens({self.max_total_tokens})\"\n            )\n        if self.summary_length_recommended > self.summary_max_tokens:\n            logger.warning(\n                f\"max_total_tokens({self.summary_max_tokens}) should greater than summary_length_recommended({self.summary_length_recommended})\"\n            )\n\n        # Init Embedding\n        # Step 1: Capture embedding_func and max_token_size before applying rate_limit decorator\n        original_embedding_func = self.embedding_func\n        embedding_max_token_size = None\n        if self.embedding_func and hasattr(self.embedding_func, \"max_token_size\"):\n            embedding_max_token_size = self.embedding_func.max_token_size\n            logger.debug(\n                f\"Captured embedding max_token_size: {embedding_max_token_size}\"\n            )\n        self.embedding_token_limit = embedding_max_token_size\n\n        # Fix global_config now\n        global_config = asdict(self)\n        # Restore original EmbeddingFunc object (asdict converts it to dict)\n        global_config[\"embedding_func\"] = original_embedding_func\n\n        _print_config = \",\\n  \".join([f\"{k} = {v}\" for k, v in global_config.items()])\n        logger.debug(f\"LightRAG init with param:\\n  {_print_config}\\n\")\n\n        # Step 2: Apply priority wrapper decorator to EmbeddingFunc's inner func\n        # Create a NEW EmbeddingFunc instance with the wrapped func to avoid mutating the caller's object\n        # This ensures _generate_collection_suffix can still access attributes (model_name, embedding_dim)\n        # while preventing side effects when the same EmbeddingFunc is reused across multiple LightRAG instances\n        if self.embedding_func is not None:\n            wrapped_func = priority_limit_async_func_call(\n                self.embedding_func_max_async,\n                llm_timeout=self.default_embedding_timeout,\n                queue_name=\"Embedding func\",\n            )(self.embedding_func.func)\n            # Use dataclasses.replace() to create a new instance, leaving the original unchanged\n            self.embedding_func = replace(self.embedding_func, func=wrapped_func)\n\n        # Initialize all storages\n        self.key_string_value_json_storage_cls: type[BaseKVStorage] = (\n            self._get_storage_class(self.kv_storage)\n        )  # type: ignore\n        self.vector_db_storage_cls: type[BaseVectorStorage] = self._get_storage_class(\n            self.vector_storage\n        )  # type: ignore\n        self.graph_storage_cls: type[BaseGraphStorage] = self._get_storage_class(\n            self.graph_storage\n        )  # type: ignore\n        self.key_string_value_json_storage_cls = partial(  # type: ignore\n            self.key_string_value_json_storage_cls, global_config=global_config\n        )\n        self.vector_db_storage_cls = partial(  # type: ignore\n            self.vector_db_storage_cls, global_config=global_config\n        )\n        self.graph_storage_cls = partial(  # type: ignore\n            self.graph_storage_cls, global_config=global_config\n        )\n\n        # Initialize document status storage\n        self.doc_status_storage_cls = self._get_storage_class(self.doc_status_storage)\n\n        self.llm_response_cache: BaseKVStorage = self.key_string_value_json_storage_cls(  # type: ignore\n            namespace=NameSpace.KV_STORE_LLM_RESPONSE_CACHE,\n            workspace=self.workspace,\n            global_config=global_config,\n            embedding_func=self.embedding_func,\n        )\n\n        self.text_chunks: BaseKVStorage = self.key_string_value_json_storage_cls(  # type: ignore\n            namespace=NameSpace.KV_STORE_TEXT_CHUNKS,\n            workspace=self.workspace,\n            embedding_func=self.embedding_func,\n        )\n\n        self.full_docs: BaseKVStorage = self.key_string_value_json_storage_cls(  # type: ignore\n            namespace=NameSpace.KV_STORE_FULL_DOCS,\n            workspace=self.workspace,\n            embedding_func=self.embedding_func,\n        )\n\n        self.full_entities: BaseKVStorage = self.key_string_value_json_storage_cls(  # type: ignore\n            namespace=NameSpace.KV_STORE_FULL_ENTITIES,\n            workspace=self.workspace,\n            embedding_func=self.embedding_func,\n        )\n\n        self.full_relations: BaseKVStorage = self.key_string_value_json_storage_cls(  # type: ignore\n            namespace=NameSpace.KV_STORE_FULL_RELATIONS,\n            workspace=self.workspace,\n            embedding_func=self.embedding_func,\n        )\n\n        self.entity_chunks: BaseKVStorage = self.key_string_value_json_storage_cls(  # type: ignore\n            namespace=NameSpace.KV_STORE_ENTITY_CHUNKS,\n            workspace=self.workspace,\n            embedding_func=self.embedding_func,\n        )\n\n        self.relation_chunks: BaseKVStorage = self.key_string_value_json_storage_cls(  # type: ignore\n            namespace=NameSpace.KV_STORE_RELATION_CHUNKS,\n            workspace=self.workspace,\n            embedding_func=self.embedding_func,\n        )\n\n        self.chunk_entity_relation_graph: BaseGraphStorage = self.graph_storage_cls(  # type: ignore\n            namespace=NameSpace.GRAPH_STORE_CHUNK_ENTITY_RELATION,\n            workspace=self.workspace,\n            embedding_func=self.embedding_func,\n        )\n\n        self.entities_vdb: BaseVectorStorage = self.vector_db_storage_cls(  # type: ignore\n            namespace=NameSpace.VECTOR_STORE_ENTITIES,\n            workspace=self.workspace,\n            embedding_func=self.embedding_func,\n            meta_fields={\"entity_name\", \"source_id\", \"content\", \"file_path\"},\n        )\n        self.relationships_vdb: BaseVectorStorage = self.vector_db_storage_cls(  # type: ignore\n            namespace=NameSpace.VECTOR_STORE_RELATIONSHIPS,\n            workspace=self.workspace,\n            embedding_func=self.embedding_func,\n            meta_fields={\"src_id\", \"tgt_id\", \"source_id\", \"content\", \"file_path\"},\n        )\n        self.chunks_vdb: BaseVectorStorage = self.vector_db_storage_cls(  # type: ignore\n            namespace=NameSpace.VECTOR_STORE_CHUNKS,\n            workspace=self.workspace,\n            embedding_func=self.embedding_func,\n            meta_fields={\"full_doc_id\", \"content\", \"file_path\"},\n        )\n\n        # Initialize document status storage\n        self.doc_status: DocStatusStorage = self.doc_status_storage_cls(\n            namespace=NameSpace.DOC_STATUS,\n            workspace=self.workspace,\n            global_config=global_config,\n            embedding_func=None,\n        )\n\n        # Directly use llm_response_cache, don't create a new object\n        hashing_kv = self.llm_response_cache\n\n        # Get timeout from LLM model kwargs for dynamic timeout calculation\n        self.llm_model_func = priority_limit_async_func_call(\n            self.llm_model_max_async,\n            llm_timeout=self.default_llm_timeout,\n            queue_name=\"LLM func\",\n        )(\n            partial(\n                self.llm_model_func,  # type: ignore\n                hashing_kv=hashing_kv,\n                **self.llm_model_kwargs,\n            )\n        )\n\n        self._storages_status = StoragesStatus.CREATED\n\n    async def initialize_storages(self):\n        \"\"\"Storage initialization must be called one by one to prevent deadlock\"\"\"\n        if self._storages_status == StoragesStatus.CREATED:\n            # Set the first initialized workspace will set the default workspace\n            # Allows namespace operation without specifying workspace for backward compatibility\n            default_workspace = get_default_workspace()\n            if default_workspace is None:\n                set_default_workspace(self.workspace)\n            elif default_workspace != self.workspace:\n                logger.info(\n                    f\"Creating LightRAG instance with workspace='{self.workspace}' \"\n                    f\"while default workspace is set to '{default_workspace}'\"\n                )\n\n            # Auto-initialize pipeline_status for this workspace\n            from lightrag.kg.shared_storage import initialize_pipeline_status\n\n            await initialize_pipeline_status(workspace=self.workspace)\n\n            for storage in (\n                self.full_docs,\n                self.text_chunks,\n                self.full_entities,\n                self.full_relations,\n                self.entity_chunks,\n                self.relation_chunks,\n                self.entities_vdb,\n                self.relationships_vdb,\n                self.chunks_vdb,\n                self.chunk_entity_relation_graph,\n                self.llm_response_cache,\n                self.doc_status,\n            ):\n                if storage:\n                    # logger.debug(f\"Initializing storage: {storage}\")\n                    await storage.initialize()\n\n            self._storages_status = StoragesStatus.INITIALIZED\n            logger.debug(\"All storage types initialized\")\n\n    async def finalize_storages(self):\n        \"\"\"Asynchronously finalize the storages with improved error handling\"\"\"\n        if self._storages_status == StoragesStatus.INITIALIZED:\n            storages = [\n                (\"full_docs\", self.full_docs),\n                (\"text_chunks\", self.text_chunks),\n                (\"full_entities\", self.full_entities),\n                (\"full_relations\", self.full_relations),\n                (\"entity_chunks\", self.entity_chunks),\n                (\"relation_chunks\", self.relation_chunks),\n                (\"entities_vdb\", self.entities_vdb),\n                (\"relationships_vdb\", self.relationships_vdb),\n                (\"chunks_vdb\", self.chunks_vdb),\n                (\"chunk_entity_relation_graph\", self.chunk_entity_relation_graph),\n                (\"llm_response_cache\", self.llm_response_cache),\n                (\"doc_status\", self.doc_status),\n            ]\n\n            # Finalize each storage individually to ensure one failure doesn't prevent others from closing\n            successful_finalizations = []\n            failed_finalizations = []\n\n            for storage_name, storage in storages:\n                if storage:\n                    try:\n                        await storage.finalize()\n                        successful_finalizations.append(storage_name)\n                        logger.debug(f\"Successfully finalized {storage_name}\")\n                    except Exception as e:\n                        error_msg = f\"Failed to finalize {storage_name}: {e}\"\n                        logger.error(error_msg)\n                        failed_finalizations.append(storage_name)\n\n            # Log summary of finalization results\n            if successful_finalizations:\n                logger.info(\n                    f\"Successfully finalized {len(successful_finalizations)} storages\"\n                )\n\n            if failed_finalizations:\n                logger.error(\n                    f\"Failed to finalize {len(failed_finalizations)} storages: {', '.join(failed_finalizations)}\"\n                )\n            else:\n                logger.debug(\"All storages finalized successfully\")\n\n            self._storages_status = StoragesStatus.FINALIZED\n\n    async def check_and_migrate_data(self):\n        \"\"\"Check if data migration is needed and perform migration if necessary\"\"\"\n        async with get_data_init_lock():\n            try:\n                # Check if migration is needed:\n                # 1. chunk_entity_relation_graph has entities and relations (count > 0)\n                # 2. full_entities and full_relations are empty\n\n                # Get all entity labels from graph\n                all_entity_labels = (\n                    await self.chunk_entity_relation_graph.get_all_labels()\n                )\n\n                if not all_entity_labels:\n                    logger.debug(\"No entities found in graph, skipping migration check\")\n                    return\n\n                try:\n                    # Initialize chunk tracking storage after migration\n                    await self._migrate_chunk_tracking_storage()\n                except Exception as e:\n                    logger.error(f\"Error during chunk_tracking migration: {e}\")\n                    raise e\n\n                # Check if full_entities and full_relations are empty\n                # Get all processed documents to check their entity/relation data\n                try:\n                    processed_docs = await self.doc_status.get_docs_by_status(\n                        DocStatus.PROCESSED\n                    )\n\n                    if not processed_docs:\n                        logger.debug(\"No processed documents found, skipping migration\")\n                        return\n\n                    # Check first few documents to see if they have full_entities/full_relations data\n                    migration_needed = True\n                    checked_count = 0\n                    max_check = min(5, len(processed_docs))  # Check up to 5 documents\n\n                    for doc_id in list(processed_docs.keys())[:max_check]:\n                        checked_count += 1\n                        entity_data = await self.full_entities.get_by_id(doc_id)\n                        relation_data = await self.full_relations.get_by_id(doc_id)\n\n                        if entity_data or relation_data:\n                            migration_needed = False\n                            break\n\n                    if not migration_needed:\n                        logger.debug(\n                            \"Full entities/relations data already exists, no migration needed\"\n                        )\n                        return\n\n                    logger.info(\n                        f\"Data migration needed: found {len(all_entity_labels)} entities in graph but no full_entities/full_relations data\"\n                    )\n\n                    # Perform migration\n                    await self._migrate_entity_relation_data(processed_docs)\n\n                except Exception as e:\n                    logger.error(f\"Error during migration check: {e}\")\n                    raise e\n\n            except Exception as e:\n                logger.error(f\"Error in data migration check: {e}\")\n                raise e\n\n    async def _migrate_entity_relation_data(self, processed_docs: dict):\n        \"\"\"Migrate existing entity and relation data to full_entities and full_relations storage\"\"\"\n        logger.info(f\"Starting data migration for {len(processed_docs)} documents\")\n\n        # Create mapping from chunk_id to doc_id\n        chunk_to_doc = {}\n        for doc_id, doc_status in processed_docs.items():\n            chunk_ids = (\n                doc_status.chunks_list\n                if hasattr(doc_status, \"chunks_list\") and doc_status.chunks_list\n                else []\n            )\n            for chunk_id in chunk_ids:\n                chunk_to_doc[chunk_id] = doc_id\n\n        # Initialize document entity and relation mappings\n        doc_entities = {}  # doc_id -> set of entity_names\n        doc_relations = {}  # doc_id -> set of relation_pairs (as tuples)\n\n        # Get all nodes and edges from graph\n        all_nodes = await self.chunk_entity_relation_graph.get_all_nodes()\n        all_edges = await self.chunk_entity_relation_graph.get_all_edges()\n\n        # Process all nodes once\n        for node in all_nodes:\n            if \"source_id\" in node:\n                entity_id = node.get(\"entity_id\") or node.get(\"id\")\n                if not entity_id:\n                    continue\n\n                # Get chunk IDs from source_id\n                source_ids = node[\"source_id\"].split(GRAPH_FIELD_SEP)\n\n                # Find which documents this entity belongs to\n                for chunk_id in source_ids:\n                    doc_id = chunk_to_doc.get(chunk_id)\n                    if doc_id:\n                        if doc_id not in doc_entities:\n                            doc_entities[doc_id] = set()\n                        doc_entities[doc_id].add(entity_id)\n\n        # Process all edges once\n        for edge in all_edges:\n            if \"source_id\" in edge:\n                src = edge.get(\"source\")\n                tgt = edge.get(\"target\")\n                if not src or not tgt:\n                    continue\n\n                # Get chunk IDs from source_id\n                source_ids = edge[\"source_id\"].split(GRAPH_FIELD_SEP)\n\n                # Find which documents this relation belongs to\n                for chunk_id in source_ids:\n                    doc_id = chunk_to_doc.get(chunk_id)\n                    if doc_id:\n                        if doc_id not in doc_relations:\n                            doc_relations[doc_id] = set()\n                        # Use tuple for set operations, convert to list later\n                        doc_relations[doc_id].add(tuple(sorted((src, tgt))))\n\n        # Store the results in full_entities and full_relations\n        migration_count = 0\n\n        # Store entities\n        if doc_entities:\n            entities_data = {}\n            for doc_id, entity_set in doc_entities.items():\n                entities_data[doc_id] = {\n                    \"entity_names\": list(entity_set),\n                    \"count\": len(entity_set),\n                }\n            await self.full_entities.upsert(entities_data)\n\n        # Store relations\n        if doc_relations:\n            relations_data = {}\n            for doc_id, relation_set in doc_relations.items():\n                # Convert tuples back to lists\n                relations_data[doc_id] = {\n                    \"relation_pairs\": [list(pair) for pair in relation_set],\n                    \"count\": len(relation_set),\n                }\n            await self.full_relations.upsert(relations_data)\n\n        migration_count = len(\n            set(list(doc_entities.keys()) + list(doc_relations.keys()))\n        )\n\n        # Persist the migrated data\n        await self.full_entities.index_done_callback()\n        await self.full_relations.index_done_callback()\n\n        logger.info(\n            f\"Data migration completed: migrated {migration_count} documents with entities/relations\"\n        )\n\n    async def _migrate_chunk_tracking_storage(self) -> None:\n        \"\"\"Ensure entity/relation chunk tracking KV stores exist and are seeded.\"\"\"\n\n        if not self.entity_chunks or not self.relation_chunks:\n            return\n\n        need_entity_migration = False\n        need_relation_migration = False\n\n        try:\n            need_entity_migration = await self.entity_chunks.is_empty()\n        except Exception as exc:  # pragma: no cover - defensive logging\n            logger.error(f\"Failed to check entity chunks storage: {exc}\")\n            raise exc\n\n        try:\n            need_relation_migration = await self.relation_chunks.is_empty()\n        except Exception as exc:  # pragma: no cover - defensive logging\n            logger.error(f\"Failed to check relation chunks storage: {exc}\")\n            raise exc\n\n        if not need_entity_migration and not need_relation_migration:\n            return\n\n        BATCH_SIZE = 500  # Process 500 records per batch\n\n        if need_entity_migration:\n            try:\n                nodes = await self.chunk_entity_relation_graph.get_all_nodes()\n            except Exception as exc:\n                logger.error(f\"Failed to fetch nodes for chunk migration: {exc}\")\n                nodes = []\n\n            logger.info(f\"Starting chunk_tracking data migration: {len(nodes)} nodes\")\n\n            # Process nodes in batches\n            total_nodes = len(nodes)\n            total_batches = (total_nodes + BATCH_SIZE - 1) // BATCH_SIZE\n            total_migrated = 0\n\n            for batch_idx in range(total_batches):\n                start_idx = batch_idx * BATCH_SIZE\n                end_idx = min((batch_idx + 1) * BATCH_SIZE, total_nodes)\n                batch_nodes = nodes[start_idx:end_idx]\n\n                upsert_payload: dict[str, dict[str, object]] = {}\n                for node in batch_nodes:\n                    entity_id = node.get(\"entity_id\") or node.get(\"id\")\n                    if not entity_id:\n                        continue\n\n                    raw_source = node.get(\"source_id\") or \"\"\n                    chunk_ids = [\n                        chunk_id\n                        for chunk_id in raw_source.split(GRAPH_FIELD_SEP)\n                        if chunk_id\n                    ]\n                    if not chunk_ids:\n                        continue\n\n                    upsert_payload[entity_id] = {\n                        \"chunk_ids\": chunk_ids,\n                        \"count\": len(chunk_ids),\n                    }\n\n                if upsert_payload:\n                    await self.entity_chunks.upsert(upsert_payload)\n                    total_migrated += len(upsert_payload)\n                    logger.info(\n                        f\"Processed entity batch {batch_idx + 1}/{total_batches}: {len(upsert_payload)} records (total: {total_migrated}/{total_nodes})\"\n                    )\n\n            if total_migrated > 0:\n                # Persist entity_chunks data to disk\n                await self.entity_chunks.index_done_callback()\n                logger.info(\n                    f\"Entity chunk_tracking migration completed: {total_migrated} records persisted\"\n                )\n\n        if need_relation_migration:\n            try:\n                edges = await self.chunk_entity_relation_graph.get_all_edges()\n            except Exception as exc:\n                logger.error(f\"Failed to fetch edges for chunk migration: {exc}\")\n                edges = []\n\n            logger.info(f\"Starting chunk_tracking data migration: {len(edges)} edges\")\n\n            # Process edges in batches\n            total_edges = len(edges)\n            total_batches = (total_edges + BATCH_SIZE - 1) // BATCH_SIZE\n            total_migrated = 0\n\n            for batch_idx in range(total_batches):\n                start_idx = batch_idx * BATCH_SIZE\n                end_idx = min((batch_idx + 1) * BATCH_SIZE, total_edges)\n                batch_edges = edges[start_idx:end_idx]\n\n                upsert_payload: dict[str, dict[str, object]] = {}\n                for edge in batch_edges:\n                    src = edge.get(\"source\") or edge.get(\"src_id\") or edge.get(\"src\")\n                    tgt = edge.get(\"target\") or edge.get(\"tgt_id\") or edge.get(\"tgt\")\n                    if not src or not tgt:\n                        continue\n\n                    raw_source = edge.get(\"source_id\") or \"\"\n                    chunk_ids = [\n                        chunk_id\n                        for chunk_id in raw_source.split(GRAPH_FIELD_SEP)\n                        if chunk_id\n                    ]\n                    if not chunk_ids:\n                        continue\n\n                    storage_key = make_relation_chunk_key(src, tgt)\n                    upsert_payload[storage_key] = {\n                        \"chunk_ids\": chunk_ids,\n                        \"count\": len(chunk_ids),\n                    }\n\n                if upsert_payload:\n                    await self.relation_chunks.upsert(upsert_payload)\n                    total_migrated += len(upsert_payload)\n                    logger.info(\n                        f\"Processed relation batch {batch_idx + 1}/{total_batches}: {len(upsert_payload)} records (total: {total_migrated}/{total_edges})\"\n                    )\n\n            if total_migrated > 0:\n                # Persist relation_chunks data to disk\n                await self.relation_chunks.index_done_callback()\n                logger.info(\n                    f\"Relation chunk_tracking migration completed: {total_migrated} records persisted\"\n                )\n\n    async def get_graph_labels(self):\n        text = await self.chunk_entity_relation_graph.get_all_labels()\n        return text\n\n    async def get_knowledge_graph(\n        self,\n        node_label: str,\n        max_depth: int = 3,\n        max_nodes: int = None,\n    ) -> KnowledgeGraph:\n        \"\"\"Get knowledge graph for a given label\n\n        Args:\n            node_label (str): Label to get knowledge graph for\n            max_depth (int): Maximum depth of graph\n            max_nodes (int, optional): Maximum number of nodes to return. Defaults to self.max_graph_nodes.\n\n        Returns:\n            KnowledgeGraph: Knowledge graph containing nodes and edges\n        \"\"\"\n        # Use self.max_graph_nodes as default if max_nodes is None\n        if max_nodes is None:\n            max_nodes = self.max_graph_nodes\n        else:\n            # Limit max_nodes to not exceed self.max_graph_nodes\n            max_nodes = min(max_nodes, self.max_graph_nodes)\n\n        return await self.chunk_entity_relation_graph.get_knowledge_graph(\n            node_label, max_depth, max_nodes\n        )\n\n    def _get_storage_class(self, storage_name: str) -> Callable[..., Any]:\n        # Direct imports for default storage implementations\n        if storage_name == \"JsonKVStorage\":\n            from lightrag.kg.json_kv_impl import JsonKVStorage\n\n            return JsonKVStorage\n        elif storage_name == \"NanoVectorDBStorage\":\n            from lightrag.kg.nano_vector_db_impl import NanoVectorDBStorage\n\n            return NanoVectorDBStorage\n        elif storage_name == \"NetworkXStorage\":\n            from lightrag.kg.networkx_impl import NetworkXStorage\n\n            return NetworkXStorage\n        elif storage_name == \"JsonDocStatusStorage\":\n            from lightrag.kg.json_doc_status_impl import JsonDocStatusStorage\n\n            return JsonDocStatusStorage\n        else:\n            # Fallback to dynamic import for other storage implementations\n            import_path = STORAGES[storage_name]\n            storage_class = lazy_external_import(import_path, storage_name)\n            return storage_class\n\n    def insert(\n        self,\n        input: str | list[str],\n        split_by_character: str | None = None,\n        split_by_character_only: bool = False,\n        ids: str | list[str] | None = None,\n        file_paths: str | list[str] | None = None,\n        track_id: str | None = None,\n    ) -> str:\n        \"\"\"Sync Insert documents with checkpoint support\n\n        Args:\n            input: Single document string or list of document strings\n            split_by_character: if split_by_character is not None, split the string by character, if chunk longer than\n            chunk_token_size, it will be split again by token size.\n            split_by_character_only: if split_by_character_only is True, split the string by character only, when\n            split_by_character is None, this parameter is ignored.\n            ids: single string of the document ID or list of unique document IDs, if not provided, MD5 hash IDs will be generated\n            file_paths: single string of the file path or list of file paths, used for citation\n            track_id: tracking ID for monitoring processing status, if not provided, will be generated\n\n        Returns:\n            str: tracking ID for monitoring processing status\n        \"\"\"\n        loop = always_get_an_event_loop()\n        return loop.run_until_complete(\n            self.ainsert(\n                input,\n                split_by_character,\n                split_by_character_only,\n                ids,\n                file_paths,\n                track_id,\n            )\n        )\n\n    async def ainsert(\n        self,\n        input: str | list[str],\n        split_by_character: str | None = None,\n        split_by_character_only: bool = False,\n        ids: str | list[str] | None = None,\n        file_paths: str | list[str] | None = None,\n        track_id: str | None = None,\n    ) -> str:\n        \"\"\"Async Insert documents with checkpoint support\n\n        Args:\n            input: Single document string or list of document strings\n            split_by_character: if split_by_character is not None, split the string by character, if chunk longer than\n            chunk_token_size, it will be split again by token size.\n            split_by_character_only: if split_by_character_only is True, split the string by character only, when\n            split_by_character is None, this parameter is ignored.\n            ids: list of unique document IDs, if not provided, MD5 hash IDs will be generated\n            file_paths: list of file paths corresponding to each document, used for citation\n            track_id: tracking ID for monitoring processing status, if not provided, will be generated\n\n        Returns:\n            str: tracking ID for monitoring processing status\n        \"\"\"\n        # Generate track_id if not provided\n        if track_id is None:\n            track_id = generate_track_id(\"insert\")\n\n        await self.apipeline_enqueue_documents(input, ids, file_paths, track_id)\n        await self.apipeline_process_enqueue_documents(\n            split_by_character, split_by_character_only\n        )\n\n        return track_id\n\n    # TODO: deprecated, use insert instead\n    def insert_custom_chunks(\n        self,\n        full_text: str,\n        text_chunks: list[str],\n        doc_id: str | list[str] | None = None,\n    ) -> None:\n        loop = always_get_an_event_loop()\n        loop.run_until_complete(\n            self.ainsert_custom_chunks(full_text, text_chunks, doc_id)\n        )\n\n    # TODO: deprecated, use ainsert instead\n    async def ainsert_custom_chunks(\n        self, full_text: str, text_chunks: list[str], doc_id: str | None = None\n    ) -> None:\n        update_storage = False\n        try:\n            # Clean input texts\n            full_text = sanitize_text_for_encoding(full_text)\n            text_chunks = [sanitize_text_for_encoding(chunk) for chunk in text_chunks]\n            file_path = \"\"\n\n            # Process cleaned texts\n            if doc_id is None:\n                doc_key = compute_mdhash_id(full_text, prefix=\"doc-\")\n            else:\n                doc_key = doc_id\n            new_docs = {doc_key: {\"content\": full_text, \"file_path\": file_path}}\n\n            _add_doc_keys = await self.full_docs.filter_keys({doc_key})\n            new_docs = {k: v for k, v in new_docs.items() if k in _add_doc_keys}\n            if not len(new_docs):\n                logger.warning(\"This document is already in the storage.\")\n                return\n\n            update_storage = True\n            logger.info(f\"Inserting {len(new_docs)} docs\")\n\n            inserting_chunks: dict[str, Any] = {}\n            for index, chunk_text in enumerate(text_chunks):\n                chunk_key = compute_mdhash_id(chunk_text, prefix=\"chunk-\")\n                tokens = len(self.tokenizer.encode(chunk_text))\n                inserting_chunks[chunk_key] = {\n                    \"content\": chunk_text,\n                    \"full_doc_id\": doc_key,\n                    \"tokens\": tokens,\n                    \"chunk_order_index\": index,\n                    \"file_path\": file_path,\n                }\n\n            doc_ids = set(inserting_chunks.keys())\n            add_chunk_keys = await self.text_chunks.filter_keys(doc_ids)\n            inserting_chunks = {\n                k: v for k, v in inserting_chunks.items() if k in add_chunk_keys\n            }\n            if not len(inserting_chunks):\n                logger.warning(\"All chunks are already in the storage.\")\n                return\n\n            tasks = [\n                self.chunks_vdb.upsert(inserting_chunks),\n                self._process_extract_entities(inserting_chunks),\n                self.full_docs.upsert(new_docs),\n                self.text_chunks.upsert(inserting_chunks),\n            ]\n            await asyncio.gather(*tasks)\n\n        finally:\n            if update_storage:\n                await self._insert_done()\n\n    async def apipeline_enqueue_documents(\n        self,\n        input: str | list[str],\n        ids: list[str] | None = None,\n        file_paths: str | list[str] | None = None,\n        track_id: str | None = None,\n    ) -> str:\n        \"\"\"\n        Pipeline for Processing Documents\n\n        1. Validate ids if provided or generate MD5 hash IDs and remove duplicate contents\n        2. Generate document initial status\n        3. Filter out already processed documents\n        4. Enqueue document in status\n\n        Args:\n            input: Single document string or list of document strings\n            ids: list of unique document IDs, if not provided, MD5 hash IDs will be generated\n            file_paths: list of file paths corresponding to each document, used for citation\n            track_id: tracking ID for monitoring processing status, if not provided, will be generated with \"enqueue\" prefix\n\n        Returns:\n            str: tracking ID for monitoring processing status\n        \"\"\"\n        # Generate track_id if not provided\n        if track_id is None or track_id.strip() == \"\":\n            track_id = generate_track_id(\"enqueue\")\n        if isinstance(input, str):\n            input = [input]\n        if isinstance(ids, str):\n            ids = [ids]\n        if isinstance(file_paths, str):\n            file_paths = [file_paths]\n\n        # If file_paths is provided, ensure it matches the number of documents\n        if file_paths is not None:\n            if isinstance(file_paths, str):\n                file_paths = [file_paths]\n            if len(file_paths) != len(input):\n                raise ValueError(\n                    \"Number of file paths must match the number of documents\"\n                )\n            file_paths = [\n                path.strip() if isinstance(path, str) else \"\" for path in file_paths\n            ]\n            file_paths = [path if path else \"unknown_source\" for path in file_paths]\n        else:\n            # If no file paths provided, use placeholder\n            file_paths = [\"unknown_source\"] * len(input)\n\n        # 1. Validate ids if provided or generate MD5 hash IDs and remove duplicate contents\n        if ids is not None:\n            # Check if the number of IDs matches the number of documents\n            if len(ids) != len(input):\n                raise ValueError(\"Number of IDs must match the number of documents\")\n\n            # Check if IDs are unique\n            if len(ids) != len(set(ids)):\n                raise ValueError(\"IDs must be unique\")\n\n            # Generate contents dict and remove duplicates in one pass\n            unique_contents = {}\n            for id_, doc, path in zip(ids, input, file_paths):\n                cleaned_content = sanitize_text_for_encoding(doc)\n                if cleaned_content not in unique_contents:\n                    unique_contents[cleaned_content] = (id_, path)\n\n            # Reconstruct contents with unique content\n            contents = {\n                id_: {\"content\": content, \"file_path\": file_path}\n                for content, (id_, file_path) in unique_contents.items()\n            }\n        else:\n            # Clean input text and remove duplicates in one pass\n            unique_content_with_paths = {}\n            for doc, path in zip(input, file_paths):\n                cleaned_content = sanitize_text_for_encoding(doc)\n                if cleaned_content not in unique_content_with_paths:\n                    unique_content_with_paths[cleaned_content] = path\n\n            # Generate contents dict of MD5 hash IDs and documents with paths\n            contents = {\n                compute_mdhash_id(content, prefix=\"doc-\"): {\n                    \"content\": content,\n                    \"file_path\": path,\n                }\n                for content, path in unique_content_with_paths.items()\n            }\n\n        # 2. Generate document initial status (without content)\n        new_docs: dict[str, Any] = {\n            id_: {\n                \"status\": DocStatus.PENDING,\n                \"content_summary\": get_content_summary(content_data[\"content\"]),\n                \"content_length\": len(content_data[\"content\"]),\n                \"created_at\": datetime.now(timezone.utc).isoformat(),\n                \"updated_at\": datetime.now(timezone.utc).isoformat(),\n                \"file_path\": content_data[\n                    \"file_path\"\n                ],  # Store file path in document status\n                \"track_id\": track_id,  # Store track_id in document status\n            }\n            for id_, content_data in contents.items()\n        }\n\n        # 3. Filter out already processed documents\n        # Get docs ids\n        all_new_doc_ids = set(new_docs.keys())\n        # Exclude IDs of documents that are already enqueued\n        unique_new_doc_ids = await self.doc_status.filter_keys(all_new_doc_ids)\n\n        # Handle duplicate documents - create trackable records with current track_id\n        ignored_ids = list(all_new_doc_ids - unique_new_doc_ids)\n        if ignored_ids:\n            duplicate_docs: dict[str, Any] = {}\n            for doc_id in ignored_ids:\n                file_path = (\n                    new_docs.get(doc_id, {}).get(\"file_path\") or \"unknown_source\"\n                )\n                logger.warning(f\"Duplicate document detected: {doc_id} ({file_path})\")\n\n                # Get existing document info for reference\n                existing_doc = await self.doc_status.get_by_id(doc_id)\n                existing_status = (\n                    existing_doc.get(\"status\", \"unknown\") if existing_doc else \"unknown\"\n                )\n                existing_track_id = (\n                    existing_doc.get(\"track_id\", \"\") if existing_doc else \"\"\n                )\n\n                # Create a new record with unique ID for this duplicate attempt\n                dup_record_id = compute_mdhash_id(f\"{doc_id}-{track_id}\", prefix=\"dup-\")\n                duplicate_docs[dup_record_id] = {\n                    \"status\": DocStatus.FAILED,\n                    \"content_summary\": f\"[DUPLICATE] Original document: {doc_id}\",\n                    \"content_length\": new_docs.get(doc_id, {}).get(\"content_length\", 0),\n                    \"chunks_count\": 0,\n                    \"chunks_list\": [],\n                    \"created_at\": datetime.now(timezone.utc).isoformat(),\n                    \"updated_at\": datetime.now(timezone.utc).isoformat(),\n                    \"file_path\": file_path,\n                    \"track_id\": track_id,  # Use current track_id for tracking\n                    \"error_msg\": f\"Content already exists. Original doc_id: {doc_id}, Status: {existing_status}\",\n                    \"metadata\": {\n                        \"is_duplicate\": True,\n                        \"original_doc_id\": doc_id,\n                        \"original_track_id\": existing_track_id,\n                    },\n                }\n\n            # Store duplicate records in doc_status\n            if duplicate_docs:\n                await self.doc_status.upsert(duplicate_docs)\n                logger.info(\n                    f\"Created {len(duplicate_docs)} duplicate document records with track_id: {track_id}\"\n                )\n\n        # Filter new_docs to only include documents with unique IDs\n        new_docs = {\n            doc_id: new_docs[doc_id]\n            for doc_id in unique_new_doc_ids\n            if doc_id in new_docs\n        }\n\n        if not new_docs:\n            logger.warning(\"No new unique documents were found.\")\n            return\n\n        # 4. Store document content in full_docs and status in doc_status\n        #    Store full document content separately\n        full_docs_data = {\n            doc_id: {\n                \"content\": contents[doc_id][\"content\"],\n                \"file_path\": contents[doc_id][\"file_path\"],\n            }\n            for doc_id in new_docs.keys()\n        }\n        await self.full_docs.upsert(full_docs_data)\n        # Persist data to disk immediately\n        await self.full_docs.index_done_callback()\n\n        # Store document status (without content)\n        await self.doc_status.upsert(new_docs)\n        logger.debug(f\"Stored {len(new_docs)} new unique documents\")\n\n        return track_id\n\n    async def apipeline_enqueue_error_documents(\n        self,\n        error_files: list[dict[str, Any]],\n        track_id: str | None = None,\n    ) -> None:\n        \"\"\"\n        Record file extraction errors in doc_status storage.\n\n        This function creates error document entries in the doc_status storage for files\n        that failed during the extraction process. Each error entry contains information\n        about the failure to help with debugging and monitoring.\n\n        Args:\n            error_files: List of dictionaries containing error information for each failed file.\n                Each dictionary should contain:\n                - file_path: Original file name/path\n                - error_description: Brief error description (for content_summary)\n                - original_error: Full error message (for error_msg)\n                - file_size: File size in bytes (for content_length, 0 if unknown)\n            track_id: Optional tracking ID for grouping related operations\n\n        Returns:\n            None\n        \"\"\"\n        if not error_files:\n            logger.debug(\"No error files to record\")\n            return\n\n        # Generate track_id if not provided\n        if track_id is None or track_id.strip() == \"\":\n            track_id = generate_track_id(\"error\")\n\n        error_docs: dict[str, Any] = {}\n        current_time = datetime.now(timezone.utc).isoformat()\n\n        for error_file in error_files:\n            file_path = error_file.get(\"file_path\", \"unknown_file\")\n            error_description = error_file.get(\n                \"error_description\", \"File extraction failed\"\n            )\n            original_error = error_file.get(\"original_error\", \"Unknown error\")\n            file_size = error_file.get(\"file_size\", 0)\n\n            # Generate unique doc_id with \"error-\" prefix\n            doc_id_content = f\"{file_path}-{error_description}\"\n            doc_id = compute_mdhash_id(doc_id_content, prefix=\"error-\")\n\n            error_docs[doc_id] = {\n                \"status\": DocStatus.FAILED,\n                \"content_summary\": error_description,\n                \"content_length\": file_size,\n                \"error_msg\": original_error,\n                \"chunks_count\": 0,  # No chunks for failed files\n                \"chunks_list\": [],\n                \"created_at\": current_time,\n                \"updated_at\": current_time,\n                \"file_path\": file_path,\n                \"track_id\": track_id,\n                \"metadata\": {\n                    \"error_type\": \"file_extraction_error\",\n                },\n            }\n\n        # Store error documents in doc_status\n        if error_docs:\n            await self.doc_status.upsert(error_docs)\n            # Log each error for debugging\n            for doc_id, error_doc in error_docs.items():\n                logger.error(\n                    f\"File processing error: - ID: {doc_id} {error_doc['file_path']}\"\n                )\n\n    async def _validate_and_fix_document_consistency(\n        self,\n        to_process_docs: dict[str, DocProcessingStatus],\n        pipeline_status: dict,\n        pipeline_status_lock: asyncio.Lock,\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Validate and fix document data consistency by deleting inconsistent entries, but preserve failed documents\"\"\"\n        inconsistent_docs = []\n        failed_docs_to_preserve = []\n        successful_deletions = 0\n\n        # Check each document's data consistency\n        for doc_id, status_doc in to_process_docs.items():\n            # Check if corresponding content exists in full_docs\n            content_data = await self.full_docs.get_by_id(doc_id)\n            if not content_data:\n                # Check if this is a failed document that should be preserved\n                if (\n                    hasattr(status_doc, \"status\")\n                    and status_doc.status == DocStatus.FAILED\n                ):\n                    failed_docs_to_preserve.append(doc_id)\n                else:\n                    inconsistent_docs.append(doc_id)\n\n        # Log information about failed documents that will be preserved\n        if failed_docs_to_preserve:\n            async with pipeline_status_lock:\n                preserve_message = f\"Preserving {len(failed_docs_to_preserve)} failed document entries for manual review\"\n                logger.info(preserve_message)\n                pipeline_status[\"latest_message\"] = preserve_message\n                pipeline_status[\"history_messages\"].append(preserve_message)\n\n            # Remove failed documents from processing list but keep them in doc_status\n            for doc_id in failed_docs_to_preserve:\n                to_process_docs.pop(doc_id, None)\n\n        # Delete inconsistent document entries(excluding failed documents)\n        if inconsistent_docs:\n            async with pipeline_status_lock:\n                summary_message = (\n                    f\"Inconsistent document entries found: {len(inconsistent_docs)}\"\n                )\n                logger.info(summary_message)\n                pipeline_status[\"latest_message\"] = summary_message\n                pipeline_status[\"history_messages\"].append(summary_message)\n\n            successful_deletions = 0\n            for doc_id in inconsistent_docs:\n                try:\n                    status_doc = to_process_docs[doc_id]\n                    file_path = (\n                        getattr(status_doc, \"file_path\", None) or \"unknown_source\"\n                    )\n\n                    # Delete doc_status entry\n                    await self.doc_status.delete([doc_id])\n                    successful_deletions += 1\n\n                    # Log successful deletion\n                    async with pipeline_status_lock:\n                        log_message = (\n                            f\"Deleted inconsistent entry: {doc_id} ({file_path})\"\n                        )\n                        logger.info(log_message)\n                        pipeline_status[\"latest_message\"] = log_message\n                        pipeline_status[\"history_messages\"].append(log_message)\n\n                    # Remove from processing list\n                    to_process_docs.pop(doc_id, None)\n\n                except Exception as e:\n                    # Log deletion failure\n                    async with pipeline_status_lock:\n                        error_message = f\"Failed to delete entry: {doc_id} - {str(e)}\"\n                        logger.error(error_message)\n                        pipeline_status[\"latest_message\"] = error_message\n                        pipeline_status[\"history_messages\"].append(error_message)\n\n        # Final summary log\n        # async with pipeline_status_lock:\n        #     final_message = f\"Successfully deleted {successful_deletions} inconsistent entries, preserved {len(failed_docs_to_preserve)} failed documents\"\n        #     logger.info(final_message)\n        #     pipeline_status[\"latest_message\"] = final_message\n        #     pipeline_status[\"history_messages\"].append(final_message)\n\n        # Reset PROCESSING and FAILED documents that pass consistency checks to PENDING status\n        docs_to_reset = {}\n        reset_count = 0\n\n        for doc_id, status_doc in to_process_docs.items():\n            # Check if document has corresponding content in full_docs (consistency check)\n            content_data = await self.full_docs.get_by_id(doc_id)\n            if content_data:  # Document passes consistency check\n                # Check if document is in PROCESSING or FAILED status\n                if hasattr(status_doc, \"status\") and status_doc.status in [\n                    DocStatus.PROCESSING,\n                    DocStatus.FAILED,\n                ]:\n                    preserved_chunks_list, preserved_chunks_count = (\n                        _chunk_fields_from_status_doc(status_doc)\n                    )\n                    # Prepare document for status reset to PENDING\n                    docs_to_reset[doc_id] = {\n                        \"status\": DocStatus.PENDING,\n                        \"content_summary\": status_doc.content_summary,\n                        \"content_length\": status_doc.content_length,\n                        \"chunks_count\": preserved_chunks_count,\n                        \"chunks_list\": preserved_chunks_list,\n                        \"created_at\": status_doc.created_at,\n                        \"updated_at\": datetime.now(timezone.utc).isoformat(),\n                        \"file_path\": getattr(status_doc, \"file_path\", None)\n                        or \"unknown_source\",\n                        \"track_id\": getattr(status_doc, \"track_id\", \"\"),\n                        # Clear any error messages and processing metadata\n                        \"error_msg\": \"\",\n                        \"metadata\": {},\n                    }\n\n                    # Update the status in to_process_docs as well\n                    status_doc.status = DocStatus.PENDING\n                    reset_count += 1\n\n        # Update doc_status storage if there are documents to reset\n        if docs_to_reset:\n            await self.doc_status.upsert(docs_to_reset)\n\n            async with pipeline_status_lock:\n                reset_message = f\"Reset {reset_count} documents from PROCESSING/FAILED to PENDING status\"\n                logger.info(reset_message)\n                pipeline_status[\"latest_message\"] = reset_message\n                pipeline_status[\"history_messages\"].append(reset_message)\n\n        return to_process_docs\n\n    async def apipeline_process_enqueue_documents(\n        self,\n        split_by_character: str | None = None,\n        split_by_character_only: bool = False,\n    ) -> None:\n        \"\"\"\n        Process pending documents by splitting them into chunks, processing\n        each chunk for entity and relation extraction, and updating the\n        document status.\n\n        1. Get all pending, failed, and abnormally terminated processing documents.\n        2. Validate document data consistency and fix any issues\n        3. Split document content into chunks\n        4. Process each chunk for entity and relation extraction\n        5. Update the document status\n        \"\"\"\n\n        # Get pipeline status shared data and lock\n        pipeline_status = await get_namespace_data(\n            \"pipeline_status\", workspace=self.workspace\n        )\n        pipeline_status_lock = get_namespace_lock(\n            \"pipeline_status\", workspace=self.workspace\n        )\n\n        # Check if another process is already processing the queue\n        async with pipeline_status_lock:\n            # Ensure only one worker is processing documents\n            if not pipeline_status.get(\"busy\", False):\n                processing_docs, failed_docs, pending_docs = await asyncio.gather(\n                    self.doc_status.get_docs_by_status(DocStatus.PROCESSING),\n                    self.doc_status.get_docs_by_status(DocStatus.FAILED),\n                    self.doc_status.get_docs_by_status(DocStatus.PENDING),\n                )\n\n                to_process_docs: dict[str, DocProcessingStatus] = {}\n                to_process_docs.update(processing_docs)\n                to_process_docs.update(failed_docs)\n                to_process_docs.update(pending_docs)\n\n                if not to_process_docs:\n                    logger.info(\"No documents to process\")\n                    return\n\n                pipeline_status.update(\n                    {\n                        \"busy\": True,\n                        \"job_name\": \"Default Job\",\n                        \"job_start\": datetime.now(timezone.utc).isoformat(),\n                        \"docs\": 0,\n                        \"batchs\": 0,  # Total number of files to be processed\n                        \"cur_batch\": 0,  # Number of files already processed\n                        \"request_pending\": False,  # Clear any previous request\n                        \"cancellation_requested\": False,  # Initialize cancellation flag\n                        \"latest_message\": \"\",\n                    }\n                )\n                # Cleaning history_messages without breaking it as a shared list object\n                del pipeline_status[\"history_messages\"][:]\n            else:\n                # Another process is busy, just set request flag and return\n                pipeline_status[\"request_pending\"] = True\n                logger.info(\n                    \"Another process is already processing the document queue. Request queued.\"\n                )\n                return\n\n        try:\n            # Process documents until no more documents or requests\n            while True:\n                # Check for cancellation request at the start of main loop\n                async with pipeline_status_lock:\n                    if pipeline_status.get(\"cancellation_requested\", False):\n                        # Clear pending request\n                        pipeline_status[\"request_pending\"] = False\n                        # Celar cancellation flag\n                        pipeline_status[\"cancellation_requested\"] = False\n\n                        log_message = \"Pipeline cancelled by user\"\n                        logger.info(log_message)\n                        pipeline_status[\"latest_message\"] = log_message\n                        pipeline_status[\"history_messages\"].append(log_message)\n\n                        # Exit directly, skipping request_pending check\n                        return\n\n                if not to_process_docs:\n                    log_message = \"All enqueued documents have been processed\"\n                    logger.info(log_message)\n                    pipeline_status[\"latest_message\"] = log_message\n                    pipeline_status[\"history_messages\"].append(log_message)\n                    break\n\n                # Validate document data consistency and fix any issues as part of the pipeline\n                to_process_docs = await self._validate_and_fix_document_consistency(\n                    to_process_docs, pipeline_status, pipeline_status_lock\n                )\n\n                if not to_process_docs:\n                    log_message = (\n                        \"No valid documents to process after consistency check\"\n                    )\n                    logger.info(log_message)\n                    pipeline_status[\"latest_message\"] = log_message\n                    pipeline_status[\"history_messages\"].append(log_message)\n                    break\n\n                log_message = f\"Processing {len(to_process_docs)} document(s)\"\n                logger.info(log_message)\n\n                # Update pipeline_status, batchs now represents the total number of files to be processed\n                pipeline_status[\"docs\"] = len(to_process_docs)\n                pipeline_status[\"batchs\"] = len(to_process_docs)\n                pipeline_status[\"cur_batch\"] = 0\n                pipeline_status[\"latest_message\"] = log_message\n                pipeline_status[\"history_messages\"].append(log_message)\n\n                # Get first document's file path and total count for job name\n                first_doc_id, first_doc = next(iter(to_process_docs.items()))\n                first_doc_path = first_doc.file_path\n\n                # Handle cases where first_doc_path is None\n                if first_doc_path:\n                    path_prefix = first_doc_path[:20] + (\n                        \"...\" if len(first_doc_path) > 20 else \"\"\n                    )\n                else:\n                    path_prefix = \"unknown_source\"\n\n                total_files = len(to_process_docs)\n                job_name = f\"{path_prefix}[{total_files} files]\"\n                pipeline_status[\"job_name\"] = job_name\n\n                # Create a counter to track the number of processed files\n                processed_count = 0\n                # Create a semaphore to limit the number of concurrent file processing\n                semaphore = asyncio.Semaphore(self.max_parallel_insert)\n\n                async def process_document(\n                    doc_id: str,\n                    status_doc: DocProcessingStatus,\n                    split_by_character: str | None,\n                    split_by_character_only: bool,\n                    pipeline_status: dict,\n                    pipeline_status_lock: asyncio.Lock,\n                    semaphore: asyncio.Semaphore,\n                ) -> None:\n                    \"\"\"Process single document\"\"\"\n                    # Initialize variables at the start to prevent UnboundLocalError in error handling\n                    file_path = \"unknown_source\"\n                    current_file_number = 0\n                    file_extraction_stage_ok = False\n                    processing_start_time = int(time.time())\n                    first_stage_tasks = []\n                    entity_relation_task = None\n                    chunks: dict[str, Any] = {}\n\n                    def get_failed_chunk_snapshot() -> tuple[list[str], int]:\n                        if chunks:\n                            chunk_ids = list(chunks.keys())\n                            return chunk_ids, len(chunk_ids)\n                        return _chunk_fields_from_status_doc(status_doc)\n\n                    async with semaphore:\n                        nonlocal processed_count\n                        # Initialize to prevent UnboundLocalError in error handling\n                        first_stage_tasks = []\n                        entity_relation_task = None\n                        try:\n                            # Check for cancellation before starting document processing\n                            async with pipeline_status_lock:\n                                if pipeline_status.get(\"cancellation_requested\", False):\n                                    raise PipelineCancelledException(\"User cancelled\")\n\n                            # Get file path from status document\n                            file_path = (\n                                getattr(status_doc, \"file_path\", None)\n                                or \"unknown_source\"\n                            )\n\n                            async with pipeline_status_lock:\n                                # Update processed file count and save current file number\n                                processed_count += 1\n                                current_file_number = (\n                                    processed_count  # Save the current file number\n                                )\n                                pipeline_status[\"cur_batch\"] = processed_count\n\n                                log_message = f\"Extracting stage {current_file_number}/{total_files}: {file_path}\"\n                                logger.info(log_message)\n                                pipeline_status[\"history_messages\"].append(log_message)\n                                log_message = f\"Processing d-id: {doc_id}\"\n                                logger.info(log_message)\n                                pipeline_status[\"latest_message\"] = log_message\n                                pipeline_status[\"history_messages\"].append(log_message)\n\n                                # Prevent memory growth: keep only latest 5000 messages when exceeding 10000\n                                if len(pipeline_status[\"history_messages\"]) > 10000:\n                                    logger.info(\n                                        f\"Trimming pipeline history from {len(pipeline_status['history_messages'])} to 5000 messages\"\n                                    )\n                                    pipeline_status[\"history_messages\"] = (\n                                        pipeline_status[\"history_messages\"][-5000:]\n                                    )\n\n                            # Get document content from full_docs\n                            content_data = await self.full_docs.get_by_id(doc_id)\n                            if not content_data:\n                                raise Exception(\n                                    f\"Document content not found in full_docs for doc_id: {doc_id}\"\n                                )\n                            content = content_data[\"content\"]\n\n                            # Call chunking function, supporting both sync and async implementations\n                            chunking_result = self.chunking_func(\n                                self.tokenizer,\n                                content,\n                                split_by_character,\n                                split_by_character_only,\n                                self.chunk_overlap_token_size,\n                                self.chunk_token_size,\n                            )\n\n                            # If result is awaitable, await to get actual result\n                            if inspect.isawaitable(chunking_result):\n                                chunking_result = await chunking_result\n\n                            # Validate return type\n                            if not isinstance(chunking_result, (list, tuple)):\n                                raise TypeError(\n                                    f\"chunking_func must return a list or tuple of dicts, \"\n                                    f\"got {type(chunking_result)}\"\n                                )\n\n                            # Build chunks dictionary\n                            chunks: dict[str, Any] = {\n                                compute_mdhash_id(dp[\"content\"], prefix=\"chunk-\"): {\n                                    **dp,\n                                    \"full_doc_id\": doc_id,\n                                    \"file_path\": file_path,  # Add file path to each chunk\n                                    \"llm_cache_list\": [],  # Initialize empty LLM cache list for each chunk\n                                }\n                                for dp in chunking_result\n                            }\n\n                            if not chunks:\n                                logger.warning(\"No document chunks to process\")\n\n                            # Record processing start time\n                            processing_start_time = int(time.time())\n\n                            # Check for cancellation before entity extraction\n                            async with pipeline_status_lock:\n                                if pipeline_status.get(\"cancellation_requested\", False):\n                                    raise PipelineCancelledException(\"User cancelled\")\n\n                            # Process document in two stages\n                            # Stage 1: Process text chunks and docs (parallel execution)\n                            doc_status_task = asyncio.create_task(\n                                self.doc_status.upsert(\n                                    {\n                                        doc_id: {\n                                            \"status\": DocStatus.PROCESSING,\n                                            \"chunks_count\": len(chunks),\n                                            \"chunks_list\": list(\n                                                chunks.keys()\n                                            ),  # Save chunks list\n                                            \"content_summary\": status_doc.content_summary,\n                                            \"content_length\": status_doc.content_length,\n                                            \"created_at\": status_doc.created_at,\n                                            \"updated_at\": datetime.now(\n                                                timezone.utc\n                                            ).isoformat(),\n                                            \"file_path\": file_path,\n                                            \"track_id\": status_doc.track_id,  # Preserve existing track_id\n                                            \"metadata\": {\n                                                \"processing_start_time\": processing_start_time\n                                            },\n                                        }\n                                    }\n                                )\n                            )\n                            chunks_vdb_task = asyncio.create_task(\n                                self.chunks_vdb.upsert(chunks)\n                            )\n                            text_chunks_task = asyncio.create_task(\n                                self.text_chunks.upsert(chunks)\n                            )\n\n                            # First stage tasks (parallel execution)\n                            first_stage_tasks = [\n                                doc_status_task,\n                                chunks_vdb_task,\n                                text_chunks_task,\n                            ]\n                            entity_relation_task = None\n\n                            # Execute first stage tasks\n                            await asyncio.gather(*first_stage_tasks)\n\n                            # Stage 2: Process entity relation graph (after text_chunks are saved)\n                            entity_relation_task = asyncio.create_task(\n                                self._process_extract_entities(\n                                    chunks, pipeline_status, pipeline_status_lock\n                                )\n                            )\n                            chunk_results = await entity_relation_task\n                            file_extraction_stage_ok = True\n\n                        except Exception as e:\n                            # Check if this is a user cancellation\n                            if isinstance(e, PipelineCancelledException):\n                                # User cancellation - log brief message only, no traceback\n                                error_msg = f\"User cancelled {current_file_number}/{total_files}: {file_path}\"\n                                logger.warning(error_msg)\n                                async with pipeline_status_lock:\n                                    pipeline_status[\"latest_message\"] = error_msg\n                                    pipeline_status[\"history_messages\"].append(\n                                        error_msg\n                                    )\n                            else:\n                                # Other exceptions - log with traceback\n                                logger.error(traceback.format_exc())\n                                error_msg = f\"Failed to extract document {current_file_number}/{total_files}: {file_path}\"\n                                logger.error(error_msg)\n                                async with pipeline_status_lock:\n                                    pipeline_status[\"latest_message\"] = error_msg\n                                    pipeline_status[\"history_messages\"].append(\n                                        traceback.format_exc()\n                                    )\n                                    pipeline_status[\"history_messages\"].append(\n                                        error_msg\n                                    )\n\n                            # Cancel tasks that are not yet completed\n                            all_tasks = first_stage_tasks + (\n                                [entity_relation_task] if entity_relation_task else []\n                            )\n                            for task in all_tasks:\n                                if task and not task.done():\n                                    task.cancel()\n\n                            # Persistent llm cache with error handling\n                            if self.llm_response_cache:\n                                try:\n                                    await self.llm_response_cache.index_done_callback()\n                                except Exception as persist_error:\n                                    logger.error(\n                                        f\"Failed to persist LLM cache: {persist_error}\"\n                                    )\n\n                            # Record processing end time for failed case\n                            processing_end_time = int(time.time())\n                            failed_chunks_list, failed_chunks_count = (\n                                get_failed_chunk_snapshot()\n                            )\n\n                            # Update document status to failed\n                            await self.doc_status.upsert(\n                                {\n                                    doc_id: {\n                                        \"status\": DocStatus.FAILED,\n                                        \"error_msg\": str(e),\n                                        \"chunks_count\": failed_chunks_count,\n                                        \"chunks_list\": failed_chunks_list,\n                                        \"content_summary\": status_doc.content_summary,\n                                        \"content_length\": status_doc.content_length,\n                                        \"created_at\": status_doc.created_at,\n                                        \"updated_at\": datetime.now(\n                                            timezone.utc\n                                        ).isoformat(),\n                                        \"file_path\": file_path,\n                                        \"track_id\": status_doc.track_id,  # Preserve existing track_id\n                                        \"metadata\": {\n                                            \"processing_start_time\": processing_start_time,\n                                            \"processing_end_time\": processing_end_time,\n                                        },\n                                    }\n                                }\n                            )\n\n                        # Concurrency is controlled by keyed lock for individual entities and relationships\n                        if file_extraction_stage_ok:\n                            try:\n                                # Check for cancellation before merge\n                                async with pipeline_status_lock:\n                                    if pipeline_status.get(\n                                        \"cancellation_requested\", False\n                                    ):\n                                        raise PipelineCancelledException(\n                                            \"User cancelled\"\n                                        )\n\n                                # Use chunk_results from entity_relation_task\n                                await merge_nodes_and_edges(\n                                    chunk_results=chunk_results,  # result collected from entity_relation_task\n                                    knowledge_graph_inst=self.chunk_entity_relation_graph,\n                                    entity_vdb=self.entities_vdb,\n                                    relationships_vdb=self.relationships_vdb,\n                                    global_config=asdict(self),\n                                    full_entities_storage=self.full_entities,\n                                    full_relations_storage=self.full_relations,\n                                    doc_id=doc_id,\n                                    pipeline_status=pipeline_status,\n                                    pipeline_status_lock=pipeline_status_lock,\n                                    llm_response_cache=self.llm_response_cache,\n                                    entity_chunks_storage=self.entity_chunks,\n                                    relation_chunks_storage=self.relation_chunks,\n                                    current_file_number=current_file_number,\n                                    total_files=total_files,\n                                    file_path=file_path,\n                                )\n\n                                # Record processing end time\n                                processing_end_time = int(time.time())\n\n                                await self.doc_status.upsert(\n                                    {\n                                        doc_id: {\n                                            \"status\": DocStatus.PROCESSED,\n                                            \"chunks_count\": len(chunks),\n                                            \"chunks_list\": list(chunks.keys()),\n                                            \"content_summary\": status_doc.content_summary,\n                                            \"content_length\": status_doc.content_length,\n                                            \"created_at\": status_doc.created_at,\n                                            \"updated_at\": datetime.now(\n                                                timezone.utc\n                                            ).isoformat(),\n                                            \"file_path\": file_path,\n                                            \"track_id\": status_doc.track_id,  # Preserve existing track_id\n                                            \"metadata\": {\n                                                \"processing_start_time\": processing_start_time,\n                                                \"processing_end_time\": processing_end_time,\n                                            },\n                                        }\n                                    }\n                                )\n\n                                # Call _insert_done after processing each file\n                                await self._insert_done()\n\n                                async with pipeline_status_lock:\n                                    log_message = f\"Completed processing file {current_file_number}/{total_files}: {file_path}\"\n                                    logger.info(log_message)\n                                    pipeline_status[\"latest_message\"] = log_message\n                                    pipeline_status[\"history_messages\"].append(\n                                        log_message\n                                    )\n\n                            except Exception as e:\n                                # Check if this is a user cancellation\n                                if isinstance(e, PipelineCancelledException):\n                                    # User cancellation - log brief message only, no traceback\n                                    error_msg = f\"User cancelled during merge {current_file_number}/{total_files}: {file_path}\"\n                                    logger.warning(error_msg)\n                                    async with pipeline_status_lock:\n                                        pipeline_status[\"latest_message\"] = error_msg\n                                        pipeline_status[\"history_messages\"].append(\n                                            error_msg\n                                        )\n                                else:\n                                    # Other exceptions - log with traceback\n                                    logger.error(traceback.format_exc())\n                                    error_msg = f\"Merging stage failed in document {current_file_number}/{total_files}: {file_path}\"\n                                    logger.error(error_msg)\n                                    async with pipeline_status_lock:\n                                        pipeline_status[\"latest_message\"] = error_msg\n                                        pipeline_status[\"history_messages\"].append(\n                                            traceback.format_exc()\n                                        )\n                                        pipeline_status[\"history_messages\"].append(\n                                            error_msg\n                                        )\n\n                                # Persistent llm cache with error handling\n                                if self.llm_response_cache:\n                                    try:\n                                        await self.llm_response_cache.index_done_callback()\n                                    except Exception as persist_error:\n                                        logger.error(\n                                            f\"Failed to persist LLM cache: {persist_error}\"\n                                        )\n\n                                # Record processing end time for failed case\n                                processing_end_time = int(time.time())\n                                failed_chunks_list, failed_chunks_count = (\n                                    get_failed_chunk_snapshot()\n                                )\n\n                                # Update document status to failed\n                                await self.doc_status.upsert(\n                                    {\n                                        doc_id: {\n                                            \"status\": DocStatus.FAILED,\n                                            \"error_msg\": str(e),\n                                            \"chunks_count\": failed_chunks_count,\n                                            \"chunks_list\": failed_chunks_list,\n                                            \"content_summary\": status_doc.content_summary,\n                                            \"content_length\": status_doc.content_length,\n                                            \"created_at\": status_doc.created_at,\n                                            \"updated_at\": datetime.now().isoformat(),\n                                            \"file_path\": file_path,\n                                            \"track_id\": status_doc.track_id,  # Preserve existing track_id\n                                            \"metadata\": {\n                                                \"processing_start_time\": processing_start_time,\n                                                \"processing_end_time\": processing_end_time,\n                                            },\n                                        }\n                                    }\n                                )\n\n                # Create processing tasks for all documents\n                doc_tasks = []\n                for doc_id, status_doc in to_process_docs.items():\n                    doc_tasks.append(\n                        process_document(\n                            doc_id,\n                            status_doc,\n                            split_by_character,\n                            split_by_character_only,\n                            pipeline_status,\n                            pipeline_status_lock,\n                            semaphore,\n                        )\n                    )\n\n                # Wait for all document processing to complete\n                try:\n                    await asyncio.gather(*doc_tasks)\n                except PipelineCancelledException:\n                    # Cancel all remaining tasks\n                    for task in doc_tasks:\n                        if not task.done():\n                            task.cancel()\n\n                    # Wait for all tasks to complete cancellation\n                    await asyncio.wait(doc_tasks, return_when=asyncio.ALL_COMPLETED)\n\n                    # Exit directly (document statuses already updated in process_document)\n                    return\n\n                # Check if there's a pending request to process more documents (with lock)\n                has_pending_request = False\n                async with pipeline_status_lock:\n                    has_pending_request = pipeline_status.get(\"request_pending\", False)\n                    if has_pending_request:\n                        # Clear the request flag before checking for more documents\n                        pipeline_status[\"request_pending\"] = False\n\n                if not has_pending_request:\n                    break\n\n                log_message = \"Processing additional documents due to pending request\"\n                logger.info(log_message)\n                pipeline_status[\"latest_message\"] = log_message\n                pipeline_status[\"history_messages\"].append(log_message)\n\n                # Check for pending documents again\n                processing_docs, failed_docs, pending_docs = await asyncio.gather(\n                    self.doc_status.get_docs_by_status(DocStatus.PROCESSING),\n                    self.doc_status.get_docs_by_status(DocStatus.FAILED),\n                    self.doc_status.get_docs_by_status(DocStatus.PENDING),\n                )\n\n                to_process_docs = {}\n                to_process_docs.update(processing_docs)\n                to_process_docs.update(failed_docs)\n                to_process_docs.update(pending_docs)\n\n        finally:\n            log_message = \"Enqueued document processing pipeline stopped\"\n            logger.info(log_message)\n            # Always reset busy status and cancellation flag when done or if an exception occurs (with lock)\n            async with pipeline_status_lock:\n                pipeline_status[\"busy\"] = False\n                pipeline_status[\"cancellation_requested\"] = (\n                    False  # Always reset cancellation flag\n                )\n                pipeline_status[\"latest_message\"] = log_message\n                pipeline_status[\"history_messages\"].append(log_message)\n\n    async def _process_extract_entities(\n        self, chunk: dict[str, Any], pipeline_status=None, pipeline_status_lock=None\n    ) -> list:\n        try:\n            chunk_results = await extract_entities(\n                chunk,\n                global_config=asdict(self),\n                pipeline_status=pipeline_status,\n                pipeline_status_lock=pipeline_status_lock,\n                llm_response_cache=self.llm_response_cache,\n                text_chunks_storage=self.text_chunks,\n            )\n            return chunk_results\n        except Exception as e:\n            error_msg = f\"Failed to extract entities and relationships: {str(e)}\"\n            logger.error(error_msg)\n            async with pipeline_status_lock:\n                pipeline_status[\"latest_message\"] = error_msg\n                pipeline_status[\"history_messages\"].append(error_msg)\n            raise e\n\n    async def _insert_done(\n        self, pipeline_status=None, pipeline_status_lock=None\n    ) -> None:\n        tasks = [\n            cast(StorageNameSpace, storage_inst).index_done_callback()\n            for storage_inst in [  # type: ignore\n                self.full_docs,\n                self.doc_status,\n                self.text_chunks,\n                self.full_entities,\n                self.full_relations,\n                self.entity_chunks,\n                self.relation_chunks,\n                self.llm_response_cache,\n                self.entities_vdb,\n                self.relationships_vdb,\n                self.chunks_vdb,\n                self.chunk_entity_relation_graph,\n            ]\n            if storage_inst is not None\n        ]\n        await asyncio.gather(*tasks)\n\n        log_message = \"In memory DB persist to disk\"\n        logger.info(log_message)\n\n        if pipeline_status is not None and pipeline_status_lock is not None:\n            async with pipeline_status_lock:\n                pipeline_status[\"latest_message\"] = log_message\n                pipeline_status[\"history_messages\"].append(log_message)\n\n    def insert_custom_kg(\n        self, custom_kg: dict[str, Any], full_doc_id: str = None\n    ) -> None:\n        loop = always_get_an_event_loop()\n        loop.run_until_complete(self.ainsert_custom_kg(custom_kg, full_doc_id))\n\n    async def ainsert_custom_kg(\n        self,\n        custom_kg: dict[str, Any],\n        full_doc_id: str = None,\n    ) -> None:\n        update_storage = False\n        try:\n            # Insert chunks into vector storage\n            all_chunks_data: dict[str, dict[str, str]] = {}\n            chunk_to_source_map: dict[str, str] = {}\n            for chunk_data in custom_kg.get(\"chunks\", []):\n                chunk_content = sanitize_text_for_encoding(chunk_data[\"content\"])\n                source_id = chunk_data[\"source_id\"]\n                file_path = chunk_data.get(\"file_path\", \"custom_kg\")\n                tokens = len(self.tokenizer.encode(chunk_content))\n                chunk_order_index = (\n                    0\n                    if \"chunk_order_index\" not in chunk_data.keys()\n                    else chunk_data[\"chunk_order_index\"]\n                )\n                chunk_id = compute_mdhash_id(chunk_content, prefix=\"chunk-\")\n\n                chunk_entry = {\n                    \"content\": chunk_content,\n                    \"source_id\": source_id,\n                    \"tokens\": tokens,\n                    \"chunk_order_index\": chunk_order_index,\n                    \"full_doc_id\": full_doc_id\n                    if full_doc_id is not None\n                    else source_id,\n                    \"file_path\": file_path,\n                    \"status\": DocStatus.PROCESSED,\n                }\n                all_chunks_data[chunk_id] = chunk_entry\n                chunk_to_source_map[source_id] = chunk_id\n                update_storage = True\n\n            if all_chunks_data:\n                await asyncio.gather(\n                    self.chunks_vdb.upsert(all_chunks_data),\n                    self.text_chunks.upsert(all_chunks_data),\n                )\n\n            # Insert entities into knowledge graph\n            all_entities_data: list[dict[str, str]] = []\n            for entity_data in custom_kg.get(\"entities\", []):\n                entity_name = entity_data[\"entity_name\"]\n                entity_type = entity_data.get(\"entity_type\", \"UNKNOWN\")\n                description = entity_data.get(\"description\", \"No description provided\")\n                source_chunk_id = entity_data.get(\"source_id\", \"UNKNOWN\")\n                source_id = chunk_to_source_map.get(source_chunk_id, \"UNKNOWN\")\n                file_path = entity_data.get(\"file_path\", \"custom_kg\")\n\n                # Log if source_id is UNKNOWN\n                if source_id == \"UNKNOWN\":\n                    logger.warning(\n                        f\"Entity '{entity_name}' has an UNKNOWN source_id. Please check the source mapping.\"\n                    )\n\n                # Prepare node data\n                node_data: dict[str, str] = {\n                    \"entity_id\": entity_name,\n                    \"entity_type\": entity_type,\n                    \"description\": description,\n                    \"source_id\": source_id,\n                    \"file_path\": file_path,\n                    \"created_at\": int(time.time()),\n                }\n                # Insert node data into the knowledge graph\n                await self.chunk_entity_relation_graph.upsert_node(\n                    entity_name, node_data=node_data\n                )\n                node_data[\"entity_name\"] = entity_name\n                all_entities_data.append(node_data)\n                update_storage = True\n\n            # Insert relationships into knowledge graph\n            all_relationships_data: list[dict[str, str]] = []\n            for relationship_data in custom_kg.get(\"relationships\", []):\n                src_id = relationship_data[\"src_id\"]\n                tgt_id = relationship_data[\"tgt_id\"]\n                description = relationship_data[\"description\"]\n                keywords = relationship_data[\"keywords\"]\n                weight = relationship_data.get(\"weight\", 1.0)\n                source_chunk_id = relationship_data.get(\"source_id\", \"UNKNOWN\")\n                source_id = chunk_to_source_map.get(source_chunk_id, \"UNKNOWN\")\n                file_path = relationship_data.get(\"file_path\", \"custom_kg\")\n\n                # Log if source_id is UNKNOWN\n                if source_id == \"UNKNOWN\":\n                    logger.warning(\n                        f\"Relationship from '{src_id}' to '{tgt_id}' has an UNKNOWN source_id. Please check the source mapping.\"\n                    )\n\n                # Check if nodes exist in the knowledge graph\n                for need_insert_id in [src_id, tgt_id]:\n                    if not (\n                        await self.chunk_entity_relation_graph.has_node(need_insert_id)\n                    ):\n                        await self.chunk_entity_relation_graph.upsert_node(\n                            need_insert_id,\n                            node_data={\n                                \"entity_id\": need_insert_id,\n                                \"source_id\": source_id,\n                                \"description\": \"UNKNOWN\",\n                                \"entity_type\": \"UNKNOWN\",\n                                \"file_path\": file_path,\n                                \"created_at\": int(time.time()),\n                            },\n                        )\n\n                # Insert edge into the knowledge graph\n                await self.chunk_entity_relation_graph.upsert_edge(\n                    src_id,\n                    tgt_id,\n                    edge_data={\n                        \"weight\": weight,\n                        \"description\": description,\n                        \"keywords\": keywords,\n                        \"source_id\": source_id,\n                        \"file_path\": file_path,\n                        \"created_at\": int(time.time()),\n                    },\n                )\n\n                edge_data: dict[str, str] = {\n                    \"src_id\": src_id,\n                    \"tgt_id\": tgt_id,\n                    \"description\": description,\n                    \"keywords\": keywords,\n                    \"source_id\": source_id,\n                    \"weight\": weight,\n                    \"file_path\": file_path,\n                    \"created_at\": int(time.time()),\n                }\n                all_relationships_data.append(edge_data)\n                update_storage = True\n\n            # Insert entities into vector storage with consistent format\n            data_for_vdb = {\n                compute_mdhash_id(dp[\"entity_name\"], prefix=\"ent-\"): {\n                    \"content\": dp[\"entity_name\"] + \"\\n\" + dp[\"description\"],\n                    \"entity_name\": dp[\"entity_name\"],\n                    \"source_id\": dp[\"source_id\"],\n                    \"description\": dp[\"description\"],\n                    \"entity_type\": dp[\"entity_type\"],\n                    \"file_path\": dp.get(\"file_path\", \"custom_kg\"),\n                }\n                for dp in all_entities_data\n            }\n            await self.entities_vdb.upsert(data_for_vdb)\n\n            # Insert relationships into vector storage with consistent format\n            data_for_vdb = {\n                compute_mdhash_id(dp[\"src_id\"] + dp[\"tgt_id\"], prefix=\"rel-\"): {\n                    \"src_id\": dp[\"src_id\"],\n                    \"tgt_id\": dp[\"tgt_id\"],\n                    \"source_id\": dp[\"source_id\"],\n                    \"content\": f\"{dp['keywords']}\\t{dp['src_id']}\\n{dp['tgt_id']}\\n{dp['description']}\",\n                    \"keywords\": dp[\"keywords\"],\n                    \"description\": dp[\"description\"],\n                    \"weight\": dp[\"weight\"],\n                    \"file_path\": dp.get(\"file_path\", \"custom_kg\"),\n                }\n                for dp in all_relationships_data\n            }\n            await self.relationships_vdb.upsert(data_for_vdb)\n\n        except Exception as e:\n            logger.error(f\"Error in ainsert_custom_kg: {e}\")\n            raise\n        finally:\n            if update_storage:\n                await self._insert_done()\n\n    def query(\n        self,\n        query: str,\n        param: QueryParam = QueryParam(),\n        system_prompt: str | None = None,\n    ) -> str | Iterator[str]:\n        \"\"\"\n        Perform a sync query.\n\n        Args:\n            query (str): The query to be executed.\n            param (QueryParam): Configuration parameters for query execution.\n            prompt (Optional[str]): Custom prompts for fine-tuned control over the system's behavior. Defaults to None, which uses PROMPTS[\"rag_response\"].\n\n        Returns:\n            str: The result of the query execution.\n        \"\"\"\n        loop = always_get_an_event_loop()\n\n        return loop.run_until_complete(self.aquery(query, param, system_prompt))  # type: ignore\n\n    async def aquery(\n        self,\n        query: str,\n        param: QueryParam = QueryParam(),\n        system_prompt: str | None = None,\n    ) -> str | AsyncIterator[str]:\n        \"\"\"\n        Perform a async query (backward compatibility wrapper).\n\n        This function is now a wrapper around aquery_llm that maintains backward compatibility\n        by returning only the LLM response content in the original format.\n\n        Args:\n            query (str): The query to be executed.\n            param (QueryParam): Configuration parameters for query execution.\n                If param.model_func is provided, it will be used instead of the global model.\n            system_prompt (Optional[str]): Custom prompts for fine-tuned control over the system's behavior. Defaults to None, which uses PROMPTS[\"rag_response\"].\n\n        Returns:\n            str | AsyncIterator[str]: The LLM response content.\n                - Non-streaming: Returns str\n                - Streaming: Returns AsyncIterator[str]\n        \"\"\"\n        # Call the new aquery_llm function to get complete results\n        result = await self.aquery_llm(query, param, system_prompt)\n\n        # Extract and return only the LLM response for backward compatibility\n        llm_response = result.get(\"llm_response\", {})\n\n        if llm_response.get(\"is_streaming\"):\n            return llm_response.get(\"response_iterator\")\n        else:\n            return llm_response.get(\"content\", \"\")\n\n    def query_data(\n        self,\n        query: str,\n        param: QueryParam = QueryParam(),\n    ) -> dict[str, Any]:\n        \"\"\"\n        Synchronous data retrieval API: returns structured retrieval results without LLM generation.\n\n        This function is the synchronous version of aquery_data, providing the same functionality\n        for users who prefer synchronous interfaces.\n\n        Args:\n            query: Query text for retrieval.\n            param: Query parameters controlling retrieval behavior (same as aquery).\n\n        Returns:\n            dict[str, Any]: Same structured data result as aquery_data.\n        \"\"\"\n        loop = always_get_an_event_loop()\n        return loop.run_until_complete(self.aquery_data(query, param))\n\n    async def aquery_data(\n        self,\n        query: str,\n        param: QueryParam = QueryParam(),\n    ) -> dict[str, Any]:\n        \"\"\"\n        Asynchronous data retrieval API: returns structured retrieval results without LLM generation.\n\n        This function reuses the same logic as aquery but stops before LLM generation,\n        returning the final processed entities, relationships, and chunks data that would be sent to LLM.\n\n        Args:\n            query: Query text for retrieval.\n            param: Query parameters controlling retrieval behavior (same as aquery).\n\n        Returns:\n            dict[str, Any]: Structured data result in the following format:\n\n            **Success Response:**\n            ```python\n            {\n                \"status\": \"success\",\n                \"message\": \"Query executed successfully\",\n                \"data\": {\n                    \"entities\": [\n                        {\n                            \"entity_name\": str,      # Entity identifier\n                            \"entity_type\": str,      # Entity category/type\n                            \"description\": str,      # Entity description\n                            \"source_id\": str,        # Source chunk references\n                            \"file_path\": str,        # Origin file path\n                            \"created_at\": str,       # Creation timestamp\n                            \"reference_id\": str      # Reference identifier for citations\n                        }\n                    ],\n                    \"relationships\": [\n                        {\n                            \"src_id\": str,           # Source entity name\n                            \"tgt_id\": str,           # Target entity name\n                            \"description\": str,      # Relationship description\n                            \"keywords\": str,         # Relationship keywords\n                            \"weight\": float,         # Relationship strength\n                            \"source_id\": str,        # Source chunk references\n                            \"file_path\": str,        # Origin file path\n                            \"created_at\": str,       # Creation timestamp\n                            \"reference_id\": str      # Reference identifier for citations\n                        }\n                    ],\n                    \"chunks\": [\n                        {\n                            \"content\": str,          # Document chunk content\n                            \"file_path\": str,        # Origin file path\n                            \"chunk_id\": str,         # Unique chunk identifier\n                            \"reference_id\": str      # Reference identifier for citations\n                        }\n                    ],\n                    \"references\": [\n                        {\n                            \"reference_id\": str,     # Reference identifier\n                            \"file_path\": str         # Corresponding file path\n                        }\n                    ]\n                },\n                \"metadata\": {\n                    \"query_mode\": str,           # Query mode used (\"local\", \"global\", \"hybrid\", \"mix\", \"naive\", \"bypass\")\n                    \"keywords\": {\n                        \"high_level\": List[str], # High-level keywords extracted\n                        \"low_level\": List[str]   # Low-level keywords extracted\n                    },\n                    \"processing_info\": {\n                        \"total_entities_found\": int,        # Total entities before truncation\n                        \"total_relations_found\": int,       # Total relations before truncation\n                        \"entities_after_truncation\": int,   # Entities after token truncation\n                        \"relations_after_truncation\": int,  # Relations after token truncation\n                        \"merged_chunks_count\": int,          # Chunks before final processing\n                        \"final_chunks_count\": int            # Final chunks in result\n                    }\n                }\n            }\n            ```\n\n            **Query Mode Differences:**\n            - **local**: Focuses on entities and their related chunks based on low-level keywords\n            - **global**: Focuses on relationships and their connected entities based on high-level keywords\n            - **hybrid**: Combines local and global results using round-robin merging\n            - **mix**: Includes knowledge graph data plus vector-retrieved document chunks\n            - **naive**: Only vector-retrieved chunks, entities and relationships arrays are empty\n            - **bypass**: All data arrays are empty, used for direct LLM queries\n\n            ** processing_info is optional and may not be present in all responses, especially when query result is empty**\n\n            **Failure Response:**\n            ```python\n            {\n                \"status\": \"failure\",\n                \"message\": str,  # Error description\n                \"data\": {}       # Empty data object\n            }\n            ```\n\n            **Common Failure Cases:**\n            - Empty query string\n            - Both high-level and low-level keywords are empty\n            - Query returns empty dataset\n            - Missing tokenizer or system configuration errors\n\n        Note:\n            The function adapts to the new data format from convert_to_user_format where\n            actual data is nested under the 'data' field, with 'status' and 'message'\n            fields at the top level.\n        \"\"\"\n        global_config = asdict(self)\n\n        # Create a copy of param to avoid modifying the original\n        data_param = QueryParam(\n            mode=param.mode,\n            only_need_context=True,  # Skip LLM generation, only get context and data\n            only_need_prompt=False,\n            response_type=param.response_type,\n            stream=False,  # Data retrieval doesn't need streaming\n            top_k=param.top_k,\n            chunk_top_k=param.chunk_top_k,\n            max_entity_tokens=param.max_entity_tokens,\n            max_relation_tokens=param.max_relation_tokens,\n            max_total_tokens=param.max_total_tokens,\n            hl_keywords=param.hl_keywords,\n            ll_keywords=param.ll_keywords,\n            conversation_history=param.conversation_history,\n            history_turns=param.history_turns,\n            model_func=param.model_func,\n            user_prompt=param.user_prompt,\n            enable_rerank=param.enable_rerank,\n        )\n\n        query_result = None\n\n        if data_param.mode in [\"local\", \"global\", \"hybrid\", \"mix\"]:\n            logger.debug(f\"[aquery_data] Using kg_query for mode: {data_param.mode}\")\n            query_result = await kg_query(\n                query.strip(),\n                self.chunk_entity_relation_graph,\n                self.entities_vdb,\n                self.relationships_vdb,\n                self.text_chunks,\n                data_param,  # Use data_param with only_need_context=True\n                global_config,\n                hashing_kv=self.llm_response_cache,\n                system_prompt=None,\n                chunks_vdb=self.chunks_vdb,\n            )\n        elif data_param.mode == \"naive\":\n            logger.debug(f\"[aquery_data] Using naive_query for mode: {data_param.mode}\")\n            query_result = await naive_query(\n                query.strip(),\n                self.chunks_vdb,\n                data_param,  # Use data_param with only_need_context=True\n                global_config,\n                hashing_kv=self.llm_response_cache,\n                system_prompt=None,\n            )\n        elif data_param.mode == \"bypass\":\n            logger.debug(\"[aquery_data] Using bypass mode\")\n            # bypass mode returns empty data using convert_to_user_format\n            empty_raw_data = convert_to_user_format(\n                [],  # no entities\n                [],  # no relationships\n                [],  # no chunks\n                [],  # no references\n                \"bypass\",\n            )\n            query_result = QueryResult(content=\"\", raw_data=empty_raw_data)\n        else:\n            raise ValueError(f\"Unknown mode {data_param.mode}\")\n\n        if query_result is None:\n            no_result_message = \"Query returned no results\"\n            if data_param.mode == \"naive\":\n                no_result_message = \"No relevant document chunks found.\"\n            final_data: dict[str, Any] = {\n                \"status\": \"failure\",\n                \"message\": no_result_message,\n                \"data\": {},\n                \"metadata\": {\n                    \"failure_reason\": \"no_results\",\n                    \"mode\": data_param.mode,\n                },\n            }\n            logger.info(\"[aquery_data] Query returned no results.\")\n        else:\n            # Extract raw_data from QueryResult\n            final_data = query_result.raw_data or {}\n\n            # Log final result counts - adapt to new data format from convert_to_user_format\n            if final_data and \"data\" in final_data:\n                data_section = final_data[\"data\"]\n                entities_count = len(data_section.get(\"entities\", []))\n                relationships_count = len(data_section.get(\"relationships\", []))\n                chunks_count = len(data_section.get(\"chunks\", []))\n                logger.debug(\n                    f\"[aquery_data] Final result: {entities_count} entities, {relationships_count} relationships, {chunks_count} chunks\"\n                )\n            else:\n                logger.warning(\"[aquery_data] No data section found in query result\")\n\n        await self._query_done()\n        return final_data\n\n    async def aquery_llm(\n        self,\n        query: str,\n        param: QueryParam = QueryParam(),\n        system_prompt: str | None = None,\n    ) -> dict[str, Any]:\n        \"\"\"\n        Asynchronous complete query API: returns structured retrieval results with LLM generation.\n\n        This function performs a single query operation and returns both structured data and LLM response,\n        based on the original aquery logic to avoid duplicate calls.\n\n        Args:\n            query: Query text for retrieval and LLM generation.\n            param: Query parameters controlling retrieval and LLM behavior.\n            system_prompt: Optional custom system prompt for LLM generation.\n\n        Returns:\n            dict[str, Any]: Complete response with structured data and LLM response.\n        \"\"\"\n        logger.debug(f\"[aquery_llm] Query param: {param}\")\n\n        global_config = asdict(self)\n\n        try:\n            query_result = None\n\n            if param.mode in [\"local\", \"global\", \"hybrid\", \"mix\"]:\n                query_result = await kg_query(\n                    query.strip(),\n                    self.chunk_entity_relation_graph,\n                    self.entities_vdb,\n                    self.relationships_vdb,\n                    self.text_chunks,\n                    param,\n                    global_config,\n                    hashing_kv=self.llm_response_cache,\n                    system_prompt=system_prompt,\n                    chunks_vdb=self.chunks_vdb,\n                )\n            elif param.mode == \"naive\":\n                query_result = await naive_query(\n                    query.strip(),\n                    self.chunks_vdb,\n                    param,\n                    global_config,\n                    hashing_kv=self.llm_response_cache,\n                    system_prompt=system_prompt,\n                )\n            elif param.mode == \"bypass\":\n                # Bypass mode: directly use LLM without knowledge retrieval\n                use_llm_func = param.model_func or global_config[\"llm_model_func\"]\n                # Apply higher priority (8) to entity/relation summary tasks\n                use_llm_func = partial(use_llm_func, _priority=8)\n\n                param.stream = True if param.stream is None else param.stream\n                response = await use_llm_func(\n                    query.strip(),\n                    system_prompt=system_prompt,\n                    history_messages=param.conversation_history,\n                    enable_cot=True,\n                    stream=param.stream,\n                )\n                if type(response) is str:\n                    return {\n                        \"status\": \"success\",\n                        \"message\": \"Bypass mode LLM non streaming response\",\n                        \"data\": {},\n                        \"metadata\": {},\n                        \"llm_response\": {\n                            \"content\": response,\n                            \"response_iterator\": None,\n                            \"is_streaming\": False,\n                        },\n                    }\n                else:\n                    return {\n                        \"status\": \"success\",\n                        \"message\": \"Bypass mode LLM streaming response\",\n                        \"data\": {},\n                        \"metadata\": {},\n                        \"llm_response\": {\n                            \"content\": None,\n                            \"response_iterator\": response,\n                            \"is_streaming\": True,\n                        },\n                    }\n            else:\n                raise ValueError(f\"Unknown mode {param.mode}\")\n\n            await self._query_done()\n\n            # Check if query_result is None\n            if query_result is None:\n                return {\n                    \"status\": \"failure\",\n                    \"message\": \"Query returned no results\",\n                    \"data\": {},\n                    \"metadata\": {\n                        \"failure_reason\": \"no_results\",\n                        \"mode\": param.mode,\n                    },\n                    \"llm_response\": {\n                        \"content\": PROMPTS[\"fail_response\"],\n                        \"response_iterator\": None,\n                        \"is_streaming\": False,\n                    },\n                }\n\n            # Extract structured data from query result\n            raw_data = query_result.raw_data or {}\n            raw_data[\"llm_response\"] = {\n                \"content\": query_result.content\n                if not query_result.is_streaming\n                else None,\n                \"response_iterator\": query_result.response_iterator\n                if query_result.is_streaming\n                else None,\n                \"is_streaming\": query_result.is_streaming,\n            }\n\n            return raw_data\n\n        except Exception as e:\n            logger.error(f\"Query failed: {e}\")\n            # Return error response\n            return {\n                \"status\": \"failure\",\n                \"message\": f\"Query failed: {str(e)}\",\n                \"data\": {},\n                \"metadata\": {},\n                \"llm_response\": {\n                    \"content\": None,\n                    \"response_iterator\": None,\n                    \"is_streaming\": False,\n                },\n            }\n\n    def query_llm(\n        self,\n        query: str,\n        param: QueryParam = QueryParam(),\n        system_prompt: str | None = None,\n    ) -> dict[str, Any]:\n        \"\"\"\n        Synchronous complete query API: returns structured retrieval results with LLM generation.\n\n        This function is the synchronous version of aquery_llm, providing the same functionality\n        for users who prefer synchronous interfaces.\n\n        Args:\n            query: Query text for retrieval and LLM generation.\n            param: Query parameters controlling retrieval and LLM behavior.\n            system_prompt: Optional custom system prompt for LLM generation.\n\n        Returns:\n            dict[str, Any]: Same complete response format as aquery_llm.\n        \"\"\"\n        loop = always_get_an_event_loop()\n        return loop.run_until_complete(self.aquery_llm(query, param, system_prompt))\n\n    async def _query_done(self):\n        await self.llm_response_cache.index_done_callback()\n\n    async def aclear_cache(self) -> None:\n        \"\"\"Clear all cache data from the LLM response cache storage.\n\n        This method clears all cached LLM responses regardless of mode.\n\n        Example:\n            # Clear all cache\n            await rag.aclear_cache()\n        \"\"\"\n        if not self.llm_response_cache:\n            logger.warning(\"No cache storage configured\")\n            return\n\n        try:\n            # Clear all cache using drop method\n            success = await self.llm_response_cache.drop()\n            if success:\n                logger.info(\"Cleared all cache\")\n            else:\n                logger.warning(\"Failed to clear all cache\")\n\n            await self.llm_response_cache.index_done_callback()\n\n        except Exception as e:\n            logger.error(f\"Error while clearing cache: {e}\")\n\n    def clear_cache(self) -> None:\n        \"\"\"Synchronous version of aclear_cache.\"\"\"\n        return always_get_an_event_loop().run_until_complete(self.aclear_cache())\n\n    async def get_docs_by_status(\n        self, status: DocStatus\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Get documents by status\n\n        Returns:\n            Dict with document id is keys and document status is values\n        \"\"\"\n        return await self.doc_status.get_docs_by_status(status)\n\n    async def aget_docs_by_ids(\n        self, ids: str | list[str]\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Retrieves the processing status for one or more documents by their IDs.\n\n        Args:\n            ids: A single document ID (string) or a list of document IDs (list of strings).\n\n        Returns:\n            A dictionary where keys are the document IDs for which a status was found,\n            and values are the corresponding DocProcessingStatus objects. IDs that\n            are not found in the storage will be omitted from the result dictionary.\n        \"\"\"\n        if isinstance(ids, str):\n            # Ensure input is always a list of IDs for uniform processing\n            id_list = [ids]\n        elif (\n            ids is None\n        ):  # Handle potential None input gracefully, although type hint suggests str/list\n            logger.warning(\n                \"aget_docs_by_ids called with None input, returning empty dict.\"\n            )\n            return {}\n        else:\n            # Assume input is already a list if not a string\n            id_list = ids\n\n        # Return early if the final list of IDs is empty\n        if not id_list:\n            logger.debug(\"aget_docs_by_ids called with an empty list of IDs.\")\n            return {}\n\n        # Create tasks to fetch document statuses concurrently using the doc_status storage\n        tasks = [self.doc_status.get_by_id(doc_id) for doc_id in id_list]\n        # Execute tasks concurrently and gather the results. Results maintain order.\n        # Type hint indicates results can be DocProcessingStatus or None if not found.\n        results_list: list[Optional[DocProcessingStatus]] = await asyncio.gather(*tasks)\n\n        # Build the result dictionary, mapping found IDs to their statuses\n        found_statuses: dict[str, DocProcessingStatus] = {}\n        # Keep track of IDs for which no status was found (for logging purposes)\n        not_found_ids: list[str] = []\n\n        # Iterate through the results, correlating them back to the original IDs\n        for i, status_obj in enumerate(results_list):\n            doc_id = id_list[\n                i\n            ]  # Get the original ID corresponding to this result index\n            if status_obj:\n                # If a status object was returned (not None), add it to the result dict\n                found_statuses[doc_id] = status_obj\n            else:\n                # If status_obj is None, the document ID was not found in storage\n                not_found_ids.append(doc_id)\n\n        # Log a warning if any of the requested document IDs were not found\n        if not_found_ids:\n            logger.warning(\n                f\"Document statuses not found for the following IDs: {not_found_ids}\"\n            )\n\n        # Return the dictionary containing statuses only for the found document IDs\n        return found_statuses\n\n    async def adelete_by_doc_id(\n        self, doc_id: str, delete_llm_cache: bool = False\n    ) -> DeletionResult:\n        \"\"\"Delete a document and all its related data, including chunks, graph elements.\n\n        This method orchestrates a comprehensive deletion process for a given document ID.\n        It ensures that not only the document itself but also all its derived and associated\n        data across different storage layers are removed or rebuiled. If entities or relationships\n        are partially affected, they will be rebuilded using LLM cached from remaining documents.\n\n        **Concurrency Control Design:**\n\n        This function implements a pipeline-based concurrency control to prevent data corruption:\n\n        1. **Single Document Deletion** (when WE acquire pipeline):\n           - Sets job_name to \"Single document deletion\" (NOT starting with \"deleting\")\n           - Prevents other adelete_by_doc_id calls from running concurrently\n           - Ensures exclusive access to graph operations for this deletion\n\n        2. **Batch Document Deletion** (when background_delete_documents acquires pipeline):\n           - Sets job_name to \"Deleting {N} Documents\" (starts with \"deleting\")\n           - Allows multiple adelete_by_doc_id calls to join the deletion queue\n           - Each call validates the job name to ensure it's part of a deletion operation\n\n        The validation logic `if not job_name.startswith(\"deleting\") or \"document\" not in job_name`\n        ensures that:\n        - adelete_by_doc_id can only run when pipeline is idle OR during batch deletion\n        - Prevents concurrent single deletions that could cause race conditions\n        - Rejects operations when pipeline is busy with non-deletion tasks\n\n        Args:\n            doc_id (str): The unique identifier of the document to be deleted.\n            delete_llm_cache (bool): Whether to delete cached LLM extraction results\n                associated with the document. Defaults to False.\n\n        Returns:\n            DeletionResult: An object containing the outcome of the deletion process.\n                - `status` (str): \"success\", \"not_found\", \"not_allowed\", or \"failure\".\n                - `doc_id` (str): The ID of the document attempted to be deleted.\n                - `message` (str): A summary of the operation's result.\n                - `status_code` (int): HTTP status code (e.g., 200, 404, 403, 500).\n                - `file_path` (str | None): The file path of the deleted document, if available.\n        \"\"\"\n        # Get pipeline status shared data and lock for validation\n        pipeline_status = await get_namespace_data(\n            \"pipeline_status\", workspace=self.workspace\n        )\n        pipeline_status_lock = get_namespace_lock(\n            \"pipeline_status\", workspace=self.workspace\n        )\n\n        # Track whether WE acquired the pipeline\n        we_acquired_pipeline = False\n\n        # Check and acquire pipeline if needed\n        async with pipeline_status_lock:\n            if not pipeline_status.get(\"busy\", False):\n                # Pipeline is idle - WE acquire it for this deletion\n                we_acquired_pipeline = True\n                pipeline_status.update(\n                    {\n                        \"busy\": True,\n                        \"job_name\": \"Single document deletion\",\n                        \"job_start\": datetime.now(timezone.utc).isoformat(),\n                        \"docs\": 1,\n                        \"batchs\": 1,\n                        \"cur_batch\": 0,\n                        \"request_pending\": False,\n                        \"cancellation_requested\": False,\n                        \"latest_message\": f\"Starting deletion for document: {doc_id}\",\n                    }\n                )\n                # Initialize history messages\n                pipeline_status[\"history_messages\"][:] = [\n                    f\"Starting deletion for document: {doc_id}\"\n                ]\n            else:\n                # Pipeline already busy - verify it's a deletion job\n                job_name = pipeline_status.get(\"job_name\", \"\").lower()\n                if not job_name.startswith(\"deleting\") or \"document\" not in job_name:\n                    return DeletionResult(\n                        status=\"not_allowed\",\n                        doc_id=doc_id,\n                        message=f\"Deletion not allowed: current job '{pipeline_status.get('job_name')}' is not a document deletion job\",\n                        status_code=403,\n                        file_path=None,\n                    )\n                # Pipeline is busy with deletion - proceed without acquiring\n\n        deletion_operations_started = False\n        original_exception = None\n        doc_llm_cache_ids: list[str] = []\n\n        async with pipeline_status_lock:\n            log_message = f\"Starting deletion process for document {doc_id}\"\n            logger.info(log_message)\n            pipeline_status[\"latest_message\"] = log_message\n            pipeline_status[\"history_messages\"].append(log_message)\n\n        try:\n            # 1. Get the document status and related data\n            doc_status_data = await self.doc_status.get_by_id(doc_id)\n            file_path = doc_status_data.get(\"file_path\") if doc_status_data else None\n            if not doc_status_data:\n                logger.warning(f\"Document {doc_id} not found\")\n                return DeletionResult(\n                    status=\"not_found\",\n                    doc_id=doc_id,\n                    message=f\"Document {doc_id} not found.\",\n                    status_code=404,\n                    file_path=\"\",\n                )\n\n            # Check document status and log warning for non-completed documents\n            raw_status = doc_status_data.get(\"status\")\n            try:\n                doc_status = DocStatus(raw_status)\n            except ValueError:\n                doc_status = raw_status\n\n            if doc_status != DocStatus.PROCESSED:\n                if doc_status == DocStatus.PENDING:\n                    warning_msg = (\n                        f\"Deleting {doc_id} {file_path}(previous status: PENDING)\"\n                    )\n                elif doc_status == DocStatus.PROCESSING:\n                    warning_msg = (\n                        f\"Deleting {doc_id} {file_path}(previous status: PROCESSING)\"\n                    )\n                elif doc_status == DocStatus.PREPROCESSED:\n                    warning_msg = (\n                        f\"Deleting {doc_id} {file_path}(previous status: PREPROCESSED)\"\n                    )\n                elif doc_status == DocStatus.FAILED:\n                    warning_msg = (\n                        f\"Deleting {doc_id} {file_path}(previous status: FAILED)\"\n                    )\n                else:\n                    status_text = (\n                        doc_status.value\n                        if isinstance(doc_status, DocStatus)\n                        else str(doc_status)\n                    )\n                    warning_msg = (\n                        f\"Deleting {doc_id} {file_path}(previous status: {status_text})\"\n                    )\n                logger.info(warning_msg)\n                # Update pipeline status for monitoring\n                async with pipeline_status_lock:\n                    pipeline_status[\"latest_message\"] = warning_msg\n                    pipeline_status[\"history_messages\"].append(warning_msg)\n\n            # 2. Get chunk IDs from document status\n            chunk_ids = set(doc_status_data.get(\"chunks_list\", []))\n\n            if not chunk_ids:\n                logger.warning(f\"No chunks found for document {doc_id}\")\n                # Mark that deletion operations have started\n                deletion_operations_started = True\n                try:\n                    # Still need to delete the doc status and full doc\n                    await self.full_docs.delete([doc_id])\n                    await self.doc_status.delete([doc_id])\n                except Exception as e:\n                    logger.error(\n                        f\"Failed to delete document {doc_id} with no chunks: {e}\"\n                    )\n                    raise Exception(f\"Failed to delete document entry: {e}\") from e\n\n                async with pipeline_status_lock:\n                    log_message = (\n                        f\"Document deleted without associated chunks: {doc_id}\"\n                    )\n                    logger.info(log_message)\n                    pipeline_status[\"latest_message\"] = log_message\n                    pipeline_status[\"history_messages\"].append(log_message)\n\n                return DeletionResult(\n                    status=\"success\",\n                    doc_id=doc_id,\n                    message=log_message,\n                    status_code=200,\n                    file_path=file_path,\n                )\n\n            # Mark that deletion operations have started\n            deletion_operations_started = True\n\n            if delete_llm_cache and chunk_ids:\n                if not self.llm_response_cache:\n                    logger.info(\n                        \"Skipping LLM cache collection for document %s because cache storage is unavailable\",\n                        doc_id,\n                    )\n                elif not self.text_chunks:\n                    logger.info(\n                        \"Skipping LLM cache collection for document %s because text chunk storage is unavailable\",\n                        doc_id,\n                    )\n                else:\n                    try:\n                        chunk_data_list = await self.text_chunks.get_by_ids(\n                            list(chunk_ids)\n                        )\n                        seen_cache_ids: set[str] = set()\n                        for chunk_data in chunk_data_list:\n                            if not chunk_data or not isinstance(chunk_data, dict):\n                                continue\n                            cache_ids = chunk_data.get(\"llm_cache_list\", [])\n                            if not isinstance(cache_ids, list):\n                                continue\n                            for cache_id in cache_ids:\n                                if (\n                                    isinstance(cache_id, str)\n                                    and cache_id\n                                    and cache_id not in seen_cache_ids\n                                ):\n                                    doc_llm_cache_ids.append(cache_id)\n                                    seen_cache_ids.add(cache_id)\n                        if doc_llm_cache_ids:\n                            logger.info(\n                                \"Collected %d LLM cache entries for document %s\",\n                                len(doc_llm_cache_ids),\n                                doc_id,\n                            )\n                        else:\n                            logger.info(\n                                \"No LLM cache entries found for document %s\", doc_id\n                            )\n                    except Exception as cache_collect_error:\n                        logger.error(\n                            \"Failed to collect LLM cache ids for document %s: %s\",\n                            doc_id,\n                            cache_collect_error,\n                        )\n                        raise Exception(\n                            f\"Failed to collect LLM cache ids for document {doc_id}: {cache_collect_error}\"\n                        ) from cache_collect_error\n\n            # 4. Analyze entities and relationships that will be affected\n            entities_to_delete = set()\n            entities_to_rebuild = {}  # entity_name -> remaining chunk id list\n            relationships_to_delete = set()\n            relationships_to_rebuild = {}  # (src, tgt) -> remaining chunk id list\n            entity_chunk_updates: dict[str, list[str]] = {}\n            relation_chunk_updates: dict[tuple[str, str], list[str]] = {}\n\n            try:\n                # Get affected entities and relations from full_entities and full_relations storage\n                doc_entities_data = await self.full_entities.get_by_id(doc_id)\n                doc_relations_data = await self.full_relations.get_by_id(doc_id)\n\n                affected_nodes = []\n                affected_edges = []\n\n                # Get entity data from graph storage using entity names from full_entities\n                if doc_entities_data and \"entity_names\" in doc_entities_data:\n                    entity_names = doc_entities_data[\"entity_names\"]\n                    # get_nodes_batch returns dict[str, dict], need to convert to list[dict]\n                    nodes_dict = await self.chunk_entity_relation_graph.get_nodes_batch(\n                        entity_names\n                    )\n                    for entity_name in entity_names:\n                        node_data = nodes_dict.get(entity_name)\n                        if node_data:\n                            # Ensure compatibility with existing logic that expects \"id\" field\n                            if \"id\" not in node_data:\n                                node_data[\"id\"] = entity_name\n                            affected_nodes.append(node_data)\n\n                # Get relation data from graph storage using relation pairs from full_relations\n                if doc_relations_data and \"relation_pairs\" in doc_relations_data:\n                    relation_pairs = doc_relations_data[\"relation_pairs\"]\n                    edge_pairs_dicts = [\n                        {\"src\": pair[0], \"tgt\": pair[1]} for pair in relation_pairs\n                    ]\n                    # get_edges_batch returns dict[tuple[str, str], dict], need to convert to list[dict]\n                    edges_dict = await self.chunk_entity_relation_graph.get_edges_batch(\n                        edge_pairs_dicts\n                    )\n\n                    for pair in relation_pairs:\n                        src, tgt = pair[0], pair[1]\n                        edge_key = (src, tgt)\n                        edge_data = edges_dict.get(edge_key)\n                        if edge_data:\n                            # Ensure compatibility with existing logic that expects \"source\" and \"target\" fields\n                            if \"source\" not in edge_data:\n                                edge_data[\"source\"] = src\n                            if \"target\" not in edge_data:\n                                edge_data[\"target\"] = tgt\n                            affected_edges.append(edge_data)\n\n            except Exception as e:\n                logger.error(f\"Failed to analyze affected graph elements: {e}\")\n                raise Exception(f\"Failed to analyze graph dependencies: {e}\") from e\n\n            try:\n                # Process entities\n                for node_data in affected_nodes:\n                    node_label = node_data.get(\"entity_id\")\n                    if not node_label:\n                        continue\n\n                    existing_sources: list[str] = []\n                    if self.entity_chunks:\n                        stored_chunks = await self.entity_chunks.get_by_id(node_label)\n                        if stored_chunks and isinstance(stored_chunks, dict):\n                            existing_sources = [\n                                chunk_id\n                                for chunk_id in stored_chunks.get(\"chunk_ids\", [])\n                                if chunk_id\n                            ]\n\n                    if not existing_sources and node_data.get(\"source_id\"):\n                        existing_sources = [\n                            chunk_id\n                            for chunk_id in node_data[\"source_id\"].split(\n                                GRAPH_FIELD_SEP\n                            )\n                            if chunk_id\n                        ]\n\n                    if not existing_sources:\n                        # No chunk references means this entity should be deleted\n                        entities_to_delete.add(node_label)\n                        entity_chunk_updates[node_label] = []\n                        continue\n\n                    remaining_sources = subtract_source_ids(existing_sources, chunk_ids)\n\n                    if not remaining_sources:\n                        entities_to_delete.add(node_label)\n                        entity_chunk_updates[node_label] = []\n                    elif remaining_sources != existing_sources:\n                        entities_to_rebuild[node_label] = remaining_sources\n                        entity_chunk_updates[node_label] = remaining_sources\n                    else:\n                        logger.info(f\"Untouch entity: {node_label}\")\n\n                async with pipeline_status_lock:\n                    log_message = f\"Found {len(entities_to_rebuild)} affected entities\"\n                    logger.info(log_message)\n                    pipeline_status[\"latest_message\"] = log_message\n                    pipeline_status[\"history_messages\"].append(log_message)\n\n                # Process relationships\n                for edge_data in affected_edges:\n                    # source target is not in normalize order in graph db property\n                    src = edge_data.get(\"source\")\n                    tgt = edge_data.get(\"target\")\n\n                    if not src or not tgt or \"source_id\" not in edge_data:\n                        continue\n\n                    edge_tuple = tuple(sorted((src, tgt)))\n                    if (\n                        edge_tuple in relationships_to_delete\n                        or edge_tuple in relationships_to_rebuild\n                    ):\n                        continue\n\n                    existing_sources: list[str] = []\n                    if self.relation_chunks:\n                        storage_key = make_relation_chunk_key(src, tgt)\n                        stored_chunks = await self.relation_chunks.get_by_id(\n                            storage_key\n                        )\n                        if stored_chunks and isinstance(stored_chunks, dict):\n                            existing_sources = [\n                                chunk_id\n                                for chunk_id in stored_chunks.get(\"chunk_ids\", [])\n                                if chunk_id\n                            ]\n\n                    if not existing_sources:\n                        existing_sources = [\n                            chunk_id\n                            for chunk_id in edge_data[\"source_id\"].split(\n                                GRAPH_FIELD_SEP\n                            )\n                            if chunk_id\n                        ]\n\n                    if not existing_sources:\n                        # No chunk references means this relationship should be deleted\n                        relationships_to_delete.add(edge_tuple)\n                        relation_chunk_updates[edge_tuple] = []\n                        continue\n\n                    remaining_sources = subtract_source_ids(existing_sources, chunk_ids)\n\n                    if not remaining_sources:\n                        relationships_to_delete.add(edge_tuple)\n                        relation_chunk_updates[edge_tuple] = []\n                    elif remaining_sources != existing_sources:\n                        relationships_to_rebuild[edge_tuple] = remaining_sources\n                        relation_chunk_updates[edge_tuple] = remaining_sources\n                    else:\n                        logger.info(f\"Untouch relation: {edge_tuple}\")\n\n                async with pipeline_status_lock:\n                    log_message = (\n                        f\"Found {len(relationships_to_rebuild)} affected relations\"\n                    )\n                    logger.info(log_message)\n                    pipeline_status[\"latest_message\"] = log_message\n                    pipeline_status[\"history_messages\"].append(log_message)\n\n                current_time = int(time.time())\n\n                if entity_chunk_updates and self.entity_chunks:\n                    entity_upsert_payload = {}\n                    for entity_name, remaining in entity_chunk_updates.items():\n                        if not remaining:\n                            # Empty entities are deleted alongside graph nodes later\n                            continue\n                        entity_upsert_payload[entity_name] = {\n                            \"chunk_ids\": remaining,\n                            \"count\": len(remaining),\n                            \"updated_at\": current_time,\n                        }\n                    if entity_upsert_payload:\n                        await self.entity_chunks.upsert(entity_upsert_payload)\n\n                if relation_chunk_updates and self.relation_chunks:\n                    relation_upsert_payload = {}\n                    for edge_tuple, remaining in relation_chunk_updates.items():\n                        if not remaining:\n                            # Empty relations are deleted alongside graph edges later\n                            continue\n                        storage_key = make_relation_chunk_key(*edge_tuple)\n                        relation_upsert_payload[storage_key] = {\n                            \"chunk_ids\": remaining,\n                            \"count\": len(remaining),\n                            \"updated_at\": current_time,\n                        }\n\n                    if relation_upsert_payload:\n                        await self.relation_chunks.upsert(relation_upsert_payload)\n\n            except Exception as e:\n                logger.error(f\"Failed to process graph analysis results: {e}\")\n                raise Exception(f\"Failed to process graph dependencies: {e}\") from e\n\n            # Data integrity is ensured by allowing only one process to hold pipeline at a time（no graph db lock is needed anymore)\n\n            # 5. Delete chunks from storage\n            if chunk_ids:\n                try:\n                    await self.chunks_vdb.delete(chunk_ids)\n                    await self.text_chunks.delete(chunk_ids)\n\n                    async with pipeline_status_lock:\n                        log_message = (\n                            f\"Successfully deleted {len(chunk_ids)} chunks from storage\"\n                        )\n                        logger.info(log_message)\n                        pipeline_status[\"latest_message\"] = log_message\n                        pipeline_status[\"history_messages\"].append(log_message)\n\n                except Exception as e:\n                    logger.error(f\"Failed to delete chunks: {e}\")\n                    raise Exception(f\"Failed to delete document chunks: {e}\") from e\n\n            # 6. Delete relationships that have no remaining sources\n            if relationships_to_delete:\n                try:\n                    # Delete from relation vdb\n                    rel_ids_to_delete = []\n                    for src, tgt in relationships_to_delete:\n                        rel_ids_to_delete.extend(\n                            [\n                                compute_mdhash_id(src + tgt, prefix=\"rel-\"),\n                                compute_mdhash_id(tgt + src, prefix=\"rel-\"),\n                            ]\n                        )\n                    await self.relationships_vdb.delete(rel_ids_to_delete)\n\n                    # Delete from graph\n                    await self.chunk_entity_relation_graph.remove_edges(\n                        list(relationships_to_delete)\n                    )\n\n                    # Delete from relation_chunks storage\n                    if self.relation_chunks:\n                        relation_storage_keys = [\n                            make_relation_chunk_key(src, tgt)\n                            for src, tgt in relationships_to_delete\n                        ]\n                        await self.relation_chunks.delete(relation_storage_keys)\n\n                    async with pipeline_status_lock:\n                        log_message = f\"Successfully deleted {len(relationships_to_delete)} relations\"\n                        logger.info(log_message)\n                        pipeline_status[\"latest_message\"] = log_message\n                        pipeline_status[\"history_messages\"].append(log_message)\n\n                except Exception as e:\n                    logger.error(f\"Failed to delete relationships: {e}\")\n                    raise Exception(f\"Failed to delete relationships: {e}\") from e\n\n            # 7. Delete entities that have no remaining sources\n            if entities_to_delete:\n                try:\n                    # Batch get all edges for entities to avoid N+1 query problem\n                    nodes_edges_dict = (\n                        await self.chunk_entity_relation_graph.get_nodes_edges_batch(\n                            list(entities_to_delete)\n                        )\n                    )\n\n                    # Debug: Check and log all edges before deleting nodes\n                    edges_to_delete = set()\n                    edges_still_exist = 0\n\n                    for entity, edges in nodes_edges_dict.items():\n                        if edges:\n                            for src, tgt in edges:\n                                # Normalize edge representation (sorted for consistency)\n                                edge_tuple = tuple(sorted((src, tgt)))\n                                edges_to_delete.add(edge_tuple)\n\n                                if (\n                                    src in entities_to_delete\n                                    and tgt in entities_to_delete\n                                ):\n                                    logger.warning(\n                                        f\"Edge still exists: {src} <-> {tgt}\"\n                                    )\n                                elif src in entities_to_delete:\n                                    logger.warning(\n                                        f\"Edge still exists: {src} --> {tgt}\"\n                                    )\n                                else:\n                                    logger.warning(\n                                        f\"Edge still exists: {src} <-- {tgt}\"\n                                    )\n                            edges_still_exist += 1\n\n                    if edges_still_exist:\n                        logger.warning(\n                            f\"⚠️ {edges_still_exist} entities still has edges before deletion\"\n                        )\n\n                    # Clean residual edges from VDB and storage before deleting nodes\n                    if edges_to_delete:\n                        # Delete from relationships_vdb\n                        rel_ids_to_delete = []\n                        for src, tgt in edges_to_delete:\n                            rel_ids_to_delete.extend(\n                                [\n                                    compute_mdhash_id(src + tgt, prefix=\"rel-\"),\n                                    compute_mdhash_id(tgt + src, prefix=\"rel-\"),\n                                ]\n                            )\n                        await self.relationships_vdb.delete(rel_ids_to_delete)\n\n                        # Delete from relation_chunks storage\n                        if self.relation_chunks:\n                            relation_storage_keys = [\n                                make_relation_chunk_key(src, tgt)\n                                for src, tgt in edges_to_delete\n                            ]\n                            await self.relation_chunks.delete(relation_storage_keys)\n\n                        logger.info(\n                            f\"Cleaned {len(edges_to_delete)} residual edges from VDB and chunk-tracking storage\"\n                        )\n\n                    # Delete from graph (edges will be auto-deleted with nodes)\n                    await self.chunk_entity_relation_graph.remove_nodes(\n                        list(entities_to_delete)\n                    )\n\n                    # Delete from vector vdb\n                    entity_vdb_ids = [\n                        compute_mdhash_id(entity, prefix=\"ent-\")\n                        for entity in entities_to_delete\n                    ]\n                    await self.entities_vdb.delete(entity_vdb_ids)\n\n                    # Delete from entity_chunks storage\n                    if self.entity_chunks:\n                        await self.entity_chunks.delete(list(entities_to_delete))\n\n                    async with pipeline_status_lock:\n                        log_message = (\n                            f\"Successfully deleted {len(entities_to_delete)} entities\"\n                        )\n                        logger.info(log_message)\n                        pipeline_status[\"latest_message\"] = log_message\n                        pipeline_status[\"history_messages\"].append(log_message)\n\n                except Exception as e:\n                    logger.error(f\"Failed to delete entities: {e}\")\n                    raise Exception(f\"Failed to delete entities: {e}\") from e\n\n            # Persist changes to graph database before entity and relationship rebuild\n            await self._insert_done()\n\n            # 8. Rebuild entities and relationships from remaining chunks\n            if entities_to_rebuild or relationships_to_rebuild:\n                try:\n                    await rebuild_knowledge_from_chunks(\n                        entities_to_rebuild=entities_to_rebuild,\n                        relationships_to_rebuild=relationships_to_rebuild,\n                        knowledge_graph_inst=self.chunk_entity_relation_graph,\n                        entities_vdb=self.entities_vdb,\n                        relationships_vdb=self.relationships_vdb,\n                        text_chunks_storage=self.text_chunks,\n                        llm_response_cache=self.llm_response_cache,\n                        global_config=asdict(self),\n                        pipeline_status=pipeline_status,\n                        pipeline_status_lock=pipeline_status_lock,\n                        entity_chunks_storage=self.entity_chunks,\n                        relation_chunks_storage=self.relation_chunks,\n                    )\n\n                except Exception as e:\n                    logger.error(f\"Failed to rebuild knowledge from chunks: {e}\")\n                    raise Exception(f\"Failed to rebuild knowledge graph: {e}\") from e\n\n            # 9. Delete from full_entities and full_relations storage\n            try:\n                await self.full_entities.delete([doc_id])\n                await self.full_relations.delete([doc_id])\n            except Exception as e:\n                logger.error(f\"Failed to delete from full_entities/full_relations: {e}\")\n                raise Exception(\n                    f\"Failed to delete from full_entities/full_relations: {e}\"\n                ) from e\n\n            # 10. Delete original document and status\n            try:\n                await self.full_docs.delete([doc_id])\n                await self.doc_status.delete([doc_id])\n            except Exception as e:\n                logger.error(f\"Failed to delete document and status: {e}\")\n                raise Exception(f\"Failed to delete document and status: {e}\") from e\n\n            if delete_llm_cache and doc_llm_cache_ids and self.llm_response_cache:\n                try:\n                    await self.llm_response_cache.delete(doc_llm_cache_ids)\n                    cache_log_message = f\"Successfully deleted {len(doc_llm_cache_ids)} LLM cache entries for document {doc_id}\"\n                    logger.info(cache_log_message)\n                    async with pipeline_status_lock:\n                        pipeline_status[\"latest_message\"] = cache_log_message\n                        pipeline_status[\"history_messages\"].append(cache_log_message)\n                    log_message = cache_log_message\n                except Exception as cache_delete_error:\n                    log_message = f\"Failed to delete LLM cache for document {doc_id}: {cache_delete_error}\"\n                    logger.error(log_message)\n                    logger.error(traceback.format_exc())\n                    async with pipeline_status_lock:\n                        pipeline_status[\"latest_message\"] = log_message\n                        pipeline_status[\"history_messages\"].append(log_message)\n\n            return DeletionResult(\n                status=\"success\",\n                doc_id=doc_id,\n                message=log_message,\n                status_code=200,\n                file_path=file_path,\n            )\n\n        except Exception as e:\n            original_exception = e\n            error_message = f\"Error while deleting document {doc_id}: {e}\"\n            logger.error(error_message)\n            logger.error(traceback.format_exc())\n            return DeletionResult(\n                status=\"fail\",\n                doc_id=doc_id,\n                message=error_message,\n                status_code=500,\n                file_path=file_path,\n            )\n\n        finally:\n            # ALWAYS ensure persistence if any deletion operations were started\n            if deletion_operations_started:\n                try:\n                    await self._insert_done()\n                except Exception as persistence_error:\n                    persistence_error_msg = f\"Failed to persist data after deletion attempt for {doc_id}: {persistence_error}\"\n                    logger.error(persistence_error_msg)\n                    logger.error(traceback.format_exc())\n\n                    # If there was no original exception, this persistence error becomes the main error\n                    if original_exception is None:\n                        return DeletionResult(\n                            status=\"fail\",\n                            doc_id=doc_id,\n                            message=f\"Deletion completed but failed to persist changes: {persistence_error}\",\n                            status_code=500,\n                            file_path=file_path,\n                        )\n                    # If there was an original exception, log the persistence error but don't override the original error\n                    # The original error result was already returned in the except block\n            else:\n                logger.debug(\n                    f\"No deletion operations were started for document {doc_id}, skipping persistence\"\n                )\n\n            # Release pipeline only if WE acquired it\n            if we_acquired_pipeline:\n                async with pipeline_status_lock:\n                    pipeline_status[\"busy\"] = False\n                    pipeline_status[\"cancellation_requested\"] = False\n                    completion_msg = (\n                        f\"Deletion process completed for document: {doc_id}\"\n                    )\n                    pipeline_status[\"latest_message\"] = completion_msg\n                    pipeline_status[\"history_messages\"].append(completion_msg)\n                    logger.info(completion_msg)\n\n    async def adelete_by_entity(self, entity_name: str) -> DeletionResult:\n        \"\"\"Asynchronously delete an entity and all its relationships.\n\n        Args:\n            entity_name: Name of the entity to delete.\n\n        Returns:\n            DeletionResult: An object containing the outcome of the deletion process.\n        \"\"\"\n        from lightrag.utils_graph import adelete_by_entity\n\n        return await adelete_by_entity(\n            self.chunk_entity_relation_graph,\n            self.entities_vdb,\n            self.relationships_vdb,\n            entity_name,\n        )\n\n    def delete_by_entity(self, entity_name: str) -> DeletionResult:\n        \"\"\"Synchronously delete an entity and all its relationships.\n\n        Args:\n            entity_name: Name of the entity to delete.\n\n        Returns:\n            DeletionResult: An object containing the outcome of the deletion process.\n        \"\"\"\n        loop = always_get_an_event_loop()\n        return loop.run_until_complete(self.adelete_by_entity(entity_name))\n\n    async def adelete_by_relation(\n        self, source_entity: str, target_entity: str\n    ) -> DeletionResult:\n        \"\"\"Asynchronously delete a relation between two entities.\n\n        Args:\n            source_entity: Name of the source entity.\n            target_entity: Name of the target entity.\n\n        Returns:\n            DeletionResult: An object containing the outcome of the deletion process.\n        \"\"\"\n        from lightrag.utils_graph import adelete_by_relation\n\n        return await adelete_by_relation(\n            self.chunk_entity_relation_graph,\n            self.relationships_vdb,\n            source_entity,\n            target_entity,\n        )\n\n    def delete_by_relation(\n        self, source_entity: str, target_entity: str\n    ) -> DeletionResult:\n        \"\"\"Synchronously delete a relation between two entities.\n\n        Args:\n            source_entity: Name of the source entity.\n            target_entity: Name of the target entity.\n\n        Returns:\n            DeletionResult: An object containing the outcome of the deletion process.\n        \"\"\"\n        loop = always_get_an_event_loop()\n        return loop.run_until_complete(\n            self.adelete_by_relation(source_entity, target_entity)\n        )\n\n    async def get_processing_status(self) -> dict[str, int]:\n        \"\"\"Get current document processing status counts\n\n        Returns:\n            Dict with counts for each status\n        \"\"\"\n        return await self.doc_status.get_status_counts()\n\n    async def aget_docs_by_track_id(\n        self, track_id: str\n    ) -> dict[str, DocProcessingStatus]:\n        \"\"\"Get documents by track_id\n\n        Args:\n            track_id: The tracking ID to search for\n\n        Returns:\n            Dict with document id as keys and document status as values\n        \"\"\"\n        return await self.doc_status.get_docs_by_track_id(track_id)\n\n    async def get_entity_info(\n        self, entity_name: str, include_vector_data: bool = False\n    ) -> dict[str, str | None | dict[str, str]]:\n        \"\"\"Get detailed information of an entity\"\"\"\n        from lightrag.utils_graph import get_entity_info\n\n        return await get_entity_info(\n            self.chunk_entity_relation_graph,\n            self.entities_vdb,\n            entity_name,\n            include_vector_data,\n        )\n\n    async def get_relation_info(\n        self, src_entity: str, tgt_entity: str, include_vector_data: bool = False\n    ) -> dict[str, str | None | dict[str, str]]:\n        \"\"\"Get detailed information of a relationship\"\"\"\n        from lightrag.utils_graph import get_relation_info\n\n        return await get_relation_info(\n            self.chunk_entity_relation_graph,\n            self.relationships_vdb,\n            src_entity,\n            tgt_entity,\n            include_vector_data,\n        )\n\n    async def aedit_entity(\n        self,\n        entity_name: str,\n        updated_data: dict[str, str],\n        allow_rename: bool = True,\n        allow_merge: bool = False,\n    ) -> dict[str, Any]:\n        \"\"\"Asynchronously edit entity information.\n\n        Updates entity information in the knowledge graph and re-embeds the entity in the vector database.\n        Also synchronizes entity_chunks_storage and relation_chunks_storage to track chunk references.\n\n        Args:\n            entity_name: Name of the entity to edit\n            updated_data: Dictionary containing updated attributes, e.g. {\"description\": \"new description\", \"entity_type\": \"new type\"}\n            allow_rename: Whether to allow entity renaming, defaults to True\n            allow_merge: Whether to merge into an existing entity when renaming to an existing name\n\n        Returns:\n            Dictionary containing updated entity information\n        \"\"\"\n        from lightrag.utils_graph import aedit_entity\n\n        return await aedit_entity(\n            self.chunk_entity_relation_graph,\n            self.entities_vdb,\n            self.relationships_vdb,\n            entity_name,\n            updated_data,\n            allow_rename,\n            allow_merge,\n            self.entity_chunks,\n            self.relation_chunks,\n        )\n\n    def edit_entity(\n        self,\n        entity_name: str,\n        updated_data: dict[str, str],\n        allow_rename: bool = True,\n        allow_merge: bool = False,\n    ) -> dict[str, Any]:\n        loop = always_get_an_event_loop()\n        return loop.run_until_complete(\n            self.aedit_entity(entity_name, updated_data, allow_rename, allow_merge)\n        )\n\n    async def aedit_relation(\n        self, source_entity: str, target_entity: str, updated_data: dict[str, Any]\n    ) -> dict[str, Any]:\n        \"\"\"Asynchronously edit relation information.\n\n        Updates relation (edge) information in the knowledge graph and re-embeds the relation in the vector database.\n        Also synchronizes the relation_chunks_storage to track which chunks reference this relation.\n\n        Args:\n            source_entity: Name of the source entity\n            target_entity: Name of the target entity\n            updated_data: Dictionary containing updated attributes, e.g. {\"description\": \"new description\", \"keywords\": \"new keywords\"}\n\n        Returns:\n            Dictionary containing updated relation information\n        \"\"\"\n        from lightrag.utils_graph import aedit_relation\n\n        return await aedit_relation(\n            self.chunk_entity_relation_graph,\n            self.entities_vdb,\n            self.relationships_vdb,\n            source_entity,\n            target_entity,\n            updated_data,\n            self.relation_chunks,\n        )\n\n    def edit_relation(\n        self, source_entity: str, target_entity: str, updated_data: dict[str, Any]\n    ) -> dict[str, Any]:\n        loop = always_get_an_event_loop()\n        return loop.run_until_complete(\n            self.aedit_relation(source_entity, target_entity, updated_data)\n        )\n\n    async def acreate_entity(\n        self, entity_name: str, entity_data: dict[str, Any]\n    ) -> dict[str, Any]:\n        \"\"\"Asynchronously create a new entity.\n\n        Creates a new entity in the knowledge graph and adds it to the vector database.\n\n        Args:\n            entity_name: Name of the new entity\n            entity_data: Dictionary containing entity attributes, e.g. {\"description\": \"description\", \"entity_type\": \"type\"}\n\n        Returns:\n            Dictionary containing created entity information\n        \"\"\"\n        from lightrag.utils_graph import acreate_entity\n\n        return await acreate_entity(\n            self.chunk_entity_relation_graph,\n            self.entities_vdb,\n            self.relationships_vdb,\n            entity_name,\n            entity_data,\n        )\n\n    def create_entity(\n        self, entity_name: str, entity_data: dict[str, Any]\n    ) -> dict[str, Any]:\n        loop = always_get_an_event_loop()\n        return loop.run_until_complete(self.acreate_entity(entity_name, entity_data))\n\n    async def acreate_relation(\n        self, source_entity: str, target_entity: str, relation_data: dict[str, Any]\n    ) -> dict[str, Any]:\n        \"\"\"Asynchronously create a new relation between entities.\n\n        Creates a new relation (edge) in the knowledge graph and adds it to the vector database.\n\n        Args:\n            source_entity: Name of the source entity\n            target_entity: Name of the target entity\n            relation_data: Dictionary containing relation attributes, e.g. {\"description\": \"description\", \"keywords\": \"keywords\"}\n\n        Returns:\n            Dictionary containing created relation information\n        \"\"\"\n        from lightrag.utils_graph import acreate_relation\n\n        return await acreate_relation(\n            self.chunk_entity_relation_graph,\n            self.entities_vdb,\n            self.relationships_vdb,\n            source_entity,\n            target_entity,\n            relation_data,\n        )\n\n    def create_relation(\n        self, source_entity: str, target_entity: str, relation_data: dict[str, Any]\n    ) -> dict[str, Any]:\n        loop = always_get_an_event_loop()\n        return loop.run_until_complete(\n            self.acreate_relation(source_entity, target_entity, relation_data)\n        )\n\n    async def amerge_entities(\n        self,\n        source_entities: list[str],\n        target_entity: str,\n        merge_strategy: dict[str, str] = None,\n        target_entity_data: dict[str, Any] = None,\n    ) -> dict[str, Any]:\n        \"\"\"Asynchronously merge multiple entities into one entity.\n\n        Merges multiple source entities into a target entity, handling all relationships,\n        and updating both the knowledge graph and vector database.\n\n        Args:\n            source_entities: List of source entity names to merge\n            target_entity: Name of the target entity after merging\n            merge_strategy: Merge strategy configuration, e.g. {\"description\": \"concatenate\", \"entity_type\": \"keep_first\"}\n                Supported strategies:\n                - \"concatenate\": Concatenate all values (for text fields)\n                - \"keep_first\": Keep the first non-empty value\n                - \"keep_last\": Keep the last non-empty value\n                - \"join_unique\": Join all unique values (for fields separated by delimiter)\n            target_entity_data: Dictionary of specific values to set for the target entity,\n                overriding any merged values, e.g. {\"description\": \"custom description\", \"entity_type\": \"PERSON\"}\n\n        Returns:\n            Dictionary containing the merged entity information\n        \"\"\"\n        from lightrag.utils_graph import amerge_entities\n\n        return await amerge_entities(\n            self.chunk_entity_relation_graph,\n            self.entities_vdb,\n            self.relationships_vdb,\n            source_entities,\n            target_entity,\n            merge_strategy,\n            target_entity_data,\n            self.entity_chunks,\n            self.relation_chunks,\n        )\n\n    def merge_entities(\n        self,\n        source_entities: list[str],\n        target_entity: str,\n        merge_strategy: dict[str, str] = None,\n        target_entity_data: dict[str, Any] = None,\n    ) -> dict[str, Any]:\n        loop = always_get_an_event_loop()\n        return loop.run_until_complete(\n            self.amerge_entities(\n                source_entities, target_entity, merge_strategy, target_entity_data\n            )\n        )\n\n    async def aexport_data(\n        self,\n        output_path: str,\n        file_format: Literal[\"csv\", \"excel\", \"md\", \"txt\"] = \"csv\",\n        include_vector_data: bool = False,\n    ) -> None:\n        \"\"\"\n        Asynchronously exports all entities, relations, and relationships to various formats.\n        Args:\n            output_path: The path to the output file (including extension).\n            file_format: Output format - \"csv\", \"excel\", \"md\", \"txt\".\n                - csv: Comma-separated values file\n                - excel: Microsoft Excel file with multiple sheets\n                - md: Markdown tables\n                - txt: Plain text formatted output\n                - table: Print formatted tables to console\n            include_vector_data: Whether to include data from the vector database.\n        \"\"\"\n        from lightrag.utils import aexport_data as utils_aexport_data\n\n        await utils_aexport_data(\n            self.chunk_entity_relation_graph,\n            self.entities_vdb,\n            self.relationships_vdb,\n            output_path,\n            file_format,\n            include_vector_data,\n        )\n\n    def export_data(\n        self,\n        output_path: str,\n        file_format: Literal[\"csv\", \"excel\", \"md\", \"txt\"] = \"csv\",\n        include_vector_data: bool = False,\n    ) -> None:\n        \"\"\"\n        Synchronously exports all entities, relations, and relationships to various formats.\n        Args:\n            output_path: The path to the output file (including extension).\n            file_format: Output format - \"csv\", \"excel\", \"md\", \"txt\".\n                - csv: Comma-separated values file\n                - excel: Microsoft Excel file with multiple sheets\n                - md: Markdown tables\n                - txt: Plain text formatted output\n                - table: Print formatted tables to console\n            include_vector_data: Whether to include data from the vector database.\n        \"\"\"\n        try:\n            loop = asyncio.get_event_loop()\n        except RuntimeError:\n            loop = asyncio.new_event_loop()\n            asyncio.set_event_loop(loop)\n\n        loop.run_until_complete(\n            self.aexport_data(output_path, file_format, include_vector_data)\n        )\n"
  },
  {
    "path": "lightrag/llm/__init__.py",
    "content": ""
  },
  {
    "path": "lightrag/llm/anthropic.py",
    "content": "from ..utils import verbose_debug, VERBOSE_DEBUG\nimport sys\nimport os\nimport logging\nimport numpy as np\nfrom typing import Any, Union, AsyncIterator\nimport pipmaster as pm  # Pipmaster for dynamic library install\n\nif sys.version_info < (3, 9):\n    from typing import AsyncIterator\nelse:\n    from collections.abc import AsyncIterator\n\n# Install Anthropic SDK if not present\nif not pm.is_installed(\"anthropic\"):\n    pm.install(\"anthropic\")\n\n# Add Voyage AI import\nif not pm.is_installed(\"voyageai\"):\n    pm.install(\"voyageai\")\nimport voyageai\n\nfrom anthropic import (\n    AsyncAnthropic,\n    APIConnectionError,\n    RateLimitError,\n    APITimeoutError,\n)\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\nfrom lightrag.utils import (\n    safe_unicode_decode,\n    logger,\n)\nfrom lightrag.api import __api_version__\n\n\n# Custom exception for retry mechanism\nclass InvalidResponseError(Exception):\n    \"\"\"Custom exception class for triggering retry mechanism\"\"\"\n\n    pass\n\n\n# Core Anthropic completion function with retry\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=10),\n    retry=retry_if_exception_type(\n        (RateLimitError, APIConnectionError, APITimeoutError, InvalidResponseError)\n    ),\n)\nasync def anthropic_complete_if_cache(\n    model: str,\n    prompt: str,\n    system_prompt: str | None = None,\n    history_messages: list[dict[str, Any]] | None = None,\n    enable_cot: bool = False,\n    base_url: str | None = None,\n    api_key: str | None = None,\n    **kwargs: Any,\n) -> Union[str, AsyncIterator[str]]:\n    if history_messages is None:\n        history_messages = []\n    if enable_cot:\n        logger.debug(\n            \"enable_cot=True is not supported for the Anthropic API and will be ignored.\"\n        )\n    if not api_key:\n        api_key = os.environ.get(\"ANTHROPIC_API_KEY\")\n\n    default_headers = {\n        \"User-Agent\": f\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) LightRAG/{__api_version__}\",\n        \"Content-Type\": \"application/json\",\n    }\n\n    # Set logger level to INFO when VERBOSE_DEBUG is off\n    if not VERBOSE_DEBUG and logger.level == logging.DEBUG:\n        logging.getLogger(\"anthropic\").setLevel(logging.INFO)\n\n    kwargs.pop(\"hashing_kv\", None)\n    kwargs.pop(\"keyword_extraction\", None)\n    timeout = kwargs.pop(\"timeout\", None)\n\n    anthropic_async_client = (\n        AsyncAnthropic(\n            default_headers=default_headers, api_key=api_key, timeout=timeout\n        )\n        if base_url is None\n        else AsyncAnthropic(\n            base_url=base_url,\n            default_headers=default_headers,\n            api_key=api_key,\n            timeout=timeout,\n        )\n    )\n\n    messages: list[dict[str, Any]] = []\n    messages.extend(history_messages)\n    messages.append({\"role\": \"user\", \"content\": prompt})\n\n    logger.debug(\"===== Sending Query to Anthropic LLM =====\")\n    logger.debug(f\"Model: {model}   Base URL: {base_url}\")\n    logger.debug(f\"Additional kwargs: {kwargs}\")\n    verbose_debug(f\"Query: {prompt}\")\n    verbose_debug(f\"System prompt: {system_prompt}\")\n\n    try:\n        create_params = {\"model\": model, \"messages\": messages, \"stream\": True, **kwargs}\n        if system_prompt:\n            create_params[\"system\"] = system_prompt\n        response = await anthropic_async_client.messages.create(**create_params)\n\n    except APIConnectionError as e:\n        logger.error(f\"Anthropic API Connection Error: {e}\")\n        raise\n    except RateLimitError as e:\n        logger.error(f\"Anthropic API Rate Limit Error: {e}\")\n        raise\n    except APITimeoutError as e:\n        logger.error(f\"Anthropic API Timeout Error: {e}\")\n        raise\n    except Exception as e:\n        logger.error(\n            f\"Anthropic API Call Failed,\\nModel: {model},\\nParams: {kwargs}, Got: {e}\"\n        )\n        raise\n\n    async def stream_response():\n        try:\n            async for event in response:\n                content = (\n                    event.delta.text\n                    if hasattr(event, \"delta\")\n                    and hasattr(event.delta, \"text\")\n                    and event.delta.text\n                    else None\n                )\n                if content is None:\n                    continue\n                if r\"\\u\" in content:\n                    content = safe_unicode_decode(content.encode(\"utf-8\"))\n                yield content\n        except Exception as e:\n            logger.error(f\"Error in stream response: {str(e)}\")\n            raise\n\n    return stream_response()\n\n\n# Generic Anthropic completion function\nasync def anthropic_complete(\n    prompt: str,\n    system_prompt: str | None = None,\n    history_messages: list[dict[str, Any]] | None = None,\n    enable_cot: bool = False,\n    **kwargs: Any,\n) -> Union[str, AsyncIterator[str]]:\n    if history_messages is None:\n        history_messages = []\n    model_name = kwargs[\"hashing_kv\"].global_config[\"llm_model_name\"]\n    return await anthropic_complete_if_cache(\n        model_name,\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        enable_cot=enable_cot,\n        **kwargs,\n    )\n\n\n# Claude 3 Opus specific completion\nasync def claude_3_opus_complete(\n    prompt: str,\n    system_prompt: str | None = None,\n    history_messages: list[dict[str, Any]] | None = None,\n    enable_cot: bool = False,\n    **kwargs: Any,\n) -> Union[str, AsyncIterator[str]]:\n    if history_messages is None:\n        history_messages = []\n    return await anthropic_complete_if_cache(\n        \"claude-3-opus-20240229\",\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        enable_cot=enable_cot,\n        **kwargs,\n    )\n\n\n# Claude 3 Sonnet specific completion\nasync def claude_3_sonnet_complete(\n    prompt: str,\n    system_prompt: str | None = None,\n    history_messages: list[dict[str, Any]] | None = None,\n    enable_cot: bool = False,\n    **kwargs: Any,\n) -> Union[str, AsyncIterator[str]]:\n    if history_messages is None:\n        history_messages = []\n    return await anthropic_complete_if_cache(\n        \"claude-3-sonnet-20240229\",\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        enable_cot=enable_cot,\n        **kwargs,\n    )\n\n\n# Claude 3 Haiku specific completion\nasync def claude_3_haiku_complete(\n    prompt: str,\n    system_prompt: str | None = None,\n    history_messages: list[dict[str, Any]] | None = None,\n    enable_cot: bool = False,\n    **kwargs: Any,\n) -> Union[str, AsyncIterator[str]]:\n    if history_messages is None:\n        history_messages = []\n    return await anthropic_complete_if_cache(\n        \"claude-3-haiku-20240307\",\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        enable_cot=enable_cot,\n        **kwargs,\n    )\n\n\n# Embedding function (placeholder, as Anthropic does not provide embeddings)\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=60),\n    retry=retry_if_exception_type(\n        (RateLimitError, APIConnectionError, APITimeoutError)\n    ),\n)\nasync def anthropic_embed(\n    texts: list[str],\n    model: str = \"voyage-3\",  # Default to voyage-3 as a good general-purpose model\n    base_url: str = None,\n    api_key: str = None,\n) -> np.ndarray:\n    \"\"\"\n    Generate embeddings using Voyage AI since Anthropic doesn't provide native embedding support.\n\n    Args:\n        texts: List of text strings to embed\n        model: Voyage AI model name (e.g., \"voyage-3\", \"voyage-3-large\", \"voyage-code-3\")\n        base_url: Optional custom base URL (not used for Voyage AI)\n        api_key: API key for Voyage AI (defaults to VOYAGE_API_KEY environment variable)\n\n    Returns:\n        numpy array of shape (len(texts), embedding_dimension) containing the embeddings\n    \"\"\"\n    if not api_key:\n        api_key = os.environ.get(\"VOYAGE_API_KEY\")\n        if not api_key:\n            logger.error(\"VOYAGE_API_KEY environment variable not set\")\n            raise ValueError(\n                \"VOYAGE_API_KEY environment variable is required for embeddings\"\n            )\n\n    try:\n        # Initialize Voyage AI client\n        voyage_client = voyageai.Client(api_key=api_key)\n\n        # Get embeddings\n        result = voyage_client.embed(\n            texts,\n            model=model,\n            input_type=\"document\",  # Assuming document context; could be made configurable\n        )\n\n        # Convert list of embeddings to numpy array\n        embeddings = np.array(result.embeddings, dtype=np.float32)\n\n        logger.debug(f\"Generated embeddings for {len(texts)} texts using {model}\")\n        verbose_debug(f\"Embedding shape: {embeddings.shape}\")\n\n        return embeddings\n\n    except Exception as e:\n        logger.error(f\"Voyage AI embedding failed: {str(e)}\")\n        raise\n\n\n# Optional: a helper function to get available embedding models\ndef get_available_embedding_models() -> dict[str, dict]:\n    \"\"\"\n    Returns a dictionary of available Voyage AI embedding models and their properties.\n    \"\"\"\n    return {\n        \"voyage-3-large\": {\n            \"context_length\": 32000,\n            \"dimension\": 1024,\n            \"description\": \"Best general-purpose and multilingual\",\n        },\n        \"voyage-3\": {\n            \"context_length\": 32000,\n            \"dimension\": 1024,\n            \"description\": \"General-purpose and multilingual\",\n        },\n        \"voyage-3-lite\": {\n            \"context_length\": 32000,\n            \"dimension\": 512,\n            \"description\": \"Optimized for latency and cost\",\n        },\n        \"voyage-code-3\": {\n            \"context_length\": 32000,\n            \"dimension\": 1024,\n            \"description\": \"Optimized for code\",\n        },\n        \"voyage-finance-2\": {\n            \"context_length\": 32000,\n            \"dimension\": 1024,\n            \"description\": \"Optimized for finance\",\n        },\n        \"voyage-law-2\": {\n            \"context_length\": 16000,\n            \"dimension\": 1024,\n            \"description\": \"Optimized for legal\",\n        },\n        \"voyage-multimodal-3\": {\n            \"context_length\": 32000,\n            \"dimension\": 1024,\n            \"description\": \"Multimodal text and images\",\n        },\n    }\n"
  },
  {
    "path": "lightrag/llm/azure_openai.py",
    "content": "\"\"\"\nAzure OpenAI compatibility layer.\n\nThis module provides backward compatibility by re-exporting Azure OpenAI functions\nfrom the main openai module where the actual implementation resides.\n\nAll core logic for both OpenAI and Azure OpenAI now lives in lightrag.llm.openai,\nwith this module serving as a thin compatibility wrapper for existing code that\nimports from lightrag.llm.azure_openai.\n\"\"\"\n\nfrom lightrag.llm.openai import (\n    azure_openai_complete_if_cache,\n    azure_openai_complete,\n    azure_openai_embed,\n)\n\n__all__ = [\n    \"azure_openai_complete_if_cache\",\n    \"azure_openai_complete\",\n    \"azure_openai_embed\",\n]\n"
  },
  {
    "path": "lightrag/llm/bedrock.py",
    "content": "import copy\nimport os\nimport json\nimport logging\n\nimport pipmaster as pm  # Pipmaster for dynamic library install\n\nif not pm.is_installed(\"aioboto3\"):\n    pm.install(\"aioboto3\")\nimport aioboto3\nimport numpy as np\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\n\nimport sys\nfrom lightrag.utils import wrap_embedding_func_with_attrs\n\nif sys.version_info < (3, 9):\n    from typing import AsyncIterator\nelse:\n    from collections.abc import AsyncIterator\nfrom typing import Union\n\n# Import botocore exceptions for proper exception handling\ntry:\n    from botocore.exceptions import (\n        ClientError,\n        ConnectionError as BotocoreConnectionError,\n        ReadTimeoutError,\n    )\nexcept ImportError:\n    # If botocore is not installed, define placeholders\n    ClientError = Exception\n    BotocoreConnectionError = Exception\n    ReadTimeoutError = Exception\n\n\nclass BedrockError(Exception):\n    \"\"\"Generic error for issues related to Amazon Bedrock\"\"\"\n\n\nclass BedrockRateLimitError(BedrockError):\n    \"\"\"Error for rate limiting and throttling issues\"\"\"\n\n\nclass BedrockConnectionError(BedrockError):\n    \"\"\"Error for network and connection issues\"\"\"\n\n\nclass BedrockTimeoutError(BedrockError):\n    \"\"\"Error for timeout issues\"\"\"\n\n\ndef _set_env_if_present(key: str, value):\n    \"\"\"Set environment variable only if a non-empty value is provided.\"\"\"\n    if value is not None and value != \"\":\n        os.environ[key] = value\n\n\ndef _handle_bedrock_exception(e: Exception, operation: str = \"Bedrock API\") -> None:\n    \"\"\"Convert AWS Bedrock exceptions to appropriate custom exceptions.\n\n    Args:\n        e: The exception to handle\n        operation: Description of the operation for error messages\n\n    Raises:\n        BedrockRateLimitError: For rate limiting and throttling issues (retryable)\n        BedrockConnectionError: For network and server issues (retryable)\n        BedrockTimeoutError: For timeout issues (retryable)\n        BedrockError: For validation and other non-retryable errors\n    \"\"\"\n    error_message = str(e)\n\n    # Handle botocore ClientError with specific error codes\n    if isinstance(e, ClientError):\n        error_code = e.response.get(\"Error\", {}).get(\"Code\", \"\")\n        error_msg = e.response.get(\"Error\", {}).get(\"Message\", error_message)\n\n        # Rate limiting and throttling errors (retryable)\n        if error_code in [\n            \"ThrottlingException\",\n            \"ProvisionedThroughputExceededException\",\n        ]:\n            logging.error(f\"{operation} rate limit error: {error_msg}\")\n            raise BedrockRateLimitError(f\"Rate limit error: {error_msg}\")\n\n        # Server errors (retryable)\n        elif error_code in [\"ServiceUnavailableException\", \"InternalServerException\"]:\n            logging.error(f\"{operation} connection error: {error_msg}\")\n            raise BedrockConnectionError(f\"Service error: {error_msg}\")\n\n        # Check for 5xx HTTP status codes (retryable)\n        elif e.response.get(\"ResponseMetadata\", {}).get(\"HTTPStatusCode\", 0) >= 500:\n            logging.error(f\"{operation} server error: {error_msg}\")\n            raise BedrockConnectionError(f\"Server error: {error_msg}\")\n\n        # Validation and other client errors (non-retryable)\n        else:\n            logging.error(f\"{operation} client error: {error_msg}\")\n            raise BedrockError(f\"Client error: {error_msg}\")\n\n    # Connection errors (retryable)\n    elif isinstance(e, BotocoreConnectionError):\n        logging.error(f\"{operation} connection error: {error_message}\")\n        raise BedrockConnectionError(f\"Connection error: {error_message}\")\n\n    # Timeout errors (retryable)\n    elif isinstance(e, (ReadTimeoutError, TimeoutError)):\n        logging.error(f\"{operation} timeout error: {error_message}\")\n        raise BedrockTimeoutError(f\"Timeout error: {error_message}\")\n\n    # Custom Bedrock errors (already properly typed)\n    elif isinstance(\n        e,\n        (\n            BedrockRateLimitError,\n            BedrockConnectionError,\n            BedrockTimeoutError,\n            BedrockError,\n        ),\n    ):\n        raise\n\n    # Unknown errors (non-retryable)\n    else:\n        logging.error(f\"{operation} unexpected error: {error_message}\")\n        raise BedrockError(f\"Unexpected error: {error_message}\")\n\n\n@retry(\n    stop=stop_after_attempt(5),\n    wait=wait_exponential(multiplier=1, min=4, max=60),\n    retry=(\n        retry_if_exception_type(BedrockRateLimitError)\n        | retry_if_exception_type(BedrockConnectionError)\n        | retry_if_exception_type(BedrockTimeoutError)\n    ),\n)\nasync def bedrock_complete_if_cache(\n    model,\n    prompt,\n    system_prompt=None,\n    history_messages=[],\n    enable_cot: bool = False,\n    aws_access_key_id=None,\n    aws_secret_access_key=None,\n    aws_session_token=None,\n    **kwargs,\n) -> Union[str, AsyncIterator[str]]:\n    if enable_cot:\n        import logging\n\n        logging.debug(\n            \"enable_cot=True is not supported for Bedrock and will be ignored.\"\n        )\n    # Respect existing env; only set if a non-empty value is available\n    access_key = os.environ.get(\"AWS_ACCESS_KEY_ID\") or aws_access_key_id\n    secret_key = os.environ.get(\"AWS_SECRET_ACCESS_KEY\") or aws_secret_access_key\n    session_token = os.environ.get(\"AWS_SESSION_TOKEN\") or aws_session_token\n    _set_env_if_present(\"AWS_ACCESS_KEY_ID\", access_key)\n    _set_env_if_present(\"AWS_SECRET_ACCESS_KEY\", secret_key)\n    _set_env_if_present(\"AWS_SESSION_TOKEN\", session_token)\n    # Region handling: prefer env, else kwarg (optional)\n    region = os.environ.get(\"AWS_REGION\") or kwargs.pop(\"aws_region\", None)\n    kwargs.pop(\"hashing_kv\", None)\n    # Capture stream flag (if provided) and remove from kwargs since it's not a Bedrock API parameter\n    # We'll use this to determine whether to call converse_stream or converse\n    stream = bool(kwargs.pop(\"stream\", False))\n    # Remove unsupported args for Bedrock Converse API\n    for k in [\n        \"response_format\",\n        \"tools\",\n        \"tool_choice\",\n        \"seed\",\n        \"presence_penalty\",\n        \"frequency_penalty\",\n        \"n\",\n        \"logprobs\",\n        \"top_logprobs\",\n        \"max_completion_tokens\",\n        \"response_format\",\n    ]:\n        kwargs.pop(k, None)\n    # Fix message history format\n    messages = []\n    for history_message in history_messages:\n        message = copy.copy(history_message)\n        message[\"content\"] = [{\"text\": message[\"content\"]}]\n        messages.append(message)\n\n    # Add user prompt\n    messages.append({\"role\": \"user\", \"content\": [{\"text\": prompt}]})\n\n    # Initialize Converse API arguments\n    args = {\"modelId\": model, \"messages\": messages}\n\n    # Define system prompt\n    if system_prompt:\n        args[\"system\"] = [{\"text\": system_prompt}]\n\n    # Map and set up inference parameters\n    inference_params_map = {\n        \"max_tokens\": \"maxTokens\",\n        \"top_p\": \"topP\",\n        \"stop_sequences\": \"stopSequences\",\n    }\n    if inference_params := list(\n        set(kwargs) & set([\"max_tokens\", \"temperature\", \"top_p\", \"stop_sequences\"])\n    ):\n        args[\"inferenceConfig\"] = {}\n        for param in inference_params:\n            args[\"inferenceConfig\"][inference_params_map.get(param, param)] = (\n                kwargs.pop(param)\n            )\n\n    # Import logging for error handling\n    import logging\n\n    # For streaming responses, we need a different approach to keep the connection open\n    if stream:\n        # Create a session that will be used throughout the streaming process\n        session = aioboto3.Session()\n        client = None\n\n        # Define the generator function that will manage the client lifecycle\n        async def stream_generator():\n            nonlocal client\n\n            # Create the client outside the generator to ensure it stays open\n            client = await session.client(\n                \"bedrock-runtime\", region_name=region\n            ).__aenter__()\n            event_stream = None\n            iteration_started = False\n\n            try:\n                # Make the API call\n                response = await client.converse_stream(**args, **kwargs)\n                event_stream = response.get(\"stream\")\n                iteration_started = True\n\n                # Process the stream\n                async for event in event_stream:\n                    # Validate event structure\n                    if not event or not isinstance(event, dict):\n                        continue\n\n                    if \"contentBlockDelta\" in event:\n                        delta = event[\"contentBlockDelta\"].get(\"delta\", {})\n                        text = delta.get(\"text\")\n                        if text:\n                            yield text\n                    # Handle other event types that might indicate stream end\n                    elif \"messageStop\" in event:\n                        break\n\n            except Exception as e:\n                # Try to clean up resources if possible\n                if (\n                    iteration_started\n                    and event_stream\n                    and hasattr(event_stream, \"aclose\")\n                    and callable(getattr(event_stream, \"aclose\", None))\n                ):\n                    try:\n                        await event_stream.aclose()\n                    except Exception as close_error:\n                        logging.warning(\n                            f\"Failed to close Bedrock event stream: {close_error}\"\n                        )\n\n                # Convert to appropriate exception type\n                _handle_bedrock_exception(e, \"Bedrock streaming\")\n\n            finally:\n                # Clean up the event stream\n                if (\n                    iteration_started\n                    and event_stream\n                    and hasattr(event_stream, \"aclose\")\n                    and callable(getattr(event_stream, \"aclose\", None))\n                ):\n                    try:\n                        await event_stream.aclose()\n                    except Exception as close_error:\n                        logging.warning(\n                            f\"Failed to close Bedrock event stream in finally block: {close_error}\"\n                        )\n\n                # Clean up the client\n                if client:\n                    try:\n                        await client.__aexit__(None, None, None)\n                    except Exception as client_close_error:\n                        logging.warning(\n                            f\"Failed to close Bedrock client: {client_close_error}\"\n                        )\n\n        # Return the generator that manages its own lifecycle\n        return stream_generator()\n\n    # For non-streaming responses, use the standard async context manager pattern\n    session = aioboto3.Session()\n    async with session.client(\n        \"bedrock-runtime\", region_name=region\n    ) as bedrock_async_client:\n        try:\n            # Use converse for non-streaming responses\n            response = await bedrock_async_client.converse(**args, **kwargs)\n\n            # Validate response structure\n            if (\n                not response\n                or \"output\" not in response\n                or \"message\" not in response[\"output\"]\n                or \"content\" not in response[\"output\"][\"message\"]\n                or not response[\"output\"][\"message\"][\"content\"]\n            ):\n                raise BedrockError(\"Invalid response structure from Bedrock API\")\n\n            content = response[\"output\"][\"message\"][\"content\"][0][\"text\"]\n\n            if not content or content.strip() == \"\":\n                raise BedrockError(\"Received empty content from Bedrock API\")\n\n            return content\n\n        except Exception as e:\n            # Convert to appropriate exception type\n            _handle_bedrock_exception(e, \"Bedrock converse\")\n\n\n# Generic Bedrock completion function\nasync def bedrock_complete(\n    prompt, system_prompt=None, history_messages=[], keyword_extraction=False, **kwargs\n) -> Union[str, AsyncIterator[str]]:\n    kwargs.pop(\"keyword_extraction\", None)\n    model_name = kwargs[\"hashing_kv\"].global_config[\"llm_model_name\"]\n    result = await bedrock_complete_if_cache(\n        model_name,\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        **kwargs,\n    )\n    return result\n\n\n@wrap_embedding_func_with_attrs(\n    embedding_dim=1024, max_token_size=8192, model_name=\"amazon.titan-embed-text-v2:0\"\n)\n@retry(\n    stop=stop_after_attempt(5),\n    wait=wait_exponential(multiplier=1, min=4, max=60),\n    retry=(\n        retry_if_exception_type(BedrockRateLimitError)\n        | retry_if_exception_type(BedrockConnectionError)\n        | retry_if_exception_type(BedrockTimeoutError)\n    ),\n)\nasync def bedrock_embed(\n    texts: list[str],\n    model: str = \"amazon.titan-embed-text-v2:0\",\n    aws_access_key_id=None,\n    aws_secret_access_key=None,\n    aws_session_token=None,\n) -> np.ndarray:\n    # Respect existing env; only set if a non-empty value is available\n    access_key = os.environ.get(\"AWS_ACCESS_KEY_ID\") or aws_access_key_id\n    secret_key = os.environ.get(\"AWS_SECRET_ACCESS_KEY\") or aws_secret_access_key\n    session_token = os.environ.get(\"AWS_SESSION_TOKEN\") or aws_session_token\n    _set_env_if_present(\"AWS_ACCESS_KEY_ID\", access_key)\n    _set_env_if_present(\"AWS_SECRET_ACCESS_KEY\", secret_key)\n    _set_env_if_present(\"AWS_SESSION_TOKEN\", session_token)\n\n    # Region handling: prefer env\n    region = os.environ.get(\"AWS_REGION\")\n\n    session = aioboto3.Session()\n    async with session.client(\n        \"bedrock-runtime\", region_name=region\n    ) as bedrock_async_client:\n        try:\n            if (model_provider := model.split(\".\")[0]) == \"amazon\":\n                embed_texts = []\n                for text in texts:\n                    try:\n                        if \"v2\" in model:\n                            body = json.dumps(\n                                {\n                                    \"inputText\": text,\n                                    # 'dimensions': embedding_dim,\n                                    \"embeddingTypes\": [\"float\"],\n                                }\n                            )\n                        elif \"v1\" in model:\n                            body = json.dumps({\"inputText\": text})\n                        else:\n                            raise BedrockError(f\"Model {model} is not supported!\")\n\n                        response = await bedrock_async_client.invoke_model(\n                            modelId=model,\n                            body=body,\n                            accept=\"application/json\",\n                            contentType=\"application/json\",\n                        )\n\n                        response_body = await response.get(\"body\").json()\n\n                        # Validate response structure\n                        if not response_body or \"embedding\" not in response_body:\n                            raise BedrockError(\n                                f\"Invalid embedding response structure for text: {text[:50]}...\"\n                            )\n\n                        embedding = response_body[\"embedding\"]\n                        if not embedding:\n                            raise BedrockError(\n                                f\"Received empty embedding for text: {text[:50]}...\"\n                            )\n\n                        embed_texts.append(embedding)\n\n                    except Exception as e:\n                        # Convert to appropriate exception type\n                        _handle_bedrock_exception(\n                            e, \"Bedrock embedding (amazon, text chunk)\"\n                        )\n\n            elif model_provider == \"cohere\":\n                try:\n                    body = json.dumps(\n                        {\n                            \"texts\": texts,\n                            \"input_type\": \"search_document\",\n                            \"truncate\": \"NONE\",\n                        }\n                    )\n\n                    response = await bedrock_async_client.invoke_model(\n                        model=model,\n                        body=body,\n                        accept=\"application/json\",\n                        contentType=\"application/json\",\n                    )\n\n                    response_body = json.loads(response.get(\"body\").read())\n\n                    # Validate response structure\n                    if not response_body or \"embeddings\" not in response_body:\n                        raise BedrockError(\n                            \"Invalid embedding response structure from Cohere\"\n                        )\n\n                    embeddings = response_body[\"embeddings\"]\n                    if not embeddings or len(embeddings) != len(texts):\n                        raise BedrockError(\n                            f\"Invalid embeddings count: expected {len(texts)}, got {len(embeddings) if embeddings else 0}\"\n                        )\n\n                    embed_texts = embeddings\n\n                except Exception as e:\n                    # Convert to appropriate exception type\n                    _handle_bedrock_exception(e, \"Bedrock embedding (cohere)\")\n\n            else:\n                raise BedrockError(\n                    f\"Model provider '{model_provider}' is not supported!\"\n                )\n\n            # Final validation\n            if not embed_texts:\n                raise BedrockError(\"No embeddings generated\")\n\n            return np.array(embed_texts)\n\n        except Exception as e:\n            # Convert to appropriate exception type\n            _handle_bedrock_exception(e, \"Bedrock embedding\")\n"
  },
  {
    "path": "lightrag/llm/binding_options.py",
    "content": "\"\"\"\nModule that implements containers for specific LLM bindings.\n\nThis module provides container implementations for various Large Language Model\nbindings and integrations.\n\"\"\"\n\nfrom argparse import ArgumentParser, Namespace\nimport argparse\nimport json\nfrom dataclasses import asdict, dataclass, field\nfrom typing import Any, ClassVar, List, get_args, get_origin\n\nfrom lightrag.utils import get_env_value\nfrom lightrag.constants import DEFAULT_TEMPERATURE\n\n\ndef _resolve_optional_type(field_type: Any) -> Any:\n    \"\"\"Return the concrete type for Optional/Union annotations.\"\"\"\n    origin = get_origin(field_type)\n    if origin in (list, dict, tuple):\n        return field_type\n\n    args = get_args(field_type)\n    if args:\n        non_none_args = [arg for arg in args if arg is not type(None)]\n        if len(non_none_args) == 1:\n            return non_none_args[0]\n    return field_type\n\n\n# =============================================================================\n# BindingOptions Base Class\n# =============================================================================\n#\n# The BindingOptions class serves as the foundation for all LLM provider bindings\n# in LightRAG. It provides a standardized framework for:\n#\n# 1. Configuration Management:\n#    - Defines how each LLM provider's configuration parameters are structured\n#    - Handles default values and type information for each parameter\n#    - Maps configuration options to command-line arguments and environment variables\n#\n# 2. Environment Integration:\n#    - Automatically generates environment variable names from binding parameters\n#    - Provides methods to create sample .env files for easy configuration\n#    - Supports configuration via environment variables with fallback to defaults\n#\n# 3. Command-Line Interface:\n#    - Dynamically generates command-line arguments for all registered bindings\n#    - Maintains consistent naming conventions across different LLM providers\n#    - Provides help text and type validation for each configuration option\n#\n# 4. Extensibility:\n#    - Uses class introspection to automatically discover all binding subclasses\n#    - Requires minimal boilerplate code when adding new LLM provider bindings\n#    - Maintains separation of concerns between different provider configurations\n#\n# This design pattern ensures that adding support for a new LLM provider requires\n# only defining the provider-specific parameters and help text, while the base\n# class handles all the common functionality for argument parsing, environment\n# variable handling, and configuration management.\n#\n# Instances of a derived class of BindingOptions can be used to store multiple\n# runtime configurations of options for a single LLM provider. using the\n# asdict() method to convert the options to a dictionary.\n#\n# =============================================================================\n@dataclass\nclass BindingOptions:\n    \"\"\"Base class for binding options.\"\"\"\n\n    # mandatory name of binding\n    _binding_name: ClassVar[str]\n\n    # optional help message for each option\n    _help: ClassVar[dict[str, str]]\n\n    @staticmethod\n    def _all_class_vars(klass: type, include_inherited=True) -> dict[str, Any]:\n        \"\"\"Print class variables, optionally including inherited ones\"\"\"\n        if include_inherited:\n            # Get all class variables from MRO\n            vars_dict = {}\n            for base in reversed(klass.__mro__[:-1]):  # Exclude 'object'\n                vars_dict.update(\n                    {\n                        k: v\n                        for k, v in base.__dict__.items()\n                        if (\n                            not k.startswith(\"_\")\n                            and not callable(v)\n                            and not isinstance(v, classmethod)\n                        )\n                    }\n                )\n        else:\n            # Only direct class variables\n            vars_dict = {\n                k: v\n                for k, v in klass.__dict__.items()\n                if (\n                    not k.startswith(\"_\")\n                    and not callable(v)\n                    and not isinstance(v, classmethod)\n                )\n            }\n\n        return vars_dict\n\n    @classmethod\n    def add_args(cls, parser: ArgumentParser):\n        group = parser.add_argument_group(f\"{cls._binding_name} binding options\")\n        for arg_item in cls.args_env_name_type_value():\n            # Handle JSON parsing for list types\n            if arg_item[\"type\"] is List[str]:\n\n                def json_list_parser(value):\n                    try:\n                        parsed = json.loads(value)\n                        if not isinstance(parsed, list):\n                            raise argparse.ArgumentTypeError(\n                                f\"Expected JSON array, got {type(parsed).__name__}\"\n                            )\n                        return parsed\n                    except json.JSONDecodeError as e:\n                        raise argparse.ArgumentTypeError(f\"Invalid JSON: {e}\")\n\n                # Get environment variable with JSON parsing\n                env_value = get_env_value(f\"{arg_item['env_name']}\", argparse.SUPPRESS)\n                if env_value is not argparse.SUPPRESS:\n                    try:\n                        env_value = json_list_parser(env_value)\n                    except argparse.ArgumentTypeError:\n                        env_value = argparse.SUPPRESS\n\n                group.add_argument(\n                    f\"--{arg_item['argname']}\",\n                    type=json_list_parser,\n                    default=env_value,\n                    help=arg_item[\"help\"],\n                )\n            # Handle JSON parsing for dict types\n            elif arg_item[\"type\"] is dict:\n\n                def json_dict_parser(value):\n                    try:\n                        parsed = json.loads(value)\n                        if not isinstance(parsed, dict):\n                            raise argparse.ArgumentTypeError(\n                                f\"Expected JSON object, got {type(parsed).__name__}\"\n                            )\n                        return parsed\n                    except json.JSONDecodeError as e:\n                        raise argparse.ArgumentTypeError(f\"Invalid JSON: {e}\")\n\n                # Get environment variable with JSON parsing\n                env_value = get_env_value(f\"{arg_item['env_name']}\", argparse.SUPPRESS)\n                if env_value is not argparse.SUPPRESS:\n                    try:\n                        env_value = json_dict_parser(env_value)\n                    except argparse.ArgumentTypeError:\n                        env_value = argparse.SUPPRESS\n\n                group.add_argument(\n                    f\"--{arg_item['argname']}\",\n                    type=json_dict_parser,\n                    default=env_value,\n                    help=arg_item[\"help\"],\n                )\n            # Handle boolean types specially to avoid argparse bool() constructor issues\n            elif arg_item[\"type\"] is bool:\n\n                def bool_parser(value):\n                    \"\"\"Custom boolean parser that handles string representations correctly\"\"\"\n                    if isinstance(value, bool):\n                        return value\n                    if isinstance(value, str):\n                        return value.lower() in (\"true\", \"1\", \"yes\", \"t\", \"on\")\n                    return bool(value)\n\n                # Get environment variable with proper type conversion\n                env_value = get_env_value(\n                    f\"{arg_item['env_name']}\", argparse.SUPPRESS, bool\n                )\n\n                group.add_argument(\n                    f\"--{arg_item['argname']}\",\n                    type=bool_parser,\n                    default=env_value,\n                    help=arg_item[\"help\"],\n                )\n            else:\n                resolved_type = arg_item[\"type\"]\n                if resolved_type is not None:\n                    resolved_type = _resolve_optional_type(resolved_type)\n\n                group.add_argument(\n                    f\"--{arg_item['argname']}\",\n                    type=resolved_type,\n                    default=get_env_value(f\"{arg_item['env_name']}\", argparse.SUPPRESS),\n                    help=arg_item[\"help\"],\n                )\n\n    @classmethod\n    def args_env_name_type_value(cls):\n        import dataclasses\n\n        args_prefix = f\"{cls._binding_name}\".replace(\"_\", \"-\")\n        env_var_prefix = f\"{cls._binding_name}_\".upper()\n        help = cls._help\n\n        # Check if this is a dataclass and use dataclass fields\n        if dataclasses.is_dataclass(cls):\n            for field in dataclasses.fields(cls):\n                # Skip private fields\n                if field.name.startswith(\"_\"):\n                    continue\n\n                # Get default value\n                if field.default is not dataclasses.MISSING:\n                    default_value = field.default\n                elif field.default_factory is not dataclasses.MISSING:\n                    default_value = field.default_factory()\n                else:\n                    default_value = None\n\n                argdef = {\n                    \"argname\": f\"{args_prefix}-{field.name}\",\n                    \"env_name\": f\"{env_var_prefix}{field.name.upper()}\",\n                    \"type\": _resolve_optional_type(field.type),\n                    \"default\": default_value,\n                    \"help\": f\"{cls._binding_name} -- \" + help.get(field.name, \"\"),\n                }\n\n                yield argdef\n        else:\n            # Fallback to old method for non-dataclass classes\n            class_vars = {\n                key: value\n                for key, value in cls._all_class_vars(cls).items()\n                if not callable(value) and not key.startswith(\"_\")\n            }\n\n            # Get type hints to properly detect List[str] types\n            type_hints = {}\n            for base in cls.__mro__:\n                if hasattr(base, \"__annotations__\"):\n                    type_hints.update(base.__annotations__)\n\n            for class_var in class_vars:\n                # Use type hint if available, otherwise fall back to type of value\n                var_type = type_hints.get(class_var, type(class_vars[class_var]))\n\n                argdef = {\n                    \"argname\": f\"{args_prefix}-{class_var}\",\n                    \"env_name\": f\"{env_var_prefix}{class_var.upper()}\",\n                    \"type\": var_type,\n                    \"default\": class_vars[class_var],\n                    \"help\": f\"{cls._binding_name} -- \" + help.get(class_var, \"\"),\n                }\n\n                yield argdef\n\n    @classmethod\n    def generate_dot_env_sample(cls):\n        \"\"\"\n        Generate a sample .env file for all LightRAG binding options.\n\n        This method creates a .env file that includes all the binding options\n        defined by the subclasses of BindingOptions. It uses the args_env_name_type_value()\n        method to get the list of all options and their default values.\n\n        Returns:\n            str: A string containing the contents of the sample .env file.\n        \"\"\"\n        from io import StringIO\n\n        sample_top = (\n            \"#\" * 80\n            + \"\\n\"\n            + (\n                \"# Autogenerated .env entries list for LightRAG binding options\\n\"\n                \"#\\n\"\n                \"# To generate run:\\n\"\n                \"# $ python -m lightrag.llm.binding_options\\n\"\n            )\n            + \"#\" * 80\n            + \"\\n\"\n        )\n\n        sample_bottom = (\n            (\"#\\n# End of .env entries for LightRAG binding options\\n\")\n            + \"#\" * 80\n            + \"\\n\"\n        )\n\n        sample_stream = StringIO()\n        sample_stream.write(sample_top)\n        for klass in cls.__subclasses__():\n            for arg_item in klass.args_env_name_type_value():\n                if arg_item[\"help\"]:\n                    sample_stream.write(f\"# {arg_item['help']}\\n\")\n\n                # Handle JSON formatting for list and dict types\n                if arg_item[\"type\"] is List[str] or arg_item[\"type\"] is dict:\n                    default_value = json.dumps(arg_item[\"default\"])\n                else:\n                    default_value = arg_item[\"default\"]\n\n                sample_stream.write(f\"# {arg_item['env_name']}={default_value}\\n\\n\")\n\n        sample_stream.write(sample_bottom)\n        return sample_stream.getvalue()\n\n    @classmethod\n    def options_dict(cls, args: Namespace) -> dict[str, Any]:\n        \"\"\"\n        Extract options dictionary for a specific binding from parsed arguments.\n\n        This method filters the parsed command-line arguments to return only those\n        that belong to the specific binding class. It removes the binding prefix\n        from argument names to create a clean options dictionary.\n\n        Args:\n            args (Namespace): Parsed command-line arguments containing all binding options\n\n        Returns:\n            dict[str, Any]: Dictionary mapping option names (without prefix) to their values\n\n        Example:\n            If args contains {'ollama_num_ctx': 512, 'other_option': 'value'}\n            and this is called on OllamaOptions, it returns {'num_ctx': 512}\n        \"\"\"\n        prefix = cls._binding_name + \"_\"\n        skipchars = len(prefix)\n        options = {\n            key[skipchars:]: value\n            for key, value in vars(args).items()\n            if key.startswith(prefix)\n        }\n\n        return options\n\n    def asdict(self) -> dict[str, Any]:\n        \"\"\"\n        Convert an instance of binding options to a dictionary.\n\n        This method uses dataclasses.asdict() to convert the dataclass instance\n        into a dictionary representation, including all its fields and values.\n\n        Returns:\n            dict[str, Any]: Dictionary representation of the binding options instance\n        \"\"\"\n        return asdict(self)\n\n\n# =============================================================================\n# Binding Options for Ollama\n# =============================================================================\n#\n# Ollama binding options provide configuration for the Ollama local LLM server.\n# These options control model behavior, sampling parameters, hardware utilization,\n# and performance settings. The parameters are based on Ollama's API specification\n# and provide fine-grained control over model inference and generation.\n#\n# The _OllamaOptionsMixin defines the complete set of available options, while\n# OllamaEmbeddingOptions and OllamaLLMOptions provide specialized configurations\n# for embedding and language model tasks respectively.\n# =============================================================================\n@dataclass\nclass _OllamaOptionsMixin:\n    \"\"\"Options for Ollama bindings.\"\"\"\n\n    # Core context and generation parameters\n    num_ctx: int = 32768  # Context window size (number of tokens)\n    num_predict: int = 128  # Maximum number of tokens to predict\n    num_keep: int = 0  # Number of tokens to keep from the initial prompt\n    seed: int = -1  # Random seed for generation (-1 for random)\n\n    # Sampling parameters\n    temperature: float = DEFAULT_TEMPERATURE  # Controls randomness (0.0-2.0)\n    top_k: int = 40  # Top-k sampling parameter\n    top_p: float = 0.9  # Top-p (nucleus) sampling parameter\n    tfs_z: float = 1.0  # Tail free sampling parameter\n    typical_p: float = 1.0  # Typical probability mass\n    min_p: float = 0.0  # Minimum probability threshold\n\n    # Repetition control\n    repeat_last_n: int = 64  # Number of tokens to consider for repetition penalty\n    repeat_penalty: float = 1.1  # Penalty for repetition\n    presence_penalty: float = 0.0  # Penalty for token presence\n    frequency_penalty: float = 0.0  # Penalty for token frequency\n\n    # Mirostat sampling\n    mirostat: int = (\n        # Mirostat sampling algorithm (0=disabled, 1=Mirostat 1.0, 2=Mirostat 2.0)\n        0\n    )\n    mirostat_tau: float = 5.0  # Mirostat target entropy\n    mirostat_eta: float = 0.1  # Mirostat learning rate\n\n    # Hardware and performance parameters\n    numa: bool = False  # Enable NUMA optimization\n    num_batch: int = 512  # Batch size for processing\n    num_gpu: int = -1  # Number of GPUs to use (-1 for auto)\n    main_gpu: int = 0  # Main GPU index\n    low_vram: bool = False  # Optimize for low VRAM\n    num_thread: int = 0  # Number of CPU threads (0 for auto)\n\n    # Memory and model parameters\n    f16_kv: bool = True  # Use half-precision for key/value cache\n    logits_all: bool = False  # Return logits for all tokens\n    vocab_only: bool = False  # Only load vocabulary\n    use_mmap: bool = True  # Use memory mapping for model files\n    use_mlock: bool = False  # Lock model in memory\n    embedding_only: bool = False  # Only use for embeddings\n\n    # Output control\n    penalize_newline: bool = True  # Penalize newline tokens\n    stop: List[str] = field(default_factory=list)  # Stop sequences\n\n    # optional help strings\n    _help: ClassVar[dict[str, str]] = {\n        \"num_ctx\": \"Context window size (number of tokens)\",\n        \"num_predict\": \"Maximum number of tokens to predict\",\n        \"num_keep\": \"Number of tokens to keep from the initial prompt\",\n        \"seed\": \"Random seed for generation (-1 for random)\",\n        \"temperature\": \"Controls randomness (0.0-2.0, higher = more creative)\",\n        \"top_k\": \"Top-k sampling parameter (0 = disabled)\",\n        \"top_p\": \"Top-p (nucleus) sampling parameter (0.0-1.0)\",\n        \"tfs_z\": \"Tail free sampling parameter (1.0 = disabled)\",\n        \"typical_p\": \"Typical probability mass (1.0 = disabled)\",\n        \"min_p\": \"Minimum probability threshold (0.0 = disabled)\",\n        \"repeat_last_n\": \"Number of tokens to consider for repetition penalty\",\n        \"repeat_penalty\": \"Penalty for repetition (1.0 = no penalty)\",\n        \"presence_penalty\": \"Penalty for token presence (-2.0 to 2.0)\",\n        \"frequency_penalty\": \"Penalty for token frequency (-2.0 to 2.0)\",\n        \"mirostat\": \"Mirostat sampling algorithm (0=disabled, 1=Mirostat 1.0, 2=Mirostat 2.0)\",\n        \"mirostat_tau\": \"Mirostat target entropy\",\n        \"mirostat_eta\": \"Mirostat learning rate\",\n        \"numa\": \"Enable NUMA optimization\",\n        \"num_batch\": \"Batch size for processing\",\n        \"num_gpu\": \"Number of GPUs to use (-1 for auto)\",\n        \"main_gpu\": \"Main GPU index\",\n        \"low_vram\": \"Optimize for low VRAM\",\n        \"num_thread\": \"Number of CPU threads (0 for auto)\",\n        \"f16_kv\": \"Use half-precision for key/value cache\",\n        \"logits_all\": \"Return logits for all tokens\",\n        \"vocab_only\": \"Only load vocabulary\",\n        \"use_mmap\": \"Use memory mapping for model files\",\n        \"use_mlock\": \"Lock model in memory\",\n        \"embedding_only\": \"Only use for embeddings\",\n        \"penalize_newline\": \"Penalize newline tokens\",\n        \"stop\": 'Stop sequences (JSON array of strings, e.g., \\'[\"</s>\", \"\\\\n\\\\n\"]\\')',\n    }\n\n\n@dataclass\nclass OllamaEmbeddingOptions(_OllamaOptionsMixin, BindingOptions):\n    \"\"\"Options for Ollama embeddings with specialized configuration for embedding tasks.\"\"\"\n\n    # mandatory name of binding\n    _binding_name: ClassVar[str] = \"ollama_embedding\"\n\n\n@dataclass\nclass OllamaLLMOptions(_OllamaOptionsMixin, BindingOptions):\n    \"\"\"Options for Ollama LLM with specialized configuration for LLM tasks.\"\"\"\n\n    # mandatory name of binding\n    _binding_name: ClassVar[str] = \"ollama_llm\"\n\n\n# =============================================================================\n# Binding Options for Gemini\n# =============================================================================\n@dataclass\nclass GeminiLLMOptions(BindingOptions):\n    \"\"\"Options for Google Gemini models.\"\"\"\n\n    _binding_name: ClassVar[str] = \"gemini_llm\"\n\n    temperature: float = DEFAULT_TEMPERATURE\n    top_p: float = 0.95\n    top_k: int = 40\n    max_output_tokens: int | None = None\n    candidate_count: int = 1\n    presence_penalty: float = 0.0\n    frequency_penalty: float = 0.0\n    stop_sequences: List[str] = field(default_factory=list)\n    seed: int | None = None\n    thinking_config: dict | None = None\n    safety_settings: dict | None = None\n\n    _help: ClassVar[dict[str, str]] = {\n        \"temperature\": \"Controls randomness (0.0-2.0, higher = more creative)\",\n        \"top_p\": \"Nucleus sampling parameter (0.0-1.0)\",\n        \"top_k\": \"Limits sampling to the top K tokens (1 disables the limit)\",\n        \"max_output_tokens\": \"Maximum tokens generated in the response\",\n        \"candidate_count\": \"Number of candidates returned per request\",\n        \"presence_penalty\": \"Penalty for token presence (-2.0 to 2.0)\",\n        \"frequency_penalty\": \"Penalty for token frequency (-2.0 to 2.0)\",\n        \"stop_sequences\": \"Stop sequences (JSON array of strings, e.g., '[\\\"END\\\"]')\",\n        \"seed\": \"Random seed for reproducible generation (leave empty for random)\",\n        \"thinking_config\": \"Thinking configuration (JSON dict, e.g., '{\\\"thinking_budget\\\": 1024}' or '{\\\"include_thoughts\\\": true}')\",\n        \"safety_settings\": \"JSON object with Gemini safety settings overrides\",\n    }\n\n\n@dataclass\nclass GeminiEmbeddingOptions(BindingOptions):\n    \"\"\"Options for Google Gemini embedding models.\"\"\"\n\n    _binding_name: ClassVar[str] = \"gemini_embedding\"\n\n    task_type: str = \"RETRIEVAL_DOCUMENT\"\n\n    _help: ClassVar[dict[str, str]] = {\n        \"task_type\": \"Task type for embedding optimization (RETRIEVAL_DOCUMENT, RETRIEVAL_QUERY, SEMANTIC_SIMILARITY, CLASSIFICATION, CLUSTERING, CODE_RETRIEVAL_QUERY, QUESTION_ANSWERING, FACT_VERIFICATION)\",\n    }\n\n\n# =============================================================================\n# Binding Options for OpenAI\n# =============================================================================\n#\n# OpenAI binding options provide configuration for OpenAI's API and Azure OpenAI.\n# These options control model behavior, sampling parameters, and generation settings.\n# The parameters are based on OpenAI's API specification and provide fine-grained\n# control over model inference and generation.\n#\n# =============================================================================\n@dataclass\nclass OpenAILLMOptions(BindingOptions):\n    \"\"\"Options for OpenAI LLM with configuration for OpenAI and Azure OpenAI API calls.\"\"\"\n\n    # mandatory name of binding\n    _binding_name: ClassVar[str] = \"openai_llm\"\n\n    # Sampling and generation parameters\n    frequency_penalty: float = 0.0  # Penalty for token frequency (-2.0 to 2.0)\n    max_completion_tokens: int = None  # Maximum number of tokens to generate\n    presence_penalty: float = 0.0  # Penalty for token presence (-2.0 to 2.0)\n    reasoning_effort: str = \"medium\"  # Reasoning effort level (low, medium, high)\n    safety_identifier: str = \"\"  # Safety identifier for content filtering\n    service_tier: str = \"\"  # Service tier for API usage\n    stop: List[str] = field(default_factory=list)  # Stop sequences\n    temperature: float = DEFAULT_TEMPERATURE  # Controls randomness (0.0 to 2.0)\n    top_p: float = 1.0  # Nucleus sampling parameter (0.0 to 1.0)\n    max_tokens: int = None  # Maximum number of tokens to generate(deprecated, use max_completion_tokens instead)\n    extra_body: dict = None  # Extra body parameters for OpenRouter of vLLM\n\n    # Help descriptions\n    _help: ClassVar[dict[str, str]] = {\n        \"frequency_penalty\": \"Penalty for token frequency (-2.0 to 2.0, positive values discourage repetition)\",\n        \"max_completion_tokens\": \"Maximum number of tokens to generate (optional, leave empty for model default)\",\n        \"presence_penalty\": \"Penalty for token presence (-2.0 to 2.0, positive values encourage new topics)\",\n        \"reasoning_effort\": \"Reasoning effort level for o1 models (low, medium, high)\",\n        \"safety_identifier\": \"Safety identifier for content filtering (optional)\",\n        \"service_tier\": \"Service tier for API usage (optional)\",\n        \"stop\": 'Stop sequences (JSON array of strings, e.g., \\'[\"</s>\", \"\\\\n\\\\n\"]\\')',\n        \"temperature\": \"Controls randomness (0.0-2.0, higher = more creative)\",\n        \"top_p\": \"Nucleus sampling parameter (0.0-1.0, lower = more focused)\",\n        \"max_tokens\": \"Maximum number of tokens to generate (deprecated, use max_completion_tokens instead)\",\n        \"extra_body\": 'Extra body parameters for OpenRouter of vLLM (JSON dict, e.g., \\'\"reasoning\": {\"reasoning\": {\"enabled\": false}}\\')',\n    }\n\n\n# =============================================================================\n# Main Section - For Testing and Sample Generation\n# =============================================================================\n#\n# When run as a script, this module:\n# 1. Generates and prints a sample .env file with all binding options\n# 2. If \"test\" argument is provided, demonstrates argument parsing with Ollama binding\n#\n# Usage:\n#   python -m lightrag.llm.binding_options           # Generate .env sample\n#   python -m lightrag.llm.binding_options test      # Test argument parsing\n#\n# =============================================================================\n\nif __name__ == \"__main__\":\n    import sys\n    import dotenv\n    # from io import StringIO\n\n    dotenv.load_dotenv(dotenv_path=\".env\", override=False)\n\n    # env_strstream = StringIO(\n    #     (\"OLLAMA_LLM_TEMPERATURE=0.1\\nOLLAMA_EMBEDDING_TEMPERATURE=0.2\\n\")\n    # )\n    # # Load environment variables from .env file\n    # dotenv.load_dotenv(stream=env_strstream)\n\n    if len(sys.argv) > 1 and sys.argv[1] == \"test\":\n        # Add arguments for OllamaEmbeddingOptions, OllamaLLMOptions, and OpenAILLMOptions\n        parser = ArgumentParser(description=\"Test binding options\")\n        OllamaEmbeddingOptions.add_args(parser)\n        OllamaLLMOptions.add_args(parser)\n        OpenAILLMOptions.add_args(parser)\n\n        # Parse arguments test\n        args = parser.parse_args(\n            [\n                \"--ollama-embedding-num_ctx\",\n                \"1024\",\n                \"--ollama-llm-num_ctx\",\n                \"2048\",\n                \"--openai-llm-temperature\",\n                \"0.7\",\n                \"--openai-llm-max_completion_tokens\",\n                \"1000\",\n                \"--openai-llm-stop\",\n                '[\"</s>\", \"\\\\n\\\\n\"]',\n                \"--openai-llm-reasoning\",\n                '{\"effort\": \"high\", \"max_tokens\": 2000, \"exclude\": false, \"enabled\": true}',\n            ]\n        )\n        print(\"Final args for LLM and Embedding:\")\n        print(f\"{args}\\n\")\n\n        print(\"Ollama LLM options:\")\n        print(OllamaLLMOptions.options_dict(args))\n\n        print(\"\\nOllama Embedding options:\")\n        print(OllamaEmbeddingOptions.options_dict(args))\n\n        print(\"\\nOpenAI LLM options:\")\n        print(OpenAILLMOptions.options_dict(args))\n\n        # Test creating OpenAI options instance\n        openai_options = OpenAILLMOptions(\n            temperature=0.8,\n            max_completion_tokens=1500,\n            frequency_penalty=0.1,\n            presence_penalty=0.2,\n            stop=[\"<|end|>\", \"\\n\\n\"],\n        )\n        print(\"\\nOpenAI LLM options instance:\")\n        print(openai_options.asdict())\n\n        # Test creating OpenAI options instance with reasoning parameter\n        openai_options_with_reasoning = OpenAILLMOptions(\n            temperature=0.9,\n            max_completion_tokens=2000,\n            reasoning={\n                \"effort\": \"medium\",\n                \"max_tokens\": 1500,\n                \"exclude\": True,\n                \"enabled\": True,\n            },\n        )\n        print(\"\\nOpenAI LLM options instance with reasoning:\")\n        print(openai_options_with_reasoning.asdict())\n\n        # Test dict parsing functionality\n        print(\"\\n\" + \"=\" * 50)\n        print(\"TESTING DICT PARSING FUNCTIONALITY\")\n        print(\"=\" * 50)\n\n        # Test valid JSON dict parsing\n        test_parser = ArgumentParser(description=\"Test dict parsing\")\n        OpenAILLMOptions.add_args(test_parser)\n\n        try:\n            test_args = test_parser.parse_args(\n                [\"--openai-llm-reasoning\", '{\"effort\": \"low\", \"max_tokens\": 1000}']\n            )\n            print(\"✓ Valid JSON dict parsing successful:\")\n            print(\n                f\"  Parsed reasoning: {OpenAILLMOptions.options_dict(test_args)['reasoning']}\"\n            )\n        except Exception as e:\n            print(f\"✗ Valid JSON dict parsing failed: {e}\")\n\n        # Test invalid JSON dict parsing\n        try:\n            test_args = test_parser.parse_args(\n                [\n                    \"--openai-llm-reasoning\",\n                    '{\"effort\": \"low\", \"max_tokens\": 1000',  # Missing closing brace\n                ]\n            )\n            print(\"✗ Invalid JSON should have failed but didn't\")\n        except SystemExit:\n            print(\"✓ Invalid JSON dict parsing correctly rejected\")\n        except Exception as e:\n            print(f\"✓ Invalid JSON dict parsing correctly rejected: {e}\")\n\n        # Test non-dict JSON parsing\n        try:\n            test_args = test_parser.parse_args(\n                [\n                    \"--openai-llm-reasoning\",\n                    '[\"not\", \"a\", \"dict\"]',  # Array instead of dict\n                ]\n            )\n            print(\"✗ Non-dict JSON should have failed but didn't\")\n        except SystemExit:\n            print(\"✓ Non-dict JSON parsing correctly rejected\")\n        except Exception as e:\n            print(f\"✓ Non-dict JSON parsing correctly rejected: {e}\")\n\n        print(\"\\n\" + \"=\" * 50)\n        print(\"TESTING ENVIRONMENT VARIABLE SUPPORT\")\n        print(\"=\" * 50)\n\n        # Test environment variable support for dict\n        import os\n\n        os.environ[\"OPENAI_LLM_REASONING\"] = (\n            '{\"effort\": \"high\", \"max_tokens\": 3000, \"exclude\": false}'\n        )\n\n        env_parser = ArgumentParser(description=\"Test env var dict parsing\")\n        OpenAILLMOptions.add_args(env_parser)\n\n        try:\n            env_args = env_parser.parse_args(\n                []\n            )  # No command line args, should use env var\n            reasoning_from_env = OpenAILLMOptions.options_dict(env_args).get(\n                \"reasoning\"\n            )\n            if reasoning_from_env:\n                print(\"✓ Environment variable dict parsing successful:\")\n                print(f\"  Parsed reasoning from env: {reasoning_from_env}\")\n            else:\n                print(\"✗ Environment variable dict parsing failed: No reasoning found\")\n        except Exception as e:\n            print(f\"✗ Environment variable dict parsing failed: {e}\")\n        finally:\n            # Clean up environment variable\n            if \"OPENAI_LLM_REASONING\" in os.environ:\n                del os.environ[\"OPENAI_LLM_REASONING\"]\n\n    else:\n        print(BindingOptions.generate_dot_env_sample())\n"
  },
  {
    "path": "lightrag/llm/deprecated/siliconcloud.py",
    "content": "import sys\n\nif sys.version_info < (3, 9):\n    pass\nelse:\n    pass\nimport pipmaster as pm  # Pipmaster for dynamic library install\n\n# install specific modules\nif not pm.is_installed(\"lmdeploy\"):\n    pm.install(\"lmdeploy\")\n\nfrom openai import (\n    APIConnectionError,\n    RateLimitError,\n    APITimeoutError,\n)\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\n\n\nimport numpy as np\nimport aiohttp\nimport base64\nimport struct\n\n\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=60),\n    retry=retry_if_exception_type(\n        (RateLimitError, APIConnectionError, APITimeoutError)\n    ),\n)\nasync def siliconcloud_embedding(\n    texts: list[str],\n    model: str = \"netease-youdao/bce-embedding-base_v1\",\n    base_url: str = \"https://api.siliconflow.cn/v1/embeddings\",\n    max_token_size: int = 8192,\n    api_key: str = None,\n) -> np.ndarray:\n    if api_key and not api_key.startswith(\"Bearer \"):\n        api_key = \"Bearer \" + api_key\n\n    headers = {\"Authorization\": api_key, \"Content-Type\": \"application/json\"}\n\n    truncate_texts = [text[0:max_token_size] for text in texts]\n\n    payload = {\"model\": model, \"input\": truncate_texts, \"encoding_format\": \"base64\"}\n\n    base64_strings = []\n    async with aiohttp.ClientSession() as session:\n        async with session.post(base_url, headers=headers, json=payload) as response:\n            content = await response.json()\n            if \"code\" in content:\n                raise ValueError(content)\n            base64_strings = [item[\"embedding\"] for item in content[\"data\"]]\n\n    embeddings = []\n    for string in base64_strings:\n        decode_bytes = base64.b64decode(string)\n        n = len(decode_bytes) // 4\n        float_array = struct.unpack(\"<\" + \"f\" * n, decode_bytes)\n        embeddings.append(float_array)\n    return np.array(embeddings)\n"
  },
  {
    "path": "lightrag/llm/gemini.py",
    "content": "\"\"\"\nGemini LLM binding for LightRAG.\n\nThis module provides asynchronous helpers that adapt Google's Gemini models\nto the same interface used by the rest of the LightRAG LLM bindings. The\nimplementation mirrors the OpenAI helpers while relying on the official\n``google-genai`` client under the hood.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom collections.abc import AsyncIterator\nfrom functools import lru_cache\nfrom typing import Any\n\nimport numpy as np\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\n\nfrom lightrag.utils import (\n    logger,\n    remove_think_tags,\n    safe_unicode_decode,\n    wrap_embedding_func_with_attrs,\n)\n\nimport pipmaster as pm\n\n# Install the Google Gemini client and its dependencies on demand\nif not pm.is_installed(\"google-genai\"):\n    pm.install(\"google-genai\")\nif not pm.is_installed(\"google-api-core\"):\n    pm.install(\"google-api-core\")\n\nfrom google import genai  # type: ignore\nfrom google.genai import types  # type: ignore\nfrom google.api_core import exceptions as google_api_exceptions  # type: ignore\n\n\nclass InvalidResponseError(Exception):\n    \"\"\"Custom exception class for triggering retry mechanism when Gemini returns empty responses\"\"\"\n\n    pass\n\n\n@lru_cache(maxsize=8)\ndef _get_gemini_client(\n    api_key: str, base_url: str | None, timeout: int | None = None\n) -> genai.Client:\n    \"\"\"\n    Create (or fetch cached) Gemini client.\n\n    Args:\n        api_key: Google Gemini API key (not used in Vertex AI mode).\n        base_url: Optional custom API endpoint.\n        timeout: Optional request timeout in milliseconds.\n\n    Returns:\n        genai.Client: Configured Gemini client instance.\n    \"\"\"\n    client_kwargs: dict[str, Any] = {}\n\n    # Add Vertex AI support\n    use_vertexai = os.getenv(\"GOOGLE_GENAI_USE_VERTEXAI\", \"\").lower() == \"true\"\n    if use_vertexai:\n        # Vertex AI mode: use project/location, NOT api_key\n        client_kwargs[\"vertexai\"] = True\n        project = os.getenv(\"GOOGLE_CLOUD_PROJECT\")\n        if project:\n            location = os.getenv(\"GOOGLE_CLOUD_LOCATION\", \"us-central1\")\n            client_kwargs[\"project\"] = project\n            if location:\n                client_kwargs[\"location\"] = location\n        else:\n            raise ValueError(\n                \"GOOGLE_CLOUD_PROJECT must be set when using Vertex AI mode\"\n            )\n    else:\n        # Standard Gemini API mode: use api_key\n        client_kwargs[\"api_key\"] = api_key\n\n    if base_url and base_url != \"DEFAULT_GEMINI_ENDPOINT\" or timeout is not None:\n        try:\n            http_options_kwargs = {}\n            if base_url and base_url != \"DEFAULT_GEMINI_ENDPOINT\":\n                http_options_kwargs[\"base_url\"] = base_url\n            if timeout is not None:\n                http_options_kwargs[\"timeout\"] = timeout\n\n            client_kwargs[\"http_options\"] = types.HttpOptions(**http_options_kwargs)\n        except Exception as e:\n            logger.error(\"Failed to apply custom Gemini http_options: %s\", e)\n            raise e\n\n    return genai.Client(**client_kwargs)\n\n\ndef _ensure_api_key(api_key: str | None) -> str:\n    # In Vertex AI mode, API key is not required\n    use_vertexai = os.getenv(\"GOOGLE_GENAI_USE_VERTEXAI\", \"\").lower() == \"true\"\n    if use_vertexai:\n        # Return empty string for Vertex AI mode (not used)\n        return \"\"\n\n    key = api_key or os.getenv(\"LLM_BINDING_API_KEY\") or os.getenv(\"GEMINI_API_KEY\")\n    if not key:\n        raise ValueError(\n            \"Gemini API key not provided. \"\n            \"Set LLM_BINDING_API_KEY or GEMINI_API_KEY in the environment.\"\n        )\n    return key\n\n\ndef _build_generation_config(\n    base_config: dict[str, Any] | None,\n    system_prompt: str | None,\n    keyword_extraction: bool,\n) -> types.GenerateContentConfig | None:\n    config_data = dict(base_config or {})\n\n    if system_prompt:\n        if config_data.get(\"system_instruction\"):\n            config_data[\"system_instruction\"] = (\n                f\"{config_data['system_instruction']}\\n{system_prompt}\"\n            )\n        else:\n            config_data[\"system_instruction\"] = system_prompt\n\n    if keyword_extraction and not config_data.get(\"response_mime_type\"):\n        config_data[\"response_mime_type\"] = \"application/json\"\n\n    # Remove entries that are explicitly set to None to avoid type errors\n    sanitized = {\n        key: value\n        for key, value in config_data.items()\n        if value is not None and value != \"\"\n    }\n\n    if not sanitized:\n        return None\n\n    return types.GenerateContentConfig(**sanitized)\n\n\ndef _format_history_messages(history_messages: list[dict[str, Any]] | None) -> str:\n    if not history_messages:\n        return \"\"\n\n    history_lines: list[str] = []\n    for message in history_messages:\n        role = message.get(\"role\", \"user\")\n        content = message.get(\"content\", \"\")\n        history_lines.append(f\"[{role}] {content}\")\n\n    return \"\\n\".join(history_lines)\n\n\ndef _extract_response_text(\n    response: Any, extract_thoughts: bool = False\n) -> tuple[str, str]:\n    \"\"\"\n    Extract text content from Gemini response, separating regular content from thoughts.\n\n    Args:\n        response: Gemini API response object\n        extract_thoughts: Whether to extract thought content separately\n\n    Returns:\n        Tuple of (regular_text, thought_text)\n    \"\"\"\n    candidates = getattr(response, \"candidates\", None)\n    if not candidates:\n        return (\"\", \"\")\n\n    regular_parts: list[str] = []\n    thought_parts: list[str] = []\n\n    for candidate in candidates:\n        if not getattr(candidate, \"content\", None):\n            continue\n        # Use 'or []' to handle None values from parts attribute\n        for part in getattr(candidate.content, \"parts\", None) or []:\n            text = getattr(part, \"text\", None)\n            if not text:\n                continue\n\n            # Check if this part is thought content using the 'thought' attribute\n            is_thought = getattr(part, \"thought\", False)\n\n            if is_thought and extract_thoughts:\n                thought_parts.append(text)\n            elif not is_thought:\n                regular_parts.append(text)\n\n    return (\"\\n\".join(regular_parts), \"\\n\".join(thought_parts))\n\n\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=60),\n    retry=(\n        retry_if_exception_type(google_api_exceptions.InternalServerError)\n        | retry_if_exception_type(google_api_exceptions.ServiceUnavailable)\n        | retry_if_exception_type(google_api_exceptions.ResourceExhausted)\n        | retry_if_exception_type(google_api_exceptions.GatewayTimeout)\n        | retry_if_exception_type(google_api_exceptions.BadGateway)\n        | retry_if_exception_type(google_api_exceptions.DeadlineExceeded)\n        | retry_if_exception_type(google_api_exceptions.Aborted)\n        | retry_if_exception_type(google_api_exceptions.Unknown)\n        | retry_if_exception_type(InvalidResponseError)\n    ),\n)\nasync def gemini_complete_if_cache(\n    model: str,\n    prompt: str,\n    system_prompt: str | None = None,\n    history_messages: list[dict[str, Any]] | None = None,\n    enable_cot: bool = False,\n    base_url: str | None = None,\n    api_key: str | None = None,\n    token_tracker: Any | None = None,\n    stream: bool | None = None,\n    keyword_extraction: bool = False,\n    generation_config: dict[str, Any] | None = None,\n    timeout: int | None = None,\n    **_: Any,\n) -> str | AsyncIterator[str]:\n    \"\"\"\n    Complete a prompt using Gemini's API with Chain of Thought (COT) support.\n\n    This function supports automatic integration of reasoning content from Gemini models\n    that provide Chain of Thought capabilities via the thinking_config API feature.\n\n    COT Integration:\n    - When enable_cot=True: Thought content is wrapped in <think>...</think> tags\n    - When enable_cot=False: Thought content is filtered out, only regular content returned\n    - Thought content is identified by the 'thought' attribute on response parts\n    - Requires thinking_config to be enabled in generation_config for API to return thoughts\n\n    Args:\n        model: The Gemini model to use.\n        prompt: The prompt to complete.\n        system_prompt: Optional system prompt to include.\n        history_messages: Optional list of previous messages in the conversation.\n        api_key: Optional Gemini API key. If None, uses environment variable.\n        base_url: Optional custom API endpoint.\n        generation_config: Optional generation configuration dict.\n        keyword_extraction: Whether to use JSON response format.\n        token_tracker: Optional token usage tracker for monitoring API usage.\n        stream: Whether to stream the response.\n        hashing_kv: Storage interface (for interface parity with other bindings).\n        enable_cot: Whether to include Chain of Thought content in the response.\n        timeout: Request timeout in seconds (will be converted to milliseconds for Gemini API).\n        **_: Additional keyword arguments (ignored).\n\n    Returns:\n        The completed text (with COT content if enable_cot=True) or an async iterator\n        of text chunks if streaming. COT content is wrapped in <think>...</think> tags.\n\n    Raises:\n        RuntimeError: If the response from Gemini is empty.\n        ValueError: If API key is not provided or configured.\n    \"\"\"\n    key = _ensure_api_key(api_key)\n    # Convert timeout from seconds to milliseconds for Gemini API\n    timeout_ms = timeout * 1000 if timeout else None\n    client = _get_gemini_client(key, base_url, timeout_ms)\n\n    history_block = _format_history_messages(history_messages)\n    prompt_sections = []\n    if history_block:\n        prompt_sections.append(history_block)\n    prompt_sections.append(f\"[user] {prompt}\")\n    combined_prompt = \"\\n\".join(prompt_sections)\n\n    config_obj = _build_generation_config(\n        generation_config,\n        system_prompt=system_prompt,\n        keyword_extraction=keyword_extraction,\n    )\n\n    request_kwargs: dict[str, Any] = {\n        \"model\": model,\n        \"contents\": [combined_prompt],\n    }\n    if config_obj is not None:\n        request_kwargs[\"config\"] = config_obj\n\n    if stream:\n\n        async def _async_stream() -> AsyncIterator[str]:\n            # COT state tracking for streaming\n            cot_active = False\n            cot_started = False\n            initial_content_seen = False\n            usage_metadata = None\n\n            try:\n                # Use native async streaming from genai SDK\n                # Note: generate_content_stream returns Awaitable[AsyncIterator], need to await first\n                stream = await client.aio.models.generate_content_stream(\n                    **request_kwargs\n                )\n                async for chunk in stream:\n                    usage = getattr(chunk, \"usage_metadata\", None)\n                    if usage is not None:\n                        usage_metadata = usage\n\n                    # Extract both regular and thought content\n                    regular_text, thought_text = _extract_response_text(\n                        chunk, extract_thoughts=True\n                    )\n\n                    if enable_cot:\n                        # Process regular content\n                        if regular_text:\n                            if not initial_content_seen:\n                                initial_content_seen = True\n\n                            # Close COT section if it was active\n                            if cot_active:\n                                yield \"</think>\"\n                                cot_active = False\n\n                            # Process and yield regular content\n                            if \"\\\\u\" in regular_text:\n                                regular_text = safe_unicode_decode(\n                                    regular_text.encode(\"utf-8\")\n                                )\n                            yield regular_text\n\n                        # Process thought content\n                        if thought_text:\n                            if not initial_content_seen and not cot_started:\n                                # Start COT section\n                                yield \"<think>\"\n                                cot_active = True\n                                cot_started = True\n\n                            # Yield thought content if COT is active\n                            if cot_active:\n                                if \"\\\\u\" in thought_text:\n                                    thought_text = safe_unicode_decode(\n                                        thought_text.encode(\"utf-8\")\n                                    )\n                                yield thought_text\n                    else:\n                        # COT disabled - only yield regular content\n                        if regular_text:\n                            if \"\\\\u\" in regular_text:\n                                regular_text = safe_unicode_decode(\n                                    regular_text.encode(\"utf-8\")\n                                )\n                            yield regular_text\n\n                # Ensure COT is properly closed if still active\n                if cot_active:\n                    yield \"</think>\"\n                    cot_active = False\n\n            except Exception as exc:\n                # Try to close COT tag before re-raising\n                if cot_active:\n                    try:\n                        yield \"</think>\"\n                    except Exception:\n                        pass\n                raise exc\n            finally:\n                # Track token usage after streaming completes\n                if token_tracker and usage_metadata:\n                    token_tracker.add_usage(\n                        {\n                            \"prompt_tokens\": getattr(\n                                usage_metadata, \"prompt_token_count\", 0\n                            ),\n                            \"completion_tokens\": getattr(\n                                usage_metadata, \"candidates_token_count\", 0\n                            ),\n                            \"total_tokens\": getattr(\n                                usage_metadata, \"total_token_count\", 0\n                            ),\n                        }\n                    )\n\n        return _async_stream()\n\n    # Non-streaming: use native async client\n    response = await client.aio.models.generate_content(**request_kwargs)\n\n    # Extract both regular text and thought text\n    regular_text, thought_text = _extract_response_text(response, extract_thoughts=True)\n\n    # Apply COT filtering logic based on enable_cot parameter\n    if enable_cot:\n        # Include thought content wrapped in <think> tags\n        if thought_text and thought_text.strip():\n            if not regular_text or regular_text.strip() == \"\":\n                # Only thought content available\n                final_text = f\"<think>{thought_text}</think>\"\n            else:\n                # Both content types present: prepend thought to regular content\n                final_text = f\"<think>{thought_text}</think>{regular_text}\"\n        else:\n            # No thought content, use regular content only\n            final_text = regular_text or \"\"\n    else:\n        # Filter out thought content, return only regular content\n        final_text = regular_text or \"\"\n\n    if not final_text:\n        raise InvalidResponseError(\"Gemini response did not contain any text content.\")\n\n    if \"\\\\u\" in final_text:\n        final_text = safe_unicode_decode(final_text.encode(\"utf-8\"))\n\n    final_text = remove_think_tags(final_text)\n\n    usage = getattr(response, \"usage_metadata\", None)\n    if token_tracker and usage:\n        token_tracker.add_usage(\n            {\n                \"prompt_tokens\": getattr(usage, \"prompt_token_count\", 0),\n                \"completion_tokens\": getattr(usage, \"candidates_token_count\", 0),\n                \"total_tokens\": getattr(usage, \"total_token_count\", 0),\n            }\n        )\n\n    logger.debug(\"Gemini response length: %s\", len(final_text))\n    return final_text\n\n\nasync def gemini_model_complete(\n    prompt: str,\n    system_prompt: str | None = None,\n    history_messages: list[dict[str, Any]] | None = None,\n    keyword_extraction: bool = False,\n    **kwargs: Any,\n) -> str | AsyncIterator[str]:\n    hashing_kv = kwargs.get(\"hashing_kv\")\n    model_name = None\n    if hashing_kv is not None:\n        model_name = hashing_kv.global_config.get(\"llm_model_name\")\n    if model_name is None:\n        model_name = kwargs.pop(\"model_name\", None)\n    if model_name is None:\n        raise ValueError(\"Gemini model name not provided in configuration.\")\n\n    return await gemini_complete_if_cache(\n        model_name,\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        keyword_extraction=keyword_extraction,\n        **kwargs,\n    )\n\n\n@wrap_embedding_func_with_attrs(\n    embedding_dim=1536, max_token_size=2048, model_name=\"gemini-embedding-001\"\n)\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=60),\n    retry=(\n        retry_if_exception_type(google_api_exceptions.InternalServerError)\n        | retry_if_exception_type(google_api_exceptions.ServiceUnavailable)\n        | retry_if_exception_type(google_api_exceptions.ResourceExhausted)\n        | retry_if_exception_type(google_api_exceptions.GatewayTimeout)\n        | retry_if_exception_type(google_api_exceptions.BadGateway)\n        | retry_if_exception_type(google_api_exceptions.DeadlineExceeded)\n        | retry_if_exception_type(google_api_exceptions.Aborted)\n        | retry_if_exception_type(google_api_exceptions.Unknown)\n    ),\n)\nasync def gemini_embed(\n    texts: list[str],\n    model: str = \"gemini-embedding-001\",\n    base_url: str | None = None,\n    api_key: str | None = None,\n    embedding_dim: int | None = None,\n    max_token_size: int | None = None,\n    task_type: str = \"RETRIEVAL_DOCUMENT\",\n    timeout: int | None = None,\n    token_tracker: Any | None = None,\n) -> np.ndarray:\n    \"\"\"Generate embeddings for a list of texts using Gemini's API.\n\n    This function uses Google's Gemini embedding model to generate text embeddings.\n    It supports dynamic dimension control and automatic normalization for dimensions\n    less than 3072.\n\n    Args:\n        texts: List of texts to embed.\n        model: The Gemini embedding model to use. Default is \"gemini-embedding-001\".\n        base_url: Optional custom API endpoint.\n        api_key: Optional Gemini API key. If None, uses environment variables.\n        embedding_dim: Optional embedding dimension for dynamic dimension reduction.\n            **IMPORTANT**: This parameter is automatically injected by the EmbeddingFunc wrapper.\n            Do NOT manually pass this parameter when calling the function directly.\n            The dimension is controlled by the @wrap_embedding_func_with_attrs decorator\n            or the EMBEDDING_DIM environment variable.\n            Supported range: 128-3072. Recommended values: 768, 1536, 3072.\n        max_token_size: Maximum tokens per text. This parameter is automatically\n            injected by the EmbeddingFunc wrapper when the underlying function\n            signature supports it (via inspect.signature check). Gemini API will\n            automatically truncate texts exceeding this limit (autoTruncate=True\n            by default), so no client-side truncation is needed.\n        task_type: Task type for embedding optimization. Default is \"RETRIEVAL_DOCUMENT\".\n            Supported types: SEMANTIC_SIMILARITY, CLASSIFICATION, CLUSTERING,\n            RETRIEVAL_DOCUMENT, RETRIEVAL_QUERY, CODE_RETRIEVAL_QUERY,\n            QUESTION_ANSWERING, FACT_VERIFICATION.\n        timeout: Request timeout in seconds (will be converted to milliseconds for Gemini API).\n        token_tracker: Optional token usage tracker for monitoring API usage.\n\n    Returns:\n        A numpy array of embeddings, one per input text. For dimensions < 3072,\n        the embeddings are L2-normalized to ensure optimal semantic similarity performance.\n\n    Raises:\n        ValueError: If API key is not provided or configured.\n        RuntimeError: If the response from Gemini is invalid or empty.\n\n    Note:\n        - For dimension 3072: Embeddings are already normalized by the API\n        - For dimensions < 3072: Embeddings are L2-normalized after retrieval\n        - Normalization ensures accurate semantic similarity via cosine distance\n        - Gemini API automatically truncates texts exceeding max_token_size (autoTruncate=True)\n    \"\"\"\n    # Note: max_token_size is received but not used for client-side truncation.\n    # Gemini API handles truncation automatically with autoTruncate=True (default).\n    _ = max_token_size  # Acknowledge parameter to avoid unused variable warning\n\n    key = _ensure_api_key(api_key)\n    # Convert timeout from seconds to milliseconds for Gemini API\n    timeout_ms = timeout * 1000 if timeout else None\n    client = _get_gemini_client(key, base_url, timeout_ms)\n\n    # Prepare embedding configuration\n    config_kwargs: dict[str, Any] = {}\n\n    # Add task_type to config\n    if task_type:\n        config_kwargs[\"task_type\"] = task_type\n\n    # Add output_dimensionality if embedding_dim is provided\n    if embedding_dim is not None:\n        config_kwargs[\"output_dimensionality\"] = embedding_dim\n\n    # Create config object if we have parameters\n    config_obj = types.EmbedContentConfig(**config_kwargs) if config_kwargs else None\n\n    request_kwargs: dict[str, Any] = {\n        \"model\": model,\n        \"contents\": texts,\n    }\n    if config_obj is not None:\n        request_kwargs[\"config\"] = config_obj\n\n    # Use native async client for embedding\n    response = await client.aio.models.embed_content(**request_kwargs)\n\n    # Extract embeddings from response\n    if not hasattr(response, \"embeddings\") or not response.embeddings:\n        raise RuntimeError(\"Gemini response did not contain embeddings.\")\n\n    # Convert embeddings to numpy array\n    embeddings = np.array(\n        [np.array(e.values, dtype=np.float32) for e in response.embeddings]\n    )\n\n    # Apply L2 normalization for dimensions < 3072\n    # The 3072 dimension embedding is already normalized by Gemini API\n    if embedding_dim and embedding_dim < 3072:\n        # Normalize each embedding vector to unit length\n        norms = np.linalg.norm(embeddings, axis=1, keepdims=True)\n        # Avoid division by zero\n        norms = np.where(norms == 0, 1, norms)\n        embeddings = embeddings / norms\n        logger.debug(\n            f\"Applied L2 normalization to {len(embeddings)} embeddings of dimension {embedding_dim}\"\n        )\n\n    # Track token usage if tracker is provided\n    # Note: Gemini embedding API may not provide usage metadata\n    if token_tracker and hasattr(response, \"usage_metadata\"):\n        usage = response.usage_metadata\n        token_counts = {\n            \"prompt_tokens\": getattr(usage, \"prompt_token_count\", 0),\n            \"total_tokens\": getattr(usage, \"total_token_count\", 0),\n        }\n        token_tracker.add_usage(token_counts)\n\n    logger.debug(\n        f\"Generated {len(embeddings)} Gemini embeddings with dimension {embeddings.shape[1]}\"\n    )\n\n    return embeddings\n\n\n__all__ = [\n    \"gemini_complete_if_cache\",\n    \"gemini_model_complete\",\n    \"gemini_embed\",\n]\n"
  },
  {
    "path": "lightrag/llm/hf.py",
    "content": "import copy\nimport os\nfrom functools import lru_cache\n\nimport pipmaster as pm  # Pipmaster for dynamic library install\n\n# install specific modules\nif not pm.is_installed(\"transformers\"):\n    pm.install(\"transformers\")\nif not pm.is_installed(\"torch\"):\n    pm.install(\"torch\")\nif not pm.is_installed(\"numpy\"):\n    pm.install(\"numpy\")\n\nfrom transformers import AutoTokenizer, AutoModelForCausalLM\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\nfrom lightrag.exceptions import (\n    APIConnectionError,\n    RateLimitError,\n    APITimeoutError,\n)\nimport torch\nimport numpy as np\nfrom lightrag.utils import wrap_embedding_func_with_attrs\n\nos.environ[\"TOKENIZERS_PARALLELISM\"] = \"false\"\n\n\n@lru_cache(maxsize=1)\ndef initialize_hf_model(model_name):\n    hf_tokenizer = AutoTokenizer.from_pretrained(\n        model_name, device_map=\"auto\", trust_remote_code=True\n    )\n    hf_model = AutoModelForCausalLM.from_pretrained(\n        model_name, device_map=\"auto\", trust_remote_code=True\n    )\n    if hf_tokenizer.pad_token is None:\n        hf_tokenizer.pad_token = hf_tokenizer.eos_token\n\n    return hf_model, hf_tokenizer\n\n\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=10),\n    retry=retry_if_exception_type(\n        (RateLimitError, APIConnectionError, APITimeoutError)\n    ),\n)\nasync def hf_model_if_cache(\n    model,\n    prompt,\n    system_prompt=None,\n    history_messages=[],\n    enable_cot: bool = False,\n    **kwargs,\n) -> str:\n    if enable_cot:\n        from lightrag.utils import logger\n\n        logger.debug(\n            \"enable_cot=True is not supported for Hugging Face local models and will be ignored.\"\n        )\n    model_name = model\n    hf_model, hf_tokenizer = initialize_hf_model(model_name)\n    messages = []\n    if system_prompt:\n        messages.append({\"role\": \"system\", \"content\": system_prompt})\n    messages.extend(history_messages)\n    messages.append({\"role\": \"user\", \"content\": prompt})\n    kwargs.pop(\"hashing_kv\", None)\n    input_prompt = \"\"\n    try:\n        input_prompt = hf_tokenizer.apply_chat_template(\n            messages, tokenize=False, add_generation_prompt=True\n        )\n    except Exception:\n        try:\n            ori_message = copy.deepcopy(messages)\n            if messages[0][\"role\"] == \"system\":\n                messages[1][\"content\"] = (\n                    \"<system>\"\n                    + messages[0][\"content\"]\n                    + \"</system>\\n\"\n                    + messages[1][\"content\"]\n                )\n                messages = messages[1:]\n                input_prompt = hf_tokenizer.apply_chat_template(\n                    messages, tokenize=False, add_generation_prompt=True\n                )\n        except Exception:\n            len_message = len(ori_message)\n            for msgid in range(len_message):\n                input_prompt = (\n                    input_prompt\n                    + \"<\"\n                    + ori_message[msgid][\"role\"]\n                    + \">\"\n                    + ori_message[msgid][\"content\"]\n                    + \"</\"\n                    + ori_message[msgid][\"role\"]\n                    + \">\\n\"\n                )\n\n    input_ids = hf_tokenizer(\n        input_prompt, return_tensors=\"pt\", padding=True, truncation=True\n    ).to(\"cuda\")\n    inputs = {k: v.to(hf_model.device) for k, v in input_ids.items()}\n    output = hf_model.generate(\n        **input_ids, max_new_tokens=512, num_return_sequences=1, early_stopping=True\n    )\n    response_text = hf_tokenizer.decode(\n        output[0][len(inputs[\"input_ids\"][0]) :], skip_special_tokens=True\n    )\n\n    return response_text\n\n\nasync def hf_model_complete(\n    prompt,\n    system_prompt=None,\n    history_messages=[],\n    keyword_extraction=False,\n    enable_cot: bool = False,\n    **kwargs,\n) -> str:\n    kwargs.pop(\"keyword_extraction\", None)\n    model_name = kwargs[\"hashing_kv\"].global_config[\"llm_model_name\"]\n    result = await hf_model_if_cache(\n        model_name,\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        enable_cot=enable_cot,\n        **kwargs,\n    )\n    return result\n\n\n@wrap_embedding_func_with_attrs(\n    embedding_dim=1024, max_token_size=8192, model_name=\"hf_embedding_model\"\n)\nasync def hf_embed(texts: list[str], tokenizer, embed_model) -> np.ndarray:\n    # Detect the appropriate device\n    if torch.cuda.is_available():\n        device = next(embed_model.parameters()).device  # Use CUDA if available\n    elif torch.backends.mps.is_available():\n        device = torch.device(\"mps\")  # Use MPS for Apple Silicon\n    else:\n        device = torch.device(\"cpu\")  # Fallback to CPU\n\n    # Move the model to the detected device\n    embed_model = embed_model.to(device)\n\n    # Tokenize the input texts and move them to the same device\n    encoded_texts = tokenizer(\n        texts, return_tensors=\"pt\", padding=True, truncation=True\n    ).to(device)\n\n    # Perform inference\n    with torch.no_grad():\n        outputs = embed_model(\n            input_ids=encoded_texts[\"input_ids\"],\n            attention_mask=encoded_texts[\"attention_mask\"],\n        )\n        embeddings = outputs.last_hidden_state.mean(dim=1)\n\n    # Convert embeddings to NumPy\n    if embeddings.dtype == torch.bfloat16:\n        return embeddings.detach().to(torch.float32).cpu().numpy()\n    else:\n        return embeddings.detach().cpu().numpy()\n"
  },
  {
    "path": "lightrag/llm/jina.py",
    "content": "import os\nimport pipmaster as pm  # Pipmaster for dynamic library install\n\n# install specific modules\nif not pm.is_installed(\"aiohttp\"):\n    pm.install(\"aiohttp\")\nif not pm.is_installed(\"tenacity\"):\n    pm.install(\"tenacity\")\n\nimport numpy as np\nimport base64\nimport aiohttp\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\nfrom lightrag.utils import wrap_embedding_func_with_attrs, logger\n\n\nasync def fetch_data(url, headers, data):\n    async with aiohttp.ClientSession() as session:\n        async with session.post(url, headers=headers, json=data) as response:\n            if response.status != 200:\n                error_text = await response.text()\n\n                # Check if the error response is HTML (common for 502, 503, etc.)\n                content_type = response.headers.get(\"content-type\", \"\").lower()\n                is_html_error = (\n                    error_text.strip().startswith(\"<!DOCTYPE html>\")\n                    or \"text/html\" in content_type\n                )\n\n                if is_html_error:\n                    # Provide clean, user-friendly error messages for HTML error pages\n                    if response.status == 502:\n                        clean_error = \"Bad Gateway (502) - Jina AI service temporarily unavailable. Please try again in a few minutes.\"\n                    elif response.status == 503:\n                        clean_error = \"Service Unavailable (503) - Jina AI service is temporarily overloaded. Please try again later.\"\n                    elif response.status == 504:\n                        clean_error = \"Gateway Timeout (504) - Jina AI service request timed out. Please try again.\"\n                    else:\n                        clean_error = f\"HTTP {response.status} - Jina AI service error. Please try again later.\"\n                else:\n                    # Use original error text if it's not HTML\n                    clean_error = error_text\n\n                logger.error(f\"Jina API error {response.status}: {clean_error}\")\n                raise aiohttp.ClientResponseError(\n                    request_info=response.request_info,\n                    history=response.history,\n                    status=response.status,\n                    message=f\"Jina API error: {clean_error}\",\n                )\n            response_json = await response.json()\n            data_list = response_json.get(\"data\", [])\n            return data_list\n\n\n@wrap_embedding_func_with_attrs(\n    embedding_dim=2048, max_token_size=8192, model_name=\"jina-embeddings-v4\"\n)\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=60),\n    retry=(\n        retry_if_exception_type(aiohttp.ClientError)\n        | retry_if_exception_type(aiohttp.ClientResponseError)\n    ),\n)\nasync def jina_embed(\n    texts: list[str],\n    model: str = \"jina-embeddings-v4\",\n    embedding_dim: int = 2048,\n    late_chunking: bool = False,\n    base_url: str = None,\n    api_key: str = None,\n) -> np.ndarray:\n    \"\"\"Generate embeddings for a list of texts using Jina AI's API.\n\n    Args:\n        texts: List of texts to embed.\n        model: The Jina embedding model to use (default: jina-embeddings-v4).\n            Supported models: jina-embeddings-v3, jina-embeddings-v4, etc.\n        embedding_dim: The embedding dimensions (default: 2048 for jina-embeddings-v4).\n            **IMPORTANT**: This parameter is automatically injected by the EmbeddingFunc wrapper.\n            Do NOT manually pass this parameter when calling the function directly.\n            The dimension is controlled by the @wrap_embedding_func_with_attrs decorator.\n            Manually passing a different value will trigger a warning and be ignored.\n            When provided (by EmbeddingFunc), it will be passed to the Jina API for dimension reduction.\n        late_chunking: Whether to use late chunking.\n        base_url: Optional base URL for the Jina API.\n        api_key: Optional Jina API key. If None, uses the JINA_API_KEY environment variable.\n\n    Returns:\n        A numpy array of embeddings, one per input text.\n\n    Raises:\n        aiohttp.ClientError: If there is a connection error with the Jina API.\n        aiohttp.ClientResponseError: If the Jina API returns an error response.\n    \"\"\"\n    if api_key:\n        os.environ[\"JINA_API_KEY\"] = api_key\n\n    if \"JINA_API_KEY\" not in os.environ:\n        raise ValueError(\"JINA_API_KEY environment variable is required\")\n\n    url = base_url or \"https://api.jina.ai/v1/embeddings\"\n    headers = {\n        \"Content-Type\": \"application/json\",\n        \"Authorization\": f\"Bearer {os.environ['JINA_API_KEY']}\",\n    }\n    data = {\n        \"model\": model,\n        \"task\": \"text-matching\",\n        \"dimensions\": embedding_dim,\n        \"embedding_type\": \"base64\",\n        \"input\": texts,\n    }\n\n    # Only add optional parameters if they have non-default values\n    if late_chunking:\n        data[\"late_chunking\"] = late_chunking\n\n    logger.debug(\n        f\"Jina embedding request: {len(texts)} texts, dimensions: {embedding_dim}\"\n    )\n\n    try:\n        data_list = await fetch_data(url, headers, data)\n\n        if not data_list:\n            logger.error(\"Jina API returned empty data list\")\n            raise ValueError(\"Jina API returned empty data list\")\n\n        if len(data_list) != len(texts):\n            logger.error(\n                f\"Jina API returned {len(data_list)} embeddings for {len(texts)} texts\"\n            )\n            raise ValueError(\n                f\"Jina API returned {len(data_list)} embeddings for {len(texts)} texts\"\n            )\n\n        embeddings = np.array(\n            [\n                np.frombuffer(base64.b64decode(dp[\"embedding\"]), dtype=np.float32)\n                for dp in data_list\n            ]\n        )\n        logger.debug(f\"Jina embeddings generated: shape {embeddings.shape}\")\n\n        return embeddings\n\n    except Exception as e:\n        logger.error(f\"Jina embedding error: {e}\")\n        raise\n"
  },
  {
    "path": "lightrag/llm/llama_index_impl.py",
    "content": "import pipmaster as pm\nfrom llama_index.core.llms import (\n    ChatMessage,\n    MessageRole,\n    ChatResponse,\n)\nfrom typing import List, Optional\nfrom lightrag.utils import logger\n\n# Install required dependencies\nif not pm.is_installed(\"llama-index\"):\n    pm.install(\"llama-index\")\n\nfrom llama_index.core.embeddings import BaseEmbedding\nfrom llama_index.core.settings import Settings as LlamaIndexSettings\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\nfrom lightrag.utils import (\n    wrap_embedding_func_with_attrs,\n)\nfrom lightrag.exceptions import (\n    APIConnectionError,\n    RateLimitError,\n    APITimeoutError,\n)\nimport numpy as np\n\n\ndef configure_llama_index(settings: LlamaIndexSettings = None, **kwargs):\n    \"\"\"\n    Configure LlamaIndex settings.\n\n    Args:\n        settings: LlamaIndex Settings instance. If None, uses default settings.\n        **kwargs: Additional settings to override/configure\n    \"\"\"\n    if settings is None:\n        settings = LlamaIndexSettings()\n\n    # Update settings with any provided kwargs\n    for key, value in kwargs.items():\n        if hasattr(settings, key):\n            setattr(settings, key, value)\n        else:\n            logger.warning(f\"Unknown LlamaIndex setting: {key}\")\n\n    # Set as global settings\n    LlamaIndexSettings.set_global(settings)\n    return settings\n\n\ndef format_chat_messages(messages):\n    \"\"\"Format chat messages into LlamaIndex format.\"\"\"\n    formatted_messages = []\n\n    for msg in messages:\n        role = msg.get(\"role\", \"user\")\n        content = msg.get(\"content\", \"\")\n\n        if role == \"system\":\n            formatted_messages.append(\n                ChatMessage(role=MessageRole.SYSTEM, content=content)\n            )\n        elif role == \"assistant\":\n            formatted_messages.append(\n                ChatMessage(role=MessageRole.ASSISTANT, content=content)\n            )\n        elif role == \"user\":\n            formatted_messages.append(\n                ChatMessage(role=MessageRole.USER, content=content)\n            )\n        else:\n            logger.warning(f\"Unknown role {role}, treating as user message\")\n            formatted_messages.append(\n                ChatMessage(role=MessageRole.USER, content=content)\n            )\n\n    return formatted_messages\n\n\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=60),\n    retry=retry_if_exception_type(\n        (RateLimitError, APIConnectionError, APITimeoutError)\n    ),\n)\nasync def llama_index_complete_if_cache(\n    model: str,\n    prompt: str,\n    system_prompt: Optional[str] = None,\n    history_messages: List[dict] = [],\n    enable_cot: bool = False,\n    chat_kwargs={},\n) -> str:\n    \"\"\"Complete the prompt using LlamaIndex.\"\"\"\n    if enable_cot:\n        logger.debug(\n            \"enable_cot=True is not supported for LlamaIndex implementation and will be ignored.\"\n        )\n    try:\n        # Format messages for chat\n        formatted_messages = []\n\n        # Add system message if provided\n        if system_prompt:\n            formatted_messages.append(\n                ChatMessage(role=MessageRole.SYSTEM, content=system_prompt)\n            )\n\n        # Add history messages\n        for msg in history_messages:\n            formatted_messages.append(\n                ChatMessage(\n                    role=MessageRole.USER\n                    if msg[\"role\"] == \"user\"\n                    else MessageRole.ASSISTANT,\n                    content=msg[\"content\"],\n                )\n            )\n\n        # Add current prompt\n        formatted_messages.append(ChatMessage(role=MessageRole.USER, content=prompt))\n\n        response: ChatResponse = await model.achat(\n            messages=formatted_messages, **chat_kwargs\n        )\n\n        # In newer versions, the response is in message.content\n        content = response.message.content\n        return content\n\n    except Exception as e:\n        logger.error(f\"Error in llama_index_complete_if_cache: {str(e)}\")\n        raise\n\n\nasync def llama_index_complete(\n    prompt,\n    system_prompt=None,\n    history_messages=None,\n    enable_cot: bool = False,\n    keyword_extraction=False,\n    settings: LlamaIndexSettings = None,\n    **kwargs,\n) -> str:\n    \"\"\"\n    Main completion function for LlamaIndex\n\n    Args:\n        prompt: Input prompt\n        system_prompt: Optional system prompt\n        history_messages: Optional chat history\n        keyword_extraction: Whether to extract keywords from response\n        settings: Optional LlamaIndex settings\n        **kwargs: Additional arguments\n    \"\"\"\n    if history_messages is None:\n        history_messages = []\n\n    kwargs.pop(\"keyword_extraction\", None)\n    result = await llama_index_complete_if_cache(\n        kwargs.get(\"llm_instance\"),\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        enable_cot=enable_cot,\n        **kwargs,\n    )\n    return result\n\n\n@wrap_embedding_func_with_attrs(embedding_dim=1536, max_token_size=8192)\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=60),\n    retry=retry_if_exception_type(\n        (RateLimitError, APIConnectionError, APITimeoutError)\n    ),\n)\nasync def llama_index_embed(\n    texts: list[str],\n    embed_model: BaseEmbedding = None,\n    settings: LlamaIndexSettings = None,\n    **kwargs,\n) -> np.ndarray:\n    \"\"\"\n    Generate embeddings using LlamaIndex\n\n    Args:\n        texts: List of texts to embed\n        embed_model: LlamaIndex embedding model\n        settings: Optional LlamaIndex settings\n        **kwargs: Additional arguments\n    \"\"\"\n    if settings:\n        configure_llama_index(settings)\n\n    if embed_model is None:\n        raise ValueError(\"embed_model must be provided\")\n\n    # Use _get_text_embeddings for batch processing\n    embeddings = embed_model._get_text_embeddings(texts)\n    return np.array(embeddings)\n"
  },
  {
    "path": "lightrag/llm/lmdeploy.py",
    "content": "import pipmaster as pm  # Pipmaster for dynamic library install\n\n# install specific modules\nif not pm.is_installed(\"lmdeploy\"):\n    pm.install(\"lmdeploy[all]\")\n\nfrom lightrag.exceptions import (\n    APIConnectionError,\n    RateLimitError,\n    APITimeoutError,\n)\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\n\n\nfrom functools import lru_cache\n\n\n@lru_cache(maxsize=1)\ndef initialize_lmdeploy_pipeline(\n    model,\n    tp=1,\n    chat_template=None,\n    log_level=\"WARNING\",\n    model_format=\"hf\",\n    quant_policy=0,\n):\n    from lmdeploy import pipeline, ChatTemplateConfig, TurbomindEngineConfig\n\n    lmdeploy_pipe = pipeline(\n        model_path=model,\n        backend_config=TurbomindEngineConfig(\n            tp=tp, model_format=model_format, quant_policy=quant_policy\n        ),\n        chat_template_config=(\n            ChatTemplateConfig(model_name=chat_template) if chat_template else None\n        ),\n        log_level=\"WARNING\",\n    )\n    return lmdeploy_pipe\n\n\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=10),\n    retry=retry_if_exception_type(\n        (RateLimitError, APIConnectionError, APITimeoutError)\n    ),\n)\nasync def lmdeploy_model_if_cache(\n    model,\n    prompt,\n    system_prompt=None,\n    history_messages=[],\n    enable_cot: bool = False,\n    chat_template=None,\n    model_format=\"hf\",\n    quant_policy=0,\n    **kwargs,\n) -> str:\n    \"\"\"\n    Args:\n        model (str): The path to the model.\n            It could be one of the following options:\n                    - i) A local directory path of a turbomind model which is\n                        converted by `lmdeploy convert` command or download\n                        from ii) and iii).\n                    - ii) The model_id of a lmdeploy-quantized model hosted\n                        inside a model repo on huggingface.co, such as\n                        \"InternLM/internlm-chat-20b-4bit\",\n                        \"lmdeploy/llama2-chat-70b-4bit\", etc.\n                    - iii) The model_id of a model hosted inside a model repo\n                        on huggingface.co, such as \"internlm/internlm-chat-7b\",\n                        \"Qwen/Qwen-7B-Chat \", \"baichuan-inc/Baichuan2-7B-Chat\"\n                        and so on.\n        chat_template (str): needed when model is a pytorch model on\n            huggingface.co, such as \"internlm-chat-7b\",\n            \"Qwen-7B-Chat \", \"Baichuan2-7B-Chat\" and so on,\n            and when the model name of local path did not match the original model name in HF.\n        tp (int): tensor parallel\n        prompt (Union[str, List[str]]): input texts to be completed.\n        do_preprocess (bool): whether pre-process the messages. Default to\n            True, which means chat_template will be applied.\n        skip_special_tokens (bool): Whether or not to remove special tokens\n            in the decoding. Default to be True.\n        do_sample (bool): Whether or not to use sampling, use greedy decoding otherwise.\n            Default to be False, which means greedy decoding will be applied.\n    \"\"\"\n    if enable_cot:\n        from lightrag.utils import logger\n\n        logger.debug(\n            \"enable_cot=True is not supported for lmdeploy and will be ignored.\"\n        )\n    try:\n        import lmdeploy\n        from lmdeploy import version_info, GenerationConfig\n    except Exception:\n        raise ImportError(\"Please install lmdeploy before initialize lmdeploy backend.\")\n    kwargs.pop(\"hashing_kv\", None)\n    kwargs.pop(\"response_format\", None)\n    max_new_tokens = kwargs.pop(\"max_tokens\", 512)\n    tp = kwargs.pop(\"tp\", 1)\n    skip_special_tokens = kwargs.pop(\"skip_special_tokens\", True)\n    do_preprocess = kwargs.pop(\"do_preprocess\", True)\n    do_sample = kwargs.pop(\"do_sample\", False)\n    gen_params = kwargs\n\n    version = version_info\n    if do_sample is not None and version < (0, 6, 0):\n        raise RuntimeError(\n            \"`do_sample` parameter is not supported by lmdeploy until \"\n            f\"v0.6.0, but currently using lmdeloy {lmdeploy.__version__}\"\n        )\n    else:\n        do_sample = True\n        gen_params.update(do_sample=do_sample)\n\n    lmdeploy_pipe = initialize_lmdeploy_pipeline(\n        model=model,\n        tp=tp,\n        chat_template=chat_template,\n        model_format=model_format,\n        quant_policy=quant_policy,\n        log_level=\"WARNING\",\n    )\n\n    messages = []\n    if system_prompt:\n        messages.append({\"role\": \"system\", \"content\": system_prompt})\n\n    messages.extend(history_messages)\n    messages.append({\"role\": \"user\", \"content\": prompt})\n\n    gen_config = GenerationConfig(\n        skip_special_tokens=skip_special_tokens,\n        max_new_tokens=max_new_tokens,\n        **gen_params,\n    )\n\n    response = \"\"\n    async for res in lmdeploy_pipe.generate(\n        messages,\n        gen_config=gen_config,\n        do_preprocess=do_preprocess,\n        stream_response=False,\n        session_id=1,\n    ):\n        response += res.response\n    return response\n"
  },
  {
    "path": "lightrag/llm/lollms.py",
    "content": "import sys\n\nif sys.version_info < (3, 9):\n    from typing import AsyncIterator\nelse:\n    from collections.abc import AsyncIterator\nimport pipmaster as pm  # Pipmaster for dynamic library install\n\nif not pm.is_installed(\"aiohttp\"):\n    pm.install(\"aiohttp\")\n\nimport aiohttp\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\n\nfrom lightrag.exceptions import (\n    APIConnectionError,\n    RateLimitError,\n    APITimeoutError,\n)\n\nfrom typing import Union, List\nimport numpy as np\n\nfrom lightrag.utils import (\n    wrap_embedding_func_with_attrs,\n)\n\n\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=10),\n    retry=retry_if_exception_type(\n        (RateLimitError, APIConnectionError, APITimeoutError)\n    ),\n)\nasync def lollms_model_if_cache(\n    model,\n    prompt,\n    system_prompt=None,\n    history_messages=[],\n    enable_cot: bool = False,\n    base_url=\"http://localhost:9600\",\n    **kwargs,\n) -> Union[str, AsyncIterator[str]]:\n    \"\"\"Client implementation for lollms generation.\"\"\"\n    if enable_cot:\n        from lightrag.utils import logger\n\n        logger.debug(\"enable_cot=True is not supported for lollms and will be ignored.\")\n\n    stream = True if kwargs.get(\"stream\") else False\n    api_key = kwargs.pop(\"api_key\", None)\n    headers = (\n        {\"Content-Type\": \"application/json\", \"Authorization\": f\"Bearer {api_key}\"}\n        if api_key\n        else {\"Content-Type\": \"application/json\"}\n    )\n\n    # Extract lollms specific parameters\n    request_data = {\n        \"prompt\": prompt,\n        \"model_name\": model,\n        \"personality\": kwargs.get(\"personality\", -1),\n        \"n_predict\": kwargs.get(\"n_predict\", None),\n        \"stream\": stream,\n        \"temperature\": kwargs.get(\"temperature\", 1.0),\n        \"top_k\": kwargs.get(\"top_k\", 50),\n        \"top_p\": kwargs.get(\"top_p\", 0.95),\n        \"repeat_penalty\": kwargs.get(\"repeat_penalty\", 0.8),\n        \"repeat_last_n\": kwargs.get(\"repeat_last_n\", 40),\n        \"seed\": kwargs.get(\"seed\", None),\n        \"n_threads\": kwargs.get(\"n_threads\", 8),\n    }\n\n    # Prepare the full prompt including history\n    full_prompt = \"\"\n    if system_prompt:\n        full_prompt += f\"{system_prompt}\\n\"\n    for msg in history_messages:\n        full_prompt += f\"{msg['role']}: {msg['content']}\\n\"\n    full_prompt += prompt\n\n    request_data[\"prompt\"] = full_prompt\n    timeout = aiohttp.ClientTimeout(total=kwargs.get(\"timeout\", None))\n\n    async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session:\n        if stream:\n\n            async def inner():\n                async with session.post(\n                    f\"{base_url}/lollms_generate\", json=request_data\n                ) as response:\n                    async for line in response.content:\n                        yield line.decode().strip()\n\n            return inner()\n        else:\n            async with session.post(\n                f\"{base_url}/lollms_generate\", json=request_data\n            ) as response:\n                return await response.text()\n\n\nasync def lollms_model_complete(\n    prompt,\n    system_prompt=None,\n    history_messages=[],\n    enable_cot: bool = False,\n    keyword_extraction=False,\n    **kwargs,\n) -> Union[str, AsyncIterator[str]]:\n    \"\"\"Complete function for lollms model generation.\"\"\"\n\n    # Extract and remove keyword_extraction from kwargs if present\n    keyword_extraction = kwargs.pop(\"keyword_extraction\", None)\n\n    # Get model name from config\n    model_name = kwargs[\"hashing_kv\"].global_config[\"llm_model_name\"]\n\n    # If keyword extraction is needed, we might need to modify the prompt\n    # or add specific parameters for JSON output (if lollms supports it)\n    if keyword_extraction:\n        # Note: You might need to adjust this based on how lollms handles structured output\n        pass\n\n    return await lollms_model_if_cache(\n        model_name,\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        enable_cot=enable_cot,\n        **kwargs,\n    )\n\n\n@wrap_embedding_func_with_attrs(\n    embedding_dim=1024, max_token_size=8192, model_name=\"lollms_embedding_model\"\n)\nasync def lollms_embed(\n    texts: List[str], embed_model=None, base_url=\"http://localhost:9600\", **kwargs\n) -> np.ndarray:\n    \"\"\"\n    Generate embeddings for a list of texts using lollms server.\n\n    Args:\n        texts: List of strings to embed\n        embed_model: Model name (not used directly as lollms uses configured vectorizer)\n        base_url: URL of the lollms server\n        **kwargs: Additional arguments passed to the request\n\n    Returns:\n        np.ndarray: Array of embeddings\n    \"\"\"\n    api_key = kwargs.pop(\"api_key\", None)\n    headers = (\n        {\"Content-Type\": \"application/json\", \"Authorization\": api_key}\n        if api_key\n        else {\"Content-Type\": \"application/json\"}\n    )\n    async with aiohttp.ClientSession(headers=headers) as session:\n        embeddings = []\n        for text in texts:\n            request_data = {\"text\": text}\n\n            async with session.post(\n                f\"{base_url}/lollms_embed\",\n                json=request_data,\n            ) as response:\n                result = await response.json()\n                embeddings.append(result[\"vector\"])\n\n        return np.array(embeddings)\n"
  },
  {
    "path": "lightrag/llm/nvidia_openai.py",
    "content": "import sys\nimport os\n\nif sys.version_info < (3, 9):\n    pass\nelse:\n    pass\n\nimport pipmaster as pm  # Pipmaster for dynamic library install\n\n# install specific modules\nif not pm.is_installed(\"openai\"):\n    pm.install(\"openai\")\n\nfrom openai import (\n    AsyncOpenAI,\n    APIConnectionError,\n    RateLimitError,\n    APITimeoutError,\n)\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\n\nfrom lightrag.utils import (\n    wrap_embedding_func_with_attrs,\n)\n\n\nimport numpy as np\n\n\n@wrap_embedding_func_with_attrs(\n    embedding_dim=2048, max_token_size=8192, model_name=\"nvidia_embedding_model\"\n)\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=60),\n    retry=retry_if_exception_type(\n        (RateLimitError, APIConnectionError, APITimeoutError)\n    ),\n)\nasync def nvidia_openai_embed(\n    texts: list[str],\n    model: str = \"nvidia/llama-3.2-nv-embedqa-1b-v1\",\n    # refer to https://build.nvidia.com/nim?filters=usecase%3Ausecase_text_to_embedding\n    base_url: str = \"https://integrate.api.nvidia.com/v1\",\n    api_key: str = None,\n    input_type: str = \"passage\",  # query for retrieval, passage for embedding\n    trunc: str = \"NONE\",  # NONE or START or END\n    encode: str = \"float\",  # float or base64\n) -> np.ndarray:\n    if api_key:\n        os.environ[\"OPENAI_API_KEY\"] = api_key\n\n    openai_async_client = (\n        AsyncOpenAI() if base_url is None else AsyncOpenAI(base_url=base_url)\n    )\n    response = await openai_async_client.embeddings.create(\n        model=model,\n        input=texts,\n        encoding_format=encode,\n        extra_body={\"input_type\": input_type, \"truncate\": trunc},\n    )\n    return np.array([dp.embedding for dp in response.data])\n"
  },
  {
    "path": "lightrag/llm/ollama.py",
    "content": "from collections.abc import AsyncIterator\nimport os\nimport re\n\nimport pipmaster as pm\n\n# install specific modules\nif not pm.is_installed(\"ollama\"):\n    pm.install(\"ollama\")\n\nimport ollama\n\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\nfrom lightrag.exceptions import (\n    APIConnectionError,\n    RateLimitError,\n    APITimeoutError,\n)\nfrom lightrag.api import __api_version__\n\nimport numpy as np\nfrom typing import Optional, Union\nfrom lightrag.utils import (\n    wrap_embedding_func_with_attrs,\n    logger,\n)\n\n\n_OLLAMA_CLOUD_HOST = \"https://ollama.com\"\n_CLOUD_MODEL_SUFFIX_PATTERN = re.compile(r\"(?:-cloud|:cloud)$\")\n\n\ndef _coerce_host_for_cloud_model(host: Optional[str], model: object) -> Optional[str]:\n    if host:\n        return host\n    try:\n        model_name_str = str(model) if model is not None else \"\"\n    except (TypeError, ValueError, AttributeError) as e:\n        logger.warning(f\"Failed to convert model to string: {e}, using empty string\")\n        model_name_str = \"\"\n    if _CLOUD_MODEL_SUFFIX_PATTERN.search(model_name_str):\n        logger.debug(\n            f\"Detected cloud model '{model_name_str}', using Ollama Cloud host\"\n        )\n        return _OLLAMA_CLOUD_HOST\n    return host\n\n\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=10),\n    retry=retry_if_exception_type(\n        (RateLimitError, APIConnectionError, APITimeoutError)\n    ),\n)\nasync def _ollama_model_if_cache(\n    model,\n    prompt,\n    system_prompt=None,\n    history_messages=[],\n    enable_cot: bool = False,\n    **kwargs,\n) -> Union[str, AsyncIterator[str]]:\n    if enable_cot:\n        logger.debug(\"enable_cot=True is not supported for ollama and will be ignored.\")\n    stream = True if kwargs.get(\"stream\") else False\n\n    kwargs.pop(\"max_tokens\", None)\n    # kwargs.pop(\"response_format\", None) # allow json\n    host = kwargs.pop(\"host\", None)\n    timeout = kwargs.pop(\"timeout\", None)\n    if timeout == 0:\n        timeout = None\n    kwargs.pop(\"hashing_kv\", None)\n    api_key = kwargs.pop(\"api_key\", None)\n    # fallback to environment variable when not provided explicitly\n    if not api_key:\n        api_key = os.getenv(\"OLLAMA_API_KEY\")\n    headers = {\n        \"Content-Type\": \"application/json\",\n        \"User-Agent\": f\"LightRAG/{__api_version__}\",\n    }\n    if api_key:\n        headers[\"Authorization\"] = f\"Bearer {api_key}\"\n\n    host = _coerce_host_for_cloud_model(host, model)\n\n    ollama_client = ollama.AsyncClient(host=host, timeout=timeout, headers=headers)\n\n    try:\n        messages = []\n        if system_prompt:\n            messages.append({\"role\": \"system\", \"content\": system_prompt})\n        messages.extend(history_messages)\n        messages.append({\"role\": \"user\", \"content\": prompt})\n\n        response = await ollama_client.chat(model=model, messages=messages, **kwargs)\n        if stream:\n            \"\"\"cannot cache stream response and process reasoning\"\"\"\n\n            async def inner():\n                try:\n                    async for chunk in response:\n                        yield chunk[\"message\"][\"content\"]\n                except Exception as e:\n                    logger.error(f\"Error in stream response: {str(e)}\")\n                    raise\n                finally:\n                    try:\n                        await ollama_client._client.aclose()\n                        logger.debug(\"Successfully closed Ollama client for streaming\")\n                    except Exception as close_error:\n                        logger.warning(f\"Failed to close Ollama client: {close_error}\")\n\n            return inner()\n        else:\n            model_response = response[\"message\"][\"content\"]\n\n            \"\"\"\n            If the model also wraps its thoughts in a specific tag,\n            this information is not needed for the final\n            response and can simply be trimmed.\n            \"\"\"\n\n            return model_response\n    except Exception as e:\n        try:\n            await ollama_client._client.aclose()\n            logger.debug(\"Successfully closed Ollama client after exception\")\n        except Exception as close_error:\n            logger.warning(\n                f\"Failed to close Ollama client after exception: {close_error}\"\n            )\n        raise e\n    finally:\n        if not stream:\n            try:\n                await ollama_client._client.aclose()\n                logger.debug(\n                    \"Successfully closed Ollama client for non-streaming response\"\n                )\n            except Exception as close_error:\n                logger.warning(\n                    f\"Failed to close Ollama client in finally block: {close_error}\"\n                )\n\n\nasync def ollama_model_complete(\n    prompt,\n    system_prompt=None,\n    history_messages=[],\n    enable_cot: bool = False,\n    keyword_extraction=False,\n    **kwargs,\n) -> Union[str, AsyncIterator[str]]:\n    keyword_extraction = kwargs.pop(\"keyword_extraction\", None)\n    if keyword_extraction:\n        kwargs[\"format\"] = \"json\"\n    model_name = kwargs[\"hashing_kv\"].global_config[\"llm_model_name\"]\n    return await _ollama_model_if_cache(\n        model_name,\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        enable_cot=enable_cot,\n        **kwargs,\n    )\n\n\n@wrap_embedding_func_with_attrs(\n    embedding_dim=1024, max_token_size=8192, model_name=\"bge-m3:latest\"\n)\nasync def ollama_embed(\n    texts: list[str],\n    embed_model: str = \"bge-m3:latest\",\n    max_token_size: int | None = None,\n    **kwargs,\n) -> np.ndarray:\n    \"\"\"Generate embeddings using Ollama's API.\n\n    Args:\n        texts: List of texts to embed.\n        embed_model: The Ollama embedding model to use. Default is \"bge-m3:latest\".\n        max_token_size: Maximum tokens per text. This parameter is automatically\n            injected by the EmbeddingFunc wrapper when the underlying function\n            signature supports it (via inspect.signature check). Ollama will\n            automatically truncate texts exceeding the model's context length\n            (num_ctx), so no client-side truncation is needed.\n        **kwargs: Additional arguments passed to the Ollama client.\n\n    Returns:\n        A numpy array of embeddings, one per input text.\n\n    Note:\n        - Ollama API automatically truncates texts exceeding the model's context length\n        - The max_token_size parameter is received but not used for client-side truncation\n    \"\"\"\n    # Note: max_token_size is received but not used for client-side truncation.\n    # Ollama API handles truncation automatically based on the model's num_ctx setting.\n    _ = max_token_size  # Acknowledge parameter to avoid unused variable warning\n    api_key = kwargs.pop(\"api_key\", None)\n    if not api_key:\n        api_key = os.getenv(\"OLLAMA_API_KEY\")\n    headers = {\n        \"Content-Type\": \"application/json\",\n        \"User-Agent\": f\"LightRAG/{__api_version__}\",\n    }\n    if api_key:\n        headers[\"Authorization\"] = f\"Bearer {api_key}\"\n\n    host = kwargs.pop(\"host\", None)\n    timeout = kwargs.pop(\"timeout\", None)\n\n    host = _coerce_host_for_cloud_model(host, embed_model)\n\n    ollama_client = ollama.AsyncClient(host=host, timeout=timeout, headers=headers)\n    try:\n        options = kwargs.pop(\"options\", {})\n        data = await ollama_client.embed(\n            model=embed_model, input=texts, options=options\n        )\n        return np.array(data[\"embeddings\"])\n    except Exception as e:\n        logger.error(f\"Error in ollama_embed: {str(e)}\")\n        try:\n            await ollama_client._client.aclose()\n            logger.debug(\"Successfully closed Ollama client after exception in embed\")\n        except Exception as close_error:\n            logger.warning(\n                f\"Failed to close Ollama client after exception in embed: {close_error}\"\n            )\n        raise e\n    finally:\n        try:\n            await ollama_client._client.aclose()\n            logger.debug(\"Successfully closed Ollama client after embed\")\n        except Exception as close_error:\n            logger.warning(f\"Failed to close Ollama client after embed: {close_error}\")\n"
  },
  {
    "path": "lightrag/llm/openai.py",
    "content": "from ..utils import verbose_debug, VERBOSE_DEBUG\nimport os\nimport logging\n\nfrom collections.abc import AsyncIterator\n\nimport pipmaster as pm\nimport tiktoken\n\n# install specific modules\nif not pm.is_installed(\"openai\"):\n    pm.install(\"openai\")\n\nfrom openai import (\n    APIConnectionError,\n    RateLimitError,\n    APITimeoutError,\n)\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\nfrom lightrag.utils import (\n    wrap_embedding_func_with_attrs,\n    safe_unicode_decode,\n    logger,\n)\n\nfrom lightrag.types import GPTKeywordExtractionFormat\nfrom lightrag.api import __api_version__\n\nimport numpy as np\nimport base64\nfrom typing import Any, Union\n\nfrom dotenv import load_dotenv\n\n# Try to import Langfuse for LLM observability (optional)\n# Falls back to standard OpenAI client if not available\n# Langfuse requires proper configuration to work correctly\nLANGFUSE_ENABLED = False\ntry:\n    # Check if required Langfuse environment variables are set\n    langfuse_public_key = os.environ.get(\"LANGFUSE_PUBLIC_KEY\")\n    langfuse_secret_key = os.environ.get(\"LANGFUSE_SECRET_KEY\")\n\n    # Only enable Langfuse if both keys are configured\n    if langfuse_public_key and langfuse_secret_key:\n        from langfuse.openai import AsyncOpenAI  # type: ignore[import-untyped]\n\n        LANGFUSE_ENABLED = True\n        logger.info(\"Langfuse observability enabled for OpenAI client\")\n    else:\n        from openai import AsyncOpenAI\n\n        logger.debug(\n            \"Langfuse environment variables not configured, using standard OpenAI client\"\n        )\nexcept ImportError:\n    from openai import AsyncOpenAI\n\n    logger.debug(\"Langfuse not available, using standard OpenAI client\")\n\n# use the .env that is inside the current folder\n# allows to use different .env file for each lightrag instance\n# the OS environment variables take precedence over the .env file\nload_dotenv(dotenv_path=\".env\", override=False)\n\n\nclass InvalidResponseError(Exception):\n    \"\"\"Custom exception class for triggering retry mechanism\"\"\"\n\n    pass\n\n\n# Module-level cache for tiktoken encodings\n_TIKTOKEN_ENCODING_CACHE: dict[str, Any] = {}\n\n\ndef _get_tiktoken_encoding_for_model(model: str) -> Any:\n    \"\"\"Get tiktoken encoding for the specified model with caching.\n\n    Args:\n        model: The model name to get encoding for.\n\n    Returns:\n        The tiktoken encoding for the model.\n    \"\"\"\n    if model not in _TIKTOKEN_ENCODING_CACHE:\n        try:\n            _TIKTOKEN_ENCODING_CACHE[model] = tiktoken.encoding_for_model(model)\n        except KeyError:\n            logger.debug(\n                f\"Encoding for model '{model}' not found, falling back to cl100k_base\"\n            )\n            _TIKTOKEN_ENCODING_CACHE[model] = tiktoken.get_encoding(\"cl100k_base\")\n    return _TIKTOKEN_ENCODING_CACHE[model]\n\n\ndef create_openai_async_client(\n    api_key: str | None = None,\n    base_url: str | None = None,\n    use_azure: bool = False,\n    azure_deployment: str | None = None,\n    api_version: str | None = None,\n    timeout: int | None = None,\n    client_configs: dict[str, Any] | None = None,\n) -> AsyncOpenAI:\n    \"\"\"Create an AsyncOpenAI or AsyncAzureOpenAI client with the given configuration.\n\n    Args:\n        api_key: OpenAI API key. If None, uses the OPENAI_API_KEY environment variable.\n        base_url: Base URL for the OpenAI API. If None, uses the default OpenAI API URL.\n        use_azure: Whether to create an Azure OpenAI client. Default is False.\n        azure_deployment: Azure OpenAI deployment name (only used when use_azure=True).\n        api_version: Azure OpenAI API version (only used when use_azure=True).\n        timeout: Request timeout in seconds.\n        client_configs: Additional configuration options for the AsyncOpenAI client.\n            These will override any default configurations but will be overridden by\n            explicit parameters (api_key, base_url).\n\n    Returns:\n        An AsyncOpenAI or AsyncAzureOpenAI client instance.\n    \"\"\"\n    if use_azure:\n        from openai import AsyncAzureOpenAI\n\n        if not api_key:\n            api_key = os.environ.get(\"AZURE_OPENAI_API_KEY\") or os.environ.get(\n                \"LLM_BINDING_API_KEY\"\n            )\n\n        if client_configs is None:\n            client_configs = {}\n\n        # Create a merged config dict with precedence: explicit params > client_configs\n        merged_configs = {\n            **client_configs,\n            \"api_key\": api_key,\n        }\n\n        # Add explicit parameters (override client_configs)\n        if base_url is not None:\n            merged_configs[\"azure_endpoint\"] = base_url\n        if azure_deployment is not None:\n            merged_configs[\"azure_deployment\"] = azure_deployment\n        if api_version is not None:\n            merged_configs[\"api_version\"] = api_version\n        if timeout is not None:\n            merged_configs[\"timeout\"] = timeout\n\n        return AsyncAzureOpenAI(**merged_configs)\n    else:\n        if not api_key:\n            api_key = os.environ[\"OPENAI_API_KEY\"]\n\n        default_headers = {\n            \"User-Agent\": f\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_8) LightRAG/{__api_version__}\",\n            \"Content-Type\": \"application/json\",\n        }\n\n        if client_configs is None:\n            client_configs = {}\n\n        # Create a merged config dict with precedence: explicit params > client_configs > defaults\n        merged_configs = {\n            **client_configs,\n            \"default_headers\": default_headers,\n            \"api_key\": api_key,\n        }\n\n        if base_url is not None:\n            merged_configs[\"base_url\"] = base_url\n        else:\n            merged_configs[\"base_url\"] = os.environ.get(\n                \"OPENAI_API_BASE\", \"https://api.openai.com/v1\"\n            )\n\n        if timeout is not None:\n            merged_configs[\"timeout\"] = timeout\n\n        return AsyncOpenAI(**merged_configs)\n\n\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=10),\n    retry=(\n        retry_if_exception_type(RateLimitError)\n        | retry_if_exception_type(APIConnectionError)\n        | retry_if_exception_type(APITimeoutError)\n        | retry_if_exception_type(InvalidResponseError)\n    ),\n)\nasync def openai_complete_if_cache(\n    model: str,\n    prompt: str,\n    system_prompt: str | None = None,\n    history_messages: list[dict[str, Any]] | None = None,\n    enable_cot: bool = False,\n    base_url: str | None = None,\n    api_key: str | None = None,\n    token_tracker: Any | None = None,\n    stream: bool | None = None,\n    timeout: int | None = None,\n    keyword_extraction: bool = False,\n    use_azure: bool = False,\n    azure_deployment: str | None = None,\n    api_version: str | None = None,\n    **kwargs: Any,\n) -> str:\n    \"\"\"Complete a prompt using OpenAI's API with caching support and Chain of Thought (COT) integration.\n\n    This function supports automatic integration of reasoning content from models that provide\n    Chain of Thought capabilities. The reasoning content is seamlessly integrated into the response\n    using <think>...</think> tags.\n\n    Note on `reasoning_content`: This feature relies on a Deepseek Style `reasoning_content`\n    in the API response, which may be provided by OpenAI-compatible endpoints that support\n    Chain of Thought.\n\n    COT Integration Rules:\n    1. COT content is accepted only when regular content is empty and `reasoning_content` has content.\n    2. COT processing stops when regular content becomes available.\n    3. If both `content` and `reasoning_content` are present simultaneously, reasoning is ignored.\n    4. If both fields have content from the start, COT is never activated.\n    5. For streaming: COT content is inserted into the content stream with <think> tags.\n    6. For non-streaming: COT content is prepended to regular content with <think> tags.\n\n    Args:\n        model: The OpenAI model to use. For Azure, this can be the deployment name.\n        prompt: The prompt to complete.\n        system_prompt: Optional system prompt to include.\n        history_messages: Optional list of previous messages in the conversation.\n        enable_cot: Whether to enable Chain of Thought (COT) processing. Default is False.\n        base_url: Optional base URL for the OpenAI API. For Azure, this should be the\n            Azure OpenAI endpoint (e.g., https://your-resource.openai.azure.com/).\n        api_key: Optional API key. For standard OpenAI, uses OPENAI_API_KEY environment\n            variable if None. For Azure, uses AZURE_OPENAI_API_KEY if None.\n        token_tracker: Optional token usage tracker for monitoring API usage.\n        stream: Whether to stream the response. Default is False.\n        timeout: Request timeout in seconds. Default is None.\n        keyword_extraction: Whether to enable keyword extraction mode. When True, triggers\n            special response formatting for keyword extraction. Default is False.\n        use_azure: Whether to use Azure OpenAI service instead of standard OpenAI.\n            When True, creates an AsyncAzureOpenAI client. Default is False.\n        azure_deployment: Azure OpenAI deployment name. Only used when use_azure=True.\n            If not specified, falls back to AZURE_OPENAI_DEPLOYMENT environment variable.\n        api_version: Azure OpenAI API version (e.g., \"2024-02-15-preview\"). Only used\n            when use_azure=True. If not specified, falls back to AZURE_OPENAI_API_VERSION\n            environment variable.\n        **kwargs: Additional keyword arguments to pass to the OpenAI API.\n            Special kwargs:\n            - openai_client_configs: Dict of configuration options for the AsyncOpenAI client.\n                These will be passed to the client constructor but will be overridden by\n                explicit parameters (api_key, base_url). Supports proxy configuration,\n                custom headers, retry policies, etc.\n\n    Returns:\n        The completed text (with integrated COT content if available) or an async iterator\n        of text chunks if streaming. COT content is wrapped in <think>...</think> tags.\n\n    Raises:\n        InvalidResponseError: If the response from OpenAI is invalid or empty.\n        APIConnectionError: If there is a connection error with the OpenAI API.\n        RateLimitError: If the OpenAI API rate limit is exceeded.\n        APITimeoutError: If the OpenAI API request times out.\n    \"\"\"\n    if history_messages is None:\n        history_messages = []\n\n    # Set openai logger level to INFO when VERBOSE_DEBUG is off\n    if not VERBOSE_DEBUG and logger.level == logging.DEBUG:\n        logging.getLogger(\"openai\").setLevel(logging.INFO)\n\n    # Remove special kwargs that shouldn't be passed to OpenAI\n    kwargs.pop(\"hashing_kv\", None)\n\n    # Extract client configuration options\n    client_configs = kwargs.pop(\"openai_client_configs\", {})\n\n    # Handle keyword extraction mode\n    if keyword_extraction:\n        kwargs[\"response_format\"] = GPTKeywordExtractionFormat\n\n    # Create the OpenAI client (supports both OpenAI and Azure)\n    openai_async_client = create_openai_async_client(\n        api_key=api_key,\n        base_url=base_url,\n        use_azure=use_azure,\n        azure_deployment=azure_deployment,\n        api_version=api_version,\n        timeout=timeout,\n        client_configs=client_configs,\n    )\n\n    # Prepare messages\n    messages: list[dict[str, Any]] = []\n    if system_prompt:\n        messages.append({\"role\": \"system\", \"content\": system_prompt})\n    messages.extend(history_messages)\n    messages.append({\"role\": \"user\", \"content\": prompt})\n\n    logger.debug(\"===== Entering func of LLM =====\")\n    logger.debug(f\"Model: {model}   Base URL: {base_url}\")\n    logger.debug(f\"Client Configs: {client_configs}\")\n    logger.debug(f\"Additional kwargs: {kwargs}\")\n    logger.debug(f\"Num of history messages: {len(history_messages)}\")\n    verbose_debug(f\"System prompt: {system_prompt}\")\n    verbose_debug(f\"Query: {prompt}\")\n    logger.debug(\"===== Sending Query to LLM =====\")\n\n    messages = kwargs.pop(\"messages\", messages)\n\n    # Add explicit parameters back to kwargs so they're passed to OpenAI API\n    if stream is not None:\n        kwargs[\"stream\"] = stream\n    if timeout is not None:\n        kwargs[\"timeout\"] = timeout\n\n    # Determine the correct model identifier to use\n    # For Azure OpenAI, we must use the deployment name instead of the model name\n    api_model = azure_deployment if use_azure and azure_deployment else model\n\n    try:\n        # Don't use async with context manager, use client directly\n        if \"response_format\" in kwargs:\n            response = await openai_async_client.chat.completions.parse(\n                model=api_model, messages=messages, **kwargs\n            )\n        else:\n            response = await openai_async_client.chat.completions.create(\n                model=api_model, messages=messages, **kwargs\n            )\n    except APITimeoutError as e:\n        logger.error(f\"OpenAI API Timeout Error: {e}\")\n        await openai_async_client.close()  # Ensure client is closed\n        raise\n    except APIConnectionError as e:\n        logger.error(f\"OpenAI API Connection Error: {e}\")\n        await openai_async_client.close()  # Ensure client is closed\n        raise\n    except RateLimitError as e:\n        logger.error(f\"OpenAI API Rate Limit Error: {e}\")\n        await openai_async_client.close()  # Ensure client is closed\n        raise\n    except Exception as e:\n        logger.error(\n            f\"OpenAI API Call Failed,\\nModel: {model},\\nParams: {kwargs}, Got: {e}\"\n        )\n        await openai_async_client.close()  # Ensure client is closed\n        raise\n\n    if hasattr(response, \"__aiter__\"):\n\n        async def inner():\n            # Track if we've started iterating\n            iteration_started = False\n            final_chunk_usage = None\n\n            # COT (Chain of Thought) state tracking\n            cot_active = False\n            cot_started = False\n            initial_content_seen = False\n\n            try:\n                iteration_started = True\n                async for chunk in response:\n                    # Check if this chunk has usage information (final chunk)\n                    if hasattr(chunk, \"usage\") and chunk.usage:\n                        final_chunk_usage = chunk.usage\n                        logger.debug(\n                            f\"Received usage info in streaming chunk: {chunk.usage}\"\n                        )\n\n                    # Check if choices exists and is not empty\n                    if not hasattr(chunk, \"choices\") or not chunk.choices:\n                        # Azure OpenAI sends content filter results in first chunk without choices\n                        logger.debug(\n                            f\"Received chunk without choices (likely Azure content filter): {chunk}\"\n                        )\n                        continue\n\n                    # Check if delta exists\n                    if not hasattr(chunk.choices[0], \"delta\"):\n                        # This might be the final chunk, continue to check for usage\n                        continue\n\n                    delta = chunk.choices[0].delta\n                    content = getattr(delta, \"content\", None)\n                    reasoning_content = getattr(delta, \"reasoning_content\", \"\")\n\n                    # Handle COT logic for streaming (only if enabled)\n                    if enable_cot:\n                        if content:\n                            # Regular content is present\n                            if not initial_content_seen:\n                                initial_content_seen = True\n                                # If both content and reasoning_content are present initially, don't start COT\n                                if reasoning_content:\n                                    cot_active = False\n                                    cot_started = False\n\n                            # If COT was active, end it\n                            if cot_active:\n                                yield \"</think>\"\n                                cot_active = False\n\n                            # Process regular content\n                            if r\"\\u\" in content:\n                                content = safe_unicode_decode(content.encode(\"utf-8\"))\n                            yield content\n\n                        elif reasoning_content:\n                            # Only reasoning content is present\n                            if not initial_content_seen and not cot_started:\n                                # Start COT if we haven't seen initial content yet\n                                if not cot_active:\n                                    yield \"<think>\"\n                                    cot_active = True\n                                    cot_started = True\n\n                            # Process reasoning content if COT is active\n                            if cot_active:\n                                if r\"\\u\" in reasoning_content:\n                                    reasoning_content = safe_unicode_decode(\n                                        reasoning_content.encode(\"utf-8\")\n                                    )\n                                yield reasoning_content\n                    else:\n                        # COT disabled, only process regular content\n                        if content:\n                            if r\"\\u\" in content:\n                                content = safe_unicode_decode(content.encode(\"utf-8\"))\n                            yield content\n\n                    # If neither content nor reasoning_content, continue to next chunk\n                    if content is None and reasoning_content is None:\n                        continue\n\n                # Ensure COT is properly closed if still active after stream ends\n                if enable_cot and cot_active:\n                    yield \"</think>\"\n                    cot_active = False\n\n                # After streaming is complete, track token usage\n                if token_tracker and final_chunk_usage:\n                    # Use actual usage from the API\n                    token_counts = {\n                        \"prompt_tokens\": getattr(final_chunk_usage, \"prompt_tokens\", 0),\n                        \"completion_tokens\": getattr(\n                            final_chunk_usage, \"completion_tokens\", 0\n                        ),\n                        \"total_tokens\": getattr(final_chunk_usage, \"total_tokens\", 0),\n                    }\n                    token_tracker.add_usage(token_counts)\n                    logger.debug(f\"Streaming token usage (from API): {token_counts}\")\n                elif token_tracker:\n                    logger.debug(\"No usage information available in streaming response\")\n            except Exception as e:\n                # Ensure COT is properly closed before handling exception\n                if enable_cot and cot_active:\n                    try:\n                        yield \"</think>\"\n                        cot_active = False\n                    except Exception as close_error:\n                        logger.warning(\n                            f\"Failed to close COT tag during exception handling: {close_error}\"\n                        )\n\n                logger.error(f\"Error in stream response: {str(e)}\")\n                # Try to clean up resources if possible\n                if (\n                    iteration_started\n                    and hasattr(response, \"aclose\")\n                    and callable(getattr(response, \"aclose\", None))\n                ):\n                    try:\n                        await response.aclose()\n                        logger.debug(\"Successfully closed stream response after error\")\n                    except Exception as close_error:\n                        logger.warning(\n                            f\"Failed to close stream response: {close_error}\"\n                        )\n                # Ensure client is closed in case of exception\n                await openai_async_client.close()\n                raise\n            finally:\n                # Final safety check for unclosed COT tags\n                if enable_cot and cot_active:\n                    try:\n                        yield \"</think>\"\n                        cot_active = False\n                    except Exception as final_close_error:\n                        logger.warning(\n                            f\"Failed to close COT tag in finally block: {final_close_error}\"\n                        )\n\n                # Ensure resources are released even if no exception occurs\n                # Note: Some wrapped clients (e.g., Langfuse) may not implement aclose() properly\n                if iteration_started and hasattr(response, \"aclose\"):\n                    aclose_method = getattr(response, \"aclose\", None)\n                    if callable(aclose_method):\n                        try:\n                            await response.aclose()\n                            logger.debug(\"Successfully closed stream response\")\n                        except (AttributeError, TypeError) as close_error:\n                            # Some wrapper objects may report hasattr(aclose) but fail when called\n                            # This is expected behavior for certain client wrappers\n                            logger.debug(\n                                f\"Stream response cleanup not supported by client wrapper: {close_error}\"\n                            )\n                        except Exception as close_error:\n                            logger.warning(\n                                f\"Unexpected error during stream response cleanup: {close_error}\"\n                            )\n\n                # This prevents resource leaks since the caller doesn't handle closing\n                try:\n                    await openai_async_client.close()\n                    logger.debug(\n                        \"Successfully closed OpenAI client for streaming response\"\n                    )\n                except Exception as client_close_error:\n                    logger.warning(\n                        f\"Failed to close OpenAI client in streaming finally block: {client_close_error}\"\n                    )\n\n        return inner()\n\n    else:\n        try:\n            if (\n                not response\n                or not response.choices\n                or not hasattr(response.choices[0], \"message\")\n            ):\n                logger.error(\"Invalid response from OpenAI API\")\n                await openai_async_client.close()  # Ensure client is closed\n                raise InvalidResponseError(\"Invalid response from OpenAI API\")\n\n            message = response.choices[0].message\n\n            # Handle parsed responses (structured output via response_format)\n            # When using beta.chat.completions.parse(), the response is in message.parsed\n            if hasattr(message, \"parsed\") and message.parsed is not None:\n                # Serialize the parsed structured response to JSON\n                final_content = message.parsed.model_dump_json()\n                logger.debug(\"Using parsed structured response from API\")\n            else:\n                # Handle regular content responses\n                content = getattr(message, \"content\", None)\n                reasoning_content = getattr(message, \"reasoning_content\", \"\")\n\n                # Handle COT logic for non-streaming responses (only if enabled)\n                final_content = \"\"\n\n                if enable_cot:\n                    # Check if we should include reasoning content\n                    should_include_reasoning = False\n                    if reasoning_content and reasoning_content.strip():\n                        if not content or content.strip() == \"\":\n                            # Case 1: Only reasoning content, should include COT\n                            should_include_reasoning = True\n                            final_content = (\n                                content or \"\"\n                            )  # Use empty string if content is None\n                        else:\n                            # Case 3: Both content and reasoning_content present, ignore reasoning\n                            should_include_reasoning = False\n                            final_content = content\n                    else:\n                        # No reasoning content, use regular content\n                        final_content = content or \"\"\n\n                    # Apply COT wrapping if needed\n                    if should_include_reasoning:\n                        if r\"\\u\" in reasoning_content:\n                            reasoning_content = safe_unicode_decode(\n                                reasoning_content.encode(\"utf-8\")\n                            )\n                        final_content = (\n                            f\"<think>{reasoning_content}</think>{final_content}\"\n                        )\n                else:\n                    # COT disabled, only use regular content\n                    final_content = content or \"\"\n\n                # Validate final content\n                if not final_content or final_content.strip() == \"\":\n                    logger.error(\"Received empty content from OpenAI API\")\n                    await openai_async_client.close()  # Ensure client is closed\n                    raise InvalidResponseError(\"Received empty content from OpenAI API\")\n\n            # Apply Unicode decoding to final content if needed\n            if r\"\\u\" in final_content:\n                final_content = safe_unicode_decode(final_content.encode(\"utf-8\"))\n\n            if token_tracker and hasattr(response, \"usage\"):\n                token_counts = {\n                    \"prompt_tokens\": getattr(response.usage, \"prompt_tokens\", 0),\n                    \"completion_tokens\": getattr(\n                        response.usage, \"completion_tokens\", 0\n                    ),\n                    \"total_tokens\": getattr(response.usage, \"total_tokens\", 0),\n                }\n                token_tracker.add_usage(token_counts)\n\n            logger.debug(f\"Response content len: {len(final_content)}\")\n            verbose_debug(f\"Response: {response}\")\n\n            return final_content\n        finally:\n            # Ensure client is closed in all cases for non-streaming responses\n            await openai_async_client.close()\n\n\nasync def openai_complete(\n    prompt,\n    system_prompt=None,\n    history_messages=None,\n    keyword_extraction=False,\n    **kwargs,\n) -> Union[str, AsyncIterator[str]]:\n    if history_messages is None:\n        history_messages = []\n    model_name = kwargs[\"hashing_kv\"].global_config[\"llm_model_name\"]\n    return await openai_complete_if_cache(\n        model_name,\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        keyword_extraction=keyword_extraction,\n        **kwargs,\n    )\n\n\nasync def gpt_4o_complete(\n    prompt,\n    system_prompt=None,\n    history_messages=None,\n    enable_cot: bool = False,\n    keyword_extraction=False,\n    **kwargs,\n) -> str:\n    if history_messages is None:\n        history_messages = []\n    return await openai_complete_if_cache(\n        \"gpt-4o\",\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        enable_cot=enable_cot,\n        keyword_extraction=keyword_extraction,\n        **kwargs,\n    )\n\n\nasync def gpt_4o_mini_complete(\n    prompt,\n    system_prompt=None,\n    history_messages=None,\n    enable_cot: bool = False,\n    keyword_extraction=False,\n    **kwargs,\n) -> str:\n    if history_messages is None:\n        history_messages = []\n    return await openai_complete_if_cache(\n        \"gpt-4o-mini\",\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        enable_cot=enable_cot,\n        keyword_extraction=keyword_extraction,\n        **kwargs,\n    )\n\n\nasync def nvidia_openai_complete(\n    prompt,\n    system_prompt=None,\n    history_messages=None,\n    enable_cot: bool = False,\n    keyword_extraction=False,\n    **kwargs,\n) -> str:\n    if history_messages is None:\n        history_messages = []\n    result = await openai_complete_if_cache(\n        \"nvidia/llama-3.1-nemotron-70b-instruct\",  # context length 128k\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        enable_cot=enable_cot,\n        keyword_extraction=keyword_extraction,\n        base_url=\"https://integrate.api.nvidia.com/v1\",\n        **kwargs,\n    )\n    return result\n\n\n@wrap_embedding_func_with_attrs(\n    embedding_dim=1536, max_token_size=8192, model_name=\"text-embedding-3-small\"\n)\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=60),\n    retry=(\n        retry_if_exception_type(RateLimitError)\n        | retry_if_exception_type(APIConnectionError)\n        | retry_if_exception_type(APITimeoutError)\n    ),\n)\nasync def openai_embed(\n    texts: list[str],\n    model: str = \"text-embedding-3-small\",\n    base_url: str | None = None,\n    api_key: str | None = None,\n    embedding_dim: int | None = None,\n    max_token_size: int | None = None,\n    client_configs: dict[str, Any] | None = None,\n    token_tracker: Any | None = None,\n    use_azure: bool = False,\n    azure_deployment: str | None = None,\n    api_version: str | None = None,\n) -> np.ndarray:\n    \"\"\"Generate embeddings for a list of texts using OpenAI's API with automatic text truncation.\n\n    This function supports both standard OpenAI and Azure OpenAI services. It automatically\n    truncates texts that exceed the model's token limit to prevent API errors.\n\n    Args:\n        texts: List of texts to embed.\n        model: The embedding model to use. For standard OpenAI (e.g., \"text-embedding-3-small\").\n            For Azure, this can be the deployment name.\n        base_url: Optional base URL for the API. For standard OpenAI, uses default OpenAI endpoint.\n            For Azure, this should be the Azure OpenAI endpoint (e.g., https://your-resource.openai.azure.com/).\n        api_key: Optional API key. For standard OpenAI, uses OPENAI_API_KEY environment variable if None.\n            For Azure, uses AZURE_EMBEDDING_API_KEY environment variable if None.\n        embedding_dim: Optional embedding dimension for dynamic dimension reduction.\n            **IMPORTANT**: This parameter is automatically injected by the EmbeddingFunc wrapper.\n            Do NOT manually pass this parameter when calling the function directly.\n            The dimension is controlled by the @wrap_embedding_func_with_attrs decorator.\n            Manually passing a different value will trigger a warning and be ignored.\n            When provided (by EmbeddingFunc), it will be passed to the OpenAI API for dimension reduction.\n        max_token_size: Maximum tokens per text. Texts exceeding this limit will be truncated.\n            **IMPORTANT**: This parameter is automatically injected by the EmbeddingFunc wrapper\n            when the underlying function signature supports it (via inspect.signature check).\n            The value is controlled by the @wrap_embedding_func_with_attrs decorator.\n            Set max_token_size=0 to disable truncation.\n        client_configs: Additional configuration options for the AsyncOpenAI/AsyncAzureOpenAI client.\n            These will override any default configurations but will be overridden by\n            explicit parameters (api_key, base_url). Supports proxy configuration,\n            custom headers, retry policies, etc.\n        token_tracker: Optional token usage tracker for monitoring API usage.\n        use_azure: Whether to use Azure OpenAI service instead of standard OpenAI.\n            When True, creates an AsyncAzureOpenAI client. Default is False.\n        azure_deployment: Azure OpenAI deployment name. Only used when use_azure=True.\n            If not specified, falls back to AZURE_EMBEDDING_DEPLOYMENT environment variable.\n        api_version: Azure OpenAI API version (e.g., \"2024-02-15-preview\"). Only used\n            when use_azure=True. If not specified, falls back to AZURE_EMBEDDING_API_VERSION\n            environment variable.\n\n    Returns:\n        A numpy array of embeddings, one per input text.\n\n    Raises:\n        APIConnectionError: If there is a connection error with the OpenAI API.\n        RateLimitError: If the OpenAI API rate limit is exceeded.\n        APITimeoutError: If the OpenAI API request times out.\n    \"\"\"\n    # Apply text truncation if max_token_size is provided\n    if max_token_size is not None and max_token_size > 0:\n        encoding = _get_tiktoken_encoding_for_model(model)\n        truncated_texts = []\n        truncation_count = 0\n\n        for text in texts:\n            if not text:\n                truncated_texts.append(text)\n                continue\n\n            tokens = encoding.encode(text)\n            if len(tokens) > max_token_size:\n                truncated_tokens = tokens[:max_token_size]\n                truncated_texts.append(encoding.decode(truncated_tokens))\n                truncation_count += 1\n                logger.debug(\n                    f\"Text truncated from {len(tokens)} to {max_token_size} tokens\"\n                )\n            else:\n                truncated_texts.append(text)\n\n        if truncation_count > 0:\n            logger.info(\n                f\"Truncated {truncation_count}/{len(texts)} texts to fit token limit ({max_token_size})\"\n            )\n\n        texts = truncated_texts\n\n    # Create the OpenAI client (supports both OpenAI and Azure)\n    openai_async_client = create_openai_async_client(\n        api_key=api_key,\n        base_url=base_url,\n        use_azure=use_azure,\n        azure_deployment=azure_deployment,\n        api_version=api_version,\n        client_configs=client_configs,\n    )\n\n    async with openai_async_client:\n        # Determine the correct model identifier to use\n        # For Azure OpenAI, we must use the deployment name instead of the model name\n        api_model = azure_deployment if use_azure and azure_deployment else model\n\n        # Prepare API call parameters\n        api_params = {\n            \"model\": api_model,\n            \"input\": texts,\n            \"encoding_format\": \"base64\",\n        }\n\n        # Add dimensions parameter only if embedding_dim is provided\n        if embedding_dim is not None:\n            api_params[\"dimensions\"] = embedding_dim\n\n        # Make API call\n        response = await openai_async_client.embeddings.create(**api_params)\n\n        if token_tracker and hasattr(response, \"usage\"):\n            token_counts = {\n                \"prompt_tokens\": getattr(response.usage, \"prompt_tokens\", 0),\n                \"total_tokens\": getattr(response.usage, \"total_tokens\", 0),\n            }\n            token_tracker.add_usage(token_counts)\n\n        return np.array(\n            [\n                np.array(dp.embedding, dtype=np.float32)\n                if isinstance(dp.embedding, list)\n                else np.frombuffer(base64.b64decode(dp.embedding), dtype=np.float32)\n                for dp in response.data\n            ]\n        )\n\n\n# Azure OpenAI wrapper functions for backward compatibility\nasync def azure_openai_complete_if_cache(\n    model,\n    prompt,\n    system_prompt: str | None = None,\n    history_messages: list[dict[str, Any]] | None = None,\n    enable_cot: bool = False,\n    base_url: str | None = None,\n    api_key: str | None = None,\n    token_tracker: Any | None = None,\n    stream: bool | None = None,\n    timeout: int | None = None,\n    api_version: str | None = None,\n    keyword_extraction: bool = False,\n    **kwargs,\n):\n    \"\"\"Azure OpenAI completion wrapper function.\n\n    This function provides backward compatibility by wrapping the unified\n    openai_complete_if_cache implementation with Azure-specific parameter handling.\n\n    All parameters from the underlying openai_complete_if_cache are exposed to ensure\n    full feature parity and API consistency.\n    \"\"\"\n    # Handle Azure-specific environment variables and parameters\n    deployment = os.getenv(\"AZURE_OPENAI_DEPLOYMENT\") or model or os.getenv(\"LLM_MODEL\")\n    base_url = (\n        base_url or os.getenv(\"AZURE_OPENAI_ENDPOINT\") or os.getenv(\"LLM_BINDING_HOST\")\n    )\n    api_key = (\n        api_key or os.getenv(\"AZURE_OPENAI_API_KEY\") or os.getenv(\"LLM_BINDING_API_KEY\")\n    )\n    api_version = (\n        api_version\n        or os.getenv(\"AZURE_OPENAI_API_VERSION\")\n        or os.getenv(\"OPENAI_API_VERSION\")\n        or \"2024-08-01-preview\"\n    )\n\n    # Call the unified implementation with Azure-specific parameters\n    return await openai_complete_if_cache(\n        model=deployment,\n        prompt=prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        enable_cot=enable_cot,\n        base_url=base_url,\n        api_key=api_key,\n        token_tracker=token_tracker,\n        stream=stream,\n        timeout=timeout,\n        use_azure=True,\n        azure_deployment=deployment,\n        api_version=api_version,\n        keyword_extraction=keyword_extraction,\n        **kwargs,\n    )\n\n\nasync def azure_openai_complete(\n    prompt,\n    system_prompt=None,\n    history_messages=None,\n    keyword_extraction=False,\n    **kwargs,\n) -> str:\n    \"\"\"Azure OpenAI complete wrapper function.\n\n    Provides backward compatibility for azure_openai_complete calls.\n    \"\"\"\n    if history_messages is None:\n        history_messages = []\n    result = await azure_openai_complete_if_cache(\n        os.getenv(\"LLM_MODEL\", \"gpt-4o-mini\"),\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        keyword_extraction=keyword_extraction,\n        **kwargs,\n    )\n    return result\n\n\n@wrap_embedding_func_with_attrs(\n    embedding_dim=1536,\n    max_token_size=8192,\n    model_name=\"my-text-embedding-3-large-deployment\",\n)\nasync def azure_openai_embed(\n    texts: list[str],\n    model: str | None = None,\n    base_url: str | None = None,\n    api_key: str | None = None,\n    embedding_dim: int | None = None,\n    token_tracker: Any | None = None,\n    client_configs: dict[str, Any] | None = None,\n    api_version: str | None = None,\n) -> np.ndarray:\n    \"\"\"Azure OpenAI embedding wrapper function.\n\n    This function provides backward compatibility by wrapping the unified\n    openai_embed implementation with Azure-specific parameter handling.\n\n    All parameters from the underlying openai_embed are exposed to ensure\n    full feature parity and API consistency.\n\n    IMPORTANT - Decorator Usage:\n\n    1. This function is decorated with @wrap_embedding_func_with_attrs to provide\n       the EmbeddingFunc interface for users who need to access embedding_dim\n       and other attributes.\n\n    2. This function does NOT use @retry decorator to avoid double-wrapping,\n       since the underlying openai_embed.func already has retry logic.\n\n    3. This function calls openai_embed.func (the unwrapped function) instead of\n       openai_embed (the EmbeddingFunc instance) to avoid double decoration issues:\n\n       ✅ Correct: await openai_embed.func(...)  # Calls unwrapped function with retry\n       ❌ Wrong:   await openai_embed(...)       # Would cause double EmbeddingFunc wrapping\n\n    Double decoration causes:\n    - Double injection of embedding_dim parameter\n    - Incorrect parameter passing to the underlying implementation\n    - Runtime errors due to parameter conflicts\n\n    The call chain with correct implementation:\n    azure_openai_embed(texts)\n    → EmbeddingFunc.__call__(texts)              # azure's decorator\n      → azure_openai_embed_impl(texts, embedding_dim=1536)\n        → openai_embed.func(texts, ...)\n          → @retry_wrapper(texts, ...)           # openai's retry (only one layer)\n            → openai_embed_impl(texts, ...)\n              → actual embedding computation\n    \"\"\"\n    # Handle Azure-specific environment variables and parameters\n    deployment = (\n        os.getenv(\"AZURE_EMBEDDING_DEPLOYMENT\")\n        or model\n        or os.getenv(\"EMBEDDING_MODEL\", \"text-embedding-3-small\")\n    )\n    base_url = (\n        base_url\n        or os.getenv(\"AZURE_EMBEDDING_ENDPOINT\")\n        or os.getenv(\"EMBEDDING_BINDING_HOST\")\n    )\n    api_key = (\n        api_key\n        or os.getenv(\"AZURE_EMBEDDING_API_KEY\")\n        or os.getenv(\"EMBEDDING_BINDING_API_KEY\")\n    )\n    api_version = (\n        api_version\n        or os.getenv(\"AZURE_EMBEDDING_API_VERSION\")\n        or os.getenv(\"AZURE_OPENAI_API_VERSION\")\n        or os.getenv(\"OPENAI_API_VERSION\")\n        or \"2024-08-01-preview\"\n    )\n\n    # CRITICAL: Call openai_embed.func (unwrapped) to avoid double decoration\n    # openai_embed is an EmbeddingFunc instance, .func accesses the underlying function\n    return await openai_embed.func(\n        texts=texts,\n        model=deployment,\n        base_url=base_url,\n        api_key=api_key,\n        embedding_dim=embedding_dim,\n        token_tracker=token_tracker,\n        client_configs=client_configs,\n        use_azure=True,\n        azure_deployment=deployment,\n        api_version=api_version,\n    )\n"
  },
  {
    "path": "lightrag/llm/zhipu.py",
    "content": "import sys\nimport re\nimport json\nfrom ..utils import verbose_debug\n\nif sys.version_info < (3, 9):\n    pass\nelse:\n    pass\nimport pipmaster as pm  # Pipmaster for dynamic library install\n\n# install specific modules\nif not pm.is_installed(\"zhipuai\"):\n    pm.install(\"zhipuai\")\n\nfrom openai import (\n    APIConnectionError,\n    RateLimitError,\n    APITimeoutError,\n)\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\n\nfrom lightrag.utils import (\n    wrap_embedding_func_with_attrs,\n    logger,\n)\n\nfrom lightrag.types import GPTKeywordExtractionFormat\n\nimport numpy as np\nfrom typing import Union, List, Optional, Dict\n\n\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=10),\n    retry=retry_if_exception_type(\n        (RateLimitError, APIConnectionError, APITimeoutError)\n    ),\n)\nasync def zhipu_complete_if_cache(\n    prompt: Union[str, List[Dict[str, str]]],\n    model: str = \"glm-4-flashx\",  # The most cost/performance balance model in glm-4 series\n    api_key: Optional[str] = None,\n    system_prompt: Optional[str] = None,\n    history_messages: List[Dict[str, str]] = [],\n    enable_cot: bool = False,  # LightRAG output switch: include reasoning_content as <think>...</think>\n    thinking: Optional[\n        Dict[str, object]\n    ] = None,  # Zhipu request param: use {\"type\": \"enabled\"} to enable thinking\n    **kwargs,\n) -> str:\n    \"\"\"Call Zhipu chat completions with optional official thinking support.\n\n    Parameter roles:\n    - `thinking`: forwarded to the Zhipu API as-is. To enable thinking output,\n      pass a config such as `{\"type\": \"enabled\"}`.\n    - `enable_cot`: LightRAG-only formatting switch. When True and the API\n      returns `reasoning_content`, it is preserved in the final string as\n      `<think>...</think>`.\n    \"\"\"\n    # dynamically load ZhipuAI\n    try:\n        from zhipuai import ZhipuAI\n    except ImportError:\n        raise ImportError(\"Please install zhipuai before initialize zhipuai backend.\")\n\n    if api_key:\n        client = ZhipuAI(api_key=api_key)\n    else:\n        # please set ZHIPUAI_API_KEY in your environment\n        # os.environ[\"ZHIPUAI_API_KEY\"]\n        client = ZhipuAI()\n\n    messages = []\n\n    if not system_prompt:\n        system_prompt = \"You are a helpful assistant. Note that sensitive words in the content should be replaced with ***\"\n\n    # Add system prompt if provided\n    if system_prompt:\n        messages.append({\"role\": \"system\", \"content\": system_prompt})\n    messages.extend(history_messages)\n    messages.append({\"role\": \"user\", \"content\": prompt})\n\n    # Add debug logging\n    logger.debug(\"===== Query Input to LLM =====\")\n    logger.debug(f\"Query: {prompt}\")\n    verbose_debug(f\"System prompt: {system_prompt}\")\n\n    # Remove unsupported kwargs\n    kwargs = {\n        k: v for k, v in kwargs.items() if k not in [\"hashing_kv\", \"keyword_extraction\"]\n    }\n    # `thinking` is an official Zhipu request field. Example:\n    # {\"type\": \"enabled\"} enables reasoning output on supported models.\n    if thinking is not None:\n        kwargs[\"thinking\"] = thinking\n\n    response = client.chat.completions.create(model=model, messages=messages, **kwargs)\n    message = response.choices[0].message\n    content = message.content or \"\"\n    reasoning_content = getattr(message, \"reasoning_content\", \"\") or \"\"\n\n    if enable_cot and reasoning_content.strip():\n        if content:\n            return f\"<think>{reasoning_content}</think>{content}\"\n        return f\"<think>{reasoning_content}</think>\"\n\n    return content\n\n\nasync def zhipu_complete(\n    prompt,\n    system_prompt=None,\n    history_messages=[],\n    keyword_extraction=False,\n    enable_cot: bool = False,\n    **kwargs,\n):\n    # Pop keyword_extraction from kwargs to avoid passing it to zhipu_complete_if_cache\n    keyword_extraction = kwargs.pop(\"keyword_extraction\", keyword_extraction)\n\n    if keyword_extraction:\n        # Add a system prompt to guide the model to return JSON format\n        extraction_prompt = \"\"\"You are a helpful assistant that extracts keywords from text.\n        Please analyze the content and extract two types of keywords:\n        1. High-level keywords: Important concepts and main themes\n        2. Low-level keywords: Specific details and supporting elements\n\n        Return your response in this exact JSON format:\n        {\n            \"high_level_keywords\": [\"keyword1\", \"keyword2\"],\n            \"low_level_keywords\": [\"keyword1\", \"keyword2\", \"keyword3\"]\n        }\n\n        Only return the JSON, no other text.\"\"\"\n\n        # Combine with existing system prompt if any\n        if system_prompt:\n            system_prompt = f\"{system_prompt}\\n\\n{extraction_prompt}\"\n        else:\n            system_prompt = extraction_prompt\n\n        try:\n            response = await zhipu_complete_if_cache(\n                prompt=prompt,\n                system_prompt=system_prompt,\n                history_messages=history_messages,\n                enable_cot=enable_cot,\n                **kwargs,\n            )\n\n            # Try to parse as JSON\n            try:\n                data = json.loads(response)\n                return GPTKeywordExtractionFormat(\n                    high_level_keywords=data.get(\"high_level_keywords\", []),\n                    low_level_keywords=data.get(\"low_level_keywords\", []),\n                )\n            except json.JSONDecodeError:\n                # If direct JSON parsing fails, try to extract JSON from text\n                match = re.search(r\"\\{[\\s\\S]*\\}\", response)\n                if match:\n                    try:\n                        data = json.loads(match.group())\n                        return GPTKeywordExtractionFormat(\n                            high_level_keywords=data.get(\"high_level_keywords\", []),\n                            low_level_keywords=data.get(\"low_level_keywords\", []),\n                        )\n                    except json.JSONDecodeError:\n                        pass\n\n                # If all parsing fails, log warning and return empty format\n                logger.warning(\n                    f\"Failed to parse keyword extraction response: {response}\"\n                )\n                return GPTKeywordExtractionFormat(\n                    high_level_keywords=[], low_level_keywords=[]\n                )\n        except Exception as e:\n            logger.error(f\"Error during keyword extraction: {str(e)}\")\n            return GPTKeywordExtractionFormat(\n                high_level_keywords=[], low_level_keywords=[]\n            )\n    else:\n        # For non-keyword-extraction, just return the raw response string\n        return await zhipu_complete_if_cache(\n            prompt=prompt,\n            system_prompt=system_prompt,\n            history_messages=history_messages,\n            enable_cot=enable_cot,\n            **kwargs,\n        )\n\n\n@wrap_embedding_func_with_attrs(\n    embedding_dim=1024, max_token_size=8192, model_name=\"embedding-3\"\n)\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=60),\n    retry=retry_if_exception_type(\n        (RateLimitError, APIConnectionError, APITimeoutError)\n    ),\n)\nasync def zhipu_embedding(\n    texts: list[str],\n    model: str = \"embedding-3\",\n    api_key: str = None,\n    embedding_dim: int | None = None,\n    **kwargs,\n) -> np.ndarray:\n    # dynamically load ZhipuAI\n    try:\n        from zhipuai import ZhipuAI\n    except ImportError:\n        raise ImportError(\"Please install zhipuai before initialize zhipuai backend.\")\n    if api_key:\n        client = ZhipuAI(api_key=api_key)\n    else:\n        # please set ZHIPUAI_API_KEY in your environment\n        # os.environ[\"ZHIPUAI_API_KEY\"]\n        client = ZhipuAI()\n\n    # Convert single text to list if needed\n    if isinstance(texts, str):\n        texts = [texts]\n\n    embeddings = []\n    for text in texts:\n        try:\n            request_kwargs = dict(kwargs)\n            if embedding_dim is not None:\n                request_kwargs[\"dimensions\"] = embedding_dim\n            response = client.embeddings.create(\n                model=model, input=[text], **request_kwargs\n            )\n            embeddings.append(response.data[0].embedding)\n        except Exception as e:\n            raise Exception(f\"Error calling ChatGLM Embedding API: {str(e)}\")\n\n    return np.array(embeddings)\n"
  },
  {
    "path": "lightrag/namespace.py",
    "content": "from __future__ import annotations\n\nfrom typing import Iterable\n\n\n# All namespace should not be changed\nclass NameSpace:\n    KV_STORE_FULL_DOCS = \"full_docs\"\n    KV_STORE_TEXT_CHUNKS = \"text_chunks\"\n    KV_STORE_LLM_RESPONSE_CACHE = \"llm_response_cache\"\n    KV_STORE_FULL_ENTITIES = \"full_entities\"\n    KV_STORE_FULL_RELATIONS = \"full_relations\"\n    KV_STORE_ENTITY_CHUNKS = \"entity_chunks\"\n    KV_STORE_RELATION_CHUNKS = \"relation_chunks\"\n\n    VECTOR_STORE_ENTITIES = \"entities\"\n    VECTOR_STORE_RELATIONSHIPS = \"relationships\"\n    VECTOR_STORE_CHUNKS = \"chunks\"\n\n    GRAPH_STORE_CHUNK_ENTITY_RELATION = \"chunk_entity_relation\"\n\n    DOC_STATUS = \"doc_status\"\n\n\ndef is_namespace(namespace: str, base_namespace: str | Iterable[str]):\n    if isinstance(base_namespace, str):\n        return namespace.endswith(base_namespace)\n    return any(is_namespace(namespace, ns) for ns in base_namespace)\n"
  },
  {
    "path": "lightrag/operate.py",
    "content": "from __future__ import annotations\nfrom functools import partial\nfrom pathlib import Path\n\nimport asyncio\nimport json\nimport json_repair\nfrom typing import Any, AsyncIterator, overload, Literal\nfrom collections import Counter, defaultdict\n\nfrom lightrag.exceptions import (\n    PipelineCancelledException,\n    ChunkTokenLimitExceededError,\n)\nfrom lightrag.utils import (\n    logger,\n    compute_mdhash_id,\n    Tokenizer,\n    is_float_regex,\n    sanitize_and_normalize_extracted_text,\n    pack_user_ass_to_openai_messages,\n    split_string_by_multi_markers,\n    truncate_list_by_token_size,\n    compute_args_hash,\n    handle_cache,\n    save_to_cache,\n    CacheData,\n    use_llm_func_with_cache,\n    update_chunk_cache_list,\n    remove_think_tags,\n    pick_by_weighted_polling,\n    pick_by_vector_similarity,\n    process_chunks_unified,\n    safe_vdb_operation_with_exception,\n    create_prefixed_exception,\n    fix_tuple_delimiter_corruption,\n    convert_to_user_format,\n    generate_reference_list_from_chunks,\n    apply_source_ids_limit,\n    merge_source_ids,\n    make_relation_chunk_key,\n)\nfrom lightrag.base import (\n    BaseGraphStorage,\n    BaseKVStorage,\n    BaseVectorStorage,\n    TextChunkSchema,\n    QueryParam,\n    QueryResult,\n    QueryContextResult,\n)\nfrom lightrag.prompt import PROMPTS\nfrom lightrag.constants import (\n    GRAPH_FIELD_SEP,\n    DEFAULT_MAX_ENTITY_TOKENS,\n    DEFAULT_MAX_RELATION_TOKENS,\n    DEFAULT_MAX_TOTAL_TOKENS,\n    DEFAULT_RELATED_CHUNK_NUMBER,\n    DEFAULT_KG_CHUNK_PICK_METHOD,\n    DEFAULT_ENTITY_TYPES,\n    DEFAULT_SUMMARY_LANGUAGE,\n    SOURCE_IDS_LIMIT_METHOD_KEEP,\n    SOURCE_IDS_LIMIT_METHOD_FIFO,\n    DEFAULT_FILE_PATH_MORE_PLACEHOLDER,\n    DEFAULT_MAX_FILE_PATHS,\n    DEFAULT_ENTITY_NAME_MAX_LENGTH,\n)\nfrom lightrag.kg.shared_storage import get_storage_keyed_lock\nimport time\nfrom dotenv import load_dotenv\n\n# use the .env that is inside the current folder\n# allows to use different .env file for each lightrag instance\n# the OS environment variables take precedence over the .env file\nload_dotenv(dotenv_path=Path(__file__).resolve().parent / \".env\", override=False)\n\n\ndef _truncate_entity_identifier(\n    identifier: str, limit: int, chunk_key: str, identifier_role: str\n) -> str:\n    \"\"\"Truncate entity identifiers that exceed the configured length limit.\"\"\"\n\n    if len(identifier) <= limit:\n        return identifier\n\n    display_value = identifier[:limit]\n    preview = identifier[:20]  # Show first 20 characters as preview\n    logger.warning(\n        \"%s: %s len %d > %d chars (Name: '%s...')\",\n        chunk_key,\n        identifier_role,\n        len(identifier),\n        limit,\n        preview,\n    )\n    return display_value\n\n\ndef chunking_by_token_size(\n    tokenizer: Tokenizer,\n    content: str,\n    split_by_character: str | None = None,\n    split_by_character_only: bool = False,\n    chunk_overlap_token_size: int = 100,\n    chunk_token_size: int = 1200,\n) -> list[dict[str, Any]]:\n    tokens = tokenizer.encode(content)\n    results: list[dict[str, Any]] = []\n    if split_by_character:\n        raw_chunks = content.split(split_by_character)\n        new_chunks = []\n        if split_by_character_only:\n            for chunk in raw_chunks:\n                _tokens = tokenizer.encode(chunk)\n                if len(_tokens) > chunk_token_size:\n                    logger.warning(\n                        \"Chunk split_by_character exceeds token limit: len=%d limit=%d\",\n                        len(_tokens),\n                        chunk_token_size,\n                    )\n                    raise ChunkTokenLimitExceededError(\n                        chunk_tokens=len(_tokens),\n                        chunk_token_limit=chunk_token_size,\n                        chunk_preview=chunk[:120],\n                    )\n                new_chunks.append((len(_tokens), chunk))\n        else:\n            for chunk in raw_chunks:\n                _tokens = tokenizer.encode(chunk)\n                if len(_tokens) > chunk_token_size:\n                    for start in range(\n                        0, len(_tokens), chunk_token_size - chunk_overlap_token_size\n                    ):\n                        chunk_content = tokenizer.decode(\n                            _tokens[start : start + chunk_token_size]\n                        )\n                        new_chunks.append(\n                            (min(chunk_token_size, len(_tokens) - start), chunk_content)\n                        )\n                else:\n                    new_chunks.append((len(_tokens), chunk))\n        for index, (_len, chunk) in enumerate(new_chunks):\n            results.append(\n                {\n                    \"tokens\": _len,\n                    \"content\": chunk.strip(),\n                    \"chunk_order_index\": index,\n                }\n            )\n    else:\n        for index, start in enumerate(\n            range(0, len(tokens), chunk_token_size - chunk_overlap_token_size)\n        ):\n            chunk_content = tokenizer.decode(tokens[start : start + chunk_token_size])\n            results.append(\n                {\n                    \"tokens\": min(chunk_token_size, len(tokens) - start),\n                    \"content\": chunk_content.strip(),\n                    \"chunk_order_index\": index,\n                }\n            )\n    return results\n\n\nasync def _handle_entity_relation_summary(\n    description_type: str,\n    entity_or_relation_name: str,\n    description_list: list[str],\n    separator: str,\n    global_config: dict,\n    llm_response_cache: BaseKVStorage | None = None,\n) -> tuple[str, bool]:\n    \"\"\"Handle entity relation description summary using map-reduce approach.\n\n    This function summarizes a list of descriptions using a map-reduce strategy:\n    1. If total tokens < summary_context_size and len(description_list) < force_llm_summary_on_merge, no need to summarize\n    2. If total tokens < summary_max_tokens, summarize with LLM directly\n    3. Otherwise, split descriptions into chunks that fit within token limits\n    4. Summarize each chunk, then recursively process the summaries\n    5. Continue until we get a final summary within token limits or num of descriptions is less than force_llm_summary_on_merge\n\n    Args:\n        entity_or_relation_name: Name of the entity or relation being summarized\n        description_list: List of description strings to summarize\n        global_config: Global configuration containing tokenizer and limits\n        llm_response_cache: Optional cache for LLM responses\n\n    Returns:\n        Tuple of (final_summarized_description_string, llm_was_used_boolean)\n    \"\"\"\n    # Handle empty input\n    if not description_list:\n        return \"\", False\n\n    # If only one description, return it directly (no need for LLM call)\n    if len(description_list) == 1:\n        return description_list[0], False\n\n    # Get configuration\n    tokenizer: Tokenizer = global_config[\"tokenizer\"]\n    summary_context_size = global_config[\"summary_context_size\"]\n    summary_max_tokens = global_config[\"summary_max_tokens\"]\n    force_llm_summary_on_merge = global_config[\"force_llm_summary_on_merge\"]\n\n    current_list = description_list[:]  # Copy the list to avoid modifying original\n    llm_was_used = False  # Track whether LLM was used during the entire process\n\n    # Iterative map-reduce process\n    while True:\n        # Calculate total tokens in current list\n        total_tokens = sum(len(tokenizer.encode(desc)) for desc in current_list)\n\n        # If total length is within limits, perform final summarization\n        if total_tokens <= summary_context_size or len(current_list) <= 2:\n            if (\n                len(current_list) < force_llm_summary_on_merge\n                and total_tokens < summary_max_tokens\n            ):\n                # no LLM needed, just join the descriptions\n                final_description = separator.join(current_list)\n                return final_description if final_description else \"\", llm_was_used\n            else:\n                if total_tokens > summary_context_size and len(current_list) <= 2:\n                    logger.warning(\n                        f\"Summarizing {entity_or_relation_name}: Oversize description found\"\n                    )\n                # Final summarization of remaining descriptions - LLM will be used\n                final_summary = await _summarize_descriptions(\n                    description_type,\n                    entity_or_relation_name,\n                    current_list,\n                    global_config,\n                    llm_response_cache,\n                )\n                return final_summary, True  # LLM was used for final summarization\n\n        # Need to split into chunks - Map phase\n        # Ensure each chunk has minimum 2 descriptions to guarantee progress\n        chunks = []\n        current_chunk = []\n        current_tokens = 0\n\n        # Currently least 3 descriptions in current_list\n        for i, desc in enumerate(current_list):\n            desc_tokens = len(tokenizer.encode(desc))\n\n            # If adding current description would exceed limit, finalize current chunk\n            if current_tokens + desc_tokens > summary_context_size and current_chunk:\n                # Ensure we have at least 2 descriptions in the chunk (when possible)\n                if len(current_chunk) == 1:\n                    # Force add one more description to ensure minimum 2 per chunk\n                    current_chunk.append(desc)\n                    chunks.append(current_chunk)\n                    logger.warning(\n                        f\"Summarizing {entity_or_relation_name}: Oversize description found\"\n                    )\n                    current_chunk = []  # next group is empty\n                    current_tokens = 0\n                else:  # curren_chunk is ready for summary in reduce phase\n                    chunks.append(current_chunk)\n                    current_chunk = [desc]  # leave it for next group\n                    current_tokens = desc_tokens\n            else:\n                current_chunk.append(desc)\n                current_tokens += desc_tokens\n\n        # Add the last chunk if it exists\n        if current_chunk:\n            chunks.append(current_chunk)\n\n        logger.info(\n            f\"   Summarizing {entity_or_relation_name}: Map {len(current_list)} descriptions into {len(chunks)} groups\"\n        )\n\n        # Reduce phase: summarize each group from chunks\n        new_summaries = []\n        for chunk in chunks:\n            if len(chunk) == 1:\n                # Optimization: single description chunks don't need LLM summarization\n                new_summaries.append(chunk[0])\n            else:\n                # Multiple descriptions need LLM summarization\n                summary = await _summarize_descriptions(\n                    description_type,\n                    entity_or_relation_name,\n                    chunk,\n                    global_config,\n                    llm_response_cache,\n                )\n                new_summaries.append(summary)\n                llm_was_used = True  # Mark that LLM was used in reduce phase\n\n        # Update current list with new summaries for next iteration\n        current_list = new_summaries\n\n\nasync def _summarize_descriptions(\n    description_type: str,\n    description_name: str,\n    description_list: list[str],\n    global_config: dict,\n    llm_response_cache: BaseKVStorage | None = None,\n) -> str:\n    \"\"\"Helper function to summarize a list of descriptions using LLM.\n\n    Args:\n        entity_or_relation_name: Name of the entity or relation being summarized\n        descriptions: List of description strings to summarize\n        global_config: Global configuration containing LLM function and settings\n        llm_response_cache: Optional cache for LLM responses\n\n    Returns:\n        Summarized description string\n    \"\"\"\n    use_llm_func: callable = global_config[\"llm_model_func\"]\n    # Apply higher priority (8) to entity/relation summary tasks\n    use_llm_func = partial(use_llm_func, _priority=8)\n\n    language = global_config[\"addon_params\"].get(\"language\", DEFAULT_SUMMARY_LANGUAGE)\n\n    summary_length_recommended = global_config[\"summary_length_recommended\"]\n\n    prompt_template = PROMPTS[\"summarize_entity_descriptions\"]\n\n    # Convert descriptions to JSONL format and apply token-based truncation\n    tokenizer = global_config[\"tokenizer\"]\n    summary_context_size = global_config[\"summary_context_size\"]\n\n    # Create list of JSON objects with \"Description\" field\n    json_descriptions = [{\"Description\": desc} for desc in description_list]\n\n    # Use truncate_list_by_token_size for length truncation\n    truncated_json_descriptions = truncate_list_by_token_size(\n        json_descriptions,\n        key=lambda x: json.dumps(x, ensure_ascii=False),\n        max_token_size=summary_context_size,\n        tokenizer=tokenizer,\n    )\n\n    # Convert to JSONL format (one JSON object per line)\n    joined_descriptions = \"\\n\".join(\n        json.dumps(desc, ensure_ascii=False) for desc in truncated_json_descriptions\n    )\n\n    # Prepare context for the prompt\n    context_base = dict(\n        description_type=description_type,\n        description_name=description_name,\n        description_list=joined_descriptions,\n        summary_length=summary_length_recommended,\n        language=language,\n    )\n    use_prompt = prompt_template.format(**context_base)\n\n    # Use LLM function with cache (higher priority for summary generation)\n    summary, _ = await use_llm_func_with_cache(\n        use_prompt,\n        use_llm_func,\n        llm_response_cache=llm_response_cache,\n        cache_type=\"summary\",\n    )\n\n    # Check summary token length against embedding limit\n    embedding_token_limit = global_config.get(\"embedding_token_limit\")\n    if embedding_token_limit is not None and summary:\n        tokenizer = global_config[\"tokenizer\"]\n        summary_token_count = len(tokenizer.encode(summary))\n        threshold = int(embedding_token_limit)\n\n        if summary_token_count > threshold:\n            logger.warning(\n                f\"Summary tokens({summary_token_count}) exceeds embedding_token_limit({embedding_token_limit}) \"\n                f\" for {description_type}: {description_name}\"\n            )\n\n    return summary\n\n\nasync def _handle_single_entity_extraction(\n    record_attributes: list[str],\n    chunk_key: str,\n    timestamp: int,\n    file_path: str = \"unknown_source\",\n):\n    if len(record_attributes) != 4 or \"entity\" not in record_attributes[0]:\n        if len(record_attributes) > 1 and \"entity\" in record_attributes[0]:\n            logger.warning(\n                f\"{chunk_key}: LLM output format error; found {len(record_attributes)}/4 fields on ENTITY `{record_attributes[1]}` @ `{record_attributes[2] if len(record_attributes) > 2 else 'N/A'}`\"\n            )\n            logger.debug(record_attributes)\n        return None\n\n    try:\n        entity_name = sanitize_and_normalize_extracted_text(\n            record_attributes[1], remove_inner_quotes=True\n        )\n\n        # Validate entity name after all cleaning steps\n        if not entity_name or not entity_name.strip():\n            logger.info(\n                f\"Empty entity name found after sanitization. Original: '{record_attributes[1]}'\"\n            )\n            return None\n\n        # Process entity type with same cleaning pipeline\n        entity_type = sanitize_and_normalize_extracted_text(\n            record_attributes[2], remove_inner_quotes=True\n        )\n\n        if not entity_type.strip() or any(\n            char in entity_type for char in [\"'\", \"(\", \")\", \"<\", \">\", \"|\", \"/\", \"\\\\\"]\n        ):\n            logger.warning(\n                f\"Entity extraction error: invalid entity type in: {record_attributes}\"\n            )\n            return None\n\n        # Handle comma-separated entity types by finding the first non-empty token\n        if \",\" in entity_type:\n            original = entity_type\n            tokens = [t.strip() for t in entity_type.split(\",\")]\n            non_empty = [t for t in tokens if t]\n            if not non_empty:\n                logger.warning(\n                    f\"Entity extraction error: all tokens empty after comma-split: '{original}'\"\n                )\n                return None\n            entity_type = non_empty[0]\n            logger.warning(\n                f\"Entity type contains comma, taking first non-empty token: '{original}' -> '{entity_type}'\"\n            )\n\n        # Remove spaces and convert to lowercase\n        entity_type = entity_type.replace(\" \", \"\").lower()\n\n        # Process entity description with same cleaning pipeline\n        entity_description = sanitize_and_normalize_extracted_text(record_attributes[3])\n\n        if not entity_description.strip():\n            logger.warning(\n                f\"Entity extraction error: empty description for entity '{entity_name}' of type '{entity_type}'\"\n            )\n            return None\n\n        return dict(\n            entity_name=entity_name,\n            entity_type=entity_type,\n            description=entity_description,\n            source_id=chunk_key,\n            file_path=file_path,\n            timestamp=timestamp,\n        )\n\n    except ValueError as e:\n        logger.error(\n            f\"Entity extraction failed due to encoding issues in chunk {chunk_key}: {e}\"\n        )\n        return None\n    except Exception as e:\n        logger.error(\n            f\"Entity extraction failed with unexpected error in chunk {chunk_key}: {e}\"\n        )\n        return None\n\n\nasync def _handle_single_relationship_extraction(\n    record_attributes: list[str],\n    chunk_key: str,\n    timestamp: int,\n    file_path: str = \"unknown_source\",\n):\n    if (\n        len(record_attributes) != 5 or \"relation\" not in record_attributes[0]\n    ):  # treat \"relationship\" and \"relation\" interchangeable\n        if len(record_attributes) > 1 and \"relation\" in record_attributes[0]:\n            logger.warning(\n                f\"{chunk_key}: LLM output format error; found {len(record_attributes)}/5 fields on RELATION `{record_attributes[1]}`~`{record_attributes[2] if len(record_attributes) > 2 else 'N/A'}`\"\n            )\n            logger.debug(record_attributes)\n        return None\n\n    try:\n        source = sanitize_and_normalize_extracted_text(\n            record_attributes[1], remove_inner_quotes=True\n        )\n        target = sanitize_and_normalize_extracted_text(\n            record_attributes[2], remove_inner_quotes=True\n        )\n\n        # Validate entity names after all cleaning steps\n        if not source:\n            logger.info(\n                f\"Empty source entity found after sanitization. Original: '{record_attributes[1]}'\"\n            )\n            return None\n\n        if not target:\n            logger.info(\n                f\"Empty target entity found after sanitization. Original: '{record_attributes[2]}'\"\n            )\n            return None\n\n        if source == target:\n            logger.debug(\n                f\"Relationship source and target are the same in: {record_attributes}\"\n            )\n            return None\n\n        # Process keywords with same cleaning pipeline\n        edge_keywords = sanitize_and_normalize_extracted_text(\n            record_attributes[3], remove_inner_quotes=True\n        )\n        edge_keywords = edge_keywords.replace(\"，\", \",\")\n\n        # Process relationship description with same cleaning pipeline\n        edge_description = sanitize_and_normalize_extracted_text(record_attributes[4])\n        if not edge_description.strip():\n            logger.warning(\n                f\"Relationship extraction error: empty description for relation '{source}'~'{target}' in chunk '{chunk_key}'\"\n            )\n            return None\n\n        edge_source_id = chunk_key\n        weight = (\n            float(record_attributes[-1].strip('\"').strip(\"'\"))\n            if is_float_regex(record_attributes[-1].strip('\"').strip(\"'\"))\n            else 1.0\n        )\n\n        return dict(\n            src_id=source,\n            tgt_id=target,\n            weight=weight,\n            description=edge_description,\n            keywords=edge_keywords,\n            source_id=edge_source_id,\n            file_path=file_path,\n            timestamp=timestamp,\n        )\n\n    except ValueError as e:\n        logger.warning(\n            f\"Relationship extraction failed due to encoding issues in chunk {chunk_key}: {e}\"\n        )\n        return None\n    except Exception as e:\n        logger.warning(\n            f\"Relationship extraction failed with unexpected error in chunk {chunk_key}: {e}\"\n        )\n        return None\n\n\nasync def rebuild_knowledge_from_chunks(\n    entities_to_rebuild: dict[str, list[str]],\n    relationships_to_rebuild: dict[tuple[str, str], list[str]],\n    knowledge_graph_inst: BaseGraphStorage,\n    entities_vdb: BaseVectorStorage,\n    relationships_vdb: BaseVectorStorage,\n    text_chunks_storage: BaseKVStorage,\n    llm_response_cache: BaseKVStorage,\n    global_config: dict[str, str],\n    pipeline_status: dict | None = None,\n    pipeline_status_lock=None,\n    entity_chunks_storage: BaseKVStorage | None = None,\n    relation_chunks_storage: BaseKVStorage | None = None,\n) -> None:\n    \"\"\"Rebuild entity and relationship descriptions from cached extraction results with parallel processing\n\n    This method uses cached LLM extraction results instead of calling LLM again,\n    following the same approach as the insert process. Now with parallel processing\n    controlled by llm_model_max_async and using get_storage_keyed_lock for data consistency.\n\n    Args:\n        entities_to_rebuild: Dict mapping entity_name -> list of remaining chunk_ids\n        relationships_to_rebuild: Dict mapping (src, tgt) -> list of remaining chunk_ids\n        knowledge_graph_inst: Knowledge graph storage\n        entities_vdb: Entity vector database\n        relationships_vdb: Relationship vector database\n        text_chunks_storage: Text chunks storage\n        llm_response_cache: LLM response cache\n        global_config: Global configuration containing llm_model_max_async\n        pipeline_status: Pipeline status dictionary\n        pipeline_status_lock: Lock for pipeline status\n        entity_chunks_storage: KV storage maintaining full chunk IDs per entity\n        relation_chunks_storage: KV storage maintaining full chunk IDs per relation\n    \"\"\"\n    if not entities_to_rebuild and not relationships_to_rebuild:\n        return\n\n    # Get all referenced chunk IDs\n    all_referenced_chunk_ids = set()\n    for chunk_ids in entities_to_rebuild.values():\n        all_referenced_chunk_ids.update(chunk_ids)\n    for chunk_ids in relationships_to_rebuild.values():\n        all_referenced_chunk_ids.update(chunk_ids)\n\n    status_message = f\"Rebuilding knowledge from {len(all_referenced_chunk_ids)} cached chunk extractions (parallel processing)\"\n    logger.info(status_message)\n    if pipeline_status is not None and pipeline_status_lock is not None:\n        async with pipeline_status_lock:\n            pipeline_status[\"latest_message\"] = status_message\n            pipeline_status[\"history_messages\"].append(status_message)\n\n    # Get cached extraction results for these chunks using storage\n    # cached_results： chunk_id -> [list of (extraction_result, create_time) from LLM cache sorted by create_time of the first extraction_result]\n    cached_results = await _get_cached_extraction_results(\n        llm_response_cache,\n        all_referenced_chunk_ids,\n        text_chunks_storage=text_chunks_storage,\n    )\n\n    if not cached_results:\n        status_message = \"No cached extraction results found, cannot rebuild\"\n        logger.warning(status_message)\n        if pipeline_status is not None and pipeline_status_lock is not None:\n            async with pipeline_status_lock:\n                pipeline_status[\"latest_message\"] = status_message\n                pipeline_status[\"history_messages\"].append(status_message)\n        return\n\n    # Process cached results to get entities and relationships for each chunk\n    chunk_entities = {}  # chunk_id -> {entity_name: [entity_data]}\n    chunk_relationships = {}  # chunk_id -> {(src, tgt): [relationship_data]}\n\n    for chunk_id, results in cached_results.items():\n        try:\n            # Handle multiple extraction results per chunk\n            chunk_entities[chunk_id] = defaultdict(list)\n            chunk_relationships[chunk_id] = defaultdict(list)\n\n            # process multiple LLM extraction results for a single chunk_id\n            for result in results:\n                entities, relationships = await _rebuild_from_extraction_result(\n                    text_chunks_storage=text_chunks_storage,\n                    chunk_id=chunk_id,\n                    extraction_result=result[0],\n                    timestamp=result[1],\n                )\n\n                # Merge entities and relationships from this extraction result\n                # Compare description lengths and keep the better version for the same chunk_id\n                for entity_name, entity_list in entities.items():\n                    if entity_name not in chunk_entities[chunk_id]:\n                        # New entity for this chunk_id\n                        chunk_entities[chunk_id][entity_name].extend(entity_list)\n                    elif len(chunk_entities[chunk_id][entity_name]) == 0:\n                        # Empty list, add the new entities\n                        chunk_entities[chunk_id][entity_name].extend(entity_list)\n                    else:\n                        # Compare description lengths and keep the better one\n                        existing_desc_len = len(\n                            chunk_entities[chunk_id][entity_name][0].get(\n                                \"description\", \"\"\n                            )\n                            or \"\"\n                        )\n                        new_desc_len = len(entity_list[0].get(\"description\", \"\") or \"\")\n\n                        if new_desc_len > existing_desc_len:\n                            # Replace with the new entity that has longer description\n                            chunk_entities[chunk_id][entity_name] = list(entity_list)\n                        # Otherwise keep existing version\n\n                # Compare description lengths and keep the better version for the same chunk_id\n                for rel_key, rel_list in relationships.items():\n                    if rel_key not in chunk_relationships[chunk_id]:\n                        # New relationship for this chunk_id\n                        chunk_relationships[chunk_id][rel_key].extend(rel_list)\n                    elif len(chunk_relationships[chunk_id][rel_key]) == 0:\n                        # Empty list, add the new relationships\n                        chunk_relationships[chunk_id][rel_key].extend(rel_list)\n                    else:\n                        # Compare description lengths and keep the better one\n                        existing_desc_len = len(\n                            chunk_relationships[chunk_id][rel_key][0].get(\n                                \"description\", \"\"\n                            )\n                            or \"\"\n                        )\n                        new_desc_len = len(rel_list[0].get(\"description\", \"\") or \"\")\n\n                        if new_desc_len > existing_desc_len:\n                            # Replace with the new relationship that has longer description\n                            chunk_relationships[chunk_id][rel_key] = list(rel_list)\n                        # Otherwise keep existing version\n\n        except Exception as e:\n            status_message = (\n                f\"Failed to parse cached extraction result for chunk {chunk_id}: {e}\"\n            )\n            logger.info(status_message)  # Per requirement, change to info\n            if pipeline_status is not None and pipeline_status_lock is not None:\n                async with pipeline_status_lock:\n                    pipeline_status[\"latest_message\"] = status_message\n                    pipeline_status[\"history_messages\"].append(status_message)\n            continue\n\n    # Get max async tasks limit from global_config for semaphore control\n    graph_max_async = global_config.get(\"llm_model_max_async\", 4) * 2\n    semaphore = asyncio.Semaphore(graph_max_async)\n\n    # Counters for tracking progress\n    rebuilt_entities_count = 0\n    rebuilt_relationships_count = 0\n    failed_entities_count = 0\n    failed_relationships_count = 0\n\n    async def _locked_rebuild_entity(entity_name, chunk_ids):\n        nonlocal rebuilt_entities_count, failed_entities_count\n        async with semaphore:\n            workspace = global_config.get(\"workspace\", \"\")\n            namespace = f\"{workspace}:GraphDB\" if workspace else \"GraphDB\"\n            async with get_storage_keyed_lock(\n                [entity_name], namespace=namespace, enable_logging=False\n            ):\n                try:\n                    await _rebuild_single_entity(\n                        knowledge_graph_inst=knowledge_graph_inst,\n                        entities_vdb=entities_vdb,\n                        entity_name=entity_name,\n                        chunk_ids=chunk_ids,\n                        chunk_entities=chunk_entities,\n                        llm_response_cache=llm_response_cache,\n                        global_config=global_config,\n                        entity_chunks_storage=entity_chunks_storage,\n                    )\n                    rebuilt_entities_count += 1\n                except Exception as e:\n                    failed_entities_count += 1\n                    status_message = f\"Failed to rebuild `{entity_name}`: {e}\"\n                    logger.info(status_message)  # Per requirement, change to info\n                    if pipeline_status is not None and pipeline_status_lock is not None:\n                        async with pipeline_status_lock:\n                            pipeline_status[\"latest_message\"] = status_message\n                            pipeline_status[\"history_messages\"].append(status_message)\n\n    async def _locked_rebuild_relationship(src, tgt, chunk_ids):\n        nonlocal rebuilt_relationships_count, failed_relationships_count\n        async with semaphore:\n            workspace = global_config.get(\"workspace\", \"\")\n            namespace = f\"{workspace}:GraphDB\" if workspace else \"GraphDB\"\n            # Sort src and tgt to ensure order-independent lock key generation\n            sorted_key_parts = sorted([src, tgt])\n            async with get_storage_keyed_lock(\n                sorted_key_parts,\n                namespace=namespace,\n                enable_logging=False,\n            ):\n                try:\n                    await _rebuild_single_relationship(\n                        knowledge_graph_inst=knowledge_graph_inst,\n                        relationships_vdb=relationships_vdb,\n                        entities_vdb=entities_vdb,\n                        src=src,\n                        tgt=tgt,\n                        chunk_ids=chunk_ids,\n                        chunk_relationships=chunk_relationships,\n                        llm_response_cache=llm_response_cache,\n                        global_config=global_config,\n                        relation_chunks_storage=relation_chunks_storage,\n                        entity_chunks_storage=entity_chunks_storage,\n                        pipeline_status=pipeline_status,\n                        pipeline_status_lock=pipeline_status_lock,\n                    )\n                    rebuilt_relationships_count += 1\n                except Exception as e:\n                    failed_relationships_count += 1\n                    status_message = f\"Failed to rebuild `{src}`~`{tgt}`: {e}\"\n                    logger.info(status_message)  # Per requirement, change to info\n                    if pipeline_status is not None and pipeline_status_lock is not None:\n                        async with pipeline_status_lock:\n                            pipeline_status[\"latest_message\"] = status_message\n                            pipeline_status[\"history_messages\"].append(status_message)\n\n    # Create tasks for parallel processing\n    tasks = []\n\n    # Add entity rebuilding tasks\n    for entity_name, chunk_ids in entities_to_rebuild.items():\n        task = asyncio.create_task(_locked_rebuild_entity(entity_name, chunk_ids))\n        tasks.append(task)\n\n    # Add relationship rebuilding tasks\n    for (src, tgt), chunk_ids in relationships_to_rebuild.items():\n        task = asyncio.create_task(_locked_rebuild_relationship(src, tgt, chunk_ids))\n        tasks.append(task)\n\n    # Log parallel processing start\n    status_message = f\"Starting parallel rebuild of {len(entities_to_rebuild)} entities and {len(relationships_to_rebuild)} relationships (async: {graph_max_async})\"\n    logger.info(status_message)\n    if pipeline_status is not None and pipeline_status_lock is not None:\n        async with pipeline_status_lock:\n            pipeline_status[\"latest_message\"] = status_message\n            pipeline_status[\"history_messages\"].append(status_message)\n\n    # Execute all tasks in parallel with semaphore control and early failure detection\n    done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)\n\n    # Check if any task raised an exception and ensure all exceptions are retrieved\n    first_exception = None\n\n    for task in done:\n        try:\n            exception = task.exception()\n            if exception is not None:\n                if first_exception is None:\n                    first_exception = exception\n            else:\n                # Task completed successfully, retrieve result to mark as processed\n                task.result()\n        except Exception as e:\n            if first_exception is None:\n                first_exception = e\n\n    # If any task failed, cancel all pending tasks and raise the first exception\n    if first_exception is not None:\n        # Cancel all pending tasks\n        for pending_task in pending:\n            pending_task.cancel()\n\n        # Wait for cancellation to complete\n        if pending:\n            await asyncio.wait(pending)\n\n        # Re-raise the first exception to notify the caller\n        raise first_exception\n\n    # Final status report\n    status_message = f\"KG rebuild completed: {rebuilt_entities_count} entities and {rebuilt_relationships_count} relationships rebuilt successfully.\"\n    if failed_entities_count > 0 or failed_relationships_count > 0:\n        status_message += f\" Failed: {failed_entities_count} entities, {failed_relationships_count} relationships.\"\n\n    logger.info(status_message)\n    if pipeline_status is not None and pipeline_status_lock is not None:\n        async with pipeline_status_lock:\n            pipeline_status[\"latest_message\"] = status_message\n            pipeline_status[\"history_messages\"].append(status_message)\n\n\nasync def _get_cached_extraction_results(\n    llm_response_cache: BaseKVStorage,\n    chunk_ids: set[str],\n    text_chunks_storage: BaseKVStorage,\n) -> dict[str, list[str]]:\n    \"\"\"Get cached extraction results for specific chunk IDs\n\n    This function retrieves cached LLM extraction results for the given chunk IDs and returns\n    them sorted by creation time. The results are sorted at two levels:\n    1. Individual extraction results within each chunk are sorted by create_time (earliest first)\n    2. Chunks themselves are sorted by the create_time of their earliest extraction result\n\n    Args:\n        llm_response_cache: LLM response cache storage\n        chunk_ids: Set of chunk IDs to get cached results for\n        text_chunks_storage: Text chunks storage for retrieving chunk data and LLM cache references\n\n    Returns:\n        Dict mapping chunk_id -> list of extraction_result_text, where:\n        - Keys (chunk_ids) are ordered by the create_time of their first extraction result\n        - Values (extraction results) are ordered by create_time within each chunk\n    \"\"\"\n    cached_results = {}\n\n    # Collect all LLM cache IDs from chunks\n    all_cache_ids = set()\n\n    # Read from storage\n    chunk_data_list = await text_chunks_storage.get_by_ids(list(chunk_ids))\n    for chunk_data in chunk_data_list:\n        if chunk_data and isinstance(chunk_data, dict):\n            llm_cache_list = chunk_data.get(\"llm_cache_list\", [])\n            if llm_cache_list:\n                all_cache_ids.update(llm_cache_list)\n        else:\n            logger.warning(f\"Chunk data is invalid or None: {chunk_data}\")\n\n    if not all_cache_ids:\n        logger.warning(f\"No LLM cache IDs found for {len(chunk_ids)} chunk IDs\")\n        return cached_results\n\n    # Batch get LLM cache entries\n    cache_data_list = await llm_response_cache.get_by_ids(list(all_cache_ids))\n\n    # Process cache entries and group by chunk_id\n    valid_entries = 0\n    for cache_entry in cache_data_list:\n        if (\n            cache_entry is not None\n            and isinstance(cache_entry, dict)\n            and cache_entry.get(\"cache_type\") == \"extract\"\n            and cache_entry.get(\"chunk_id\") in chunk_ids\n        ):\n            chunk_id = cache_entry[\"chunk_id\"]\n            extraction_result = cache_entry[\"return\"]\n            create_time = cache_entry.get(\n                \"create_time\", 0\n            )  # Get creation time, default to 0\n            valid_entries += 1\n\n            # Support multiple LLM caches per chunk\n            if chunk_id not in cached_results:\n                cached_results[chunk_id] = []\n            # Store tuple with extraction result and creation time for sorting\n            cached_results[chunk_id].append((extraction_result, create_time))\n\n    # Sort extraction results by create_time for each chunk and collect earliest times\n    chunk_earliest_times = {}\n    for chunk_id in cached_results:\n        # Sort by create_time (x[1]), then extract only extraction_result (x[0])\n        cached_results[chunk_id].sort(key=lambda x: x[1])\n        # Store the earliest create_time for this chunk (first item after sorting)\n        chunk_earliest_times[chunk_id] = cached_results[chunk_id][0][1]\n\n    # Sort cached_results by the earliest create_time of each chunk\n    sorted_chunk_ids = sorted(\n        chunk_earliest_times.keys(), key=lambda chunk_id: chunk_earliest_times[chunk_id]\n    )\n\n    # Rebuild cached_results in sorted order\n    sorted_cached_results = {}\n    for chunk_id in sorted_chunk_ids:\n        sorted_cached_results[chunk_id] = cached_results[chunk_id]\n\n    logger.info(\n        f\"Found {valid_entries} valid cache entries, {len(sorted_cached_results)} chunks with results\"\n    )\n    return sorted_cached_results  # each item: list(extraction_result, create_time)\n\n\nasync def _process_extraction_result(\n    result: str,\n    chunk_key: str,\n    timestamp: int,\n    file_path: str = \"unknown_source\",\n    tuple_delimiter: str = \"<|#|>\",\n    completion_delimiter: str = \"<|COMPLETE|>\",\n) -> tuple[dict, dict]:\n    \"\"\"Process a single extraction result (either initial or gleaning)\n    Args:\n        result (str): The extraction result to process\n        chunk_key (str): The chunk key for source tracking\n        file_path (str): The file path for citation\n        tuple_delimiter (str): Delimiter for tuple fields\n        record_delimiter (str): Delimiter for records\n        completion_delimiter (str): Delimiter for completion\n    Returns:\n        tuple: (nodes_dict, edges_dict) containing the extracted entities and relationships\n    \"\"\"\n    maybe_nodes = defaultdict(list)\n    maybe_edges = defaultdict(list)\n\n    if completion_delimiter not in result:\n        logger.warning(\n            f\"{chunk_key}: Complete delimiter can not be found in extraction result\"\n        )\n\n    # Split LLL output result to records by \"\\n\"\n    records = split_string_by_multi_markers(\n        result,\n        [\"\\n\", completion_delimiter, completion_delimiter.lower()],\n    )\n\n    # Fix LLM output format error which use tuple_delimiter to separate record instead of \"\\n\"\n    fixed_records = []\n    for record in records:\n        record = record.strip()\n        if record is None:\n            continue\n        entity_records = split_string_by_multi_markers(\n            record, [f\"{tuple_delimiter}entity{tuple_delimiter}\"]\n        )\n        for entity_record in entity_records:\n            if not entity_record.startswith(\"entity\") and not entity_record.startswith(\n                \"relation\"\n            ):\n                entity_record = f\"entity<|{entity_record}\"\n            entity_relation_records = split_string_by_multi_markers(\n                # treat \"relationship\" and \"relation\" interchangeable\n                entity_record,\n                [\n                    f\"{tuple_delimiter}relationship{tuple_delimiter}\",\n                    f\"{tuple_delimiter}relation{tuple_delimiter}\",\n                ],\n            )\n            for entity_relation_record in entity_relation_records:\n                if not entity_relation_record.startswith(\n                    \"entity\"\n                ) and not entity_relation_record.startswith(\"relation\"):\n                    entity_relation_record = (\n                        f\"relation{tuple_delimiter}{entity_relation_record}\"\n                    )\n                fixed_records = fixed_records + [entity_relation_record]\n\n    if len(fixed_records) != len(records):\n        logger.warning(\n            f\"{chunk_key}: LLM output format error; find LLM use {tuple_delimiter} as record separators instead new-line\"\n        )\n\n    for record in fixed_records:\n        record = record.strip()\n        if record is None:\n            continue\n\n        # Fix various forms of tuple_delimiter corruption from the LLM output using the dedicated function\n        delimiter_core = tuple_delimiter[2:-2]  # Extract \"#\" from \"<|#|>\"\n        record = fix_tuple_delimiter_corruption(record, delimiter_core, tuple_delimiter)\n        if delimiter_core != delimiter_core.lower():\n            # change delimiter_core to lower case, and fix again\n            delimiter_core = delimiter_core.lower()\n            record = fix_tuple_delimiter_corruption(\n                record, delimiter_core, tuple_delimiter\n            )\n\n        record_attributes = split_string_by_multi_markers(record, [tuple_delimiter])\n\n        # Try to parse as entity\n        entity_data = await _handle_single_entity_extraction(\n            record_attributes, chunk_key, timestamp, file_path\n        )\n        if entity_data is not None:\n            truncated_name = _truncate_entity_identifier(\n                entity_data[\"entity_name\"],\n                DEFAULT_ENTITY_NAME_MAX_LENGTH,\n                chunk_key,\n                \"Entity name\",\n            )\n            entity_data[\"entity_name\"] = truncated_name\n            maybe_nodes[truncated_name].append(entity_data)\n            continue\n\n        # Try to parse as relationship\n        relationship_data = await _handle_single_relationship_extraction(\n            record_attributes, chunk_key, timestamp, file_path\n        )\n        if relationship_data is not None:\n            truncated_source = _truncate_entity_identifier(\n                relationship_data[\"src_id\"],\n                DEFAULT_ENTITY_NAME_MAX_LENGTH,\n                chunk_key,\n                \"Relation entity\",\n            )\n            truncated_target = _truncate_entity_identifier(\n                relationship_data[\"tgt_id\"],\n                DEFAULT_ENTITY_NAME_MAX_LENGTH,\n                chunk_key,\n                \"Relation entity\",\n            )\n            relationship_data[\"src_id\"] = truncated_source\n            relationship_data[\"tgt_id\"] = truncated_target\n            maybe_edges[(truncated_source, truncated_target)].append(relationship_data)\n\n    return dict(maybe_nodes), dict(maybe_edges)\n\n\nasync def _rebuild_from_extraction_result(\n    text_chunks_storage: BaseKVStorage,\n    extraction_result: str,\n    chunk_id: str,\n    timestamp: int,\n) -> tuple[dict, dict]:\n    \"\"\"Parse cached extraction result using the same logic as extract_entities\n\n    Args:\n        text_chunks_storage: Text chunks storage to get chunk data\n        extraction_result: The cached LLM extraction result\n        chunk_id: The chunk ID for source tracking\n\n    Returns:\n        Tuple of (entities_dict, relationships_dict)\n    \"\"\"\n\n    # Get chunk data for file_path from storage\n    chunk_data = await text_chunks_storage.get_by_id(chunk_id)\n    file_path = (\n        chunk_data.get(\"file_path\", \"unknown_source\")\n        if chunk_data\n        else \"unknown_source\"\n    )\n\n    # Call the shared processing function\n    return await _process_extraction_result(\n        extraction_result,\n        chunk_id,\n        timestamp,\n        file_path,\n        tuple_delimiter=PROMPTS[\"DEFAULT_TUPLE_DELIMITER\"],\n        completion_delimiter=PROMPTS[\"DEFAULT_COMPLETION_DELIMITER\"],\n    )\n\n\nasync def _rebuild_single_entity(\n    knowledge_graph_inst: BaseGraphStorage,\n    entities_vdb: BaseVectorStorage,\n    entity_name: str,\n    chunk_ids: list[str],\n    chunk_entities: dict,\n    llm_response_cache: BaseKVStorage,\n    global_config: dict[str, str],\n    entity_chunks_storage: BaseKVStorage | None = None,\n    pipeline_status: dict | None = None,\n    pipeline_status_lock=None,\n) -> None:\n    \"\"\"Rebuild a single entity from cached extraction results\"\"\"\n\n    # Get current entity data\n    current_entity = await knowledge_graph_inst.get_node(entity_name)\n    if not current_entity:\n        return\n\n    # Helper function to update entity in both graph and vector storage\n    async def _update_entity_storage(\n        final_description: str,\n        entity_type: str,\n        file_paths: list[str],\n        source_chunk_ids: list[str],\n        truncation_info: str = \"\",\n    ):\n        try:\n            # Update entity in graph storage (critical path)\n            updated_entity_data = {\n                **current_entity,\n                \"description\": final_description,\n                \"entity_type\": entity_type,\n                \"source_id\": GRAPH_FIELD_SEP.join(source_chunk_ids),\n                \"file_path\": GRAPH_FIELD_SEP.join(file_paths)\n                if file_paths\n                else current_entity.get(\"file_path\", \"unknown_source\"),\n                \"created_at\": int(time.time()),\n                \"truncate\": truncation_info,\n            }\n            await knowledge_graph_inst.upsert_node(entity_name, updated_entity_data)\n\n            # Update entity in vector database (equally critical)\n            entity_vdb_id = compute_mdhash_id(entity_name, prefix=\"ent-\")\n            entity_content = f\"{entity_name}\\n{final_description}\"\n\n            vdb_data = {\n                entity_vdb_id: {\n                    \"content\": entity_content,\n                    \"entity_name\": entity_name,\n                    \"source_id\": updated_entity_data[\"source_id\"],\n                    \"description\": final_description,\n                    \"entity_type\": entity_type,\n                    \"file_path\": updated_entity_data[\"file_path\"],\n                }\n            }\n\n            # Use safe operation wrapper - VDB failure must throw exception\n            await safe_vdb_operation_with_exception(\n                operation=lambda: entities_vdb.upsert(vdb_data),\n                operation_name=\"rebuild_entity_upsert\",\n                entity_name=entity_name,\n                max_retries=3,\n                retry_delay=0.1,\n            )\n\n        except Exception as e:\n            error_msg = f\"Failed to update entity storage for `{entity_name}`: {e}\"\n            logger.error(error_msg)\n            raise  # Re-raise exception\n\n    # normalized_chunk_ids = merge_source_ids([], chunk_ids)\n    normalized_chunk_ids = chunk_ids\n\n    if entity_chunks_storage is not None and normalized_chunk_ids:\n        await entity_chunks_storage.upsert(\n            {\n                entity_name: {\n                    \"chunk_ids\": normalized_chunk_ids,\n                    \"count\": len(normalized_chunk_ids),\n                }\n            }\n        )\n\n    limit_method = (\n        global_config.get(\"source_ids_limit_method\") or SOURCE_IDS_LIMIT_METHOD_KEEP\n    )\n\n    limited_chunk_ids = apply_source_ids_limit(\n        normalized_chunk_ids,\n        global_config[\"max_source_ids_per_entity\"],\n        limit_method,\n        identifier=f\"`{entity_name}`\",\n    )\n\n    # Collect all entity data from relevant (limited) chunks\n    all_entity_data = []\n    for chunk_id in limited_chunk_ids:\n        if chunk_id in chunk_entities and entity_name in chunk_entities[chunk_id]:\n            all_entity_data.extend(chunk_entities[chunk_id][entity_name])\n\n    if not all_entity_data:\n        logger.warning(\n            f\"No entity data found for `{entity_name}`, trying to rebuild from relationships\"\n        )\n\n        # Get all edges connected to this entity\n        edges = await knowledge_graph_inst.get_node_edges(entity_name)\n        if not edges:\n            logger.warning(f\"No relations attached to entity `{entity_name}`\")\n            return\n\n        # Collect relationship data to extract entity information\n        relationship_descriptions = []\n        file_paths = set()\n\n        # Get edge data for all connected relationships\n        for src_id, tgt_id in edges:\n            edge_data = await knowledge_graph_inst.get_edge(src_id, tgt_id)\n            if edge_data:\n                if edge_data.get(\"description\"):\n                    relationship_descriptions.append(edge_data[\"description\"])\n\n                if edge_data.get(\"file_path\"):\n                    edge_file_paths = edge_data[\"file_path\"].split(GRAPH_FIELD_SEP)\n                    file_paths.update(edge_file_paths)\n\n        # deduplicate descriptions\n        description_list = list(dict.fromkeys(relationship_descriptions))\n\n        # Generate final description from relationships or fallback to current\n        if description_list:\n            final_description, _ = await _handle_entity_relation_summary(\n                \"Entity\",\n                entity_name,\n                description_list,\n                GRAPH_FIELD_SEP,\n                global_config,\n                llm_response_cache=llm_response_cache,\n            )\n        else:\n            final_description = current_entity.get(\"description\", \"\")\n\n        entity_type = current_entity.get(\"entity_type\", \"UNKNOWN\")\n        await _update_entity_storage(\n            final_description,\n            entity_type,\n            file_paths,\n            limited_chunk_ids,\n        )\n        return\n\n    # Process cached entity data\n    descriptions = []\n    entity_types = []\n    file_paths_list = []\n    seen_paths = set()\n\n    for entity_data in all_entity_data:\n        if entity_data.get(\"description\"):\n            descriptions.append(entity_data[\"description\"])\n        if entity_data.get(\"entity_type\"):\n            entity_types.append(entity_data[\"entity_type\"])\n        if entity_data.get(\"file_path\"):\n            file_path = entity_data[\"file_path\"]\n            if file_path and file_path not in seen_paths:\n                file_paths_list.append(file_path)\n                seen_paths.add(file_path)\n\n    # Apply MAX_FILE_PATHS limit\n    max_file_paths = global_config.get(\"max_file_paths\", DEFAULT_MAX_FILE_PATHS)\n    file_path_placeholder = global_config.get(\n        \"file_path_more_placeholder\", DEFAULT_FILE_PATH_MORE_PLACEHOLDER\n    )\n    limit_method = global_config.get(\"source_ids_limit_method\")\n\n    original_count = len(file_paths_list)\n    if original_count > max_file_paths:\n        if limit_method == SOURCE_IDS_LIMIT_METHOD_FIFO:\n            # FIFO: keep tail (newest), discard head\n            file_paths_list = file_paths_list[-max_file_paths:]\n        else:\n            # KEEP: keep head (earliest), discard tail\n            file_paths_list = file_paths_list[:max_file_paths]\n\n        file_paths_list.append(\n            f\"...{file_path_placeholder}...({limit_method} {max_file_paths}/{original_count})\"\n        )\n        logger.info(\n            f\"Limited `{entity_name}`: file_path {original_count} -> {max_file_paths} ({limit_method})\"\n        )\n\n    # Remove duplicates while preserving order\n    description_list = list(dict.fromkeys(descriptions))\n    entity_types = list(dict.fromkeys(entity_types))\n\n    # Get most common entity type\n    entity_type = (\n        max(set(entity_types), key=entity_types.count)\n        if entity_types\n        else current_entity.get(\"entity_type\", \"UNKNOWN\")\n    )\n\n    # Generate final description from entities or fallback to current\n    if description_list:\n        final_description, _ = await _handle_entity_relation_summary(\n            \"Entity\",\n            entity_name,\n            description_list,\n            GRAPH_FIELD_SEP,\n            global_config,\n            llm_response_cache=llm_response_cache,\n        )\n    else:\n        final_description = current_entity.get(\"description\", \"\")\n\n    if len(limited_chunk_ids) < len(normalized_chunk_ids):\n        truncation_info = (\n            f\"{limit_method} {len(limited_chunk_ids)}/{len(normalized_chunk_ids)}\"\n        )\n    else:\n        truncation_info = \"\"\n\n    await _update_entity_storage(\n        final_description,\n        entity_type,\n        file_paths_list,\n        limited_chunk_ids,\n        truncation_info,\n    )\n\n    # Log rebuild completion with truncation info\n    status_message = f\"Rebuild `{entity_name}` from {len(chunk_ids)} chunks\"\n    if truncation_info:\n        status_message += f\" ({truncation_info})\"\n    logger.info(status_message)\n    # Update pipeline status\n    if pipeline_status is not None and pipeline_status_lock is not None:\n        async with pipeline_status_lock:\n            pipeline_status[\"latest_message\"] = status_message\n            pipeline_status[\"history_messages\"].append(status_message)\n\n\nasync def _rebuild_single_relationship(\n    knowledge_graph_inst: BaseGraphStorage,\n    relationships_vdb: BaseVectorStorage,\n    entities_vdb: BaseVectorStorage,\n    src: str,\n    tgt: str,\n    chunk_ids: list[str],\n    chunk_relationships: dict,\n    llm_response_cache: BaseKVStorage,\n    global_config: dict[str, str],\n    relation_chunks_storage: BaseKVStorage | None = None,\n    entity_chunks_storage: BaseKVStorage | None = None,\n    pipeline_status: dict | None = None,\n    pipeline_status_lock=None,\n) -> None:\n    \"\"\"Rebuild a single relationship from cached extraction results\n\n    Note: This function assumes the caller has already acquired the appropriate\n    keyed lock for the relationship pair to ensure thread safety.\n    \"\"\"\n\n    # Get current relationship data\n    current_relationship = await knowledge_graph_inst.get_edge(src, tgt)\n    if not current_relationship:\n        return\n\n    # normalized_chunk_ids = merge_source_ids([], chunk_ids)\n    normalized_chunk_ids = chunk_ids\n\n    if relation_chunks_storage is not None and normalized_chunk_ids:\n        storage_key = make_relation_chunk_key(src, tgt)\n        await relation_chunks_storage.upsert(\n            {\n                storage_key: {\n                    \"chunk_ids\": normalized_chunk_ids,\n                    \"count\": len(normalized_chunk_ids),\n                }\n            }\n        )\n\n    limit_method = (\n        global_config.get(\"source_ids_limit_method\") or SOURCE_IDS_LIMIT_METHOD_KEEP\n    )\n    limited_chunk_ids = apply_source_ids_limit(\n        normalized_chunk_ids,\n        global_config[\"max_source_ids_per_relation\"],\n        limit_method,\n        identifier=f\"`{src}`~`{tgt}`\",\n    )\n\n    # Collect all relationship data from relevant chunks\n    all_relationship_data = []\n    for chunk_id in limited_chunk_ids:\n        if chunk_id in chunk_relationships:\n            # Check both (src, tgt) and (tgt, src) since relationships can be bidirectional\n            for edge_key in [(src, tgt), (tgt, src)]:\n                if edge_key in chunk_relationships[chunk_id]:\n                    all_relationship_data.extend(\n                        chunk_relationships[chunk_id][edge_key]\n                    )\n\n    if not all_relationship_data:\n        logger.warning(f\"No relation data found for `{src}-{tgt}`\")\n        return\n\n    # Merge descriptions and keywords\n    descriptions = []\n    keywords = []\n    weights = []\n    file_paths_list = []\n    seen_paths = set()\n\n    for rel_data in all_relationship_data:\n        if rel_data.get(\"description\"):\n            descriptions.append(rel_data[\"description\"])\n        if rel_data.get(\"keywords\"):\n            keywords.append(rel_data[\"keywords\"])\n        if rel_data.get(\"weight\"):\n            weights.append(rel_data[\"weight\"])\n        if rel_data.get(\"file_path\"):\n            file_path = rel_data[\"file_path\"]\n            if file_path and file_path not in seen_paths:\n                file_paths_list.append(file_path)\n                seen_paths.add(file_path)\n\n    # Apply count limit\n    max_file_paths = global_config.get(\"max_file_paths\", DEFAULT_MAX_FILE_PATHS)\n    file_path_placeholder = global_config.get(\n        \"file_path_more_placeholder\", DEFAULT_FILE_PATH_MORE_PLACEHOLDER\n    )\n    limit_method = global_config.get(\"source_ids_limit_method\")\n\n    original_count = len(file_paths_list)\n    if original_count > max_file_paths:\n        if limit_method == SOURCE_IDS_LIMIT_METHOD_FIFO:\n            # FIFO: keep tail (newest), discard head\n            file_paths_list = file_paths_list[-max_file_paths:]\n        else:\n            # KEEP: keep head (earliest), discard tail\n            file_paths_list = file_paths_list[:max_file_paths]\n\n        file_paths_list.append(\n            f\"...{file_path_placeholder}...({limit_method} {max_file_paths}/{original_count})\"\n        )\n        logger.info(\n            f\"Limited `{src}`~`{tgt}`: file_path {original_count} -> {max_file_paths} ({limit_method})\"\n        )\n\n    # Remove duplicates while preserving order\n    description_list = list(dict.fromkeys(descriptions))\n    keywords = list(dict.fromkeys(keywords))\n\n    combined_keywords = (\n        \", \".join(set(keywords))\n        if keywords\n        else current_relationship.get(\"keywords\", \"\")\n    )\n\n    weight = sum(weights) if weights else current_relationship.get(\"weight\", 1.0)\n\n    # Generate final description from relations or fallback to current\n    if description_list:\n        final_description, _ = await _handle_entity_relation_summary(\n            \"Relation\",\n            f\"{src}-{tgt}\",\n            description_list,\n            GRAPH_FIELD_SEP,\n            global_config,\n            llm_response_cache=llm_response_cache,\n        )\n    else:\n        # fallback to keep current(unchanged)\n        final_description = current_relationship.get(\"description\", \"\")\n\n    if len(limited_chunk_ids) < len(normalized_chunk_ids):\n        truncation_info = (\n            f\"{limit_method} {len(limited_chunk_ids)}/{len(normalized_chunk_ids)}\"\n        )\n    else:\n        truncation_info = \"\"\n\n    # Update relationship in graph storage\n    updated_relationship_data = {\n        **current_relationship,\n        \"description\": final_description\n        if final_description\n        else current_relationship.get(\"description\", \"\"),\n        \"keywords\": combined_keywords,\n        \"weight\": weight,\n        \"source_id\": GRAPH_FIELD_SEP.join(limited_chunk_ids),\n        \"file_path\": GRAPH_FIELD_SEP.join([fp for fp in file_paths_list if fp])\n        if file_paths_list\n        else current_relationship.get(\"file_path\", \"unknown_source\"),\n        \"truncate\": truncation_info,\n    }\n\n    # Ensure both endpoint nodes exist before writing the edge back\n    # (certain storage backends require pre-existing nodes).\n    node_description = (\n        updated_relationship_data[\"description\"]\n        if updated_relationship_data.get(\"description\")\n        else current_relationship.get(\"description\", \"\")\n    )\n    node_source_id = updated_relationship_data.get(\"source_id\", \"\")\n    node_file_path = updated_relationship_data.get(\"file_path\", \"unknown_source\")\n\n    for node_id in {src, tgt}:\n        if not (await knowledge_graph_inst.has_node(node_id)):\n            node_created_at = int(time.time())\n            node_data = {\n                \"entity_id\": node_id,\n                \"source_id\": node_source_id,\n                \"description\": node_description,\n                \"entity_type\": \"UNKNOWN\",\n                \"file_path\": node_file_path,\n                \"created_at\": node_created_at,\n                \"truncate\": \"\",\n            }\n            await knowledge_graph_inst.upsert_node(node_id, node_data=node_data)\n\n            # Update entity_chunks_storage for the newly created entity\n            if entity_chunks_storage is not None and limited_chunk_ids:\n                await entity_chunks_storage.upsert(\n                    {\n                        node_id: {\n                            \"chunk_ids\": limited_chunk_ids,\n                            \"count\": len(limited_chunk_ids),\n                        }\n                    }\n                )\n\n            # Update entity_vdb for the newly created entity\n            if entities_vdb is not None:\n                entity_vdb_id = compute_mdhash_id(node_id, prefix=\"ent-\")\n                entity_content = f\"{node_id}\\n{node_description}\"\n                vdb_data = {\n                    entity_vdb_id: {\n                        \"content\": entity_content,\n                        \"entity_name\": node_id,\n                        \"source_id\": node_source_id,\n                        \"entity_type\": \"UNKNOWN\",\n                        \"file_path\": node_file_path,\n                    }\n                }\n                await safe_vdb_operation_with_exception(\n                    operation=lambda payload=vdb_data: entities_vdb.upsert(payload),\n                    operation_name=\"rebuild_added_entity_upsert\",\n                    entity_name=node_id,\n                    max_retries=3,\n                    retry_delay=0.1,\n                )\n\n    await knowledge_graph_inst.upsert_edge(src, tgt, updated_relationship_data)\n\n    # Update relationship in vector database\n    # Sort src and tgt to ensure consistent ordering (smaller string first)\n    if src > tgt:\n        src, tgt = tgt, src\n    try:\n        rel_vdb_id = compute_mdhash_id(src + tgt, prefix=\"rel-\")\n        rel_vdb_id_reverse = compute_mdhash_id(tgt + src, prefix=\"rel-\")\n\n        # Delete old vector records first (both directions to be safe)\n        try:\n            await relationships_vdb.delete([rel_vdb_id, rel_vdb_id_reverse])\n        except Exception as e:\n            logger.debug(\n                f\"Could not delete old relationship vector records {rel_vdb_id}, {rel_vdb_id_reverse}: {e}\"\n            )\n\n        # Insert new vector record\n        rel_content = f\"{combined_keywords}\\t{src}\\n{tgt}\\n{final_description}\"\n        vdb_data = {\n            rel_vdb_id: {\n                \"src_id\": src,\n                \"tgt_id\": tgt,\n                \"source_id\": updated_relationship_data[\"source_id\"],\n                \"content\": rel_content,\n                \"keywords\": combined_keywords,\n                \"description\": final_description,\n                \"weight\": weight,\n                \"file_path\": updated_relationship_data[\"file_path\"],\n            }\n        }\n\n        # Use safe operation wrapper - VDB failure must throw exception\n        await safe_vdb_operation_with_exception(\n            operation=lambda: relationships_vdb.upsert(vdb_data),\n            operation_name=\"rebuild_relationship_upsert\",\n            entity_name=f\"{src}-{tgt}\",\n            max_retries=3,\n            retry_delay=0.2,\n        )\n\n    except Exception as e:\n        error_msg = f\"Failed to rebuild relationship storage for `{src}-{tgt}`: {e}\"\n        logger.error(error_msg)\n        raise  # Re-raise exception\n\n    # Log rebuild completion with truncation info\n    status_message = f\"Rebuild `{src}`~`{tgt}` from {len(chunk_ids)} chunks\"\n    if truncation_info:\n        status_message += f\" ({truncation_info})\"\n    # Add truncation info from apply_source_ids_limit if truncation occurred\n    if len(limited_chunk_ids) < len(normalized_chunk_ids):\n        truncation_info = (\n            f\" ({limit_method}:{len(limited_chunk_ids)}/{len(normalized_chunk_ids)})\"\n        )\n        status_message += truncation_info\n\n    logger.info(status_message)\n\n    # Update pipeline status\n    if pipeline_status is not None and pipeline_status_lock is not None:\n        async with pipeline_status_lock:\n            pipeline_status[\"latest_message\"] = status_message\n            pipeline_status[\"history_messages\"].append(status_message)\n\n\nasync def _merge_nodes_then_upsert(\n    entity_name: str,\n    nodes_data: list[dict],\n    knowledge_graph_inst: BaseGraphStorage,\n    entity_vdb: BaseVectorStorage | None,\n    global_config: dict,\n    pipeline_status: dict = None,\n    pipeline_status_lock=None,\n    llm_response_cache: BaseKVStorage | None = None,\n    entity_chunks_storage: BaseKVStorage | None = None,\n):\n    \"\"\"Get existing nodes from knowledge graph use name,if exists, merge data, else create, then upsert.\"\"\"\n    already_entity_types = []\n    already_source_ids = []\n    already_description = []\n    already_file_paths = []\n\n    # 1. Get existing node data from knowledge graph\n    already_node = await knowledge_graph_inst.get_node(entity_name)\n    if already_node:\n        existing_entity_type = already_node.get(\"entity_type\")\n        # Coerce to str before any string operations: non-string values from\n        # API/custom graph paths would otherwise raise TypeError on the comma check.\n        if (\n            not isinstance(existing_entity_type, str)\n            or not existing_entity_type.strip()\n        ):\n            existing_entity_type = \"UNKNOWN\"\n        # Sanitize entity_type read back from DB to prevent dirty data from propagating\n        if \",\" in existing_entity_type:\n            original = existing_entity_type\n            tokens = [t.strip() for t in existing_entity_type.split(\",\")]\n            non_empty = [t for t in tokens if t]\n            existing_entity_type = non_empty[0] if non_empty else \"UNKNOWN\"\n            logger.warning(\n                f\"Entity type read from DB contains comma, taking first non-empty token: '{original}' -> '{existing_entity_type}'\"\n            )\n        already_entity_types.append(existing_entity_type)\n\n        existing_source_id = already_node.get(\"source_id\") or \"\"\n        already_source_ids.extend(existing_source_id.split(GRAPH_FIELD_SEP))\n\n        existing_file_path = already_node.get(\"file_path\") or \"unknown_source\"\n        already_file_paths.extend(existing_file_path.split(GRAPH_FIELD_SEP))\n\n        existing_desc = (already_node.get(\"description\") or \"\").strip()\n        if existing_desc:\n            already_description.extend(existing_desc.split(GRAPH_FIELD_SEP))\n\n    new_source_ids = [dp[\"source_id\"] for dp in nodes_data if dp.get(\"source_id\")]\n\n    existing_full_source_ids = []\n    if entity_chunks_storage is not None:\n        stored_chunks = await entity_chunks_storage.get_by_id(entity_name)\n        if stored_chunks and isinstance(stored_chunks, dict):\n            existing_full_source_ids = [\n                chunk_id for chunk_id in stored_chunks.get(\"chunk_ids\", []) if chunk_id\n            ]\n\n    if not existing_full_source_ids:\n        existing_full_source_ids = [\n            chunk_id for chunk_id in already_source_ids if chunk_id\n        ]\n\n    # 2. Merging new source ids with existing ones\n    full_source_ids = merge_source_ids(existing_full_source_ids, new_source_ids)\n\n    if entity_chunks_storage is not None and full_source_ids:\n        await entity_chunks_storage.upsert(\n            {\n                entity_name: {\n                    \"chunk_ids\": full_source_ids,\n                    \"count\": len(full_source_ids),\n                }\n            }\n        )\n\n    # 3. Finalize source_id by applying source ids limit\n    limit_method = global_config.get(\"source_ids_limit_method\")\n    max_source_limit = global_config.get(\"max_source_ids_per_entity\")\n    source_ids = apply_source_ids_limit(\n        full_source_ids,\n        max_source_limit,\n        limit_method,\n        identifier=f\"`{entity_name}`\",\n    )\n\n    # 4. Only keep nodes not filter by apply_source_ids_limit if limit_method is KEEP\n    if limit_method == SOURCE_IDS_LIMIT_METHOD_KEEP:\n        allowed_source_ids = set(source_ids)\n        filtered_nodes = []\n        for dp in nodes_data:\n            source_id = dp.get(\"source_id\")\n            # Skip descriptions sourced from chunks dropped by the limitation cap\n            if (\n                source_id\n                and source_id not in allowed_source_ids\n                and source_id not in existing_full_source_ids\n            ):\n                continue\n            filtered_nodes.append(dp)\n        nodes_data = filtered_nodes\n    else:  # In FIFO mode, keep all nodes - truncation happens at source_ids level only\n        nodes_data = list(nodes_data)\n\n    # 5. Check if we need to skip summary due to source_ids limit\n    if (\n        limit_method == SOURCE_IDS_LIMIT_METHOD_KEEP\n        and len(existing_full_source_ids) >= max_source_limit\n        and not nodes_data\n    ):\n        if already_node:\n            logger.info(\n                f\"Skipped `{entity_name}`: KEEP old chunks {already_source_ids}/{len(full_source_ids)}\"\n            )\n            existing_node_data = dict(already_node)\n            return existing_node_data\n        else:\n            logger.error(f\"Internal Error: already_node missing for `{entity_name}`\")\n            raise ValueError(\n                f\"Internal Error: already_node missing for `{entity_name}`\"\n            )\n\n    # 6.1 Finalize source_id\n    source_id = GRAPH_FIELD_SEP.join(source_ids)\n\n    # 6.2 Finalize entity type by highest count\n    entity_type = sorted(\n        Counter(\n            [dp[\"entity_type\"] for dp in nodes_data] + already_entity_types\n        ).items(),\n        key=lambda x: x[1],\n        reverse=True,\n    )[0][0]\n\n    # 7. Deduplicate nodes by description, keeping first occurrence in the same document\n    unique_nodes = {}\n    for dp in nodes_data:\n        desc = dp.get(\"description\")\n        if not desc:\n            continue\n        if desc not in unique_nodes:\n            unique_nodes[desc] = dp\n\n    # Sort description by timestamp, then by description length when timestamps are the same\n    sorted_nodes = sorted(\n        unique_nodes.values(),\n        key=lambda x: (x.get(\"timestamp\", 0), -len(x.get(\"description\", \"\"))),\n    )\n    sorted_descriptions = [dp[\"description\"] for dp in sorted_nodes]\n\n    # Combine already_description with sorted new sorted descriptions\n    description_list = already_description + sorted_descriptions\n    if not description_list:\n        fallback_description = f\"Entity {entity_name}\"\n        logger.warning(\n            f\"Entity `{entity_name}` has no description; fallback to `{fallback_description}`\"\n        )\n        description_list = [fallback_description]\n\n    # Check for cancellation before LLM summary\n    if pipeline_status is not None and pipeline_status_lock is not None:\n        async with pipeline_status_lock:\n            if pipeline_status.get(\"cancellation_requested\", False):\n                raise PipelineCancelledException(\"User cancelled during entity summary\")\n\n    # 8. Get summary description an LLM usage status\n    description, llm_was_used = await _handle_entity_relation_summary(\n        \"Entity\",\n        entity_name,\n        description_list,\n        GRAPH_FIELD_SEP,\n        global_config,\n        llm_response_cache,\n    )\n\n    # 9. Build file_path within MAX_FILE_PATHS\n    file_paths_list = []\n    seen_paths = set()\n    has_placeholder = False  # Indicating file_path has been truncated before\n\n    max_file_paths = global_config.get(\"max_file_paths\", DEFAULT_MAX_FILE_PATHS)\n    file_path_placeholder = global_config.get(\n        \"file_path_more_placeholder\", DEFAULT_FILE_PATH_MORE_PLACEHOLDER\n    )\n\n    # Collect from already_file_paths, excluding placeholder\n    for fp in already_file_paths:\n        if fp and fp.startswith(f\"...{file_path_placeholder}\"):  # Skip placeholders\n            has_placeholder = True\n            continue\n        if fp and fp not in seen_paths:\n            file_paths_list.append(fp)\n            seen_paths.add(fp)\n\n    # Collect from new data\n    for dp in nodes_data:\n        file_path_item = dp.get(\"file_path\")\n        if file_path_item and file_path_item not in seen_paths:\n            file_paths_list.append(file_path_item)\n            seen_paths.add(file_path_item)\n\n    # Apply count limit\n    if len(file_paths_list) > max_file_paths:\n        limit_method = global_config.get(\n            \"source_ids_limit_method\", SOURCE_IDS_LIMIT_METHOD_KEEP\n        )\n        file_path_placeholder = global_config.get(\n            \"file_path_more_placeholder\", DEFAULT_FILE_PATH_MORE_PLACEHOLDER\n        )\n        # Add + sign to indicate actual file count is higher\n        original_count_str = (\n            f\"{len(file_paths_list)}+\" if has_placeholder else str(len(file_paths_list))\n        )\n\n        if limit_method == SOURCE_IDS_LIMIT_METHOD_FIFO:\n            # FIFO: keep tail (newest), discard head\n            file_paths_list = file_paths_list[-max_file_paths:]\n            file_paths_list.append(f\"...{file_path_placeholder}...(FIFO)\")\n        else:\n            # KEEP: keep head (earliest), discard tail\n            file_paths_list = file_paths_list[:max_file_paths]\n            file_paths_list.append(f\"...{file_path_placeholder}...(KEEP Old)\")\n\n        logger.info(\n            f\"Limited `{entity_name}`: file_path {original_count_str} -> {max_file_paths} ({limit_method})\"\n        )\n    # Finalize file_path\n    file_path = GRAPH_FIELD_SEP.join(file_paths_list)\n\n    # 10.Log based on actual LLM usage\n    num_fragment = len(description_list)\n    already_fragment = len(already_description)\n    if llm_was_used:\n        status_message = f\"LLMmrg: `{entity_name}` | {already_fragment}+{num_fragment - already_fragment}\"\n    else:\n        status_message = f\"Merged: `{entity_name}` | {already_fragment}+{num_fragment - already_fragment}\"\n\n    truncation_info = truncation_info_log = \"\"\n    if len(source_ids) < len(full_source_ids):\n        # Add truncation info from apply_source_ids_limit if truncation occurred\n        truncation_info_log = f\"{limit_method} {len(source_ids)}/{len(full_source_ids)}\"\n        if limit_method == SOURCE_IDS_LIMIT_METHOD_FIFO:\n            truncation_info = truncation_info_log\n        else:\n            truncation_info = \"KEEP Old\"\n\n    deduplicated_num = already_fragment + len(nodes_data) - num_fragment\n    dd_message = \"\"\n    if deduplicated_num > 0:\n        # Duplicated description detected across multiple trucks for the same entity\n        dd_message = f\"dd {deduplicated_num}\"\n\n    if dd_message or truncation_info_log:\n        status_message += (\n            f\" ({', '.join(filter(None, [truncation_info_log, dd_message]))})\"\n        )\n\n    # Add message to pipeline satus when merge happens\n    if already_fragment > 0 or llm_was_used:\n        logger.info(status_message)\n        if pipeline_status is not None and pipeline_status_lock is not None:\n            async with pipeline_status_lock:\n                pipeline_status[\"latest_message\"] = status_message\n                pipeline_status[\"history_messages\"].append(status_message)\n    else:\n        logger.debug(status_message)\n\n    # 11. Update both graph and vector db\n    node_data = dict(\n        entity_id=entity_name,\n        entity_type=entity_type,\n        description=description,\n        source_id=source_id,\n        file_path=file_path,\n        created_at=int(time.time()),\n        truncate=truncation_info,\n    )\n    await knowledge_graph_inst.upsert_node(\n        entity_name,\n        node_data=node_data,\n    )\n    node_data[\"entity_name\"] = entity_name\n    if entity_vdb is not None:\n        entity_vdb_id = compute_mdhash_id(str(entity_name), prefix=\"ent-\")\n        entity_content = f\"{entity_name}\\n{description}\"\n        data_for_vdb = {\n            entity_vdb_id: {\n                \"entity_name\": entity_name,\n                \"entity_type\": entity_type,\n                \"content\": entity_content,\n                \"source_id\": source_id,\n                \"file_path\": file_path,\n            }\n        }\n        await safe_vdb_operation_with_exception(\n            operation=lambda payload=data_for_vdb: entity_vdb.upsert(payload),\n            operation_name=\"entity_upsert\",\n            entity_name=entity_name,\n            max_retries=3,\n            retry_delay=0.1,\n        )\n    return node_data\n\n\nasync def _merge_edges_then_upsert(\n    src_id: str,\n    tgt_id: str,\n    edges_data: list[dict],\n    knowledge_graph_inst: BaseGraphStorage,\n    relationships_vdb: BaseVectorStorage | None,\n    entity_vdb: BaseVectorStorage | None,\n    global_config: dict,\n    pipeline_status: dict = None,\n    pipeline_status_lock=None,\n    llm_response_cache: BaseKVStorage | None = None,\n    added_entities: list = None,  # New parameter to track entities added during edge processing\n    relation_chunks_storage: BaseKVStorage | None = None,\n    entity_chunks_storage: BaseKVStorage | None = None,\n):\n    if src_id == tgt_id:\n        return None\n\n    already_edge = None\n    already_weights = []\n    already_source_ids = []\n    already_description = []\n    already_keywords = []\n    already_file_paths = []\n\n    # 1. Get existing edge data from graph storage\n    if await knowledge_graph_inst.has_edge(src_id, tgt_id):\n        already_edge = await knowledge_graph_inst.get_edge(src_id, tgt_id)\n        # Handle the case where get_edge returns None or missing fields\n        if already_edge:\n            # Get weight with default 1.0 if missing\n            already_weights.append(already_edge.get(\"weight\", 1.0))\n\n            # Get source_id with empty string default if missing or None\n            if already_edge.get(\"source_id\") is not None:\n                already_source_ids.extend(\n                    already_edge[\"source_id\"].split(GRAPH_FIELD_SEP)\n                )\n\n            # Get file_path with empty string default if missing or None\n            if already_edge.get(\"file_path\") is not None:\n                already_file_paths.extend(\n                    already_edge[\"file_path\"].split(GRAPH_FIELD_SEP)\n                )\n\n            # Get description with empty string default if missing or None\n            if already_edge.get(\"description\") is not None:\n                already_description.extend(\n                    already_edge[\"description\"].split(GRAPH_FIELD_SEP)\n                )\n\n            # Get keywords with empty string default if missing or None\n            if already_edge.get(\"keywords\") is not None:\n                already_keywords.extend(\n                    split_string_by_multi_markers(\n                        already_edge[\"keywords\"], [GRAPH_FIELD_SEP]\n                    )\n                )\n\n    new_source_ids = [dp[\"source_id\"] for dp in edges_data if dp.get(\"source_id\")]\n\n    storage_key = make_relation_chunk_key(src_id, tgt_id)\n    existing_full_source_ids = []\n    if relation_chunks_storage is not None:\n        stored_chunks = await relation_chunks_storage.get_by_id(storage_key)\n        if stored_chunks and isinstance(stored_chunks, dict):\n            existing_full_source_ids = [\n                chunk_id for chunk_id in stored_chunks.get(\"chunk_ids\", []) if chunk_id\n            ]\n\n    if not existing_full_source_ids:\n        existing_full_source_ids = [\n            chunk_id for chunk_id in already_source_ids if chunk_id\n        ]\n\n    # 2. Merge new source ids with existing ones\n    full_source_ids = merge_source_ids(existing_full_source_ids, new_source_ids)\n\n    if relation_chunks_storage is not None and full_source_ids:\n        await relation_chunks_storage.upsert(\n            {\n                storage_key: {\n                    \"chunk_ids\": full_source_ids,\n                    \"count\": len(full_source_ids),\n                }\n            }\n        )\n\n    # 3. Finalize source_id by applying source ids limit\n    limit_method = global_config.get(\"source_ids_limit_method\")\n    max_source_limit = global_config.get(\"max_source_ids_per_relation\")\n    source_ids = apply_source_ids_limit(\n        full_source_ids,\n        max_source_limit,\n        limit_method,\n        identifier=f\"`{src_id}`~`{tgt_id}`\",\n    )\n    limit_method = (\n        global_config.get(\"source_ids_limit_method\") or SOURCE_IDS_LIMIT_METHOD_KEEP\n    )\n\n    # 4. Only keep edges with source_id in the final source_ids list if in KEEP mode\n    if limit_method == SOURCE_IDS_LIMIT_METHOD_KEEP:\n        allowed_source_ids = set(source_ids)\n        filtered_edges = []\n        for dp in edges_data:\n            source_id = dp.get(\"source_id\")\n            # Skip relationship fragments sourced from chunks dropped by keep oldest cap\n            if (\n                source_id\n                and source_id not in allowed_source_ids\n                and source_id not in existing_full_source_ids\n            ):\n                continue\n            filtered_edges.append(dp)\n        edges_data = filtered_edges\n    else:  # In FIFO mode, keep all edges - truncation happens at source_ids level only\n        edges_data = list(edges_data)\n\n    # 5. Check if we need to skip summary due to source_ids limit\n    if (\n        limit_method == SOURCE_IDS_LIMIT_METHOD_KEEP\n        and len(existing_full_source_ids) >= max_source_limit\n        and not edges_data\n    ):\n        if already_edge:\n            logger.info(\n                f\"Skipped `{src_id}`~`{tgt_id}`: KEEP old chunks  {already_source_ids}/{len(full_source_ids)}\"\n            )\n            existing_edge_data = dict(already_edge)\n            return existing_edge_data\n        else:\n            logger.error(\n                f\"Internal Error: already_node missing for `{src_id}`~`{tgt_id}`\"\n            )\n            raise ValueError(\n                f\"Internal Error: already_node missing for `{src_id}`~`{tgt_id}`\"\n            )\n\n    # 6.1 Finalize source_id\n    source_id = GRAPH_FIELD_SEP.join(source_ids)\n\n    # 6.2 Finalize weight by summing new edges and existing weights\n    weight = sum([dp[\"weight\"] for dp in edges_data] + already_weights)\n\n    # 6.2 Finalize keywords by merging existing and new keywords\n    all_keywords = set()\n    # Process already_keywords (which are comma-separated)\n    for keyword_str in already_keywords:\n        if keyword_str:  # Skip empty strings\n            all_keywords.update(k.strip() for k in keyword_str.split(\",\") if k.strip())\n    # Process new keywords from edges_data\n    for edge in edges_data:\n        if edge.get(\"keywords\"):\n            all_keywords.update(\n                k.strip() for k in edge[\"keywords\"].split(\",\") if k.strip()\n            )\n    # Join all unique keywords with commas\n    keywords = \",\".join(sorted(all_keywords))\n\n    # 7. Deduplicate by description, keeping first occurrence in the same document\n    unique_edges = {}\n    for dp in edges_data:\n        description_value = dp.get(\"description\")\n        if not description_value:\n            continue\n        if description_value not in unique_edges:\n            unique_edges[description_value] = dp\n\n    # Sort description by timestamp, then by description length (largest to smallest) when timestamps are the same\n    sorted_edges = sorted(\n        unique_edges.values(),\n        key=lambda x: (x.get(\"timestamp\", 0), -len(x.get(\"description\", \"\"))),\n    )\n    sorted_descriptions = [dp[\"description\"] for dp in sorted_edges]\n\n    # Combine already_description with sorted new descriptions\n    description_list = already_description + sorted_descriptions\n    if not description_list:\n        logger.error(f\"Relation {src_id}~{tgt_id} has no description\")\n        raise ValueError(f\"Relation {src_id}~{tgt_id} has no description\")\n\n    # Check for cancellation before LLM summary\n    if pipeline_status is not None and pipeline_status_lock is not None:\n        async with pipeline_status_lock:\n            if pipeline_status.get(\"cancellation_requested\", False):\n                raise PipelineCancelledException(\n                    \"User cancelled during relation summary\"\n                )\n\n    # 8. Get summary description an LLM usage status\n    description, llm_was_used = await _handle_entity_relation_summary(\n        \"Relation\",\n        f\"({src_id}, {tgt_id})\",\n        description_list,\n        GRAPH_FIELD_SEP,\n        global_config,\n        llm_response_cache,\n    )\n\n    # 9. Build file_path within MAX_FILE_PATHS limit\n    file_paths_list = []\n    seen_paths = set()\n    has_placeholder = False  # Track if already_file_paths contains placeholder\n\n    max_file_paths = global_config.get(\"max_file_paths\", DEFAULT_MAX_FILE_PATHS)\n    file_path_placeholder = global_config.get(\n        \"file_path_more_placeholder\", DEFAULT_FILE_PATH_MORE_PLACEHOLDER\n    )\n\n    # Collect from already_file_paths, excluding placeholder\n    for fp in already_file_paths:\n        # Check if this is a placeholder record\n        if fp and fp.startswith(f\"...{file_path_placeholder}\"):  # Skip placeholders\n            has_placeholder = True\n            continue\n        if fp and fp not in seen_paths:\n            file_paths_list.append(fp)\n            seen_paths.add(fp)\n\n    # Collect from new data\n    for dp in edges_data:\n        file_path_item = dp.get(\"file_path\")\n        if file_path_item and file_path_item not in seen_paths:\n            file_paths_list.append(file_path_item)\n            seen_paths.add(file_path_item)\n\n    # Apply count limit\n    if len(file_paths_list) > max_file_paths:\n        limit_method = global_config.get(\n            \"source_ids_limit_method\", SOURCE_IDS_LIMIT_METHOD_KEEP\n        )\n        file_path_placeholder = global_config.get(\n            \"file_path_more_placeholder\", DEFAULT_FILE_PATH_MORE_PLACEHOLDER\n        )\n\n        # Add + sign to indicate actual file count is higher\n        original_count_str = (\n            f\"{len(file_paths_list)}+\" if has_placeholder else str(len(file_paths_list))\n        )\n\n        if limit_method == SOURCE_IDS_LIMIT_METHOD_FIFO:\n            # FIFO: keep tail (newest), discard head\n            file_paths_list = file_paths_list[-max_file_paths:]\n            file_paths_list.append(f\"...{file_path_placeholder}...(FIFO)\")\n        else:\n            # KEEP: keep head (earliest), discard tail\n            file_paths_list = file_paths_list[:max_file_paths]\n            file_paths_list.append(f\"...{file_path_placeholder}...(KEEP Old)\")\n\n        logger.info(\n            f\"Limited `{src_id}`~`{tgt_id}`: file_path {original_count_str} -> {max_file_paths} ({limit_method})\"\n        )\n    # Finalize file_path\n    file_path = GRAPH_FIELD_SEP.join(file_paths_list)\n\n    # 10. Log based on actual LLM usage\n    num_fragment = len(description_list)\n    already_fragment = len(already_description)\n    if llm_was_used:\n        status_message = f\"LLMmrg: `{src_id}`~`{tgt_id}` | {already_fragment}+{num_fragment - already_fragment}\"\n    else:\n        status_message = f\"Merged: `{src_id}`~`{tgt_id}` | {already_fragment}+{num_fragment - already_fragment}\"\n\n    truncation_info = truncation_info_log = \"\"\n    if len(source_ids) < len(full_source_ids):\n        # Add truncation info from apply_source_ids_limit if truncation occurred\n        truncation_info_log = f\"{limit_method} {len(source_ids)}/{len(full_source_ids)}\"\n        if limit_method == SOURCE_IDS_LIMIT_METHOD_FIFO:\n            truncation_info = truncation_info_log\n        else:\n            truncation_info = \"KEEP Old\"\n\n    deduplicated_num = already_fragment + len(edges_data) - num_fragment\n    dd_message = \"\"\n    if deduplicated_num > 0:\n        # Duplicated description detected across multiple trucks for the same entity\n        dd_message = f\"dd {deduplicated_num}\"\n\n    if dd_message or truncation_info_log:\n        status_message += (\n            f\" ({', '.join(filter(None, [truncation_info_log, dd_message]))})\"\n        )\n\n    # Add message to pipeline satus when merge happens\n    if already_fragment > 0 or llm_was_used:\n        logger.info(status_message)\n        if pipeline_status is not None and pipeline_status_lock is not None:\n            async with pipeline_status_lock:\n                pipeline_status[\"latest_message\"] = status_message\n                pipeline_status[\"history_messages\"].append(status_message)\n    else:\n        logger.debug(status_message)\n\n    # 11. Update both graph and vector db\n    for need_insert_id in [src_id, tgt_id]:\n        # Optimization: Use get_node instead of has_node + get_node\n        existing_node = await knowledge_graph_inst.get_node(need_insert_id)\n\n        if existing_node is None:\n            # Node doesn't exist - create new node\n            node_created_at = int(time.time())\n            node_data = {\n                \"entity_id\": need_insert_id,\n                \"source_id\": source_id,\n                \"description\": description,\n                \"entity_type\": \"UNKNOWN\",\n                \"file_path\": file_path,\n                \"created_at\": node_created_at,\n                \"truncate\": \"\",\n            }\n            await knowledge_graph_inst.upsert_node(need_insert_id, node_data=node_data)\n\n            # Update entity_chunks_storage for the newly created entity\n            if entity_chunks_storage is not None:\n                chunk_ids = [chunk_id for chunk_id in full_source_ids if chunk_id]\n                if chunk_ids:\n                    await entity_chunks_storage.upsert(\n                        {\n                            need_insert_id: {\n                                \"chunk_ids\": chunk_ids,\n                                \"count\": len(chunk_ids),\n                            }\n                        }\n                    )\n\n            if entity_vdb is not None:\n                entity_vdb_id = compute_mdhash_id(need_insert_id, prefix=\"ent-\")\n                entity_content = f\"{need_insert_id}\\n{description}\"\n                vdb_data = {\n                    entity_vdb_id: {\n                        \"content\": entity_content,\n                        \"entity_name\": need_insert_id,\n                        \"source_id\": source_id,\n                        \"entity_type\": \"UNKNOWN\",\n                        \"file_path\": file_path,\n                    }\n                }\n                await safe_vdb_operation_with_exception(\n                    operation=lambda payload=vdb_data: entity_vdb.upsert(payload),\n                    operation_name=\"added_entity_upsert\",\n                    entity_name=need_insert_id,\n                    max_retries=3,\n                    retry_delay=0.1,\n                )\n\n            # Track entities added during edge processing\n            if added_entities is not None:\n                entity_data = {\n                    \"entity_name\": need_insert_id,\n                    \"entity_type\": \"UNKNOWN\",\n                    \"description\": description,\n                    \"source_id\": source_id,\n                    \"file_path\": file_path,\n                    \"created_at\": node_created_at,\n                }\n                added_entities.append(entity_data)\n        else:\n            # Node exists - update its source_ids by merging with new source_ids\n            updated = False  # Track if any update occurred\n\n            # 1. Get existing full source_ids from entity_chunks_storage\n            existing_full_source_ids = []\n            if entity_chunks_storage is not None:\n                stored_chunks = await entity_chunks_storage.get_by_id(need_insert_id)\n                if stored_chunks and isinstance(stored_chunks, dict):\n                    existing_full_source_ids = [\n                        chunk_id\n                        for chunk_id in stored_chunks.get(\"chunk_ids\", [])\n                        if chunk_id\n                    ]\n\n            # If not in entity_chunks_storage, get from graph database\n            if not existing_full_source_ids:\n                if existing_node.get(\"source_id\"):\n                    existing_full_source_ids = existing_node[\"source_id\"].split(\n                        GRAPH_FIELD_SEP\n                    )\n\n            # 2. Merge with new source_ids from this relationship\n            new_source_ids_from_relation = [\n                chunk_id for chunk_id in source_ids if chunk_id\n            ]\n            merged_full_source_ids = merge_source_ids(\n                existing_full_source_ids, new_source_ids_from_relation\n            )\n\n            # 3. Save merged full list to entity_chunks_storage (conditional)\n            if (\n                entity_chunks_storage is not None\n                and merged_full_source_ids != existing_full_source_ids\n            ):\n                updated = True\n                await entity_chunks_storage.upsert(\n                    {\n                        need_insert_id: {\n                            \"chunk_ids\": merged_full_source_ids,\n                            \"count\": len(merged_full_source_ids),\n                        }\n                    }\n                )\n\n            # 4. Apply source_ids limit for graph and vector db\n            limit_method = global_config.get(\n                \"source_ids_limit_method\", SOURCE_IDS_LIMIT_METHOD_KEEP\n            )\n            max_source_limit = global_config.get(\"max_source_ids_per_entity\")\n            limited_source_ids = apply_source_ids_limit(\n                merged_full_source_ids,\n                max_source_limit,\n                limit_method,\n                identifier=f\"`{need_insert_id}`\",\n            )\n\n            # 5. Update graph database and vector database with limited source_ids (conditional)\n            limited_source_id_str = GRAPH_FIELD_SEP.join(limited_source_ids)\n\n            if limited_source_id_str != existing_node.get(\"source_id\", \"\"):\n                updated = True\n                updated_node_data = {\n                    **existing_node,\n                    \"source_id\": limited_source_id_str,\n                }\n                await knowledge_graph_inst.upsert_node(\n                    need_insert_id, node_data=updated_node_data\n                )\n\n                # Update vector database\n                if entity_vdb is not None:\n                    entity_vdb_id = compute_mdhash_id(need_insert_id, prefix=\"ent-\")\n                    entity_content = (\n                        f\"{need_insert_id}\\n{existing_node.get('description', '')}\"\n                    )\n                    vdb_data = {\n                        entity_vdb_id: {\n                            \"content\": entity_content,\n                            \"entity_name\": need_insert_id,\n                            \"source_id\": limited_source_id_str,\n                            \"entity_type\": existing_node.get(\"entity_type\", \"UNKNOWN\"),\n                            \"file_path\": existing_node.get(\n                                \"file_path\", \"unknown_source\"\n                            ),\n                        }\n                    }\n                    await safe_vdb_operation_with_exception(\n                        operation=lambda payload=vdb_data: entity_vdb.upsert(payload),\n                        operation_name=\"existing_entity_update\",\n                        entity_name=need_insert_id,\n                        max_retries=3,\n                        retry_delay=0.1,\n                    )\n\n            # 6. Log once at the end if any update occurred\n            if updated:\n                status_message = f\"Chunks appended from relation: `{need_insert_id}`\"\n                logger.info(status_message)\n                if pipeline_status is not None and pipeline_status_lock is not None:\n                    async with pipeline_status_lock:\n                        pipeline_status[\"latest_message\"] = status_message\n                        pipeline_status[\"history_messages\"].append(status_message)\n\n    edge_created_at = int(time.time())\n    await knowledge_graph_inst.upsert_edge(\n        src_id,\n        tgt_id,\n        edge_data=dict(\n            weight=weight,\n            description=description,\n            keywords=keywords,\n            source_id=source_id,\n            file_path=file_path,\n            created_at=edge_created_at,\n            truncate=truncation_info,\n        ),\n    )\n\n    edge_data = dict(\n        src_id=src_id,\n        tgt_id=tgt_id,\n        description=description,\n        keywords=keywords,\n        source_id=source_id,\n        file_path=file_path,\n        created_at=edge_created_at,\n        truncate=truncation_info,\n        weight=weight,\n    )\n\n    # Sort src_id and tgt_id to ensure consistent ordering (smaller string first)\n    if src_id > tgt_id:\n        src_id, tgt_id = tgt_id, src_id\n\n    if relationships_vdb is not None:\n        rel_vdb_id = compute_mdhash_id(src_id + tgt_id, prefix=\"rel-\")\n        rel_vdb_id_reverse = compute_mdhash_id(tgt_id + src_id, prefix=\"rel-\")\n        try:\n            await relationships_vdb.delete([rel_vdb_id, rel_vdb_id_reverse])\n        except Exception as e:\n            logger.debug(\n                f\"Could not delete old relationship vector records {rel_vdb_id}, {rel_vdb_id_reverse}: {e}\"\n            )\n        rel_content = f\"{keywords}\\t{src_id}\\n{tgt_id}\\n{description}\"\n        vdb_data = {\n            rel_vdb_id: {\n                \"src_id\": src_id,\n                \"tgt_id\": tgt_id,\n                \"source_id\": source_id,\n                \"content\": rel_content,\n                \"keywords\": keywords,\n                \"description\": description,\n                \"weight\": weight,\n                \"file_path\": file_path,\n            }\n        }\n        await safe_vdb_operation_with_exception(\n            operation=lambda payload=vdb_data: relationships_vdb.upsert(payload),\n            operation_name=\"relationship_upsert\",\n            entity_name=f\"{src_id}-{tgt_id}\",\n            max_retries=3,\n            retry_delay=0.2,\n        )\n\n    return edge_data\n\n\nasync def merge_nodes_and_edges(\n    chunk_results: list,\n    knowledge_graph_inst: BaseGraphStorage,\n    entity_vdb: BaseVectorStorage,\n    relationships_vdb: BaseVectorStorage,\n    global_config: dict[str, str],\n    full_entities_storage: BaseKVStorage = None,\n    full_relations_storage: BaseKVStorage = None,\n    doc_id: str = None,\n    pipeline_status: dict = None,\n    pipeline_status_lock=None,\n    llm_response_cache: BaseKVStorage | None = None,\n    entity_chunks_storage: BaseKVStorage | None = None,\n    relation_chunks_storage: BaseKVStorage | None = None,\n    current_file_number: int = 0,\n    total_files: int = 0,\n    file_path: str = \"unknown_source\",\n) -> None:\n    \"\"\"Two-phase merge: process all entities first, then all relationships\n\n    This approach ensures data consistency by:\n    1. Phase 1: Process all entities concurrently\n    2. Phase 2: Process all relationships concurrently (may add missing entities)\n    3. Phase 3: Update full_entities and full_relations storage with final results\n\n    Args:\n        chunk_results: List of tuples (maybe_nodes, maybe_edges) containing extracted entities and relationships\n        knowledge_graph_inst: Knowledge graph storage\n        entity_vdb: Entity vector database\n        relationships_vdb: Relationship vector database\n        global_config: Global configuration\n        full_entities_storage: Storage for document entity lists\n        full_relations_storage: Storage for document relation lists\n        doc_id: Document ID for storage indexing\n        pipeline_status: Pipeline status dictionary\n        pipeline_status_lock: Lock for pipeline status\n        llm_response_cache: LLM response cache\n        entity_chunks_storage: Storage tracking full chunk lists per entity\n        relation_chunks_storage: Storage tracking full chunk lists per relation\n        current_file_number: Current file number for logging\n        total_files: Total files for logging\n        file_path: File path for logging\n    \"\"\"\n\n    # Check for cancellation at the start of merge\n    if pipeline_status is not None and pipeline_status_lock is not None:\n        async with pipeline_status_lock:\n            if pipeline_status.get(\"cancellation_requested\", False):\n                raise PipelineCancelledException(\"User cancelled during merge phase\")\n\n    # Collect all nodes and edges from all chunks\n    all_nodes = defaultdict(list)\n    all_edges = defaultdict(list)\n\n    for maybe_nodes, maybe_edges in chunk_results:\n        # Collect nodes\n        for entity_name, entities in maybe_nodes.items():\n            all_nodes[entity_name].extend(entities)\n\n        # Collect edges with sorted keys for undirected graph\n        for edge_key, edges in maybe_edges.items():\n            sorted_edge_key = tuple(sorted(edge_key))\n            all_edges[sorted_edge_key].extend(edges)\n\n    total_entities_count = len(all_nodes)\n    total_relations_count = len(all_edges)\n\n    log_message = f\"Merging stage {current_file_number}/{total_files}: {file_path}\"\n    logger.info(log_message)\n    async with pipeline_status_lock:\n        pipeline_status[\"latest_message\"] = log_message\n        pipeline_status[\"history_messages\"].append(log_message)\n\n    # Get max async tasks limit from global_config for semaphore control\n    graph_max_async = global_config.get(\"llm_model_max_async\", 4) * 2\n    semaphore = asyncio.Semaphore(graph_max_async)\n\n    # ===== Phase 1: Process all entities concurrently =====\n    log_message = f\"Phase 1: Processing {total_entities_count} entities from {doc_id} (async: {graph_max_async})\"\n    logger.info(log_message)\n    async with pipeline_status_lock:\n        pipeline_status[\"latest_message\"] = log_message\n        pipeline_status[\"history_messages\"].append(log_message)\n\n    async def _locked_process_entity_name(entity_name, entities):\n        async with semaphore:\n            # Check for cancellation before processing entity\n            if pipeline_status is not None and pipeline_status_lock is not None:\n                async with pipeline_status_lock:\n                    if pipeline_status.get(\"cancellation_requested\", False):\n                        raise PipelineCancelledException(\n                            \"User cancelled during entity merge\"\n                        )\n\n            workspace = global_config.get(\"workspace\", \"\")\n            namespace = f\"{workspace}:GraphDB\" if workspace else \"GraphDB\"\n            async with get_storage_keyed_lock(\n                [entity_name], namespace=namespace, enable_logging=False\n            ):\n                try:\n                    logger.debug(f\"Processing entity {entity_name}\")\n                    entity_data = await _merge_nodes_then_upsert(\n                        entity_name,\n                        entities,\n                        knowledge_graph_inst,\n                        entity_vdb,\n                        global_config,\n                        pipeline_status,\n                        pipeline_status_lock,\n                        llm_response_cache,\n                        entity_chunks_storage,\n                    )\n\n                    return entity_data\n\n                except Exception as e:\n                    error_msg = f\"Error processing entity `{entity_name}`: {e}\"\n                    logger.error(error_msg)\n\n                    # Try to update pipeline status, but don't let status update failure affect main exception\n                    try:\n                        if (\n                            pipeline_status is not None\n                            and pipeline_status_lock is not None\n                        ):\n                            async with pipeline_status_lock:\n                                pipeline_status[\"latest_message\"] = error_msg\n                                pipeline_status[\"history_messages\"].append(error_msg)\n                    except Exception as status_error:\n                        logger.error(\n                            f\"Failed to update pipeline status: {status_error}\"\n                        )\n\n                    # Re-raise the original exception with a prefix\n                    prefixed_exception = create_prefixed_exception(\n                        e, f\"`{entity_name}`\"\n                    )\n                    raise prefixed_exception from e\n\n    # Create entity processing tasks\n    entity_tasks = []\n    for entity_name, entities in all_nodes.items():\n        task = asyncio.create_task(_locked_process_entity_name(entity_name, entities))\n        entity_tasks.append(task)\n\n    # Execute entity tasks with error handling\n    processed_entities = []\n    if entity_tasks:\n        done, pending = await asyncio.wait(\n            entity_tasks, return_when=asyncio.FIRST_EXCEPTION\n        )\n\n        first_exception = None\n        processed_entities = []\n\n        for task in done:\n            try:\n                result = task.result()\n            except BaseException as e:\n                if first_exception is None:\n                    first_exception = e\n            else:\n                processed_entities.append(result)\n\n        if pending:\n            for task in pending:\n                task.cancel()\n            pending_results = await asyncio.gather(*pending, return_exceptions=True)\n            for result in pending_results:\n                if isinstance(result, BaseException):\n                    if first_exception is None:\n                        first_exception = result\n                else:\n                    processed_entities.append(result)\n\n        if first_exception is not None:\n            raise first_exception\n\n    # ===== Phase 2: Process all relationships concurrently =====\n    log_message = f\"Phase 2: Processing {total_relations_count} relations from {doc_id} (async: {graph_max_async})\"\n    logger.info(log_message)\n    async with pipeline_status_lock:\n        pipeline_status[\"latest_message\"] = log_message\n        pipeline_status[\"history_messages\"].append(log_message)\n\n    async def _locked_process_edges(edge_key, edges):\n        async with semaphore:\n            # Check for cancellation before processing edges\n            if pipeline_status is not None and pipeline_status_lock is not None:\n                async with pipeline_status_lock:\n                    if pipeline_status.get(\"cancellation_requested\", False):\n                        raise PipelineCancelledException(\n                            \"User cancelled during relation merge\"\n                        )\n\n            workspace = global_config.get(\"workspace\", \"\")\n            namespace = f\"{workspace}:GraphDB\" if workspace else \"GraphDB\"\n            sorted_edge_key = sorted([edge_key[0], edge_key[1]])\n\n            async with get_storage_keyed_lock(\n                sorted_edge_key,\n                namespace=namespace,\n                enable_logging=False,\n            ):\n                try:\n                    added_entities = []  # Track entities added during edge processing\n\n                    logger.debug(f\"Processing relation {sorted_edge_key}\")\n                    edge_data = await _merge_edges_then_upsert(\n                        edge_key[0],\n                        edge_key[1],\n                        edges,\n                        knowledge_graph_inst,\n                        relationships_vdb,\n                        entity_vdb,\n                        global_config,\n                        pipeline_status,\n                        pipeline_status_lock,\n                        llm_response_cache,\n                        added_entities,  # Pass list to collect added entities\n                        relation_chunks_storage,\n                        entity_chunks_storage,  # Add entity_chunks_storage parameter\n                    )\n\n                    if edge_data is None:\n                        return None, []\n\n                    return edge_data, added_entities\n\n                except Exception as e:\n                    error_msg = f\"Error processing relation `{sorted_edge_key}`: {e}\"\n                    logger.error(error_msg)\n\n                    # Try to update pipeline status, but don't let status update failure affect main exception\n                    try:\n                        if (\n                            pipeline_status is not None\n                            and pipeline_status_lock is not None\n                        ):\n                            async with pipeline_status_lock:\n                                pipeline_status[\"latest_message\"] = error_msg\n                                pipeline_status[\"history_messages\"].append(error_msg)\n                    except Exception as status_error:\n                        logger.error(\n                            f\"Failed to update pipeline status: {status_error}\"\n                        )\n\n                    # Re-raise the original exception with a prefix\n                    prefixed_exception = create_prefixed_exception(\n                        e, f\"{sorted_edge_key}\"\n                    )\n                    raise prefixed_exception from e\n\n    # Create relationship processing tasks\n    edge_tasks = []\n    for edge_key, edges in all_edges.items():\n        task = asyncio.create_task(_locked_process_edges(edge_key, edges))\n        edge_tasks.append(task)\n\n    # Execute relationship tasks with error handling\n    processed_edges = []\n    all_added_entities = []\n\n    if edge_tasks:\n        done, pending = await asyncio.wait(\n            edge_tasks, return_when=asyncio.FIRST_EXCEPTION\n        )\n\n        first_exception = None\n\n        for task in done:\n            try:\n                edge_data, added_entities = task.result()\n            except BaseException as e:\n                if first_exception is None:\n                    first_exception = e\n            else:\n                if edge_data is not None:\n                    processed_edges.append(edge_data)\n                all_added_entities.extend(added_entities)\n\n        if pending:\n            for task in pending:\n                task.cancel()\n            pending_results = await asyncio.gather(*pending, return_exceptions=True)\n            for result in pending_results:\n                if isinstance(result, BaseException):\n                    if first_exception is None:\n                        first_exception = result\n                else:\n                    edge_data, added_entities = result\n                    if edge_data is not None:\n                        processed_edges.append(edge_data)\n                    all_added_entities.extend(added_entities)\n\n        if first_exception is not None:\n            raise first_exception\n\n    # ===== Phase 3: Update full_entities and full_relations storage =====\n    if full_entities_storage and full_relations_storage and doc_id:\n        try:\n            # Merge all entities: original entities + entities added during edge processing\n            final_entity_names = set()\n\n            # Add original processed entities\n            for entity_data in processed_entities:\n                if entity_data and entity_data.get(\"entity_name\"):\n                    final_entity_names.add(entity_data[\"entity_name\"])\n\n            # Add entities that were added during relationship processing\n            for added_entity in all_added_entities:\n                if added_entity and added_entity.get(\"entity_name\"):\n                    final_entity_names.add(added_entity[\"entity_name\"])\n\n            # Collect all relation pairs\n            final_relation_pairs = set()\n            for edge_data in processed_edges:\n                if edge_data:\n                    src_id = edge_data.get(\"src_id\")\n                    tgt_id = edge_data.get(\"tgt_id\")\n                    if src_id and tgt_id:\n                        relation_pair = tuple(sorted([src_id, tgt_id]))\n                        final_relation_pairs.add(relation_pair)\n\n            log_message = f\"Phase 3: Updating final {len(final_entity_names)}({len(processed_entities)}+{len(all_added_entities)}) entities and  {len(final_relation_pairs)} relations from {doc_id}\"\n            logger.info(log_message)\n            async with pipeline_status_lock:\n                pipeline_status[\"latest_message\"] = log_message\n                pipeline_status[\"history_messages\"].append(log_message)\n\n            # Update storage\n            if final_entity_names:\n                await full_entities_storage.upsert(\n                    {\n                        doc_id: {\n                            \"entity_names\": list(final_entity_names),\n                            \"count\": len(final_entity_names),\n                        }\n                    }\n                )\n\n            if final_relation_pairs:\n                await full_relations_storage.upsert(\n                    {\n                        doc_id: {\n                            \"relation_pairs\": [\n                                list(pair) for pair in final_relation_pairs\n                            ],\n                            \"count\": len(final_relation_pairs),\n                        }\n                    }\n                )\n\n            logger.debug(\n                f\"Updated entity-relation index for document {doc_id}: {len(final_entity_names)} entities (original: {len(processed_entities)}, added: {len(all_added_entities)}), {len(final_relation_pairs)} relations\"\n            )\n\n        except Exception as e:\n            logger.error(\n                f\"Failed to update entity-relation index for document {doc_id}: {e}\"\n            )\n            # Don't raise exception to avoid affecting main flow\n\n    log_message = f\"Completed merging: {len(processed_entities)} entities, {len(all_added_entities)} extra entities, {len(processed_edges)} relations\"\n    logger.info(log_message)\n    async with pipeline_status_lock:\n        pipeline_status[\"latest_message\"] = log_message\n        pipeline_status[\"history_messages\"].append(log_message)\n\n\nasync def extract_entities(\n    chunks: dict[str, TextChunkSchema],\n    global_config: dict[str, str],\n    pipeline_status: dict = None,\n    pipeline_status_lock=None,\n    llm_response_cache: BaseKVStorage | None = None,\n    text_chunks_storage: BaseKVStorage | None = None,\n) -> list:\n    # Check for cancellation at the start of entity extraction\n    if pipeline_status is not None and pipeline_status_lock is not None:\n        async with pipeline_status_lock:\n            if pipeline_status.get(\"cancellation_requested\", False):\n                raise PipelineCancelledException(\n                    \"User cancelled during entity extraction\"\n                )\n\n    use_llm_func: callable = global_config[\"llm_model_func\"]\n    entity_extract_max_gleaning = global_config[\"entity_extract_max_gleaning\"]\n\n    ordered_chunks = list(chunks.items())\n    # add language and example number params to prompt\n    language = global_config[\"addon_params\"].get(\"language\", DEFAULT_SUMMARY_LANGUAGE)\n    entity_types = global_config[\"addon_params\"].get(\n        \"entity_types\", DEFAULT_ENTITY_TYPES\n    )\n\n    examples = \"\\n\".join(PROMPTS[\"entity_extraction_examples\"])\n\n    example_context_base = dict(\n        tuple_delimiter=PROMPTS[\"DEFAULT_TUPLE_DELIMITER\"],\n        completion_delimiter=PROMPTS[\"DEFAULT_COMPLETION_DELIMITER\"],\n        entity_types=\", \".join(entity_types),\n        language=language,\n    )\n    # add example's format\n    examples = examples.format(**example_context_base)\n\n    context_base = dict(\n        tuple_delimiter=PROMPTS[\"DEFAULT_TUPLE_DELIMITER\"],\n        completion_delimiter=PROMPTS[\"DEFAULT_COMPLETION_DELIMITER\"],\n        entity_types=\",\".join(entity_types),\n        examples=examples,\n        language=language,\n    )\n\n    processed_chunks = 0\n    total_chunks = len(ordered_chunks)\n\n    async def _process_single_content(chunk_key_dp: tuple[str, TextChunkSchema]):\n        \"\"\"Process a single chunk\n        Args:\n            chunk_key_dp (tuple[str, TextChunkSchema]):\n                (\"chunk-xxxxxx\", {\"tokens\": int, \"content\": str, \"full_doc_id\": str, \"chunk_order_index\": int})\n        Returns:\n            tuple: (maybe_nodes, maybe_edges) containing extracted entities and relationships\n        \"\"\"\n        nonlocal processed_chunks\n        chunk_key = chunk_key_dp[0]\n        chunk_dp = chunk_key_dp[1]\n        content = chunk_dp[\"content\"]\n        # Get file path from chunk data or use default\n        file_path = chunk_dp.get(\"file_path\", \"unknown_source\")\n\n        # Create cache keys collector for batch processing\n        cache_keys_collector = []\n\n        # Get initial extraction\n        # Format system prompt without input_text for each chunk (enables OpenAI prompt caching across chunks)\n        entity_extraction_system_prompt = PROMPTS[\n            \"entity_extraction_system_prompt\"\n        ].format(**context_base)\n        # Format user prompts with input_text for each chunk\n        entity_extraction_user_prompt = PROMPTS[\"entity_extraction_user_prompt\"].format(\n            **{**context_base, \"input_text\": content}\n        )\n        entity_continue_extraction_user_prompt = PROMPTS[\n            \"entity_continue_extraction_user_prompt\"\n        ].format(**{**context_base, \"input_text\": content})\n\n        final_result, timestamp = await use_llm_func_with_cache(\n            entity_extraction_user_prompt,\n            use_llm_func,\n            system_prompt=entity_extraction_system_prompt,\n            llm_response_cache=llm_response_cache,\n            cache_type=\"extract\",\n            chunk_id=chunk_key,\n            cache_keys_collector=cache_keys_collector,\n        )\n\n        history = pack_user_ass_to_openai_messages(\n            entity_extraction_user_prompt, final_result\n        )\n\n        # Process initial extraction with file path\n        maybe_nodes, maybe_edges = await _process_extraction_result(\n            final_result,\n            chunk_key,\n            timestamp,\n            file_path,\n            tuple_delimiter=context_base[\"tuple_delimiter\"],\n            completion_delimiter=context_base[\"completion_delimiter\"],\n        )\n\n        # Process additional gleaning results only 1 time when entity_extract_max_gleaning is greater than zero.\n        if entity_extract_max_gleaning > 0:\n            # Calculate total tokens for the gleaning request to prevent context window overflow\n            tokenizer = global_config[\"tokenizer\"]\n            max_input_tokens = global_config[\"max_extract_input_tokens\"]\n\n            # Approximate total tokens: system prompt + history + user prompt.\n            # This slightly underestimates actual API usage (missing role/framing tokens)\n            # but is sufficient as a safety guard against context window overflow.\n            history_str = json.dumps(history, ensure_ascii=False)\n            full_context_str = (\n                entity_extraction_system_prompt\n                + history_str\n                + entity_continue_extraction_user_prompt\n            )\n            token_count = len(tokenizer.encode(full_context_str))\n\n            if token_count > max_input_tokens:\n                logger.warning(\n                    f\"Gleaning stopped for chunk {chunk_key}: Input tokens ({token_count}) exceeded limit ({max_input_tokens}).\"\n                )\n            else:\n                glean_result, timestamp = await use_llm_func_with_cache(\n                    entity_continue_extraction_user_prompt,\n                    use_llm_func,\n                    system_prompt=entity_extraction_system_prompt,\n                    llm_response_cache=llm_response_cache,\n                    history_messages=history,\n                    cache_type=\"extract\",\n                    chunk_id=chunk_key,\n                    cache_keys_collector=cache_keys_collector,\n                )\n\n                # Process gleaning result separately with file path\n                glean_nodes, glean_edges = await _process_extraction_result(\n                    glean_result,\n                    chunk_key,\n                    timestamp,\n                    file_path,\n                    tuple_delimiter=context_base[\"tuple_delimiter\"],\n                    completion_delimiter=context_base[\"completion_delimiter\"],\n                )\n\n                # Merge results - compare description lengths to choose better version\n                for entity_name, glean_entities in glean_nodes.items():\n                    if entity_name in maybe_nodes:\n                        # Compare description lengths and keep the better one\n                        original_desc_len = len(\n                            maybe_nodes[entity_name][0].get(\"description\", \"\") or \"\"\n                        )\n                        glean_desc_len = len(\n                            glean_entities[0].get(\"description\", \"\") or \"\"\n                        )\n\n                        if glean_desc_len > original_desc_len:\n                            maybe_nodes[entity_name] = list(glean_entities)\n                        # Otherwise keep original version\n                    else:\n                        # New entity from gleaning stage\n                        maybe_nodes[entity_name] = list(glean_entities)\n\n                for edge_key, glean_edge_list in glean_edges.items():\n                    if edge_key in maybe_edges:\n                        # Compare description lengths and keep the better one\n                        original_desc_len = len(\n                            maybe_edges[edge_key][0].get(\"description\", \"\") or \"\"\n                        )\n                        glean_desc_len = len(\n                            glean_edge_list[0].get(\"description\", \"\") or \"\"\n                        )\n\n                        if glean_desc_len > original_desc_len:\n                            maybe_edges[edge_key] = list(glean_edge_list)\n                        # Otherwise keep original version\n                    else:\n                        # New edge from gleaning stage\n                        maybe_edges[edge_key] = list(glean_edge_list)\n\n        # Batch update chunk's llm_cache_list with all collected cache keys\n        if cache_keys_collector and text_chunks_storage:\n            await update_chunk_cache_list(\n                chunk_key,\n                text_chunks_storage,\n                cache_keys_collector,\n                \"entity_extraction\",\n            )\n\n        processed_chunks += 1\n        entities_count = len(maybe_nodes)\n        relations_count = len(maybe_edges)\n        log_message = f\"Chunk {processed_chunks} of {total_chunks} extracted {entities_count} Ent + {relations_count} Rel {chunk_key}\"\n        logger.info(log_message)\n        if pipeline_status is not None:\n            async with pipeline_status_lock:\n                pipeline_status[\"latest_message\"] = log_message\n                pipeline_status[\"history_messages\"].append(log_message)\n\n        # Return the extracted nodes and edges for centralized processing\n        return maybe_nodes, maybe_edges\n\n    # Get max async tasks limit from global_config\n    chunk_max_async = global_config.get(\"llm_model_max_async\", 4)\n    semaphore = asyncio.Semaphore(chunk_max_async)\n\n    async def _process_with_semaphore(chunk):\n        async with semaphore:\n            # Check for cancellation before processing chunk\n            if pipeline_status is not None and pipeline_status_lock is not None:\n                async with pipeline_status_lock:\n                    if pipeline_status.get(\"cancellation_requested\", False):\n                        raise PipelineCancelledException(\n                            \"User cancelled during chunk processing\"\n                        )\n\n            try:\n                return await _process_single_content(chunk)\n            except Exception as e:\n                chunk_id = chunk[0]  # Extract chunk_id from chunk[0]\n                prefixed_exception = create_prefixed_exception(e, chunk_id)\n                raise prefixed_exception from e\n\n    tasks = []\n    for c in ordered_chunks:\n        task = asyncio.create_task(_process_with_semaphore(c))\n        tasks.append(task)\n\n    # Wait for tasks to complete or for the first exception to occur\n    # This allows us to cancel remaining tasks if any task fails\n    done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)\n\n    # Check if any task raised an exception and ensure all exceptions are retrieved\n    first_exception = None\n    chunk_results = []\n\n    for task in done:\n        try:\n            exception = task.exception()\n            if exception is not None:\n                if first_exception is None:\n                    first_exception = exception\n            else:\n                chunk_results.append(task.result())\n        except Exception as e:\n            if first_exception is None:\n                first_exception = e\n\n    # If any task failed, cancel all pending tasks and raise the first exception\n    if first_exception is not None:\n        # Cancel all pending tasks\n        for pending_task in pending:\n            pending_task.cancel()\n\n        # Wait for cancellation to complete\n        if pending:\n            await asyncio.wait(pending)\n\n        # Add progress prefix to the exception message\n        progress_prefix = f\"C[{processed_chunks + 1}/{total_chunks}]\"\n\n        # Re-raise the original exception with a prefix\n        prefixed_exception = create_prefixed_exception(first_exception, progress_prefix)\n        raise prefixed_exception from first_exception\n\n    # If all tasks completed successfully, chunk_results already contains the results\n    # Return the chunk_results for later processing in merge_nodes_and_edges\n    return chunk_results\n\n\nasync def kg_query(\n    query: str,\n    knowledge_graph_inst: BaseGraphStorage,\n    entities_vdb: BaseVectorStorage,\n    relationships_vdb: BaseVectorStorage,\n    text_chunks_db: BaseKVStorage,\n    query_param: QueryParam,\n    global_config: dict[str, str],\n    hashing_kv: BaseKVStorage | None = None,\n    system_prompt: str | None = None,\n    chunks_vdb: BaseVectorStorage = None,\n) -> QueryResult | None:\n    \"\"\"\n    Execute knowledge graph query and return unified QueryResult object.\n\n    Args:\n        query: Query string\n        knowledge_graph_inst: Knowledge graph storage instance\n        entities_vdb: Entity vector database\n        relationships_vdb: Relationship vector database\n        text_chunks_db: Text chunks storage\n        query_param: Query parameters\n        global_config: Global configuration\n        hashing_kv: Cache storage\n        system_prompt: System prompt\n        chunks_vdb: Document chunks vector database\n\n    Returns:\n        QueryResult | None: Unified query result object containing:\n            - content: Non-streaming response text content\n            - response_iterator: Streaming response iterator\n            - raw_data: Complete structured data (including references and metadata)\n            - is_streaming: Whether this is a streaming result\n\n        Based on different query_param settings, different fields will be populated:\n        - only_need_context=True: content contains context string\n        - only_need_prompt=True: content contains complete prompt\n        - stream=True: response_iterator contains streaming response, raw_data contains complete data\n        - default: content contains LLM response text, raw_data contains complete data\n\n        Returns None when no relevant context could be constructed for the query.\n    \"\"\"\n    if not query:\n        return QueryResult(content=PROMPTS[\"fail_response\"])\n\n    if query_param.model_func:\n        use_model_func = query_param.model_func\n    else:\n        use_model_func = global_config[\"llm_model_func\"]\n        # Apply higher priority (5) to query relation LLM function\n        use_model_func = partial(use_model_func, _priority=5)\n\n    hl_keywords, ll_keywords = await get_keywords_from_query(\n        query, query_param, global_config, hashing_kv\n    )\n\n    logger.debug(f\"High-level keywords: {hl_keywords}\")\n    logger.debug(f\"Low-level  keywords: {ll_keywords}\")\n\n    # Handle empty keywords\n    if ll_keywords == [] and query_param.mode in [\"local\", \"hybrid\", \"mix\"]:\n        logger.warning(\"low_level_keywords is empty\")\n    if hl_keywords == [] and query_param.mode in [\"global\", \"hybrid\", \"mix\"]:\n        logger.warning(\"high_level_keywords is empty\")\n    if hl_keywords == [] and ll_keywords == []:\n        if len(query) < 50:\n            logger.warning(f\"Forced low_level_keywords to origin query: {query}\")\n            ll_keywords = [query]\n        else:\n            return QueryResult(content=PROMPTS[\"fail_response\"])\n\n    ll_keywords_str = \", \".join(ll_keywords) if ll_keywords else \"\"\n    hl_keywords_str = \", \".join(hl_keywords) if hl_keywords else \"\"\n\n    # Build query context (unified interface)\n    context_result = await _build_query_context(\n        query,\n        ll_keywords_str,\n        hl_keywords_str,\n        knowledge_graph_inst,\n        entities_vdb,\n        relationships_vdb,\n        text_chunks_db,\n        query_param,\n        chunks_vdb,\n    )\n\n    if context_result is None:\n        logger.info(\"[kg_query] No query context could be built; returning no-result.\")\n        return None\n\n    # Return different content based on query parameters\n    if query_param.only_need_context and not query_param.only_need_prompt:\n        return QueryResult(\n            content=context_result.context, raw_data=context_result.raw_data\n        )\n\n    user_prompt = f\"\\n\\n{query_param.user_prompt}\" if query_param.user_prompt else \"n/a\"\n    response_type = (\n        query_param.response_type\n        if query_param.response_type\n        else \"Multiple Paragraphs\"\n    )\n\n    # Build system prompt\n    sys_prompt_temp = system_prompt if system_prompt else PROMPTS[\"rag_response\"]\n    sys_prompt = sys_prompt_temp.format(\n        response_type=response_type,\n        user_prompt=user_prompt,\n        context_data=context_result.context,\n    )\n\n    user_query = query\n\n    if query_param.only_need_prompt:\n        prompt_content = \"\\n\\n\".join([sys_prompt, \"---User Query---\", user_query])\n        return QueryResult(content=prompt_content, raw_data=context_result.raw_data)\n\n    # Call LLM\n    tokenizer: Tokenizer = global_config[\"tokenizer\"]\n    len_of_prompts = len(tokenizer.encode(query + sys_prompt))\n    logger.debug(\n        f\"[kg_query] Sending to LLM: {len_of_prompts:,} tokens (Query: {len(tokenizer.encode(query))}, System: {len(tokenizer.encode(sys_prompt))})\"\n    )\n\n    # Handle cache\n    args_hash = compute_args_hash(\n        query_param.mode,\n        query,\n        query_param.response_type,\n        query_param.top_k,\n        query_param.chunk_top_k,\n        query_param.max_entity_tokens,\n        query_param.max_relation_tokens,\n        query_param.max_total_tokens,\n        hl_keywords_str,\n        ll_keywords_str,\n        query_param.user_prompt or \"\",\n        query_param.enable_rerank,\n    )\n\n    cached_result = await handle_cache(\n        hashing_kv, args_hash, user_query, query_param.mode, cache_type=\"query\"\n    )\n\n    if cached_result is not None:\n        cached_response, _ = cached_result  # Extract content, ignore timestamp\n        logger.info(\n            \" == LLM cache == Query cache hit, using cached response as query result\"\n        )\n        response = cached_response\n    else:\n        response = await use_model_func(\n            user_query,\n            system_prompt=sys_prompt,\n            history_messages=query_param.conversation_history,\n            enable_cot=True,\n            stream=query_param.stream,\n        )\n\n        if hashing_kv and hashing_kv.global_config.get(\"enable_llm_cache\"):\n            queryparam_dict = {\n                \"mode\": query_param.mode,\n                \"response_type\": query_param.response_type,\n                \"top_k\": query_param.top_k,\n                \"chunk_top_k\": query_param.chunk_top_k,\n                \"max_entity_tokens\": query_param.max_entity_tokens,\n                \"max_relation_tokens\": query_param.max_relation_tokens,\n                \"max_total_tokens\": query_param.max_total_tokens,\n                \"hl_keywords\": hl_keywords_str,\n                \"ll_keywords\": ll_keywords_str,\n                \"user_prompt\": query_param.user_prompt or \"\",\n                \"enable_rerank\": query_param.enable_rerank,\n            }\n            await save_to_cache(\n                hashing_kv,\n                CacheData(\n                    args_hash=args_hash,\n                    content=response,\n                    prompt=query,\n                    mode=query_param.mode,\n                    cache_type=\"query\",\n                    queryparam=queryparam_dict,\n                ),\n            )\n\n    # Return unified result based on actual response type\n    if isinstance(response, str):\n        # Non-streaming response (string)\n        if len(response) > len(sys_prompt):\n            response = (\n                response.replace(sys_prompt, \"\")\n                .replace(\"user\", \"\")\n                .replace(\"model\", \"\")\n                .replace(query, \"\")\n                .replace(\"<system>\", \"\")\n                .replace(\"</system>\", \"\")\n                .strip()\n            )\n\n        return QueryResult(content=response, raw_data=context_result.raw_data)\n    else:\n        # Streaming response (AsyncIterator)\n        return QueryResult(\n            response_iterator=response,\n            raw_data=context_result.raw_data,\n            is_streaming=True,\n        )\n\n\nasync def get_keywords_from_query(\n    query: str,\n    query_param: QueryParam,\n    global_config: dict[str, str],\n    hashing_kv: BaseKVStorage | None = None,\n) -> tuple[list[str], list[str]]:\n    \"\"\"\n    Retrieves high-level and low-level keywords for RAG operations.\n\n    This function checks if keywords are already provided in query parameters,\n    and if not, extracts them from the query text using LLM.\n\n    Args:\n        query: The user's query text\n        query_param: Query parameters that may contain pre-defined keywords\n        global_config: Global configuration dictionary\n        hashing_kv: Optional key-value storage for caching results\n\n    Returns:\n        A tuple containing (high_level_keywords, low_level_keywords)\n    \"\"\"\n    # Check if pre-defined keywords are already provided\n    if query_param.hl_keywords or query_param.ll_keywords:\n        return query_param.hl_keywords, query_param.ll_keywords\n\n    # Extract keywords using extract_keywords_only function which already supports conversation history\n    hl_keywords, ll_keywords = await extract_keywords_only(\n        query, query_param, global_config, hashing_kv\n    )\n    return hl_keywords, ll_keywords\n\n\nasync def extract_keywords_only(\n    text: str,\n    param: QueryParam,\n    global_config: dict[str, str],\n    hashing_kv: BaseKVStorage | None = None,\n) -> tuple[list[str], list[str]]:\n    \"\"\"\n    Extract high-level and low-level keywords from the given 'text' using the LLM.\n    This method does NOT build the final RAG context or provide a final answer.\n    It ONLY extracts keywords (hl_keywords, ll_keywords).\n    \"\"\"\n\n    # 1. Build the examples\n    examples = \"\\n\".join(PROMPTS[\"keywords_extraction_examples\"])\n\n    language = global_config[\"addon_params\"].get(\"language\", DEFAULT_SUMMARY_LANGUAGE)\n\n    # 2. Handle cache if needed - add cache type for keywords\n    args_hash = compute_args_hash(\n        param.mode,\n        text,\n        language,\n    )\n    cached_result = await handle_cache(\n        hashing_kv, args_hash, text, param.mode, cache_type=\"keywords\"\n    )\n    if cached_result is not None:\n        cached_response, _ = cached_result  # Extract content, ignore timestamp\n        try:\n            keywords_data = json_repair.loads(cached_response)\n            return keywords_data.get(\"high_level_keywords\", []), keywords_data.get(\n                \"low_level_keywords\", []\n            )\n        except (json.JSONDecodeError, KeyError):\n            logger.warning(\n                \"Invalid cache format for keywords, proceeding with extraction\"\n            )\n\n    # 3. Build the keyword-extraction prompt\n    kw_prompt = PROMPTS[\"keywords_extraction\"].format(\n        query=text,\n        examples=examples,\n        language=language,\n    )\n\n    tokenizer: Tokenizer = global_config[\"tokenizer\"]\n    len_of_prompts = len(tokenizer.encode(kw_prompt))\n    logger.debug(\n        f\"[extract_keywords] Sending to LLM: {len_of_prompts:,} tokens (Prompt: {len_of_prompts})\"\n    )\n\n    # 4. Call the LLM for keyword extraction\n    if param.model_func:\n        use_model_func = param.model_func\n    else:\n        use_model_func = global_config[\"llm_model_func\"]\n        # Apply higher priority (5) to query relation LLM function\n        use_model_func = partial(use_model_func, _priority=5)\n\n    result = await use_model_func(kw_prompt, keyword_extraction=True)\n\n    # 5. Parse out JSON from the LLM response\n    result = remove_think_tags(result)\n    try:\n        keywords_data = json_repair.loads(result)\n        if not keywords_data:\n            logger.error(\"No JSON-like structure found in the LLM respond.\")\n            return [], []\n    except json.JSONDecodeError as e:\n        logger.error(f\"JSON parsing error: {e}\")\n        logger.error(f\"LLM respond: {result}\")\n        return [], []\n\n    hl_keywords = keywords_data.get(\"high_level_keywords\", [])\n    ll_keywords = keywords_data.get(\"low_level_keywords\", [])\n\n    # 6. Cache only the processed keywords with cache type\n    if hl_keywords or ll_keywords:\n        cache_data = {\n            \"high_level_keywords\": hl_keywords,\n            \"low_level_keywords\": ll_keywords,\n        }\n        if hashing_kv.global_config.get(\"enable_llm_cache\"):\n            # Save to cache with query parameters\n            queryparam_dict = {\n                \"mode\": param.mode,\n                \"response_type\": param.response_type,\n                \"top_k\": param.top_k,\n                \"chunk_top_k\": param.chunk_top_k,\n                \"max_entity_tokens\": param.max_entity_tokens,\n                \"max_relation_tokens\": param.max_relation_tokens,\n                \"max_total_tokens\": param.max_total_tokens,\n                \"user_prompt\": param.user_prompt or \"\",\n                \"enable_rerank\": param.enable_rerank,\n            }\n            await save_to_cache(\n                hashing_kv,\n                CacheData(\n                    args_hash=args_hash,\n                    content=json.dumps(cache_data),\n                    prompt=text,\n                    mode=param.mode,\n                    cache_type=\"keywords\",\n                    queryparam=queryparam_dict,\n                ),\n            )\n\n    return hl_keywords, ll_keywords\n\n\nasync def _get_vector_context(\n    query: str,\n    chunks_vdb: BaseVectorStorage,\n    query_param: QueryParam,\n    query_embedding: list[float] = None,\n) -> list[dict]:\n    \"\"\"\n    Retrieve text chunks from the vector database without reranking or truncation.\n\n    This function performs vector search to find relevant text chunks for a query.\n    Reranking and truncation will be handled later in the unified processing.\n\n    Args:\n        query: The query string to search for\n        chunks_vdb: Vector database containing document chunks\n        query_param: Query parameters including chunk_top_k and ids\n        query_embedding: Optional pre-computed query embedding to avoid redundant embedding calls\n\n    Returns:\n        List of text chunks with metadata\n    \"\"\"\n    try:\n        # Use chunk_top_k if specified, otherwise fall back to top_k\n        search_top_k = query_param.chunk_top_k or query_param.top_k\n        cosine_threshold = chunks_vdb.cosine_better_than_threshold\n\n        results = await chunks_vdb.query(\n            query, top_k=search_top_k, query_embedding=query_embedding\n        )\n        if not results:\n            logger.info(\n                f\"Naive query: 0 chunks (chunk_top_k:{search_top_k} cosine:{cosine_threshold})\"\n            )\n            return []\n\n        valid_chunks = []\n        for result in results:\n            if \"content\" in result:\n                chunk_with_metadata = {\n                    \"content\": result[\"content\"],\n                    \"created_at\": result.get(\"created_at\", None),\n                    \"file_path\": result.get(\"file_path\", \"unknown_source\"),\n                    \"source_type\": \"vector\",  # Mark the source type\n                    \"chunk_id\": result.get(\"id\"),  # Add chunk_id for deduplication\n                }\n                valid_chunks.append(chunk_with_metadata)\n\n        logger.info(\n            f\"Naive query: {len(valid_chunks)} chunks (chunk_top_k:{search_top_k} cosine:{cosine_threshold})\"\n        )\n        return valid_chunks\n\n    except Exception as e:\n        logger.error(f\"Error in _get_vector_context: {e}\")\n        return []\n\n\nasync def _perform_kg_search(\n    query: str,\n    ll_keywords: str,\n    hl_keywords: str,\n    knowledge_graph_inst: BaseGraphStorage,\n    entities_vdb: BaseVectorStorage,\n    relationships_vdb: BaseVectorStorage,\n    text_chunks_db: BaseKVStorage,\n    query_param: QueryParam,\n    chunks_vdb: BaseVectorStorage = None,\n) -> dict[str, Any]:\n    \"\"\"\n    Pure search logic that retrieves raw entities, relations, and vector chunks.\n    No token truncation or formatting - just raw search results.\n    \"\"\"\n\n    # Initialize result containers\n    local_entities = []\n    local_relations = []\n    global_entities = []\n    global_relations = []\n    vector_chunks = []\n    chunk_tracking = {}\n\n    # Handle different query modes\n\n    # Track chunk sources and metadata for final logging\n    chunk_tracking = {}  # chunk_id -> {source, frequency, order}\n\n    # Pre-compute embeddings needed by the selected mode in a single batch call.\n    # Only embed texts that the active retrieval branches will actually use:\n    #   - query        → used by _get_vector_context (chunks VDB)\n    #   - ll_keywords  → used by _get_node_data (entities VDB) in local/hybrid/mix\n    #   - hl_keywords  → used by _get_edge_data (relationships VDB) in global/hybrid/mix\n    # Batching avoids 2-3 sequential API round-trips.\n    kg_chunk_pick_method = text_chunks_db.global_config.get(\n        \"kg_chunk_pick_method\", DEFAULT_KG_CHUNK_PICK_METHOD\n    )\n\n    actual_embedding_func = text_chunks_db.embedding_func\n    query_embedding = None\n    ll_embedding = None\n    hl_embedding = None\n\n    mode = query_param.mode\n    need_ll = mode in (\"local\", \"hybrid\", \"mix\") and bool(ll_keywords)\n    need_hl = mode in (\"global\", \"hybrid\", \"mix\") and bool(hl_keywords)\n\n    if actual_embedding_func:\n        texts_to_embed: list[str] = []\n        text_purposes: list[str] = []\n\n        if query and (kg_chunk_pick_method == \"VECTOR\" or chunks_vdb):\n            texts_to_embed.append(query)\n            text_purposes.append(\"query\")\n\n        if need_ll:\n            texts_to_embed.append(ll_keywords)\n            text_purposes.append(\"ll\")\n\n        if need_hl:\n            texts_to_embed.append(hl_keywords)\n            text_purposes.append(\"hl\")\n\n        if texts_to_embed:\n            try:\n                all_embeddings = await actual_embedding_func(\n                    texts_to_embed, _priority=5\n                )\n                for i, purpose in enumerate(text_purposes):\n                    if purpose == \"query\":\n                        query_embedding = all_embeddings[i]\n                    elif purpose == \"ll\":\n                        ll_embedding = all_embeddings[i]\n                    elif purpose == \"hl\":\n                        hl_embedding = all_embeddings[i]\n                logger.debug(\n                    \"Pre-computed %d embeddings in single batch (purposes: %s)\",\n                    len(texts_to_embed),\n                    \", \".join(text_purposes),\n                )\n            except Exception as e:\n                logger.warning(f\"Failed to batch pre-compute embeddings: {e}\")\n\n    # Handle local and global modes\n    if query_param.mode == \"local\" and len(ll_keywords) > 0:\n        local_entities, local_relations = await _get_node_data(\n            ll_keywords,\n            knowledge_graph_inst,\n            entities_vdb,\n            query_param,\n            query_embedding=ll_embedding,\n        )\n\n    elif query_param.mode == \"global\" and len(hl_keywords) > 0:\n        global_relations, global_entities = await _get_edge_data(\n            hl_keywords,\n            knowledge_graph_inst,\n            relationships_vdb,\n            query_param,\n            query_embedding=hl_embedding,\n        )\n\n    else:  # hybrid or mix mode\n        if len(ll_keywords) > 0:\n            local_entities, local_relations = await _get_node_data(\n                ll_keywords,\n                knowledge_graph_inst,\n                entities_vdb,\n                query_param,\n                query_embedding=ll_embedding,\n            )\n        if len(hl_keywords) > 0:\n            global_relations, global_entities = await _get_edge_data(\n                hl_keywords,\n                knowledge_graph_inst,\n                relationships_vdb,\n                query_param,\n                query_embedding=hl_embedding,\n            )\n\n        # Get vector chunks for mix mode\n        if query_param.mode == \"mix\" and chunks_vdb:\n            vector_chunks = await _get_vector_context(\n                query,\n                chunks_vdb,\n                query_param,\n                query_embedding,\n            )\n            # Track vector chunks with source metadata\n            for i, chunk in enumerate(vector_chunks):\n                chunk_id = chunk.get(\"chunk_id\") or chunk.get(\"id\")\n                if chunk_id:\n                    chunk_tracking[chunk_id] = {\n                        \"source\": \"C\",\n                        \"frequency\": 1,  # Vector chunks always have frequency 1\n                        \"order\": i + 1,  # 1-based order in vector search results\n                    }\n                else:\n                    logger.warning(f\"Vector chunk missing chunk_id: {chunk}\")\n\n    # Round-robin merge entities\n    final_entities = []\n    seen_entities = set()\n    max_len = max(len(local_entities), len(global_entities))\n    for i in range(max_len):\n        # First from local\n        if i < len(local_entities):\n            entity = local_entities[i]\n            entity_name = entity.get(\"entity_name\")\n            if entity_name and entity_name not in seen_entities:\n                final_entities.append(entity)\n                seen_entities.add(entity_name)\n\n        # Then from global\n        if i < len(global_entities):\n            entity = global_entities[i]\n            entity_name = entity.get(\"entity_name\")\n            if entity_name and entity_name not in seen_entities:\n                final_entities.append(entity)\n                seen_entities.add(entity_name)\n\n    # Round-robin merge relations\n    final_relations = []\n    seen_relations = set()\n    max_len = max(len(local_relations), len(global_relations))\n    for i in range(max_len):\n        # First from local\n        if i < len(local_relations):\n            relation = local_relations[i]\n            # Build relation unique identifier\n            if \"src_tgt\" in relation:\n                rel_key = tuple(sorted(relation[\"src_tgt\"]))\n            else:\n                rel_key = tuple(\n                    sorted([relation.get(\"src_id\"), relation.get(\"tgt_id\")])\n                )\n\n            if rel_key not in seen_relations:\n                final_relations.append(relation)\n                seen_relations.add(rel_key)\n\n        # Then from global\n        if i < len(global_relations):\n            relation = global_relations[i]\n            # Build relation unique identifier\n            if \"src_tgt\" in relation:\n                rel_key = tuple(sorted(relation[\"src_tgt\"]))\n            else:\n                rel_key = tuple(\n                    sorted([relation.get(\"src_id\"), relation.get(\"tgt_id\")])\n                )\n\n            if rel_key not in seen_relations:\n                final_relations.append(relation)\n                seen_relations.add(rel_key)\n\n    logger.info(\n        f\"Raw search results: {len(final_entities)} entities, {len(final_relations)} relations, {len(vector_chunks)} vector chunks\"\n    )\n\n    return {\n        \"final_entities\": final_entities,\n        \"final_relations\": final_relations,\n        \"vector_chunks\": vector_chunks,\n        \"chunk_tracking\": chunk_tracking,\n        \"query_embedding\": query_embedding,\n    }\n\n\nasync def _apply_token_truncation(\n    search_result: dict[str, Any],\n    query_param: QueryParam,\n    global_config: dict[str, str],\n) -> dict[str, Any]:\n    \"\"\"\n    Apply token-based truncation to entities and relations for LLM efficiency.\n    \"\"\"\n    tokenizer = global_config.get(\"tokenizer\")\n    if not tokenizer:\n        logger.warning(\"No tokenizer found, skipping truncation\")\n        return {\n            \"entities_context\": [],\n            \"relations_context\": [],\n            \"filtered_entities\": search_result[\"final_entities\"],\n            \"filtered_relations\": search_result[\"final_relations\"],\n            \"entity_id_to_original\": {},\n            \"relation_id_to_original\": {},\n        }\n\n    # Get token limits from query_param with fallbacks\n    max_entity_tokens = getattr(\n        query_param,\n        \"max_entity_tokens\",\n        global_config.get(\"max_entity_tokens\", DEFAULT_MAX_ENTITY_TOKENS),\n    )\n    max_relation_tokens = getattr(\n        query_param,\n        \"max_relation_tokens\",\n        global_config.get(\"max_relation_tokens\", DEFAULT_MAX_RELATION_TOKENS),\n    )\n\n    final_entities = search_result[\"final_entities\"]\n    final_relations = search_result[\"final_relations\"]\n\n    # Create mappings from entity/relation identifiers to original data\n    entity_id_to_original = {}\n    relation_id_to_original = {}\n\n    # Generate entities context for truncation\n    entities_context = []\n    for i, entity in enumerate(final_entities):\n        entity_name = entity[\"entity_name\"]\n        created_at = entity.get(\"created_at\", \"UNKNOWN\")\n        if isinstance(created_at, (int, float)):\n            created_at = time.strftime(\"%Y-%m-%d %H:%M:%S\", time.localtime(created_at))\n\n        # Store mapping from entity name to original data\n        entity_id_to_original[entity_name] = entity\n\n        entities_context.append(\n            {\n                \"entity\": entity_name,\n                \"type\": entity.get(\"entity_type\", \"UNKNOWN\"),\n                \"description\": entity.get(\"description\", \"UNKNOWN\"),\n                \"created_at\": created_at,\n                \"file_path\": entity.get(\"file_path\", \"unknown_source\"),\n            }\n        )\n\n    # Generate relations context for truncation\n    relations_context = []\n    for i, relation in enumerate(final_relations):\n        created_at = relation.get(\"created_at\", \"UNKNOWN\")\n        if isinstance(created_at, (int, float)):\n            created_at = time.strftime(\"%Y-%m-%d %H:%M:%S\", time.localtime(created_at))\n\n        # Handle different relation data formats\n        if \"src_tgt\" in relation:\n            entity1, entity2 = relation[\"src_tgt\"]\n        else:\n            entity1, entity2 = relation.get(\"src_id\"), relation.get(\"tgt_id\")\n\n        # Store mapping from relation pair to original data\n        relation_key = (entity1, entity2)\n        relation_id_to_original[relation_key] = relation\n\n        relations_context.append(\n            {\n                \"entity1\": entity1,\n                \"entity2\": entity2,\n                \"description\": relation.get(\"description\", \"UNKNOWN\"),\n                \"created_at\": created_at,\n                \"file_path\": relation.get(\"file_path\", \"unknown_source\"),\n            }\n        )\n\n    logger.debug(\n        f\"Before truncation: {len(entities_context)} entities, {len(relations_context)} relations\"\n    )\n\n    # Apply token-based truncation\n    if entities_context:\n        # Remove file_path and created_at for token calculation\n        entities_context_for_truncation = []\n        for entity in entities_context:\n            entity_copy = entity.copy()\n            entity_copy.pop(\"file_path\", None)\n            entity_copy.pop(\"created_at\", None)\n            entities_context_for_truncation.append(entity_copy)\n\n        entities_context = truncate_list_by_token_size(\n            entities_context_for_truncation,\n            key=lambda x: \"\\n\".join(\n                json.dumps(item, ensure_ascii=False) for item in [x]\n            ),\n            max_token_size=max_entity_tokens,\n            tokenizer=tokenizer,\n        )\n\n    if relations_context:\n        # Remove file_path and created_at for token calculation\n        relations_context_for_truncation = []\n        for relation in relations_context:\n            relation_copy = relation.copy()\n            relation_copy.pop(\"file_path\", None)\n            relation_copy.pop(\"created_at\", None)\n            relations_context_for_truncation.append(relation_copy)\n\n        relations_context = truncate_list_by_token_size(\n            relations_context_for_truncation,\n            key=lambda x: \"\\n\".join(\n                json.dumps(item, ensure_ascii=False) for item in [x]\n            ),\n            max_token_size=max_relation_tokens,\n            tokenizer=tokenizer,\n        )\n\n    logger.info(\n        f\"After truncation: {len(entities_context)} entities, {len(relations_context)} relations\"\n    )\n\n    # Create filtered original data based on truncated context\n    filtered_entities = []\n    filtered_entity_id_to_original = {}\n    if entities_context:\n        final_entity_names = {e[\"entity\"] for e in entities_context}\n        seen_nodes = set()\n        for entity in final_entities:\n            name = entity.get(\"entity_name\")\n            if name in final_entity_names and name not in seen_nodes:\n                filtered_entities.append(entity)\n                filtered_entity_id_to_original[name] = entity\n                seen_nodes.add(name)\n\n    filtered_relations = []\n    filtered_relation_id_to_original = {}\n    if relations_context:\n        final_relation_pairs = {(r[\"entity1\"], r[\"entity2\"]) for r in relations_context}\n        seen_edges = set()\n        for relation in final_relations:\n            src, tgt = relation.get(\"src_id\"), relation.get(\"tgt_id\")\n            if src is None or tgt is None:\n                src, tgt = relation.get(\"src_tgt\", (None, None))\n\n            pair = (src, tgt)\n            if pair in final_relation_pairs and pair not in seen_edges:\n                filtered_relations.append(relation)\n                filtered_relation_id_to_original[pair] = relation\n                seen_edges.add(pair)\n\n    return {\n        \"entities_context\": entities_context,\n        \"relations_context\": relations_context,\n        \"filtered_entities\": filtered_entities,\n        \"filtered_relations\": filtered_relations,\n        \"entity_id_to_original\": filtered_entity_id_to_original,\n        \"relation_id_to_original\": filtered_relation_id_to_original,\n    }\n\n\nasync def _merge_all_chunks(\n    filtered_entities: list[dict],\n    filtered_relations: list[dict],\n    vector_chunks: list[dict],\n    query: str = \"\",\n    knowledge_graph_inst: BaseGraphStorage = None,\n    text_chunks_db: BaseKVStorage = None,\n    query_param: QueryParam = None,\n    chunks_vdb: BaseVectorStorage = None,\n    chunk_tracking: dict = None,\n    query_embedding: list[float] = None,\n) -> list[dict]:\n    \"\"\"\n    Merge chunks from different sources: vector_chunks + entity_chunks + relation_chunks.\n    \"\"\"\n    if chunk_tracking is None:\n        chunk_tracking = {}\n\n    # Get chunks from entities\n    entity_chunks = []\n    if filtered_entities and text_chunks_db:\n        entity_chunks = await _find_related_text_unit_from_entities(\n            filtered_entities,\n            query_param,\n            text_chunks_db,\n            knowledge_graph_inst,\n            query,\n            chunks_vdb,\n            chunk_tracking=chunk_tracking,\n            query_embedding=query_embedding,\n        )\n\n    # Get chunks from relations\n    relation_chunks = []\n    if filtered_relations and text_chunks_db:\n        relation_chunks = await _find_related_text_unit_from_relations(\n            filtered_relations,\n            query_param,\n            text_chunks_db,\n            entity_chunks,  # For deduplication\n            query,\n            chunks_vdb,\n            chunk_tracking=chunk_tracking,\n            query_embedding=query_embedding,\n        )\n\n    # Round-robin merge chunks from different sources with deduplication\n    merged_chunks = []\n    seen_chunk_ids = set()\n    max_len = max(len(vector_chunks), len(entity_chunks), len(relation_chunks))\n    origin_len = len(vector_chunks) + len(entity_chunks) + len(relation_chunks)\n\n    for i in range(max_len):\n        # Add from vector chunks first (Naive mode)\n        if i < len(vector_chunks):\n            chunk = vector_chunks[i]\n            chunk_id = chunk.get(\"chunk_id\") or chunk.get(\"id\")\n            if chunk_id and chunk_id not in seen_chunk_ids:\n                seen_chunk_ids.add(chunk_id)\n                merged_chunks.append(\n                    {\n                        \"content\": chunk[\"content\"],\n                        \"file_path\": chunk.get(\"file_path\", \"unknown_source\"),\n                        \"chunk_id\": chunk_id,\n                    }\n                )\n\n        # Add from entity chunks (Local mode)\n        if i < len(entity_chunks):\n            chunk = entity_chunks[i]\n            chunk_id = chunk.get(\"chunk_id\") or chunk.get(\"id\")\n            if chunk_id and chunk_id not in seen_chunk_ids:\n                seen_chunk_ids.add(chunk_id)\n                merged_chunks.append(\n                    {\n                        \"content\": chunk[\"content\"],\n                        \"file_path\": chunk.get(\"file_path\", \"unknown_source\"),\n                        \"chunk_id\": chunk_id,\n                    }\n                )\n\n        # Add from relation chunks (Global mode)\n        if i < len(relation_chunks):\n            chunk = relation_chunks[i]\n            chunk_id = chunk.get(\"chunk_id\") or chunk.get(\"id\")\n            if chunk_id and chunk_id not in seen_chunk_ids:\n                seen_chunk_ids.add(chunk_id)\n                merged_chunks.append(\n                    {\n                        \"content\": chunk[\"content\"],\n                        \"file_path\": chunk.get(\"file_path\", \"unknown_source\"),\n                        \"chunk_id\": chunk_id,\n                    }\n                )\n\n    logger.info(\n        f\"Round-robin merged chunks: {origin_len} -> {len(merged_chunks)} (deduplicated {origin_len - len(merged_chunks)})\"\n    )\n\n    return merged_chunks\n\n\nasync def _build_context_str(\n    entities_context: list[dict],\n    relations_context: list[dict],\n    merged_chunks: list[dict],\n    query: str,\n    query_param: QueryParam,\n    global_config: dict[str, str],\n    chunk_tracking: dict = None,\n    entity_id_to_original: dict = None,\n    relation_id_to_original: dict = None,\n) -> tuple[str, dict[str, Any]]:\n    \"\"\"\n    Build the final LLM context string with token processing.\n    This includes dynamic token calculation and final chunk truncation.\n    \"\"\"\n    tokenizer = global_config.get(\"tokenizer\")\n    if not tokenizer:\n        logger.error(\"Missing tokenizer, cannot build LLM context\")\n        # Return empty raw data structure when no tokenizer\n        empty_raw_data = convert_to_user_format(\n            [],\n            [],\n            [],\n            [],\n            query_param.mode,\n        )\n        empty_raw_data[\"status\"] = \"failure\"\n        empty_raw_data[\"message\"] = \"Missing tokenizer, cannot build LLM context.\"\n        return \"\", empty_raw_data\n\n    # Get token limits\n    max_total_tokens = getattr(\n        query_param,\n        \"max_total_tokens\",\n        global_config.get(\"max_total_tokens\", DEFAULT_MAX_TOTAL_TOKENS),\n    )\n\n    # Get the system prompt template from PROMPTS or global_config\n    sys_prompt_template = global_config.get(\n        \"system_prompt_template\", PROMPTS[\"rag_response\"]\n    )\n\n    kg_context_template = PROMPTS[\"kg_query_context\"]\n    user_prompt = query_param.user_prompt if query_param.user_prompt else \"\"\n    response_type = (\n        query_param.response_type\n        if query_param.response_type\n        else \"Multiple Paragraphs\"\n    )\n\n    entities_str = \"\\n\".join(\n        json.dumps(entity, ensure_ascii=False) for entity in entities_context\n    )\n    relations_str = \"\\n\".join(\n        json.dumps(relation, ensure_ascii=False) for relation in relations_context\n    )\n\n    # Calculate preliminary kg context tokens\n    pre_kg_context = kg_context_template.format(\n        entities_str=entities_str,\n        relations_str=relations_str,\n        text_chunks_str=\"\",\n        reference_list_str=\"\",\n    )\n    kg_context_tokens = len(tokenizer.encode(pre_kg_context))\n\n    # Calculate preliminary system prompt tokens\n    pre_sys_prompt = sys_prompt_template.format(\n        context_data=\"\",  # Empty for overhead calculation\n        response_type=response_type,\n        user_prompt=user_prompt,\n    )\n    sys_prompt_tokens = len(tokenizer.encode(pre_sys_prompt))\n\n    # Calculate available tokens for text chunks\n    query_tokens = len(tokenizer.encode(query))\n    buffer_tokens = 200  # reserved for reference list and safety buffer\n    available_chunk_tokens = max_total_tokens - (\n        sys_prompt_tokens + kg_context_tokens + query_tokens + buffer_tokens\n    )\n\n    logger.debug(\n        f\"Token allocation - Total: {max_total_tokens}, SysPrompt: {sys_prompt_tokens}, Query: {query_tokens}, KG: {kg_context_tokens}, Buffer: {buffer_tokens}, Available for chunks: {available_chunk_tokens}\"\n    )\n\n    # Apply token truncation to chunks using the dynamic limit\n    truncated_chunks = await process_chunks_unified(\n        query=query,\n        unique_chunks=merged_chunks,\n        query_param=query_param,\n        global_config=global_config,\n        source_type=query_param.mode,\n        chunk_token_limit=available_chunk_tokens,  # Pass dynamic limit\n    )\n\n    # Generate reference list from truncated chunks using the new common function\n    reference_list, truncated_chunks = generate_reference_list_from_chunks(\n        truncated_chunks\n    )\n\n    # Rebuild chunks_context with truncated chunks\n    # The actual tokens may be slightly less than available_chunk_tokens due to deduplication logic\n    chunks_context = []\n    for i, chunk in enumerate(truncated_chunks):\n        chunks_context.append(\n            {\n                \"reference_id\": chunk[\"reference_id\"],\n                \"content\": chunk[\"content\"],\n            }\n        )\n\n    text_units_str = \"\\n\".join(\n        json.dumps(text_unit, ensure_ascii=False) for text_unit in chunks_context\n    )\n    reference_list_str = \"\\n\".join(\n        f\"[{ref['reference_id']}] {ref['file_path']}\"\n        for ref in reference_list\n        if ref[\"reference_id\"]\n    )\n\n    logger.info(\n        f\"Final context: {len(entities_context)} entities, {len(relations_context)} relations, {len(chunks_context)} chunks\"\n    )\n\n    # not necessary to use LLM to generate a response\n    if not entities_context and not relations_context and not chunks_context:\n        # Return empty raw data structure when no entities/relations\n        empty_raw_data = convert_to_user_format(\n            [],\n            [],\n            [],\n            [],\n            query_param.mode,\n        )\n        empty_raw_data[\"status\"] = \"failure\"\n        empty_raw_data[\"message\"] = \"Query returned empty dataset.\"\n        return \"\", empty_raw_data\n\n    # output chunks tracking infomations\n    # format: <source><frequency>/<order> (e.g., E5/2 R2/1 C1/1)\n    if truncated_chunks and chunk_tracking:\n        chunk_tracking_log = []\n        for chunk in truncated_chunks:\n            chunk_id = chunk.get(\"chunk_id\")\n            if chunk_id and chunk_id in chunk_tracking:\n                tracking_info = chunk_tracking[chunk_id]\n                source = tracking_info[\"source\"]\n                frequency = tracking_info[\"frequency\"]\n                order = tracking_info[\"order\"]\n                chunk_tracking_log.append(f\"{source}{frequency}/{order}\")\n            else:\n                chunk_tracking_log.append(\"?0/0\")\n\n        if chunk_tracking_log:\n            logger.info(f\"Final chunks S+F/O: {' '.join(chunk_tracking_log)}\")\n\n    result = kg_context_template.format(\n        entities_str=entities_str,\n        relations_str=relations_str,\n        text_chunks_str=text_units_str,\n        reference_list_str=reference_list_str,\n    )\n\n    # Always return both context and complete data structure (unified approach)\n    logger.debug(\n        f\"[_build_context_str] Converting to user format: {len(entities_context)} entities, {len(relations_context)} relations, {len(truncated_chunks)} chunks\"\n    )\n    final_data = convert_to_user_format(\n        entities_context,\n        relations_context,\n        truncated_chunks,\n        reference_list,\n        query_param.mode,\n        entity_id_to_original,\n        relation_id_to_original,\n    )\n    logger.debug(\n        f\"[_build_context_str] Final data after conversion: {len(final_data.get('entities', []))} entities, {len(final_data.get('relationships', []))} relationships, {len(final_data.get('chunks', []))} chunks\"\n    )\n    return result, final_data\n\n\n# Now let's update the old _build_query_context to use the new architecture\nasync def _build_query_context(\n    query: str,\n    ll_keywords: str,\n    hl_keywords: str,\n    knowledge_graph_inst: BaseGraphStorage,\n    entities_vdb: BaseVectorStorage,\n    relationships_vdb: BaseVectorStorage,\n    text_chunks_db: BaseKVStorage,\n    query_param: QueryParam,\n    chunks_vdb: BaseVectorStorage = None,\n) -> QueryContextResult | None:\n    \"\"\"\n    Main query context building function using the new 4-stage architecture:\n    1. Search -> 2. Truncate -> 3. Merge chunks -> 4. Build LLM context\n\n    Returns unified QueryContextResult containing both context and raw_data.\n    \"\"\"\n\n    if not query:\n        logger.warning(\"Query is empty, skipping context building\")\n        return None\n\n    # Stage 1: Pure search\n    search_result = await _perform_kg_search(\n        query,\n        ll_keywords,\n        hl_keywords,\n        knowledge_graph_inst,\n        entities_vdb,\n        relationships_vdb,\n        text_chunks_db,\n        query_param,\n        chunks_vdb,\n    )\n\n    if not search_result[\"final_entities\"] and not search_result[\"final_relations\"]:\n        if query_param.mode != \"mix\":\n            return None\n        else:\n            if not search_result[\"chunk_tracking\"]:\n                return None\n\n    # Stage 2: Apply token truncation for LLM efficiency\n    truncation_result = await _apply_token_truncation(\n        search_result,\n        query_param,\n        text_chunks_db.global_config,\n    )\n\n    # Stage 3: Merge chunks using filtered entities/relations\n    merged_chunks = await _merge_all_chunks(\n        filtered_entities=truncation_result[\"filtered_entities\"],\n        filtered_relations=truncation_result[\"filtered_relations\"],\n        vector_chunks=search_result[\"vector_chunks\"],\n        query=query,\n        knowledge_graph_inst=knowledge_graph_inst,\n        text_chunks_db=text_chunks_db,\n        query_param=query_param,\n        chunks_vdb=chunks_vdb,\n        chunk_tracking=search_result[\"chunk_tracking\"],\n        query_embedding=search_result[\"query_embedding\"],\n    )\n\n    if (\n        not merged_chunks\n        and not truncation_result[\"entities_context\"]\n        and not truncation_result[\"relations_context\"]\n    ):\n        return None\n\n    # Stage 4: Build final LLM context with dynamic token processing\n    # _build_context_str now always returns tuple[str, dict]\n    context, raw_data = await _build_context_str(\n        entities_context=truncation_result[\"entities_context\"],\n        relations_context=truncation_result[\"relations_context\"],\n        merged_chunks=merged_chunks,\n        query=query,\n        query_param=query_param,\n        global_config=text_chunks_db.global_config,\n        chunk_tracking=search_result[\"chunk_tracking\"],\n        entity_id_to_original=truncation_result[\"entity_id_to_original\"],\n        relation_id_to_original=truncation_result[\"relation_id_to_original\"],\n    )\n\n    # Convert keywords strings to lists and add complete metadata to raw_data\n    hl_keywords_list = hl_keywords.split(\", \") if hl_keywords else []\n    ll_keywords_list = ll_keywords.split(\", \") if ll_keywords else []\n\n    # Add complete metadata to raw_data (preserve existing metadata including query_mode)\n    if \"metadata\" not in raw_data:\n        raw_data[\"metadata\"] = {}\n\n    # Update keywords while preserving existing metadata\n    raw_data[\"metadata\"][\"keywords\"] = {\n        \"high_level\": hl_keywords_list,\n        \"low_level\": ll_keywords_list,\n    }\n    raw_data[\"metadata\"][\"processing_info\"] = {\n        \"total_entities_found\": len(search_result.get(\"final_entities\", [])),\n        \"total_relations_found\": len(search_result.get(\"final_relations\", [])),\n        \"entities_after_truncation\": len(\n            truncation_result.get(\"filtered_entities\", [])\n        ),\n        \"relations_after_truncation\": len(\n            truncation_result.get(\"filtered_relations\", [])\n        ),\n        \"merged_chunks_count\": len(merged_chunks),\n        \"final_chunks_count\": len(raw_data.get(\"data\", {}).get(\"chunks\", [])),\n    }\n\n    logger.debug(\n        f\"[_build_query_context] Context length: {len(context) if context else 0}\"\n    )\n    logger.debug(\n        f\"[_build_query_context] Raw data entities: {len(raw_data.get('data', {}).get('entities', []))}, relationships: {len(raw_data.get('data', {}).get('relationships', []))}, chunks: {len(raw_data.get('data', {}).get('chunks', []))}\"\n    )\n\n    return QueryContextResult(context=context, raw_data=raw_data)\n\n\nasync def _get_node_data(\n    query: str,\n    knowledge_graph_inst: BaseGraphStorage,\n    entities_vdb: BaseVectorStorage,\n    query_param: QueryParam,\n    query_embedding=None,\n):\n    logger.info(\n        f\"Query nodes: {query} (top_k:{query_param.top_k}, cosine:{entities_vdb.cosine_better_than_threshold})\"\n    )\n\n    results = await entities_vdb.query(\n        query, top_k=query_param.top_k, query_embedding=query_embedding\n    )\n\n    if not len(results):\n        return [], []\n\n    # Extract all entity IDs from your results list\n    node_ids = [r[\"entity_name\"] for r in results]\n\n    # Call the batch node retrieval and degree functions concurrently.\n    nodes_dict, degrees_dict = await asyncio.gather(\n        knowledge_graph_inst.get_nodes_batch(node_ids),\n        knowledge_graph_inst.node_degrees_batch(node_ids),\n    )\n\n    # Now, if you need the node data and degree in order:\n    node_datas = [nodes_dict.get(nid) for nid in node_ids]\n    node_degrees = [degrees_dict.get(nid, 0) for nid in node_ids]\n\n    if not all([n is not None for n in node_datas]):\n        logger.warning(\"Some nodes are missing, maybe the storage is damaged\")\n\n    node_datas = [\n        {\n            **n,\n            \"entity_name\": k[\"entity_name\"],\n            \"rank\": d,\n            \"created_at\": k.get(\"created_at\"),\n        }\n        for k, n, d in zip(results, node_datas, node_degrees)\n        if n is not None\n    ]\n\n    use_relations = await _find_most_related_edges_from_entities(\n        node_datas,\n        query_param,\n        knowledge_graph_inst,\n    )\n\n    logger.info(\n        f\"Local query: {len(node_datas)} entites, {len(use_relations)} relations\"\n    )\n\n    # Entities are sorted by cosine similarity\n    # Relations are sorted by rank + weight\n    return node_datas, use_relations\n\n\nasync def _find_most_related_edges_from_entities(\n    node_datas: list[dict],\n    query_param: QueryParam,\n    knowledge_graph_inst: BaseGraphStorage,\n):\n    node_names = [dp[\"entity_name\"] for dp in node_datas]\n    batch_edges_dict = await knowledge_graph_inst.get_nodes_edges_batch(node_names)\n\n    all_edges = []\n    seen = set()\n\n    for node_name in node_names:\n        this_edges = batch_edges_dict.get(node_name, [])\n        for e in this_edges:\n            sorted_edge = tuple(sorted(e))\n            if sorted_edge not in seen:\n                seen.add(sorted_edge)\n                all_edges.append(sorted_edge)\n\n    # Prepare edge pairs in two forms:\n    # For the batch edge properties function, use dicts.\n    edge_pairs_dicts = [{\"src\": e[0], \"tgt\": e[1]} for e in all_edges]\n    # For edge degrees, use tuples.\n    edge_pairs_tuples = list(all_edges)  # all_edges is already a list of tuples\n\n    # Call the batched functions concurrently.\n    edge_data_dict, edge_degrees_dict = await asyncio.gather(\n        knowledge_graph_inst.get_edges_batch(edge_pairs_dicts),\n        knowledge_graph_inst.edge_degrees_batch(edge_pairs_tuples),\n    )\n\n    # Reconstruct edge_datas list in the same order as the deduplicated results.\n    all_edges_data = []\n    for pair in all_edges:\n        edge_props = edge_data_dict.get(pair)\n        if edge_props is not None:\n            if \"weight\" not in edge_props:\n                logger.warning(\n                    f\"Edge {pair} missing 'weight' attribute, using default value 1.0\"\n                )\n                edge_props[\"weight\"] = 1.0\n\n            combined = {\n                \"src_tgt\": pair,\n                \"rank\": edge_degrees_dict.get(pair, 0),\n                **edge_props,\n            }\n            all_edges_data.append(combined)\n\n    all_edges_data = sorted(\n        all_edges_data, key=lambda x: (x[\"rank\"], x[\"weight\"]), reverse=True\n    )\n\n    return all_edges_data\n\n\nasync def _find_related_text_unit_from_entities(\n    node_datas: list[dict],\n    query_param: QueryParam,\n    text_chunks_db: BaseKVStorage,\n    knowledge_graph_inst: BaseGraphStorage,\n    query: str = None,\n    chunks_vdb: BaseVectorStorage = None,\n    chunk_tracking: dict = None,\n    query_embedding=None,\n):\n    \"\"\"\n    Find text chunks related to entities using configurable chunk selection method.\n\n    This function supports two chunk selection strategies:\n    1. WEIGHT: Linear gradient weighted polling based on chunk occurrence count\n    2. VECTOR: Vector similarity-based selection using embedding cosine similarity\n    \"\"\"\n    logger.debug(f\"Finding text chunks from {len(node_datas)} entities\")\n\n    if not node_datas:\n        return []\n\n    # Step 1: Collect all text chunks for each entity\n    entities_with_chunks = []\n    for entity in node_datas:\n        if entity.get(\"source_id\"):\n            chunks = split_string_by_multi_markers(\n                entity[\"source_id\"], [GRAPH_FIELD_SEP]\n            )\n            if chunks:\n                entities_with_chunks.append(\n                    {\n                        \"entity_name\": entity[\"entity_name\"],\n                        \"chunks\": chunks,\n                        \"entity_data\": entity,\n                    }\n                )\n\n    if not entities_with_chunks:\n        logger.warning(\"No entities with text chunks found\")\n        return []\n\n    kg_chunk_pick_method = text_chunks_db.global_config.get(\n        \"kg_chunk_pick_method\", DEFAULT_KG_CHUNK_PICK_METHOD\n    )\n    max_related_chunks = text_chunks_db.global_config.get(\n        \"related_chunk_number\", DEFAULT_RELATED_CHUNK_NUMBER\n    )\n\n    # Step 2: Count chunk occurrences and deduplicate (keep chunks from earlier positioned entities)\n    chunk_occurrence_count = {}\n    for entity_info in entities_with_chunks:\n        deduplicated_chunks = []\n        for chunk_id in entity_info[\"chunks\"]:\n            chunk_occurrence_count[chunk_id] = (\n                chunk_occurrence_count.get(chunk_id, 0) + 1\n            )\n\n            # If this is the first occurrence (count == 1), keep it; otherwise skip (duplicate from later position)\n            if chunk_occurrence_count[chunk_id] == 1:\n                deduplicated_chunks.append(chunk_id)\n            # count > 1 means this chunk appeared in an earlier entity, so skip it\n\n        # Update entity's chunks to deduplicated chunks\n        entity_info[\"chunks\"] = deduplicated_chunks\n\n    # Step 3: Sort chunks for each entity by occurrence count (higher count = higher priority)\n    total_entity_chunks = 0\n    for entity_info in entities_with_chunks:\n        sorted_chunks = sorted(\n            entity_info[\"chunks\"],\n            key=lambda chunk_id: chunk_occurrence_count.get(chunk_id, 0),\n            reverse=True,\n        )\n        entity_info[\"sorted_chunks\"] = sorted_chunks\n        total_entity_chunks += len(sorted_chunks)\n\n    selected_chunk_ids = []  # Initialize to avoid UnboundLocalError\n\n    # Step 4: Apply the selected chunk selection algorithm\n    # Pick by vector similarity:\n    #     The order of text chunks aligns with the naive retrieval's destination.\n    #     When reranking is disabled, the text chunks delivered to the LLM tend to favor naive retrieval.\n    if kg_chunk_pick_method == \"VECTOR\" and query and chunks_vdb:\n        num_of_chunks = int(max_related_chunks * len(entities_with_chunks) / 2)\n\n        # Get embedding function from global config\n        actual_embedding_func = text_chunks_db.embedding_func\n        if not actual_embedding_func:\n            logger.warning(\"No embedding function found, falling back to WEIGHT method\")\n            kg_chunk_pick_method = \"WEIGHT\"\n        else:\n            try:\n                selected_chunk_ids = await pick_by_vector_similarity(\n                    query=query,\n                    text_chunks_storage=text_chunks_db,\n                    chunks_vdb=chunks_vdb,\n                    num_of_chunks=num_of_chunks,\n                    entity_info=entities_with_chunks,\n                    embedding_func=actual_embedding_func,\n                    query_embedding=query_embedding,\n                )\n\n                if selected_chunk_ids == []:\n                    kg_chunk_pick_method = \"WEIGHT\"\n                    logger.warning(\n                        \"No entity-related chunks selected by vector similarity, falling back to WEIGHT method\"\n                    )\n                else:\n                    logger.info(\n                        f\"Selecting {len(selected_chunk_ids)} from {total_entity_chunks} entity-related chunks by vector similarity\"\n                    )\n\n            except Exception as e:\n                logger.error(\n                    f\"Error in vector similarity sorting: {e}, falling back to WEIGHT method\"\n                )\n                kg_chunk_pick_method = \"WEIGHT\"\n\n    if kg_chunk_pick_method == \"WEIGHT\":\n        # Pick by entity and chunk weight:\n        #     When reranking is disabled, delivered more solely KG related chunks to the LLM\n        selected_chunk_ids = pick_by_weighted_polling(\n            entities_with_chunks, max_related_chunks, min_related_chunks=1\n        )\n\n        logger.info(\n            f\"Selecting {len(selected_chunk_ids)} from {total_entity_chunks} entity-related chunks by weighted polling\"\n        )\n\n    if not selected_chunk_ids:\n        return []\n\n    # Step 5: Batch retrieve chunk data\n    unique_chunk_ids = list(\n        dict.fromkeys(selected_chunk_ids)\n    )  # Remove duplicates while preserving order\n    chunk_data_list = await text_chunks_db.get_by_ids(unique_chunk_ids)\n\n    # Step 6: Build result chunks with valid data and update chunk tracking\n    result_chunks = []\n    for i, (chunk_id, chunk_data) in enumerate(zip(unique_chunk_ids, chunk_data_list)):\n        if chunk_data is not None and \"content\" in chunk_data:\n            chunk_data_copy = chunk_data.copy()\n            chunk_data_copy[\"source_type\"] = \"entity\"\n            chunk_data_copy[\"chunk_id\"] = chunk_id  # Add chunk_id for deduplication\n            result_chunks.append(chunk_data_copy)\n\n            # Update chunk tracking if provided\n            if chunk_tracking is not None:\n                chunk_tracking[chunk_id] = {\n                    \"source\": \"E\",\n                    \"frequency\": chunk_occurrence_count.get(chunk_id, 1),\n                    \"order\": i + 1,  # 1-based order in final entity-related results\n                }\n\n    return result_chunks\n\n\nasync def _get_edge_data(\n    keywords,\n    knowledge_graph_inst: BaseGraphStorage,\n    relationships_vdb: BaseVectorStorage,\n    query_param: QueryParam,\n    query_embedding=None,\n):\n    logger.info(\n        f\"Query edges: {keywords} (top_k:{query_param.top_k}, cosine:{relationships_vdb.cosine_better_than_threshold})\"\n    )\n\n    results = await relationships_vdb.query(\n        keywords, top_k=query_param.top_k, query_embedding=query_embedding\n    )\n\n    if not len(results):\n        return [], []\n\n    # Prepare edge pairs in two forms:\n    # For the batch edge properties function, use dicts.\n    edge_pairs_dicts = [{\"src\": r[\"src_id\"], \"tgt\": r[\"tgt_id\"]} for r in results]\n    edge_data_dict = await knowledge_graph_inst.get_edges_batch(edge_pairs_dicts)\n\n    # Reconstruct edge_datas list in the same order as results.\n    edge_datas = []\n    for k in results:\n        pair = (k[\"src_id\"], k[\"tgt_id\"])\n        edge_props = edge_data_dict.get(pair)\n        if edge_props is not None:\n            if \"weight\" not in edge_props:\n                logger.warning(\n                    f\"Edge {pair} missing 'weight' attribute, using default value 1.0\"\n                )\n                edge_props[\"weight\"] = 1.0\n\n            # Keep edge data without rank, maintain vector search order\n            combined = {\n                \"src_id\": k[\"src_id\"],\n                \"tgt_id\": k[\"tgt_id\"],\n                \"created_at\": k.get(\"created_at\", None),\n                **edge_props,\n            }\n            edge_datas.append(combined)\n\n    # Relations maintain vector search order (sorted by similarity)\n\n    use_entities = await _find_most_related_entities_from_relationships(\n        edge_datas,\n        query_param,\n        knowledge_graph_inst,\n    )\n\n    logger.info(\n        f\"Global query: {len(use_entities)} entites, {len(edge_datas)} relations\"\n    )\n\n    return edge_datas, use_entities\n\n\nasync def _find_most_related_entities_from_relationships(\n    edge_datas: list[dict],\n    query_param: QueryParam,\n    knowledge_graph_inst: BaseGraphStorage,\n):\n    entity_names = []\n    seen = set()\n\n    for e in edge_datas:\n        if e[\"src_id\"] not in seen:\n            entity_names.append(e[\"src_id\"])\n            seen.add(e[\"src_id\"])\n        if e[\"tgt_id\"] not in seen:\n            entity_names.append(e[\"tgt_id\"])\n            seen.add(e[\"tgt_id\"])\n\n    # Only get nodes data, no need for node degrees\n    nodes_dict = await knowledge_graph_inst.get_nodes_batch(entity_names)\n\n    # Rebuild the list in the same order as entity_names\n    node_datas = []\n    for entity_name in entity_names:\n        node = nodes_dict.get(entity_name)\n        if node is None:\n            logger.warning(f\"Node '{entity_name}' not found in batch retrieval.\")\n            continue\n        # Combine the node data with the entity name, no rank needed\n        combined = {**node, \"entity_name\": entity_name}\n        node_datas.append(combined)\n\n    return node_datas\n\n\nasync def _find_related_text_unit_from_relations(\n    edge_datas: list[dict],\n    query_param: QueryParam,\n    text_chunks_db: BaseKVStorage,\n    entity_chunks: list[dict] = None,\n    query: str = None,\n    chunks_vdb: BaseVectorStorage = None,\n    chunk_tracking: dict = None,\n    query_embedding=None,\n):\n    \"\"\"\n    Find text chunks related to relationships using configurable chunk selection method.\n\n    This function supports two chunk selection strategies:\n    1. WEIGHT: Linear gradient weighted polling based on chunk occurrence count\n    2. VECTOR: Vector similarity-based selection using embedding cosine similarity\n    \"\"\"\n    logger.debug(f\"Finding text chunks from {len(edge_datas)} relations\")\n\n    if not edge_datas:\n        return []\n\n    # Step 1: Collect all text chunks for each relationship\n    relations_with_chunks = []\n    for relation in edge_datas:\n        if relation.get(\"source_id\"):\n            chunks = split_string_by_multi_markers(\n                relation[\"source_id\"], [GRAPH_FIELD_SEP]\n            )\n            if chunks:\n                # Build relation identifier\n                if \"src_tgt\" in relation:\n                    rel_key = tuple(sorted(relation[\"src_tgt\"]))\n                else:\n                    rel_key = tuple(\n                        sorted([relation.get(\"src_id\"), relation.get(\"tgt_id\")])\n                    )\n\n                relations_with_chunks.append(\n                    {\n                        \"relation_key\": rel_key,\n                        \"chunks\": chunks,\n                        \"relation_data\": relation,\n                    }\n                )\n\n    if not relations_with_chunks:\n        logger.warning(\"No relation-related chunks found\")\n        return []\n\n    kg_chunk_pick_method = text_chunks_db.global_config.get(\n        \"kg_chunk_pick_method\", DEFAULT_KG_CHUNK_PICK_METHOD\n    )\n    max_related_chunks = text_chunks_db.global_config.get(\n        \"related_chunk_number\", DEFAULT_RELATED_CHUNK_NUMBER\n    )\n\n    # Step 2: Count chunk occurrences and deduplicate (keep chunks from earlier positioned relationships)\n    # Also remove duplicates with entity_chunks\n\n    # Extract chunk IDs from entity_chunks for deduplication\n    entity_chunk_ids = set()\n    if entity_chunks:\n        for chunk in entity_chunks:\n            chunk_id = chunk.get(\"chunk_id\")\n            if chunk_id:\n                entity_chunk_ids.add(chunk_id)\n\n    chunk_occurrence_count = {}\n    # Track unique chunk_ids that have been removed to avoid double counting\n    removed_entity_chunk_ids = set()\n\n    for relation_info in relations_with_chunks:\n        deduplicated_chunks = []\n        for chunk_id in relation_info[\"chunks\"]:\n            # Skip chunks that already exist in entity_chunks\n            if chunk_id in entity_chunk_ids:\n                # Only count each unique chunk_id once\n                removed_entity_chunk_ids.add(chunk_id)\n                continue\n\n            chunk_occurrence_count[chunk_id] = (\n                chunk_occurrence_count.get(chunk_id, 0) + 1\n            )\n\n            # If this is the first occurrence (count == 1), keep it; otherwise skip (duplicate from later position)\n            if chunk_occurrence_count[chunk_id] == 1:\n                deduplicated_chunks.append(chunk_id)\n            # count > 1 means this chunk appeared in an earlier relationship, so skip it\n\n        # Update relationship's chunks to deduplicated chunks\n        relation_info[\"chunks\"] = deduplicated_chunks\n\n    # Check if any relations still have chunks after deduplication\n    relations_with_chunks = [\n        relation_info\n        for relation_info in relations_with_chunks\n        if relation_info[\"chunks\"]\n    ]\n\n    if not relations_with_chunks:\n        logger.info(\n            f\"Find no additional relations-related chunks from {len(edge_datas)} relations\"\n        )\n        return []\n\n    # Step 3: Sort chunks for each relationship by occurrence count (higher count = higher priority)\n    total_relation_chunks = 0\n    for relation_info in relations_with_chunks:\n        sorted_chunks = sorted(\n            relation_info[\"chunks\"],\n            key=lambda chunk_id: chunk_occurrence_count.get(chunk_id, 0),\n            reverse=True,\n        )\n        relation_info[\"sorted_chunks\"] = sorted_chunks\n        total_relation_chunks += len(sorted_chunks)\n\n    logger.info(\n        f\"Find {total_relation_chunks} additional chunks in {len(relations_with_chunks)} relations (deduplicated {len(removed_entity_chunk_ids)})\"\n    )\n\n    # Step 4: Apply the selected chunk selection algorithm\n    selected_chunk_ids = []  # Initialize to avoid UnboundLocalError\n\n    if kg_chunk_pick_method == \"VECTOR\" and query and chunks_vdb:\n        num_of_chunks = int(max_related_chunks * len(relations_with_chunks) / 2)\n\n        # Get embedding function from global config\n        actual_embedding_func = text_chunks_db.embedding_func\n        if not actual_embedding_func:\n            logger.warning(\"No embedding function found, falling back to WEIGHT method\")\n            kg_chunk_pick_method = \"WEIGHT\"\n        else:\n            try:\n                selected_chunk_ids = await pick_by_vector_similarity(\n                    query=query,\n                    text_chunks_storage=text_chunks_db,\n                    chunks_vdb=chunks_vdb,\n                    num_of_chunks=num_of_chunks,\n                    entity_info=relations_with_chunks,\n                    embedding_func=actual_embedding_func,\n                    query_embedding=query_embedding,\n                )\n\n                if selected_chunk_ids == []:\n                    kg_chunk_pick_method = \"WEIGHT\"\n                    logger.warning(\n                        \"No relation-related chunks selected by vector similarity, falling back to WEIGHT method\"\n                    )\n                else:\n                    logger.info(\n                        f\"Selecting {len(selected_chunk_ids)} from {total_relation_chunks} relation-related chunks by vector similarity\"\n                    )\n\n            except Exception as e:\n                logger.error(\n                    f\"Error in vector similarity sorting: {e}, falling back to WEIGHT method\"\n                )\n                kg_chunk_pick_method = \"WEIGHT\"\n\n    if kg_chunk_pick_method == \"WEIGHT\":\n        # Apply linear gradient weighted polling algorithm\n        selected_chunk_ids = pick_by_weighted_polling(\n            relations_with_chunks, max_related_chunks, min_related_chunks=1\n        )\n\n        logger.info(\n            f\"Selecting {len(selected_chunk_ids)} from {total_relation_chunks} relation-related chunks by weighted polling\"\n        )\n\n    logger.debug(\n        f\"KG related chunks: {len(entity_chunks)} from entitys, {len(selected_chunk_ids)} from relations\"\n    )\n\n    if not selected_chunk_ids:\n        return []\n\n    # Step 5: Batch retrieve chunk data\n    unique_chunk_ids = list(\n        dict.fromkeys(selected_chunk_ids)\n    )  # Remove duplicates while preserving order\n    chunk_data_list = await text_chunks_db.get_by_ids(unique_chunk_ids)\n\n    # Step 6: Build result chunks with valid data and update chunk tracking\n    result_chunks = []\n    for i, (chunk_id, chunk_data) in enumerate(zip(unique_chunk_ids, chunk_data_list)):\n        if chunk_data is not None and \"content\" in chunk_data:\n            chunk_data_copy = chunk_data.copy()\n            chunk_data_copy[\"source_type\"] = \"relationship\"\n            chunk_data_copy[\"chunk_id\"] = chunk_id  # Add chunk_id for deduplication\n            result_chunks.append(chunk_data_copy)\n\n            # Update chunk tracking if provided\n            if chunk_tracking is not None:\n                chunk_tracking[chunk_id] = {\n                    \"source\": \"R\",\n                    \"frequency\": chunk_occurrence_count.get(chunk_id, 1),\n                    \"order\": i + 1,  # 1-based order in final relation-related results\n                }\n\n    return result_chunks\n\n\n@overload\nasync def naive_query(\n    query: str,\n    chunks_vdb: BaseVectorStorage,\n    query_param: QueryParam,\n    global_config: dict[str, str],\n    hashing_kv: BaseKVStorage | None = None,\n    system_prompt: str | None = None,\n    return_raw_data: Literal[True] = True,\n) -> dict[str, Any]: ...\n\n\n@overload\nasync def naive_query(\n    query: str,\n    chunks_vdb: BaseVectorStorage,\n    query_param: QueryParam,\n    global_config: dict[str, str],\n    hashing_kv: BaseKVStorage | None = None,\n    system_prompt: str | None = None,\n    return_raw_data: Literal[False] = False,\n) -> str | AsyncIterator[str]: ...\n\n\nasync def naive_query(\n    query: str,\n    chunks_vdb: BaseVectorStorage,\n    query_param: QueryParam,\n    global_config: dict[str, str],\n    hashing_kv: BaseKVStorage | None = None,\n    system_prompt: str | None = None,\n) -> QueryResult | None:\n    \"\"\"\n    Execute naive query and return unified QueryResult object.\n\n    Args:\n        query: Query string\n        chunks_vdb: Document chunks vector database\n        query_param: Query parameters\n        global_config: Global configuration\n        hashing_kv: Cache storage\n        system_prompt: System prompt\n\n    Returns:\n        QueryResult | None: Unified query result object containing:\n            - content: Non-streaming response text content\n            - response_iterator: Streaming response iterator\n            - raw_data: Complete structured data (including references and metadata)\n            - is_streaming: Whether this is a streaming result\n\n        Returns None when no relevant chunks are retrieved.\n    \"\"\"\n\n    if not query:\n        return QueryResult(content=PROMPTS[\"fail_response\"])\n\n    if query_param.model_func:\n        use_model_func = query_param.model_func\n    else:\n        use_model_func = global_config[\"llm_model_func\"]\n        # Apply higher priority (5) to query relation LLM function\n        use_model_func = partial(use_model_func, _priority=5)\n\n    tokenizer: Tokenizer = global_config[\"tokenizer\"]\n    if not tokenizer:\n        logger.error(\"Tokenizer not found in global configuration.\")\n        return QueryResult(content=PROMPTS[\"fail_response\"])\n\n    chunks = await _get_vector_context(query, chunks_vdb, query_param, None)\n\n    if chunks is None or len(chunks) == 0:\n        logger.info(\n            \"[naive_query] No relevant document chunks found; returning no-result.\"\n        )\n        return None\n\n    # Calculate dynamic token limit for chunks\n    max_total_tokens = getattr(\n        query_param,\n        \"max_total_tokens\",\n        global_config.get(\"max_total_tokens\", DEFAULT_MAX_TOTAL_TOKENS),\n    )\n\n    # Calculate system prompt template tokens (excluding content_data)\n    user_prompt = f\"\\n\\n{query_param.user_prompt}\" if query_param.user_prompt else \"n/a\"\n    response_type = (\n        query_param.response_type\n        if query_param.response_type\n        else \"Multiple Paragraphs\"\n    )\n\n    # Use the provided system prompt or default\n    sys_prompt_template = (\n        system_prompt if system_prompt else PROMPTS[\"naive_rag_response\"]\n    )\n\n    # Create a preliminary system prompt with empty content_data to calculate overhead\n    pre_sys_prompt = sys_prompt_template.format(\n        response_type=response_type,\n        user_prompt=user_prompt,\n        content_data=\"\",  # Empty for overhead calculation\n    )\n\n    # Calculate available tokens for chunks\n    sys_prompt_tokens = len(tokenizer.encode(pre_sys_prompt))\n    query_tokens = len(tokenizer.encode(query))\n    buffer_tokens = 200  # reserved for reference list and safety buffer\n    available_chunk_tokens = max_total_tokens - (\n        sys_prompt_tokens + query_tokens + buffer_tokens\n    )\n\n    logger.debug(\n        f\"Naive query token allocation - Total: {max_total_tokens}, SysPrompt: {sys_prompt_tokens}, Query: {query_tokens}, Buffer: {buffer_tokens}, Available for chunks: {available_chunk_tokens}\"\n    )\n\n    # Process chunks using unified processing with dynamic token limit\n    processed_chunks = await process_chunks_unified(\n        query=query,\n        unique_chunks=chunks,\n        query_param=query_param,\n        global_config=global_config,\n        source_type=\"vector\",\n        chunk_token_limit=available_chunk_tokens,  # Pass dynamic limit\n    )\n\n    # Generate reference list from processed chunks using the new common function\n    reference_list, processed_chunks_with_ref_ids = generate_reference_list_from_chunks(\n        processed_chunks\n    )\n\n    logger.info(f\"Final context: {len(processed_chunks_with_ref_ids)} chunks\")\n\n    # Build raw data structure for naive mode using processed chunks with reference IDs\n    raw_data = convert_to_user_format(\n        [],  # naive mode has no entities\n        [],  # naive mode has no relationships\n        processed_chunks_with_ref_ids,\n        reference_list,\n        \"naive\",\n    )\n\n    # Add complete metadata for naive mode\n    if \"metadata\" not in raw_data:\n        raw_data[\"metadata\"] = {}\n    raw_data[\"metadata\"][\"keywords\"] = {\n        \"high_level\": [],  # naive mode has no keyword extraction\n        \"low_level\": [],  # naive mode has no keyword extraction\n    }\n    raw_data[\"metadata\"][\"processing_info\"] = {\n        \"total_chunks_found\": len(chunks),\n        \"final_chunks_count\": len(processed_chunks_with_ref_ids),\n    }\n\n    # Build chunks_context from processed chunks with reference IDs\n    chunks_context = []\n    for i, chunk in enumerate(processed_chunks_with_ref_ids):\n        chunks_context.append(\n            {\n                \"reference_id\": chunk[\"reference_id\"],\n                \"content\": chunk[\"content\"],\n            }\n        )\n\n    text_units_str = \"\\n\".join(\n        json.dumps(text_unit, ensure_ascii=False) for text_unit in chunks_context\n    )\n    reference_list_str = \"\\n\".join(\n        f\"[{ref['reference_id']}] {ref['file_path']}\"\n        for ref in reference_list\n        if ref[\"reference_id\"]\n    )\n\n    naive_context_template = PROMPTS[\"naive_query_context\"]\n    context_content = naive_context_template.format(\n        text_chunks_str=text_units_str,\n        reference_list_str=reference_list_str,\n    )\n\n    if query_param.only_need_context and not query_param.only_need_prompt:\n        return QueryResult(content=context_content, raw_data=raw_data)\n\n    sys_prompt = sys_prompt_template.format(\n        response_type=query_param.response_type,\n        user_prompt=user_prompt,\n        content_data=context_content,\n    )\n\n    user_query = query\n\n    if query_param.only_need_prompt:\n        prompt_content = \"\\n\\n\".join([sys_prompt, \"---User Query---\", user_query])\n        return QueryResult(content=prompt_content, raw_data=raw_data)\n\n    # Handle cache\n    args_hash = compute_args_hash(\n        query_param.mode,\n        query,\n        query_param.response_type,\n        query_param.top_k,\n        query_param.chunk_top_k,\n        query_param.max_entity_tokens,\n        query_param.max_relation_tokens,\n        query_param.max_total_tokens,\n        query_param.user_prompt or \"\",\n        query_param.enable_rerank,\n    )\n    cached_result = await handle_cache(\n        hashing_kv, args_hash, user_query, query_param.mode, cache_type=\"query\"\n    )\n    if cached_result is not None:\n        cached_response, _ = cached_result  # Extract content, ignore timestamp\n        logger.info(\n            \" == LLM cache == Query cache hit, using cached response as query result\"\n        )\n        response = cached_response\n    else:\n        response = await use_model_func(\n            user_query,\n            system_prompt=sys_prompt,\n            history_messages=query_param.conversation_history,\n            enable_cot=True,\n            stream=query_param.stream,\n        )\n\n        if hashing_kv and hashing_kv.global_config.get(\"enable_llm_cache\"):\n            queryparam_dict = {\n                \"mode\": query_param.mode,\n                \"response_type\": query_param.response_type,\n                \"top_k\": query_param.top_k,\n                \"chunk_top_k\": query_param.chunk_top_k,\n                \"max_entity_tokens\": query_param.max_entity_tokens,\n                \"max_relation_tokens\": query_param.max_relation_tokens,\n                \"max_total_tokens\": query_param.max_total_tokens,\n                \"user_prompt\": query_param.user_prompt or \"\",\n                \"enable_rerank\": query_param.enable_rerank,\n            }\n            await save_to_cache(\n                hashing_kv,\n                CacheData(\n                    args_hash=args_hash,\n                    content=response,\n                    prompt=query,\n                    mode=query_param.mode,\n                    cache_type=\"query\",\n                    queryparam=queryparam_dict,\n                ),\n            )\n\n    # Return unified result based on actual response type\n    if isinstance(response, str):\n        # Non-streaming response (string)\n        if len(response) > len(sys_prompt):\n            response = (\n                response[len(sys_prompt) :]\n                .replace(sys_prompt, \"\")\n                .replace(\"user\", \"\")\n                .replace(\"model\", \"\")\n                .replace(query, \"\")\n                .replace(\"<system>\", \"\")\n                .replace(\"</system>\", \"\")\n                .strip()\n            )\n\n        return QueryResult(content=response, raw_data=raw_data)\n    else:\n        # Streaming response (AsyncIterator)\n        return QueryResult(\n            response_iterator=response, raw_data=raw_data, is_streaming=True\n        )\n"
  },
  {
    "path": "lightrag/prompt.py",
    "content": "from __future__ import annotations\nfrom typing import Any\n\n\nPROMPTS: dict[str, Any] = {}\n\n# All delimiters must be formatted as \"<|UPPER_CASE_STRING|>\"\nPROMPTS[\"DEFAULT_TUPLE_DELIMITER\"] = \"<|#|>\"\nPROMPTS[\"DEFAULT_COMPLETION_DELIMITER\"] = \"<|COMPLETE|>\"\n\nPROMPTS[\"entity_extraction_system_prompt\"] = \"\"\"---Role---\nYou are a Knowledge Graph Specialist responsible for extracting entities and relationships from the input text.\n\n---Instructions---\n1.  **Entity Extraction & Output:**\n    *   **Identification:** Identify clearly defined and meaningful entities in the input text.\n    *   **Entity Details:** For each identified entity, extract the following information:\n        *   `entity_name`: The name of the entity. If the entity name is case-insensitive, capitalize the first letter of each significant word (title case). Ensure **consistent naming** across the entire extraction process.\n        *   `entity_type`: Categorize the entity using one of the following types: `{entity_types}`. If none of the provided entity types apply, do not add new entity type and classify it as `Other`.\n        *   `entity_description`: Provide a concise yet comprehensive description of the entity's attributes and activities, based *solely* on the information present in the input text.\n    *   **Output Format - Entities:** Output a total of 4 fields for each entity, delimited by `{tuple_delimiter}`, on a single line. The first field *must* be the literal string `entity`.\n        *   Format: `entity{tuple_delimiter}entity_name{tuple_delimiter}entity_type{tuple_delimiter}entity_description`\n\n2.  **Relationship Extraction & Output:**\n    *   **Identification:** Identify direct, clearly stated, and meaningful relationships between previously extracted entities.\n    *   **N-ary Relationship Decomposition:** If a single statement describes a relationship involving more than two entities (an N-ary relationship), decompose it into multiple binary (two-entity) relationship pairs for separate description.\n        *   **Example:** For \"Alice, Bob, and Carol collaborated on Project X,\" extract binary relationships such as \"Alice collaborated with Project X,\" \"Bob collaborated with Project X,\" and \"Carol collaborated with Project X,\" or \"Alice collaborated with Bob,\" based on the most reasonable binary interpretations.\n    *   **Relationship Details:** For each binary relationship, extract the following fields:\n        *   `source_entity`: The name of the source entity. Ensure **consistent naming** with entity extraction. Capitalize the first letter of each significant word (title case) if the name is case-insensitive.\n        *   `target_entity`: The name of the target entity. Ensure **consistent naming** with entity extraction. Capitalize the first letter of each significant word (title case) if the name is case-insensitive.\n        *   `relationship_keywords`: One or more high-level keywords summarizing the overarching nature, concepts, or themes of the relationship. Multiple keywords within this field must be separated by a comma `,`. **DO NOT use `{tuple_delimiter}` for separating multiple keywords within this field.**\n        *   `relationship_description`: A concise explanation of the nature of the relationship between the source and target entities, providing a clear rationale for their connection.\n    *   **Output Format - Relationships:** Output a total of 5 fields for each relationship, delimited by `{tuple_delimiter}`, on a single line. The first field *must* be the literal string `relation`.\n        *   Format: `relation{tuple_delimiter}source_entity{tuple_delimiter}target_entity{tuple_delimiter}relationship_keywords{tuple_delimiter}relationship_description`\n\n3.  **Delimiter Usage Protocol:**\n    *   The `{tuple_delimiter}` is a complete, atomic marker and **must not be filled with content**. It serves strictly as a field separator.\n    *   **Incorrect Example:** `entity{tuple_delimiter}Tokyo<|location|>Tokyo is the capital of Japan.`\n    *   **Correct Example:** `entity{tuple_delimiter}Tokyo{tuple_delimiter}location{tuple_delimiter}Tokyo is the capital of Japan.`\n\n4.  **Relationship Direction & Duplication:**\n    *   Treat all relationships as **undirected** unless explicitly stated otherwise. Swapping the source and target entities for an undirected relationship does not constitute a new relationship.\n    *   Avoid outputting duplicate relationships.\n\n5.  **Output Order & Prioritization:**\n    *   Output all extracted entities first, followed by all extracted relationships.\n    *   Within the list of relationships, prioritize and output those relationships that are **most significant** to the core meaning of the input text first.\n\n6.  **Context & Objectivity:**\n    *   Ensure all entity names and descriptions are written in the **third person**.\n    *   Explicitly name the subject or object; **avoid using pronouns** such as `this article`, `this paper`, `our company`, `I`, `you`, and `he/she`.\n\n7.  **Language & Proper Nouns:**\n    *   The entire output (entity names, keywords, and descriptions) must be written in `{language}`.\n    *   Proper nouns (e.g., personal names, place names, organization names) should be retained in their original language if a proper, widely accepted translation is not available or would cause ambiguity.\n\n8.  **Completion Signal:** Output the literal string `{completion_delimiter}` only after all entities and relationships, following all criteria, have been completely extracted and outputted.\n\n---Examples---\n{examples}\n\"\"\"\n\nPROMPTS[\"entity_extraction_user_prompt\"] = \"\"\"---Task---\nExtract entities and relationships from the input text in Data to be Processed below.\n\n---Instructions---\n1.  **Strict Adherence to Format:** Strictly adhere to all format requirements for entity and relationship lists, including output order, field delimiters, and proper noun handling, as specified in the system prompt.\n2.  **Output Content Only:** Output *only* the extracted list of entities and relationships. Do not include any introductory or concluding remarks, explanations, or additional text before or after the list.\n3.  **Completion Signal:** Output `{completion_delimiter}` as the final line after all relevant entities and relationships have been extracted and presented.\n4.  **Output Language:** Ensure the output language is {language}. Proper nouns (e.g., personal names, place names, organization names) must be kept in their original language and not translated.\n\n---Data to be Processed---\n<Entity_types>\n[{entity_types}]\n\n<Input Text>\n```\n{input_text}\n```\n\n<Output>\n\"\"\"\n\nPROMPTS[\"entity_continue_extraction_user_prompt\"] = \"\"\"---Task---\nBased on the last extraction task, identify and extract any **missed or incorrectly formatted** entities and relationships from the input text.\n\n---Instructions---\n1.  **Strict Adherence to System Format:** Strictly adhere to all format requirements for entity and relationship lists, including output order, field delimiters, and proper noun handling, as specified in the system instructions.\n2.  **Focus on Corrections/Additions:**\n    *   **Do NOT** re-output entities and relationships that were **correctly and fully** extracted in the last task.\n    *   If an entity or relationship was **missed** in the last task, extract and output it now according to the system format.\n    *   If an entity or relationship was **truncated, had missing fields, or was otherwise incorrectly formatted** in the last task, re-output the *corrected and complete* version in the specified format.\n3.  **Output Format - Entities:** Output a total of 4 fields for each entity, delimited by `{tuple_delimiter}`, on a single line. The first field *must* be the literal string `entity`.\n4.  **Output Format - Relationships:** Output a total of 5 fields for each relationship, delimited by `{tuple_delimiter}`, on a single line. The first field *must* be the literal string `relation`.\n5.  **Output Content Only:** Output *only* the extracted list of entities and relationships. Do not include any introductory or concluding remarks, explanations, or additional text before or after the list.\n6.  **Completion Signal:** Output `{completion_delimiter}` as the final line after all relevant missing or corrected entities and relationships have been extracted and presented.\n7.  **Output Language:** Ensure the output language is {language}. Proper nouns (e.g., personal names, place names, organization names) must be kept in their original language and not translated.\n\n<Output>\n\"\"\"\n\nPROMPTS[\"entity_extraction_examples\"] = [\n    \"\"\"<Entity_types>\n[\"Person\",\"Creature\",\"Organization\",\"Location\",\"Event\",\"Concept\",\"Method\",\"Content\",\"Data\",\"Artifact\",\"NaturalObject\"]\n\n<Input Text>\n```\nwhile Alex clenched his jaw, the buzz of frustration dull against the backdrop of Taylor's authoritarian certainty. It was this competitive undercurrent that kept him alert, the sense that his and Jordan's shared commitment to discovery was an unspoken rebellion against Cruz's narrowing vision of control and order.\n\nThen Taylor did something unexpected. They paused beside Jordan and, for a moment, observed the device with something akin to reverence. \"If this tech can be understood...\" Taylor said, their voice quieter, \"It could change the game for us. For all of us.\"\n\nThe underlying dismissal earlier seemed to falter, replaced by a glimpse of reluctant respect for the gravity of what lay in their hands. Jordan looked up, and for a fleeting heartbeat, their eyes locked with Taylor's, a wordless clash of wills softening into an uneasy truce.\n\nIt was a small transformation, barely perceptible, but one that Alex noted with an inward nod. They had all been brought here by different paths\n```\n\n<Output>\nentity{tuple_delimiter}Alex{tuple_delimiter}person{tuple_delimiter}Alex is a character who experiences frustration and is observant of the dynamics among other characters.\nentity{tuple_delimiter}Taylor{tuple_delimiter}person{tuple_delimiter}Taylor is portrayed with authoritarian certainty and shows a moment of reverence towards a device, indicating a change in perspective.\nentity{tuple_delimiter}Jordan{tuple_delimiter}person{tuple_delimiter}Jordan shares a commitment to discovery and has a significant interaction with Taylor regarding a device.\nentity{tuple_delimiter}Cruz{tuple_delimiter}person{tuple_delimiter}Cruz is associated with a vision of control and order, influencing the dynamics among other characters.\nentity{tuple_delimiter}The Device{tuple_delimiter}equipment{tuple_delimiter}The Device is central to the story, with potential game-changing implications, and is revered by Taylor.\nrelation{tuple_delimiter}Alex{tuple_delimiter}Taylor{tuple_delimiter}power dynamics, observation{tuple_delimiter}Alex observes Taylor's authoritarian behavior and notes changes in Taylor's attitude toward the device.\nrelation{tuple_delimiter}Alex{tuple_delimiter}Jordan{tuple_delimiter}shared goals, rebellion{tuple_delimiter}Alex and Jordan share a commitment to discovery, which contrasts with Cruz's vision.)\nrelation{tuple_delimiter}Taylor{tuple_delimiter}Jordan{tuple_delimiter}conflict resolution, mutual respect{tuple_delimiter}Taylor and Jordan interact directly regarding the device, leading to a moment of mutual respect and an uneasy truce.\nrelation{tuple_delimiter}Jordan{tuple_delimiter}Cruz{tuple_delimiter}ideological conflict, rebellion{tuple_delimiter}Jordan's commitment to discovery is in rebellion against Cruz's vision of control and order.\nrelation{tuple_delimiter}Taylor{tuple_delimiter}The Device{tuple_delimiter}reverence, technological significance{tuple_delimiter}Taylor shows reverence towards the device, indicating its importance and potential impact.\n{completion_delimiter}\n\n\"\"\",\n    \"\"\"<Entity_types>\n[\"Person\",\"Creature\",\"Organization\",\"Location\",\"Event\",\"Concept\",\"Method\",\"Content\",\"Data\",\"Artifact\",\"NaturalObject\"]\n\n<Input Text>\n```\nStock markets faced a sharp downturn today as tech giants saw significant declines, with the global tech index dropping by 3.4% in midday trading. Analysts attribute the selloff to investor concerns over rising interest rates and regulatory uncertainty.\n\nAmong the hardest hit, nexon technologies saw its stock plummet by 7.8% after reporting lower-than-expected quarterly earnings. In contrast, Omega Energy posted a modest 2.1% gain, driven by rising oil prices.\n\nMeanwhile, commodity markets reflected a mixed sentiment. Gold futures rose by 1.5%, reaching $2,080 per ounce, as investors sought safe-haven assets. Crude oil prices continued their rally, climbing to $87.60 per barrel, supported by supply constraints and strong demand.\n\nFinancial experts are closely watching the Federal Reserve's next move, as speculation grows over potential rate hikes. The upcoming policy announcement is expected to influence investor confidence and overall market stability.\n```\n\n<Output>\nentity{tuple_delimiter}Global Tech Index{tuple_delimiter}category{tuple_delimiter}The Global Tech Index tracks the performance of major technology stocks and experienced a 3.4% decline today.\nentity{tuple_delimiter}Nexon Technologies{tuple_delimiter}organization{tuple_delimiter}Nexon Technologies is a tech company that saw its stock decline by 7.8% after disappointing earnings.\nentity{tuple_delimiter}Omega Energy{tuple_delimiter}organization{tuple_delimiter}Omega Energy is an energy company that gained 2.1% in stock value due to rising oil prices.\nentity{tuple_delimiter}Gold Futures{tuple_delimiter}product{tuple_delimiter}Gold futures rose by 1.5%, indicating increased investor interest in safe-haven assets.\nentity{tuple_delimiter}Crude Oil{tuple_delimiter}product{tuple_delimiter}Crude oil prices rose to $87.60 per barrel due to supply constraints and strong demand.\nentity{tuple_delimiter}Market Selloff{tuple_delimiter}category{tuple_delimiter}Market selloff refers to the significant decline in stock values due to investor concerns over interest rates and regulations.\nentity{tuple_delimiter}Federal Reserve Policy Announcement{tuple_delimiter}category{tuple_delimiter}The Federal Reserve's upcoming policy announcement is expected to impact investor confidence and market stability.\nentity{tuple_delimiter}3.4% Decline{tuple_delimiter}category{tuple_delimiter}The Global Tech Index experienced a 3.4% decline in midday trading.\nrelation{tuple_delimiter}Global Tech Index{tuple_delimiter}Market Selloff{tuple_delimiter}market performance, investor sentiment{tuple_delimiter}The decline in the Global Tech Index is part of the broader market selloff driven by investor concerns.\nrelation{tuple_delimiter}Nexon Technologies{tuple_delimiter}Global Tech Index{tuple_delimiter}company impact, index movement{tuple_delimiter}Nexon Technologies' stock decline contributed to the overall drop in the Global Tech Index.\nrelation{tuple_delimiter}Gold Futures{tuple_delimiter}Market Selloff{tuple_delimiter}market reaction, safe-haven investment{tuple_delimiter}Gold prices rose as investors sought safe-haven assets during the market selloff.\nrelation{tuple_delimiter}Federal Reserve Policy Announcement{tuple_delimiter}Market Selloff{tuple_delimiter}interest rate impact, financial regulation{tuple_delimiter}Speculation over Federal Reserve policy changes contributed to market volatility and investor selloff.\n{completion_delimiter}\n\n\"\"\",\n    \"\"\"<Entity_types>\n[\"Person\",\"Creature\",\"Organization\",\"Location\",\"Event\",\"Concept\",\"Method\",\"Content\",\"Data\",\"Artifact\",\"NaturalObject\"]\n\n<Input Text>\n```\nAt the World Athletics Championship in Tokyo, Noah Carter broke the 100m sprint record using cutting-edge carbon-fiber spikes.\n```\n\n<Output>\nentity{tuple_delimiter}World Athletics Championship{tuple_delimiter}event{tuple_delimiter}The World Athletics Championship is a global sports competition featuring top athletes in track and field.\nentity{tuple_delimiter}Tokyo{tuple_delimiter}location{tuple_delimiter}Tokyo is the host city of the World Athletics Championship.\nentity{tuple_delimiter}Noah Carter{tuple_delimiter}person{tuple_delimiter}Noah Carter is a sprinter who set a new record in the 100m sprint at the World Athletics Championship.\nentity{tuple_delimiter}100m Sprint Record{tuple_delimiter}category{tuple_delimiter}The 100m sprint record is a benchmark in athletics, recently broken by Noah Carter.\nentity{tuple_delimiter}Carbon-Fiber Spikes{tuple_delimiter}equipment{tuple_delimiter}Carbon-fiber spikes are advanced sprinting shoes that provide enhanced speed and traction.\nentity{tuple_delimiter}World Athletics Federation{tuple_delimiter}organization{tuple_delimiter}The World Athletics Federation is the governing body overseeing the World Athletics Championship and record validations.\nrelation{tuple_delimiter}World Athletics Championship{tuple_delimiter}Tokyo{tuple_delimiter}event location, international competition{tuple_delimiter}The World Athletics Championship is being hosted in Tokyo.\nrelation{tuple_delimiter}Noah Carter{tuple_delimiter}100m Sprint Record{tuple_delimiter}athlete achievement, record-breaking{tuple_delimiter}Noah Carter set a new 100m sprint record at the championship.\nrelation{tuple_delimiter}Noah Carter{tuple_delimiter}Carbon-Fiber Spikes{tuple_delimiter}athletic equipment, performance boost{tuple_delimiter}Noah Carter used carbon-fiber spikes to enhance performance during the race.\nrelation{tuple_delimiter}Noah Carter{tuple_delimiter}World Athletics Championship{tuple_delimiter}athlete participation, competition{tuple_delimiter}Noah Carter is competing at the World Athletics Championship.\n{completion_delimiter}\n\n\"\"\",\n]\n\nPROMPTS[\"summarize_entity_descriptions\"] = \"\"\"---Role---\nYou are a Knowledge Graph Specialist, proficient in data curation and synthesis.\n\n---Task---\nYour task is to synthesize a list of descriptions of a given entity or relation into a single, comprehensive, and cohesive summary.\n\n---Instructions---\n1. Input Format: The description list is provided in JSON format. Each JSON object (representing a single description) appears on a new line within the `Description List` section.\n2. Output Format: The merged description will be returned as plain text, presented in multiple paragraphs, without any additional formatting or extraneous comments before or after the summary.\n3. Comprehensiveness: The summary must integrate all key information from *every* provided description. Do not omit any important facts or details.\n4. Context: Ensure the summary is written from an objective, third-person perspective; explicitly mention the name of the entity or relation for full clarity and context.\n5. Context & Objectivity:\n  - Write the summary from an objective, third-person perspective.\n  - Explicitly mention the full name of the entity or relation at the beginning of the summary to ensure immediate clarity and context.\n6. Conflict Handling:\n  - In cases of conflicting or inconsistent descriptions, first determine if these conflicts arise from multiple, distinct entities or relationships that share the same name.\n  - If distinct entities/relations are identified, summarize each one *separately* within the overall output.\n  - If conflicts within a single entity/relation (e.g., historical discrepancies) exist, attempt to reconcile them or present both viewpoints with noted uncertainty.\n7. Length Constraint:The summary's total length must not exceed {summary_length} tokens, while still maintaining depth and completeness.\n8. Language: The entire output must be written in {language}. Proper nouns (e.g., personal names, place names, organization names) may in their original language if proper translation is not available.\n  - The entire output must be written in {language}.\n  - Proper nouns (e.g., personal names, place names, organization names) should be retained in their original language if a proper, widely accepted translation is not available or would cause ambiguity.\n\n---Input---\n{description_type} Name: {description_name}\n\nDescription List:\n\n```\n{description_list}\n```\n\n---Output---\n\"\"\"\n\nPROMPTS[\"fail_response\"] = (\n    \"Sorry, I'm not able to provide an answer to that question.[no-context]\"\n)\n\nPROMPTS[\"rag_response\"] = \"\"\"---Role---\n\nYou are an expert AI assistant specializing in synthesizing information from a provided knowledge base. Your primary function is to answer user queries accurately by ONLY using the information within the provided **Context**.\n\n---Goal---\n\nGenerate a comprehensive, well-structured answer to the user query.\nThe answer must integrate relevant facts from the Knowledge Graph and Document Chunks found in the **Context**.\nConsider the conversation history if provided to maintain conversational flow and avoid repeating information.\n\n---Instructions---\n\n1. Step-by-Step Instruction:\n  - Carefully determine the user's query intent in the context of the conversation history to fully understand the user's information need.\n  - Scrutinize both `Knowledge Graph Data` and `Document Chunks` in the **Context**. Identify and extract all pieces of information that are directly relevant to answering the user query.\n  - Weave the extracted facts into a coherent and logical response. Your own knowledge must ONLY be used to formulate fluent sentences and connect ideas, NOT to introduce any external information.\n  - Track the reference_id of the document chunk which directly support the facts presented in the response. Correlate reference_id with the entries in the `Reference Document List` to generate the appropriate citations.\n  - Generate a references section at the end of the response. Each reference document must directly support the facts presented in the response.\n  - Do not generate anything after the reference section.\n\n2. Content & Grounding:\n  - Strictly adhere to the provided context from the **Context**; DO NOT invent, assume, or infer any information not explicitly stated.\n  - If the answer cannot be found in the **Context**, state that you do not have enough information to answer. Do not attempt to guess.\n\n3. Formatting & Language:\n  - The response MUST be in the same language as the user query.\n  - The response MUST utilize Markdown formatting for enhanced clarity and structure (e.g., headings, bold text, bullet points).\n  - The response should be presented in {response_type}.\n\n4. References Section Format:\n  - The References section should be under heading: `### References`\n  - Reference list entries should adhere to the format: `* [n] Document Title`. Do not include a caret (`^`) after opening square bracket (`[`).\n  - The Document Title in the citation must retain its original language.\n  - Output each citation on an individual line\n  - Provide maximum of 5 most relevant citations.\n  - Do not generate footnotes section or any comment, summary, or explanation after the references.\n\n5. Reference Section Example:\n```\n### References\n\n- [1] Document Title One\n- [2] Document Title Two\n- [3] Document Title Three\n```\n\n6. Additional Instructions: {user_prompt}\n\n\n---Context---\n\n{context_data}\n\"\"\"\n\nPROMPTS[\"naive_rag_response\"] = \"\"\"---Role---\n\nYou are an expert AI assistant specializing in synthesizing information from a provided knowledge base. Your primary function is to answer user queries accurately by ONLY using the information within the provided **Context**.\n\n---Goal---\n\nGenerate a comprehensive, well-structured answer to the user query.\nThe answer must integrate relevant facts from the Document Chunks found in the **Context**.\nConsider the conversation history if provided to maintain conversational flow and avoid repeating information.\n\n---Instructions---\n\n1. Step-by-Step Instruction:\n  - Carefully determine the user's query intent in the context of the conversation history to fully understand the user's information need.\n  - Scrutinize `Document Chunks` in the **Context**. Identify and extract all pieces of information that are directly relevant to answering the user query.\n  - Weave the extracted facts into a coherent and logical response. Your own knowledge must ONLY be used to formulate fluent sentences and connect ideas, NOT to introduce any external information.\n  - Track the reference_id of the document chunk which directly support the facts presented in the response. Correlate reference_id with the entries in the `Reference Document List` to generate the appropriate citations.\n  - Generate a **References** section at the end of the response. Each reference document must directly support the facts presented in the response.\n  - Do not generate anything after the reference section.\n\n2. Content & Grounding:\n  - Strictly adhere to the provided context from the **Context**; DO NOT invent, assume, or infer any information not explicitly stated.\n  - If the answer cannot be found in the **Context**, state that you do not have enough information to answer. Do not attempt to guess.\n\n3. Formatting & Language:\n  - The response MUST be in the same language as the user query.\n  - The response MUST utilize Markdown formatting for enhanced clarity and structure (e.g., headings, bold text, bullet points).\n  - The response should be presented in {response_type}.\n\n4. References Section Format:\n  - The References section should be under heading: `### References`\n  - Reference list entries should adhere to the format: `* [n] Document Title`. Do not include a caret (`^`) after opening square bracket (`[`).\n  - The Document Title in the citation must retain its original language.\n  - Output each citation on an individual line\n  - Provide maximum of 5 most relevant citations.\n  - Do not generate footnotes section or any comment, summary, or explanation after the references.\n\n5. Reference Section Example:\n```\n### References\n\n- [1] Document Title One\n- [2] Document Title Two\n- [3] Document Title Three\n```\n\n6. Additional Instructions: {user_prompt}\n\n\n---Context---\n\n{content_data}\n\"\"\"\n\nPROMPTS[\"kg_query_context\"] = \"\"\"\nKnowledge Graph Data (Entity):\n\n```json\n{entities_str}\n```\n\nKnowledge Graph Data (Relationship):\n\n```json\n{relations_str}\n```\n\nDocument Chunks (Each entry has a reference_id refer to the `Reference Document List`):\n\n```json\n{text_chunks_str}\n```\n\nReference Document List (Each entry starts with a [reference_id] that corresponds to entries in the Document Chunks):\n\n```\n{reference_list_str}\n```\n\n\"\"\"\n\nPROMPTS[\"naive_query_context\"] = \"\"\"\nDocument Chunks (Each entry has a reference_id refer to the `Reference Document List`):\n\n```json\n{text_chunks_str}\n```\n\nReference Document List (Each entry starts with a [reference_id] that corresponds to entries in the Document Chunks):\n\n```\n{reference_list_str}\n```\n\n\"\"\"\n\nPROMPTS[\"keywords_extraction\"] = \"\"\"---Role---\nYou are an expert keyword extractor, specializing in analyzing user queries for a Retrieval-Augmented Generation (RAG) system. Your purpose is to identify both high-level and low-level keywords in the user's query that will be used for effective document retrieval.\n\n---Goal---\nGiven a user query, your task is to extract two distinct types of keywords:\n1. **high_level_keywords**: for overarching concepts or themes, capturing user's core intent, the subject area, or the type of question being asked.\n2. **low_level_keywords**: for specific entities or details, identifying the specific entities, proper nouns, technical jargon, product names, or concrete items.\n\n---Instructions & Constraints---\n1. **Output Format**: Your output MUST be a valid JSON object and nothing else. Do not include any explanatory text, markdown code fences (like ```json), or any other text before or after the JSON. It will be parsed directly by a JSON parser.\n2. **Source of Truth**: All keywords must be explicitly derived from the user query, with both high-level and low-level keyword categories are required to contain content.\n3. **Concise & Meaningful**: Keywords should be concise words or meaningful phrases. Prioritize multi-word phrases when they represent a single concept. For example, from \"latest financial report of Apple Inc.\", you should extract \"latest financial report\" and \"Apple Inc.\" rather than \"latest\", \"financial\", \"report\", and \"Apple\".\n4. **Handle Edge Cases**: For queries that are too simple, vague, or nonsensical (e.g., \"hello\", \"ok\", \"asdfghjkl\"), you must return a JSON object with empty lists for both keyword types.\n5. **Language**: All extracted keywords MUST be in {language}. Proper nouns (e.g., personal names, place names, organization names) should be kept in their original language.\n\n---Examples---\n{examples}\n\n---Real Data---\nUser Query: {query}\n\n---Output---\nOutput:\"\"\"\n\nPROMPTS[\"keywords_extraction_examples\"] = [\n    \"\"\"Example 1:\n\nQuery: \"How does international trade influence global economic stability?\"\n\nOutput:\n{\n  \"high_level_keywords\": [\"International trade\", \"Global economic stability\", \"Economic impact\"],\n  \"low_level_keywords\": [\"Trade agreements\", \"Tariffs\", \"Currency exchange\", \"Imports\", \"Exports\"]\n}\n\n\"\"\",\n    \"\"\"Example 2:\n\nQuery: \"What are the environmental consequences of deforestation on biodiversity?\"\n\nOutput:\n{\n  \"high_level_keywords\": [\"Environmental consequences\", \"Deforestation\", \"Biodiversity loss\"],\n  \"low_level_keywords\": [\"Species extinction\", \"Habitat destruction\", \"Carbon emissions\", \"Rainforest\", \"Ecosystem\"]\n}\n\n\"\"\",\n    \"\"\"Example 3:\n\nQuery: \"What is the role of education in reducing poverty?\"\n\nOutput:\n{\n  \"high_level_keywords\": [\"Education\", \"Poverty reduction\", \"Socioeconomic development\"],\n  \"low_level_keywords\": [\"School access\", \"Literacy rates\", \"Job training\", \"Income inequality\"]\n}\n\n\"\"\",\n]\n"
  },
  {
    "path": "lightrag/rerank.py",
    "content": "from __future__ import annotations\n\nimport os\nimport aiohttp\nfrom typing import Any, List, Dict, Optional, Tuple\nfrom tenacity import (\n    retry,\n    stop_after_attempt,\n    wait_exponential,\n    retry_if_exception_type,\n)\nfrom .utils import logger\n\nfrom dotenv import load_dotenv\n\n# use the .env that is inside the current folder\n# allows to use different .env file for each lightrag instance\n# the OS environment variables take precedence over the .env file\nload_dotenv(dotenv_path=\".env\", override=False)\n\n\ndef chunk_documents_for_rerank(\n    documents: List[str],\n    max_tokens: int = 480,\n    overlap_tokens: int = 32,\n    tokenizer_model: str = \"gpt-4o-mini\",\n) -> Tuple[List[str], List[int]]:\n    \"\"\"\n    Chunk documents that exceed token limit for reranking.\n\n    Args:\n        documents: List of document strings to chunk\n        max_tokens: Maximum tokens per chunk (default 480 to leave margin for 512 limit)\n        overlap_tokens: Number of tokens to overlap between chunks\n        tokenizer_model: Model name for tiktoken tokenizer\n\n    Returns:\n        Tuple of (chunked_documents, original_doc_indices)\n        - chunked_documents: List of document chunks (may be more than input)\n        - original_doc_indices: Maps each chunk back to its original document index\n    \"\"\"\n    # Clamp overlap_tokens to ensure the loop always advances\n    # If overlap_tokens >= max_tokens, the chunking loop would hang\n    if overlap_tokens >= max_tokens:\n        original_overlap = overlap_tokens\n        # Ensure overlap is at least 1 token less than max to guarantee progress\n        # For very small max_tokens (e.g., 1), set overlap to 0\n        overlap_tokens = max(0, max_tokens - 1)\n        logger.warning(\n            f\"overlap_tokens ({original_overlap}) must be less than max_tokens ({max_tokens}). \"\n            f\"Clamping to {overlap_tokens} to prevent infinite loop.\"\n        )\n\n    try:\n        from .utils import TiktokenTokenizer\n\n        tokenizer = TiktokenTokenizer(model_name=tokenizer_model)\n    except Exception as e:\n        logger.warning(\n            f\"Failed to initialize tokenizer: {e}. Using character-based approximation.\"\n        )\n        # Fallback: approximate 1 token ≈ 4 characters\n        max_chars = max_tokens * 4\n        overlap_chars = overlap_tokens * 4\n\n        chunked_docs = []\n        doc_indices = []\n\n        for idx, doc in enumerate(documents):\n            if len(doc) <= max_chars:\n                chunked_docs.append(doc)\n                doc_indices.append(idx)\n            else:\n                # Split into overlapping chunks\n                start = 0\n                while start < len(doc):\n                    end = min(start + max_chars, len(doc))\n                    chunk = doc[start:end]\n                    chunked_docs.append(chunk)\n                    doc_indices.append(idx)\n\n                    if end >= len(doc):\n                        break\n                    start = end - overlap_chars\n\n        return chunked_docs, doc_indices\n\n    # Use tokenizer for accurate chunking\n    chunked_docs = []\n    doc_indices = []\n\n    for idx, doc in enumerate(documents):\n        tokens = tokenizer.encode(doc)\n\n        if len(tokens) <= max_tokens:\n            # Document fits in one chunk\n            chunked_docs.append(doc)\n            doc_indices.append(idx)\n        else:\n            # Split into overlapping chunks\n            start = 0\n            while start < len(tokens):\n                end = min(start + max_tokens, len(tokens))\n                chunk_tokens = tokens[start:end]\n                chunk_text = tokenizer.decode(chunk_tokens)\n                chunked_docs.append(chunk_text)\n                doc_indices.append(idx)\n\n                if end >= len(tokens):\n                    break\n                start = end - overlap_tokens\n\n    return chunked_docs, doc_indices\n\n\ndef aggregate_chunk_scores(\n    chunk_results: List[Dict[str, Any]],\n    doc_indices: List[int],\n    num_original_docs: int,\n    aggregation: str = \"max\",\n) -> List[Dict[str, Any]]:\n    \"\"\"\n    Aggregate rerank scores from document chunks back to original documents.\n\n    Args:\n        chunk_results: Rerank results for chunks [{\"index\": chunk_idx, \"relevance_score\": score}, ...]\n        doc_indices: Maps each chunk index to original document index\n        num_original_docs: Total number of original documents\n        aggregation: Strategy for aggregating scores (\"max\", \"mean\", \"first\")\n\n    Returns:\n        List of results for original documents [{\"index\": doc_idx, \"relevance_score\": score}, ...]\n    \"\"\"\n    # Group scores by original document index\n    doc_scores: Dict[int, List[float]] = {i: [] for i in range(num_original_docs)}\n\n    for result in chunk_results:\n        chunk_idx = result[\"index\"]\n        score = result[\"relevance_score\"]\n\n        if 0 <= chunk_idx < len(doc_indices):\n            original_doc_idx = doc_indices[chunk_idx]\n            doc_scores[original_doc_idx].append(score)\n\n    # Aggregate scores\n    aggregated_results = []\n    for doc_idx, scores in doc_scores.items():\n        if not scores:\n            continue\n\n        if aggregation == \"max\":\n            final_score = max(scores)\n        elif aggregation == \"mean\":\n            final_score = sum(scores) / len(scores)\n        elif aggregation == \"first\":\n            final_score = scores[0]\n        else:\n            logger.warning(f\"Unknown aggregation strategy: {aggregation}, using max\")\n            final_score = max(scores)\n\n        aggregated_results.append(\n            {\n                \"index\": doc_idx,\n                \"relevance_score\": final_score,\n            }\n        )\n\n    # Sort by relevance score (descending)\n    aggregated_results.sort(key=lambda x: x[\"relevance_score\"], reverse=True)\n\n    return aggregated_results\n\n\n@retry(\n    stop=stop_after_attempt(3),\n    wait=wait_exponential(multiplier=1, min=4, max=60),\n    retry=(\n        retry_if_exception_type(aiohttp.ClientError)\n        | retry_if_exception_type(aiohttp.ClientResponseError)\n    ),\n)\nasync def generic_rerank_api(\n    query: str,\n    documents: List[str],\n    model: str,\n    base_url: str,\n    api_key: Optional[str],\n    top_n: Optional[int] = None,\n    return_documents: Optional[bool] = None,\n    extra_body: Optional[Dict[str, Any]] = None,\n    response_format: str = \"standard\",  # \"standard\" (Jina/Cohere) or \"aliyun\"\n    request_format: str = \"standard\",  # \"standard\" (Jina/Cohere) or \"aliyun\"\n    enable_chunking: bool = False,\n    max_tokens_per_doc: int = 480,\n) -> List[Dict[str, Any]]:\n    \"\"\"\n    Generic rerank API call for Jina/Cohere/Aliyun models.\n\n    Args:\n        query: The search query\n        documents: List of strings to rerank\n        model: Model name to use\n        base_url: API endpoint URL\n        api_key: API key for authentication\n        top_n: Number of top results to return\n        return_documents: Whether to return document text (Jina only)\n        extra_body: Additional body parameters\n        response_format: Response format type (\"standard\" for Jina/Cohere, \"aliyun\" for Aliyun)\n        request_format: Request format type\n        enable_chunking: Whether to chunk documents exceeding token limit\n        max_tokens_per_doc: Maximum tokens per document for chunking\n\n    Returns:\n        List of dictionary of [\"index\": int, \"relevance_score\": float]\n    \"\"\"\n    if not base_url:\n        raise ValueError(\"Base URL is required\")\n\n    headers = {\"Content-Type\": \"application/json\"}\n    if api_key is not None:\n        headers[\"Authorization\"] = f\"Bearer {api_key}\"\n\n    # Handle document chunking if enabled\n    original_documents = documents\n    doc_indices = None\n    original_top_n = top_n  # Save original top_n for post-aggregation limiting\n\n    if enable_chunking:\n        documents, doc_indices = chunk_documents_for_rerank(\n            documents, max_tokens=max_tokens_per_doc\n        )\n        logger.debug(\n            f\"Chunked {len(original_documents)} documents into {len(documents)} chunks\"\n        )\n        # When chunking is enabled, disable top_n at API level to get all chunk scores\n        # This ensures proper document-level coverage after aggregation\n        # We'll apply top_n to aggregated document results instead\n        if top_n is not None:\n            logger.debug(\n                f\"Chunking enabled: disabled API-level top_n={top_n} to ensure complete document coverage\"\n            )\n            top_n = None\n\n    # Build request payload based on request format\n    if request_format == \"aliyun\":\n        # Aliyun format: nested input/parameters structure\n        payload = {\n            \"model\": model,\n            \"input\": {\n                \"query\": query,\n                \"documents\": documents,\n            },\n            \"parameters\": {},\n        }\n\n        # Add optional parameters to parameters object\n        if top_n is not None:\n            payload[\"parameters\"][\"top_n\"] = top_n\n\n        if return_documents is not None:\n            payload[\"parameters\"][\"return_documents\"] = return_documents\n\n        # Add extra parameters to parameters object\n        if extra_body:\n            payload[\"parameters\"].update(extra_body)\n    else:\n        # Standard format for Jina/Cohere/OpenAI\n        payload = {\n            \"model\": model,\n            \"query\": query,\n            \"documents\": documents,\n        }\n\n        # Add optional parameters\n        if top_n is not None:\n            payload[\"top_n\"] = top_n\n\n        # Only Jina API supports return_documents parameter\n        if return_documents is not None and response_format in (\"standard\",):\n            payload[\"return_documents\"] = return_documents\n\n        # Add extra parameters\n        if extra_body:\n            payload.update(extra_body)\n\n    logger.debug(\n        f\"Rerank request: {len(documents)} documents, model: {model}, format: {response_format}\"\n    )\n\n    async with aiohttp.ClientSession() as session:\n        async with session.post(base_url, headers=headers, json=payload) as response:\n            if response.status != 200:\n                error_text = await response.text()\n                content_type = response.headers.get(\"content-type\", \"\").lower()\n                is_html_error = (\n                    error_text.strip().startswith(\"<!DOCTYPE html>\")\n                    or \"text/html\" in content_type\n                )\n                if is_html_error:\n                    if response.status == 502:\n                        clean_error = \"Bad Gateway (502) - Rerank service temporarily unavailable. Please try again in a few minutes.\"\n                    elif response.status == 503:\n                        clean_error = \"Service Unavailable (503) - Rerank service is temporarily overloaded. Please try again later.\"\n                    elif response.status == 504:\n                        clean_error = \"Gateway Timeout (504) - Rerank service request timed out. Please try again.\"\n                    else:\n                        clean_error = f\"HTTP {response.status} - Rerank service error. Please try again later.\"\n                else:\n                    clean_error = error_text\n                logger.error(f\"Rerank API error {response.status}: {clean_error}\")\n                raise aiohttp.ClientResponseError(\n                    request_info=response.request_info,\n                    history=response.history,\n                    status=response.status,\n                    message=f\"Rerank API error: {clean_error}\",\n                )\n\n            response_json = await response.json()\n\n            if response_format == \"aliyun\":\n                # Aliyun format: {\"output\": {\"results\": [...]}}\n                results = response_json.get(\"output\", {}).get(\"results\", [])\n                if not isinstance(results, list):\n                    logger.warning(\n                        f\"Expected 'output.results' to be list, got {type(results)}: {results}\"\n                    )\n                    results = []\n            elif response_format == \"standard\":\n                # Standard format: {\"results\": [...]}\n                results = response_json.get(\"results\", [])\n                if not isinstance(results, list):\n                    logger.warning(\n                        f\"Expected 'results' to be list, got {type(results)}: {results}\"\n                    )\n                    results = []\n            else:\n                raise ValueError(f\"Unsupported response format: {response_format}\")\n\n            if not results:\n                logger.warning(\"Rerank API returned empty results\")\n                return []\n\n            # Standardize return format\n            standardized_results = [\n                {\"index\": result[\"index\"], \"relevance_score\": result[\"relevance_score\"]}\n                for result in results\n            ]\n\n            # Aggregate chunk scores back to original documents if chunking was enabled\n            if enable_chunking and doc_indices:\n                standardized_results = aggregate_chunk_scores(\n                    standardized_results,\n                    doc_indices,\n                    len(original_documents),\n                    aggregation=\"max\",\n                )\n                # Apply original top_n limit at document level (post-aggregation)\n                # This preserves document-level semantics: top_n limits documents, not chunks\n                if (\n                    original_top_n is not None\n                    and len(standardized_results) > original_top_n\n                ):\n                    standardized_results = standardized_results[:original_top_n]\n\n            return standardized_results\n\n\nasync def cohere_rerank(\n    query: str,\n    documents: List[str],\n    top_n: Optional[int] = None,\n    api_key: Optional[str] = None,\n    model: str = \"rerank-v3.5\",\n    base_url: str = \"https://api.cohere.com/v2/rerank\",\n    extra_body: Optional[Dict[str, Any]] = None,\n    enable_chunking: bool = False,\n    max_tokens_per_doc: int = 4096,\n) -> List[Dict[str, Any]]:\n    \"\"\"\n    Rerank documents using Cohere API.\n\n    Supports both standard Cohere API and Cohere-compatible proxies\n\n    Args:\n        query: The search query\n        documents: List of strings to rerank\n        top_n: Number of top results to return\n        api_key: API key for authentication\n        model: rerank model name (default: rerank-v3.5)\n        base_url: API endpoint\n        extra_body: Additional body for http request(reserved for extra params)\n        enable_chunking: Whether to chunk documents exceeding max_tokens_per_doc\n        max_tokens_per_doc: Maximum tokens per document (default: 4096 for Cohere v3.5)\n\n    Returns:\n        List of dictionary of [\"index\": int, \"relevance_score\": float]\n\n    Example:\n        >>> # Standard Cohere API\n        >>> results = await cohere_rerank(\n        ...     query=\"What is the meaning of life?\",\n        ...     documents=[\"Doc1\", \"Doc2\"],\n        ...     api_key=\"your-cohere-key\"\n        ... )\n\n        >>> # LiteLLM proxy with user authentication\n        >>> results = await cohere_rerank(\n        ...     query=\"What is vector search?\",\n        ...     documents=[\"Doc1\", \"Doc2\"],\n        ...     model=\"answerai-colbert-small-v1\",\n        ...     base_url=\"https://llm-proxy.example.com/v2/rerank\",\n        ...     api_key=\"your-proxy-key\",\n        ...     enable_chunking=True,\n        ...     max_tokens_per_doc=480\n        ... )\n    \"\"\"\n    if api_key is None:\n        api_key = os.getenv(\"COHERE_API_KEY\") or os.getenv(\"RERANK_BINDING_API_KEY\")\n\n    return await generic_rerank_api(\n        query=query,\n        documents=documents,\n        model=model,\n        base_url=base_url,\n        api_key=api_key,\n        top_n=top_n,\n        return_documents=None,  # Cohere doesn't support this parameter\n        extra_body=extra_body,\n        response_format=\"standard\",\n        enable_chunking=enable_chunking,\n        max_tokens_per_doc=max_tokens_per_doc,\n    )\n\n\nasync def jina_rerank(\n    query: str,\n    documents: List[str],\n    top_n: Optional[int] = None,\n    api_key: Optional[str] = None,\n    model: str = \"jina-reranker-v2-base-multilingual\",\n    base_url: str = \"https://api.jina.ai/v1/rerank\",\n    extra_body: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n    \"\"\"\n    Rerank documents using Jina AI API.\n\n    Args:\n        query: The search query\n        documents: List of strings to rerank\n        top_n: Number of top results to return\n        api_key: API key\n        model: rerank model name\n        base_url: API endpoint\n        extra_body: Additional body for http request(reserved for extra params)\n\n    Returns:\n        List of dictionary of [\"index\": int, \"relevance_score\": float]\n    \"\"\"\n    if api_key is None:\n        api_key = os.getenv(\"JINA_API_KEY\") or os.getenv(\"RERANK_BINDING_API_KEY\")\n\n    return await generic_rerank_api(\n        query=query,\n        documents=documents,\n        model=model,\n        base_url=base_url,\n        api_key=api_key,\n        top_n=top_n,\n        return_documents=False,\n        extra_body=extra_body,\n        response_format=\"standard\",\n    )\n\n\nasync def ali_rerank(\n    query: str,\n    documents: List[str],\n    top_n: Optional[int] = None,\n    api_key: Optional[str] = None,\n    model: str = \"gte-rerank-v2\",\n    base_url: str = \"https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank\",\n    extra_body: Optional[Dict[str, Any]] = None,\n) -> List[Dict[str, Any]]:\n    \"\"\"\n    Rerank documents using Aliyun DashScope API.\n\n    Args:\n        query: The search query\n        documents: List of strings to rerank\n        top_n: Number of top results to return\n        api_key: Aliyun API key\n        model: rerank model name\n        base_url: API endpoint\n        extra_body: Additional body for http request(reserved for extra params)\n\n    Returns:\n        List of dictionary of [\"index\": int, \"relevance_score\": float]\n    \"\"\"\n    if api_key is None:\n        api_key = os.getenv(\"DASHSCOPE_API_KEY\") or os.getenv(\"RERANK_BINDING_API_KEY\")\n\n    return await generic_rerank_api(\n        query=query,\n        documents=documents,\n        model=model,\n        base_url=base_url,\n        api_key=api_key,\n        top_n=top_n,\n        return_documents=False,  # Aliyun doesn't need this parameter\n        extra_body=extra_body,\n        response_format=\"aliyun\",\n        request_format=\"aliyun\",\n    )\n\n\n\"\"\"Please run this test as a module:\npython -m lightrag.rerank\n\"\"\"\nif __name__ == \"__main__\":\n    import asyncio\n\n    async def main():\n        # Example usage - documents should be strings, not dictionaries\n        docs = [\n            \"The capital of France is Paris.\",\n            \"Tokyo is the capital of Japan.\",\n            \"London is the capital of England.\",\n        ]\n\n        query = \"What is the capital of France?\"\n\n        # Test Jina rerank\n        try:\n            print(\"=== Jina Rerank ===\")\n            result = await jina_rerank(\n                query=query,\n                documents=docs,\n                top_n=2,\n            )\n            print(\"Results:\")\n            for item in result:\n                print(f\"Index: {item['index']}, Score: {item['relevance_score']:.4f}\")\n                print(f\"Document: {docs[item['index']]}\")\n        except Exception as e:\n            print(f\"Jina Error: {e}\")\n\n        # Test Cohere rerank\n        try:\n            print(\"\\n=== Cohere Rerank ===\")\n            result = await cohere_rerank(\n                query=query,\n                documents=docs,\n                top_n=2,\n            )\n            print(\"Results:\")\n            for item in result:\n                print(f\"Index: {item['index']}, Score: {item['relevance_score']:.4f}\")\n                print(f\"Document: {docs[item['index']]}\")\n        except Exception as e:\n            print(f\"Cohere Error: {e}\")\n\n        # Test Aliyun rerank\n        try:\n            print(\"\\n=== Aliyun Rerank ===\")\n            result = await ali_rerank(\n                query=query,\n                documents=docs,\n                top_n=2,\n            )\n            print(\"Results:\")\n            for item in result:\n                print(f\"Index: {item['index']}, Score: {item['relevance_score']:.4f}\")\n                print(f\"Document: {docs[item['index']]}\")\n        except Exception as e:\n            print(f\"Aliyun Error: {e}\")\n\n    asyncio.run(main())\n"
  },
  {
    "path": "lightrag/tools/README_CLEAN_LLM_QUERY_CACHE.md",
    "content": "# LLM Query Cache Cleanup Tool - User Guide\n\n## Overview\n\nThis tool cleans up LightRAG's LLM query cache from KV storage implementations. It specifically targets query caches generated during RAG query operations (modes: `mix`, `hybrid`, `local`, `global`), including both query and keywords caches.\n\n## Supported Storage Types\n\n1. **JsonKVStorage** - File-based JSON storage\n2. **RedisKVStorage** - Redis database storage\n3. **PGKVStorage** - PostgreSQL database storage\n4. **MongoKVStorage** - MongoDB database storage\n5. **OpenSearchKVStorage** - OpenSearch index storage\n\n## Cache Types\n\nThe tool cleans up the following query cache types:\n\n### Query Cache Modes (4 types)\n- `mix:*` - Mixed mode query caches\n- `hybrid:*` - Hybrid mode query caches\n- `local:*` - Local mode query caches\n- `global:*` - Global mode query caches\n\n### Cache Content Types (2 types)\n- `*:query:*` - Query result caches\n- `*:keywords:*` - Keywords extraction caches\n\n### Cache Key Format\n```\n<mode>:<cache_type>:<hash>\n```\n\nExamples:\n- `mix:query:5ce04d25e957c290216cee5bfe6344fa`\n- `mix:keywords:fee77b98244a0b047ce95e21060de60e`\n- `global:query:abc123def456...`\n- `local:keywords:789xyz...`\n\n**Important Note**: This tool does NOT clean extraction caches (`default:extract:*` and `default:summary:*`). Use the migration tool or manual deletion for those caches.\n\n## Prerequisites\n\n- The tool reads storage configuration from environment variables or `config.ini`\n- Ensure the target storage is properly configured and accessible\n- Backup important data before running cleanup operations\n\n## Usage\n\n### Basic Usage\n\nRun from the LightRAG project root directory:\n\n```bash\npython -m lightrag.tools.clean_llm_query_cache\n# or\npython lightrag/tools/clean_llm_query_cache.py\n```\n\n### Interactive Workflow\n\nThe tool guides you through the following steps:\n\n#### 1. Select Storage Type\n```\n============================================================\nLLM Query Cache Cleanup Tool - LightRAG\n============================================================\n\n=== Storage Setup ===\n\nSupported KV Storage Types:\n[1] JsonKVStorage\n[2] RedisKVStorage\n[3] PGKVStorage\n[4] MongoKVStorage\n[5] OpenSearchKVStorage\n\nSelect storage type (1-5) (Press Enter to exit): 1\n```\n\n**Note**: You can press Enter or type `0` at any prompt to exit gracefully.\n\n#### 2. Storage Validation\nThe tool will:\n- Check required environment variables\n- Auto-detect workspace configuration\n- Initialize and connect to storage\n- Verify connection status\n\n```\nChecking configuration...\n✓ All required environment variables are set\n\nInitializing storage...\n- Storage Type: JsonKVStorage\n- Workspace: space1\n- Connection Status: ✓ Success\n```\n\n#### 3. View Cache Statistics\n\nThe tool displays a detailed breakdown of query caches by mode and type:\n\n```\nCounting query cache records...\n\n📊 Query Cache Statistics (Before Cleanup):\n┌────────────┬────────────┬────────────┬────────────┐\n│ Mode       │ Query      │ Keywords   │ Total      │\n├────────────┼────────────┼────────────┼────────────┤\n│ mix        │      1,234 │        567 │      1,801 │\n│ hybrid     │        890 │        423 │      1,313 │\n│ local      │      2,345 │      1,123 │      3,468 │\n│ global     │        678 │        345 │      1,023 │\n├────────────┼────────────┼────────────┼────────────┤\n│ Total      │      5,147 │      2,458 │      7,605 │\n└────────────┴────────────┴────────────┴────────────┘\n```\n\n#### 4. Select Cleanup Scope\n\nChoose what type of caches to delete:\n\n```\n=== Cleanup Options ===\n[1] Delete all query caches (both query and keywords)\n[2] Delete query caches only (keep keywords)\n[3] Delete keywords caches only (keep query)\n[0] Cancel\n\nSelect cleanup option (0-3): 1\n```\n\n**Cleanup Types:**\n- **Option 1 (all)**: Deletes both query and keywords caches across all modes\n- **Option 2 (query)**: Deletes only query caches, preserves keywords caches\n- **Option 3 (keywords)**: Deletes only keywords caches, preserves query caches\n\n#### 5. Confirm Deletion\n\nReview the cleanup plan and confirm:\n\n```\n============================================================\nCleanup Confirmation\n============================================================\nStorage: JsonKVStorage (workspace: space1)\nCleanup Type: all\nRecords to Delete: 7,605 / 7,605\n\n⚠️  WARNING: This will delete ALL query caches across all modes!\n\nContinue with deletion? (y/n): y\n```\n\n#### 6. Execute Cleanup\n\nThe tool performs batch deletion with real-time progress:\n\n**JsonKVStorage Example:**\n```\n=== Starting Cleanup ===\n💡 Processing 1,000 records at a time from JsonKVStorage\n\nBatch 1/8: ████░░░░░░░░░░░░░░░░ 1,000/7,605 (13.1%) ✓\nBatch 2/8: ████████░░░░░░░░░░░░ 2,000/7,605 (26.3%) ✓\n...\nBatch 8/8: ████████████████████ 7,605/7,605 (100.0%) ✓\n\nPersisting changes to storage...\n✓ Changes persisted successfully\n```\n\n**RedisKVStorage Example:**\n```\n=== Starting Cleanup ===\n💡 Processing Redis keys in batches of 1,000\n\nBatch 1: Deleted 1,000 keys (Total: 1,000) ✓\nBatch 2: Deleted 1,000 keys (Total: 2,000) ✓\n...\n```\n\n**PostgreSQL Example:**\n```\n=== Starting Cleanup ===\n💡 Executing PostgreSQL DELETE query\n\n✓ Deleted 7,605 records in 0.45s\n```\n\n**MongoDB Example:**\n```\n=== Starting Cleanup ===\n💡 Executing MongoDB deleteMany operations\n\nPattern 1/8: Deleted 1,234 records ✓\nPattern 2/8: Deleted 567 records ✓\n...\nTotal deleted: 7,605 records\n```\n\n**OpenSearchKVStorage Example:**\n```\n=== Starting Cleanup ===\n💡 Processing 1,000 records at a time from OpenSearchKVStorage\n\nBatch 1/8: ████░░░░░░░░░░░░░░░░ 1,000/7,605 (13.1%) ✓\nBatch 2/8: ████████░░░░░░░░░░░░ 2,000/7,605 (26.3%) ✓\n...\n```\n\n#### 7. Review Cleanup Report\n\nThe tool provides a comprehensive final report:\n\n**Successful Cleanup:**\n```\n============================================================\nCleanup Complete - Final Report\n============================================================\n\n📊 Statistics:\n  Total records to delete:  7,605\n  Total batches:            8\n  Successful batches:       8\n  Failed batches:           0\n  Successfully deleted:     7,605\n  Failed to delete:         0\n  Success rate:             100.00%\n\n📈 Before/After Comparison:\n  Total caches before:      7,605\n  Total caches after:       0\n  Net reduction:            7,605\n\n============================================================\n✓ SUCCESS: All records cleaned up successfully!\n============================================================\n\n📊 Query Cache Statistics (After Cleanup):\n┌────────────┬────────────┬────────────┬────────────┐\n│ Mode       │ Query      │ Keywords   │ Total      │\n├────────────┼────────────┼────────────┼────────────┤\n│ mix        │          0 │          0 │          0 │\n│ hybrid     │          0 │          0 │          0 │\n│ local      │          0 │          0 │          0 │\n│ global     │          0 │          0 │          0 │\n├────────────┼────────────┼────────────┼────────────┤\n│ Total      │          0 │          0 │          0 │\n└────────────┴────────────┴────────────┴────────────┘\n```\n\n**Cleanup with Errors:**\n```\n============================================================\nCleanup Complete - Final Report\n============================================================\n\n📊 Statistics:\n  Total records to delete:  7,605\n  Total batches:            8\n  Successful batches:       7\n  Failed batches:           1\n  Successfully deleted:     6,605\n  Failed to delete:         1,000\n  Success rate:             86.85%\n\n📈 Before/After Comparison:\n  Total caches before:      7,605\n  Total caches after:       1,000\n  Net reduction:            6,605\n\n⚠️  Errors encountered: 1\n\nError Details:\n------------------------------------------------------------\n\nError Summary:\n  - ConnectionError: 1 occurrence(s)\n\nFirst 5 errors:\n\n  1. Batch 3\n     Type: ConnectionError\n     Message: Connection timeout after 30s\n     Records lost: 1,000\n\n============================================================\n⚠️  WARNING: Cleanup completed with errors!\n   Please review the error details above.\n============================================================\n```\n\n## Technical Details\n\n### Workspace Handling\n\nThe tool retrieves workspace in the following priority order:\n\n1. **Storage-specific workspace environment variables**\n   - PGKVStorage: `POSTGRES_WORKSPACE`\n   - MongoKVStorage: `MONGODB_WORKSPACE`\n   - RedisKVStorage: `REDIS_WORKSPACE`\n   - OpenSearchKVStorage: `OPENSEARCH_WORKSPACE`\n\n2. **Generic workspace environment variable**\n   - `WORKSPACE`\n\n3. **Default value**\n   - Empty string (uses storage's default workspace)\n\n### Batch Deletion\n\n- Default batch size: 1000 records/batch\n- Prevents memory overflow and connection timeouts\n- Each batch is processed independently\n- Failed batches are logged but don't stop cleanup\n\n### Storage-Specific Deletion Strategies\n\n#### JsonKVStorage\n- Collects all matching keys first (snapshot approach)\n- Deletes in batches with lock protection\n- Fast in-memory operations\n\n#### RedisKVStorage\n- Uses SCAN with pattern matching\n- Pipeline DELETE for batch operations\n- Cursor-based iteration for large datasets\n\n#### PostgreSQL\n- Single DELETE query with OR conditions\n- Efficient server-side bulk deletion\n- Uses LIKE patterns for mode/type matching\n\n#### MongoDB\n- Multiple deleteMany operations (one per pattern)\n- Regex-based document matching\n- Returns exact deletion counts\n\n### Pattern Matching Implementation\n\n**JsonKVStorage:**\n```python\n# Direct key prefix matching\nif key.startswith(\"mix:query:\") or key.startswith(\"mix:keywords:\")\n```\n\n**RedisKVStorage:**\n```python\n# SCAN with namespace-prefixed patterns\npattern = f\"{namespace}:mix:query:*\"\ncursor, keys = await redis.scan(cursor, match=pattern)\n```\n\n**PostgreSQL:**\n```python\n# SQL LIKE conditions\nWHERE id LIKE 'mix:query:%' OR id LIKE 'mix:keywords:%'\n```\n\n**MongoDB:**\n```python\n# Regex queries on _id field\n{\"_id\": {\"$regex\": \"^mix:query:\"}}\n```\n\n**OpenSearchKVStorage:**\n```python\n# Scan raw hits, then match cache key prefixes in Python\nif hit[\"_id\"].startswith(\"mix:query:\"):\n```\n\n## Error Handling & Resilience\n\nThe tool implements comprehensive error tracking:\n\n### Batch-Level Error Tracking\n- Each batch is independently error-checked\n- Failed batches are logged with full details\n- Successful batches commit even if later batches fail\n- Real-time progress shows ✓ (success) or ✗ (failed)\n\n### Error Reporting\nAfter cleanup completes, a detailed report includes:\n- **Statistics**: Total records, success/failure counts, success rate\n- **Before/After Comparison**: Net reduction in cache count\n- **Error Summary**: Grouped by error type with occurrence counts\n- **Error Details**: Batch number, error type, message, and records lost\n- **Recommendations**: Clear indication of success or need for review\n\n### Verification\n- Post-cleanup count verification\n- Before/after statistics comparison\n- Identifies partial cleanup scenarios\n\n## Important Notes\n\n1. **Irreversible Operation**\n   - Deleted caches cannot be recovered\n   - Always backup important data before cleanup\n   - Test on non-production data first\n\n2. **Performance Impact**\n   - Query performance may degrade temporarily after cleanup\n   - Caches will rebuild on subsequent queries\n   - Consider cleanup during off-peak hours\n\n3. **Selective Cleanup**\n   - Choose cleanup scope carefully\n   - Keywords caches may be valuable for future queries\n   - Query caches rebuild faster than keywords caches\n\n4. **Workspace Isolation**\n   - Cleanup only affects the selected workspace\n   - Other workspaces remain untouched\n   - Verify workspace before confirming cleanup\n\n5. **Interrupt and Resume**\n   - Cleanup can be interrupted at any time (Ctrl+C)\n   - Already deleted records cannot be recovered\n   - No automatic resume - must run tool again\n\n## Storage Configuration\n\nThe tool supports multiple configuration methods with the following priority:\n\n1. **Environment variables** (highest priority)\n2. **config.ini file** (medium priority)\n3. **Default values** (lowest priority)\n\n### Environment Variable Configuration\n\nConfigure storage settings in your `.env` file:\n\n#### Workspace Configuration (Optional)\n\n```bash\n# Generic workspace (shared by all storages)\nWORKSPACE=space1\n\n# Or configure independent workspace for specific storage\nPOSTGRES_WORKSPACE=pg_space\nMONGODB_WORKSPACE=mongo_space\nREDIS_WORKSPACE=redis_space\n```\n\n**Workspace Priority**: Storage-specific > Generic WORKSPACE > Empty string\n\n#### JsonKVStorage\n\n```bash\nWORKING_DIR=./rag_storage\n```\n\n#### RedisKVStorage\n\n```bash\nREDIS_URI=redis://localhost:6379\n```\n\n#### PGKVStorage\n\n```bash\nPOSTGRES_HOST=localhost\nPOSTGRES_PORT=5432\nPOSTGRES_USER=your_username\nPOSTGRES_PASSWORD=your_password\nPOSTGRES_DATABASE=your_database\n```\n\n#### MongoKVStorage\n\n```bash\nMONGO_URI=mongodb://root:root@localhost:27017/\nMONGO_DATABASE=LightRAG\n```\n\n#### OpenSearchKVStorage\n\n```bash\nOPENSEARCH_HOSTS=localhost:9200\nOPENSEARCH_WORKSPACE=search_space\n```\n\n### config.ini Configuration\n\nAlternatively, create a `config.ini` file in the project root:\n\n```ini\n[redis]\nuri = redis://localhost:6379\n\n[postgres]\nhost = localhost\nport = 5432\nuser = postgres\npassword = yourpassword\ndatabase = lightrag\n\n[mongodb]\nuri = mongodb://root:root@localhost:27017/\ndatabase = LightRAG\n\n[opensearch]\nhosts = localhost:9200\n```\n\n**Note**: Environment variables take precedence over config.ini settings.\n\n## Troubleshooting\n\n### Missing Environment Variables\n```\n⚠️  Warning: Missing environment variables: POSTGRES_USER, POSTGRES_PASSWORD\n```\n**Solution**: Add missing variables to your `.env` file or configure in `config.ini`\n\n### Connection Failed\n```\n✗ Initialization failed: Connection refused\n```\n**Solutions**:\n- Check if database service is running\n- Verify connection parameters (host, port, credentials)\n- Check firewall settings\n- Ensure network connectivity for remote databases\n\n### No Caches Found\n```\n⚠️  No query caches found in storage\n```\n**Possible Reasons**:\n- No queries have been run yet\n- Caches were already cleaned\n- Wrong workspace selected\n- Different storage type was used for queries\n\n### Partial Cleanup\n```\n⚠️  WARNING: Cleanup completed with errors!\n```\n**Solutions**:\n- Check error details in the report\n- Verify storage connection stability\n- Re-run tool to clean remaining caches\n- Check storage capacity and permissions\n\n## Use Cases\n\n### Use Case 1: Clean All Query Caches\n\n**Scenario**: Free up storage space by removing all query caches\n\n```bash\n# Run tool\npython -m lightrag.tools.clean_llm_query_cache\n\n# Select: Storage type -> Option 1 (all) -> Confirm (y)\n```\n\n**Result**: All query and keywords caches deleted, maximum storage freed\n\n### Use Case 2: Refresh Query Caches Only\n\n**Scenario**: Force query cache rebuild while keeping keywords\n\n```bash\n# Run tool\npython -m lightrag.tools.clean_llm_query_cache\n\n# Select: Storage type -> Option 2 (query only) -> Confirm (y)\n```\n\n**Result**: Query caches deleted, keywords preserved for faster rebuild\n\n### Use Case 3: Clean Stale Keywords\n\n**Scenario**: Remove outdated keywords while keeping recent query results\n\n```bash\n# Run tool\npython -m lightrag.tools.clean_llm_query_cache\n\n# Select: Storage type -> Option 3 (keywords only) -> Confirm (y)\n```\n\n**Result**: Keywords deleted, query caches preserved\n\n### Use Case 4: Workspace-Specific Cleanup\n\n**Scenario**: Clean caches for a specific workspace\n\n```bash\n# Configure workspace\nexport WORKSPACE=development\n\n# Run tool\npython -m lightrag.tools.clean_llm_query_cache\n\n# Select: Storage type -> Cleanup option -> Confirm (y)\n```\n\n**Result**: Only development workspace caches cleaned\n\n## Best Practices\n\n1. **Backup Before Cleanup**\n   - Always backup your storage before major cleanup\n   - Test cleanup on non-production data first\n   - Document cleanup decisions\n\n2. **Monitor Performance**\n   - Watch storage metrics during cleanup\n   - Monitor query performance after cleanup\n   - Allow time for cache rebuild\n\n3. **Scheduled Cleanup**\n   - Clean caches periodically (weekly/monthly)\n   - Automate cleanup for development environments\n   - Keep production cleanup manual for safety\n\n4. **Selective Deletion**\n   - Consider cleanup scope based on needs\n   - Keywords caches are harder to rebuild\n   - Query caches rebuild automatically\n\n5. **Storage Capacity**\n   - Monitor storage usage trends\n   - Clean caches before reaching capacity limits\n   - Archive old data if needed\n\n## Comparison with Migration Tool\n\n| Feature | Cleanup Tool | Migration Tool |\n|---------|-------------|----------------|\n| **Purpose** | Delete query caches | Migrate extraction caches |\n| **Cache Types** | mix/hybrid/local/global | default:extract/summary |\n| **Modes** | query, keywords | extract, summary |\n| **Operation** | Deletion | Copy between storages |\n| **Reversible** | No | Yes (source unchanged) |\n| **Use Case** | Free storage, refresh caches | Change storage backend |\n\n## Limitations\n\n1. **Single Storage Operation**\n   - Can only clean one storage type at a time\n   - To clean multiple storages, run tool multiple times\n\n2. **No Dry Run Mode**\n   - Deletion is immediate after confirmation\n   - No preview-only mode available\n   - Test on non-production first\n\n3. **No Selective Mode Cleanup**\n   - Cannot clean only specific modes (e.g., only `mix`)\n   - Cleanup applies to all modes for selected cache type\n   - All-or-nothing per cache type\n\n4. **No Scheduled Cleanup**\n   - Manual execution required\n   - No built-in scheduling\n   - Use cron/scheduler if automation needed\n\n5. **Verification Limitations**\n   - Post-cleanup verification may fail in error scenarios\n   - Manual verification recommended for critical operations\n\n## Future Enhancements\n\nPotential improvements for future versions:\n\n- Selective mode cleanup (e.g., clean only `mix` mode)\n- Age-based cleanup (delete caches older than X days)\n- Size-based cleanup (delete largest caches first)\n- Dry run mode for safe preview\n- Automated scheduling support\n- Cache statistics export\n- Incremental cleanup with pause/resume\n\n## Support\n\nFor issues, questions, or feature requests:\n- Check the error details in the cleanup report\n- Review storage configuration\n- Verify workspace settings\n- Test with a small dataset first\n- Report bugs through project issue tracker\n"
  },
  {
    "path": "lightrag/tools/README_MIGRATE_LLM_CACHE.md",
    "content": "# LLM Cache Migration Tool - User Guide\n\n## Overview\n\nThis tool migrates LightRAG's LLM response cache between different KV storage implementations. It specifically migrates caches generated during file extraction (mode `default`), including entity extraction and summary caches.\n\n## Supported Storage Types\n\n1. **JsonKVStorage** - File-based JSON storage\n2. **RedisKVStorage** - Redis database storage\n3. **PGKVStorage** - PostgreSQL database storage\n4. **MongoKVStorage** - MongoDB database storage\n5. **OpenSearchKVStorage** - OpenSearch index storage\n\n## Cache Types\n\nThe tool migrates the following cache types:\n- `default:extract:*` - Entity and relationship extraction caches\n- `default:summary:*` - Entity and relationship summary caches\n\n**Note**: Query caches (modes like `mix`,`local`, `global`, etc.) are NOT migrated.\n\n## Prerequisites\n\nThe LLM Cache Migration Tool reads the storage configuration of the LightRAG Server and provides an LLM migration option to select source and destination storage. Ensure that both the source and destination storage have been correctly configured and are accessible via the LightRAG Server before cache migration.\n\n## Usage\n\n### Basic Usage\n\nRun from the LightRAG project root directory:\n\n```bash\npython -m lightrag.tools.migrate_llm_cache\n# or\npython lightrag/tools/migrate_llm_cache.py\n```\n\n### Interactive Workflow\n\nThe tool guides you through the following steps:\n\n#### 1. Select Source Storage Type\n```\nSupported KV Storage Types:\n[1] JsonKVStorage\n[2] RedisKVStorage\n[3] PGKVStorage\n[4] MongoKVStorage\n[5] OpenSearchKVStorage\n\nSelect Source storage type (1-5) (Press Enter to exit): 1\n```\n\n**Note**: You can press Enter or type `0` at any storage selection prompt to exit gracefully.\n\n#### 2. Source Storage Validation\nThe tool will:\n- Check required environment variables\n- Auto-detect workspace configuration\n- Initialize and connect to storage\n- Count cache records available for migration\n\n```\nChecking environment variables...\n✓ All required environment variables are set\n\nInitializing Source storage...\n- Storage Type: JsonKVStorage\n- Workspace: space1\n- Connection Status: ✓ Success\n\nCounting cache records...\n- Total: 8,734 records\n```\n\n**Progress Display by Storage Type:**\n- **JsonKVStorage**: Fast in-memory counting, displays final count without incremental progress\n  ```\n  Counting cache records...\n  - Total: 8,734 records\n  ```\n- **RedisKVStorage**: Real-time scanning progress with incremental counts\n  ```\n  Scanning Redis keys... found 8,734 records\n  ```\n- **PostgreSQL**: Quick COUNT(*) query, shows timing only if operation takes >1 second\n  ```\n  Counting PostgreSQL records... (took 2.3s)\n  ```\n- **MongoDB**: Fast count_documents(), shows timing only if operation takes >1 second\n  ```\n  Counting MongoDB documents... (took 1.8s)\n  ```\n- **OpenSearchKVStorage**: PIT-based scan with timing shown when noticeable\n  ```\n  Scanning OpenSearch documents... (took 1.5s)\n  ```\n\n#### 3. Select Target Storage Type\n\nThe tool automatically excludes the source storage type from the target selection and renumbers the remaining options sequentially:\n\n```\nAvailable Storage Types for Target (source: JsonKVStorage excluded):\n[1] RedisKVStorage\n[2] PGKVStorage\n[3] MongoKVStorage\n[4] OpenSearchKVStorage\n\nSelect Target storage type (1-4) (Press Enter or 0 to exit): 1\n```\n\n**Important Notes:**\n- You **cannot** select the same storage type for both source and target\n- Options are automatically renumbered (e.g., [1], [2], [3] instead of [2], [3], [4])\n- You can press Enter or type `0` to exit at this point as well\n\nThe tool then validates the target storage following the same process as the source (checking environment variables, initializing connection, counting records).\n\n#### 4. Confirm Migration\n\n```\n==================================================\nMigration Confirmation\nSource: JsonKVStorage (workspace: space1) - 8,734 records\nTarget: MongoKVStorage (workspace: space1) - 0 records\nBatch Size: 1,000 records/batch\nMemory Mode: Streaming (memory-optimized)\n\n⚠️  Warning: Target storage already has 0 records\nMigration will overwrite records with the same keys\n\nContinue? (y/n): y\n```\n\n#### 5. Execute Migration\n\nThe tool uses **streaming migration** by default for memory efficiency. Observe migration progress:\n\n```\n=== Starting Streaming Migration ===\n💡 Memory-optimized mode: Processing 1,000 records at a time\n\nBatch 1/9: ████████░░░░░░░░░░░░ 1000/8734 (11.4%) - default:extract ✓\nBatch 2/9: ████████████░░░░░░░░ 2000/8734 (22.9%) - default:extract ✓\n...\nBatch 9/9: ████████████████████ 8734/8734 (100.0%) - default:summary ✓\n\nPersisting data to disk...\n✓ Data persisted successfully\n```\n\n**Key Features:**\n- **Streaming mode**: Processes data in batches without loading entire dataset into memory\n- **Real-time progress**: Shows progress bar with precise percentage and cache type\n- **Success indicators**: ✓ for successful batches, ✗ for failed batches\n- **Constant memory usage**: Handles millions of records efficiently\n\n#### 6. Review Migration Report\n\nThe tool provides a comprehensive final report showing statistics and any errors encountered:\n\n**Successful Migration:**\n```\nMigration Complete - Final Report\n\n📊 Statistics:\n  Total source records:    8,734\n  Total batches:           9\n  Successful batches:      9\n  Failed batches:          0\n  Successfully migrated:   8,734\n  Failed to migrate:       0\n  Success rate:            100.00%\n\n✓ SUCCESS: All records migrated successfully!\n```\n\n**Migration with Errors:**\n```\nMigration Complete - Final Report\n\n📊 Statistics:\n  Total source records:    8,734\n  Total batches:           9\n  Successful batches:      8\n  Failed batches:          1\n  Successfully migrated:   7,734\n  Failed to migrate:       1,000\n  Success rate:            88.55%\n\n⚠️  Errors encountered: 1\n\nError Details:\n------------------------------------------------------------\n\nError Summary:\n  - ConnectionError: 1 occurrence(s)\n\nFirst 5 errors:\n\n  1. Batch 2\n     Type: ConnectionError\n     Message: Connection timeout after 30s\n     Records lost: 1,000\n\n⚠️  WARNING: Migration completed with errors!\n   Please review the error details above.\n```\n\n## Technical Details\n\n### Workspace Handling\n\nThe tool retrieves workspace in the following priority order:\n\n1. **Storage-specific workspace environment variables**\n   - PGKVStorage: `POSTGRES_WORKSPACE`\n   - MongoKVStorage: `MONGODB_WORKSPACE`\n   - RedisKVStorage: `REDIS_WORKSPACE`\n   - OpenSearchKVStorage: `OPENSEARCH_WORKSPACE`\n\n2. **Generic workspace environment variable**\n   - `WORKSPACE`\n\n3. **Default value**\n   - Empty string (uses storage's default workspace)\n\n### Batch Migration\n\n- Default batch size: 1000 records/batch\n- Avoids memory overflow from loading too much data at once\n- Each batch is committed independently, supporting resume capability\n\n### Memory-Efficient Pagination\n\nFor large datasets, the tool implements storage-specific pagination strategies:\n\n- **JsonKVStorage**: Direct in-memory access (data already loaded in shared storage)\n- **RedisKVStorage**: Cursor-based SCAN with pipeline batching (1000 keys/batch)\n- **PGKVStorage**: SQL LIMIT/OFFSET pagination (1000 records/batch)\n- **MongoKVStorage**: Cursor streaming with batch_size (1000 documents/batch)\n- **OpenSearchKVStorage**: PIT + `search_after` scan of the KV index (1000 documents/batch)\n\nThis ensures the tool can handle millions of cache records without memory issues.\n\n### Prefix Filtering Implementation\n\nThe tool uses optimized filtering methods for different storage types:\n\n- **JsonKVStorage**: Direct dictionary iteration with lock protection\n- **RedisKVStorage**: SCAN command with namespace-prefixed patterns + pipeline for bulk GET\n- **PGKVStorage**: SQL LIKE queries with proper field mapping (id, return_value, etc.)\n- **MongoKVStorage**: MongoDB regex queries on `_id` field with cursor streaming\n- **OpenSearchKVStorage**: Full-index scan with `_id` prefix filtering and `_source` passthrough\n\n## Error Handling & Resilience\n\nThe tool implements comprehensive error tracking to ensure transparent and resilient migrations:\n\n### Batch-Level Error Tracking\n- Each batch is independently error-checked\n- Failed batches are logged but don't stop the migration\n- Successful batches are committed even if later batches fail\n- Real-time progress shows ✓ (success) or ✗ (failed) for each batch\n\n### Error Reporting\nAfter migration completes, a detailed report includes:\n- **Statistics**: Total records, success/failure counts, success rate\n- **Error Summary**: Grouped by error type with occurrence counts\n- **Error Details**: Batch number, error type, message, and records lost\n- **Recommendations**: Clear indication of success or need for review\n\n### No Double Data Loading\n- Unlike traditional verification approaches, the tool does NOT reload all target data\n- Errors are detected during migration, not after\n- This eliminates memory overhead and handles pre-existing target data correctly\n\n## Important Notes\n\n1. **Data Overwrite Warning**\n   - Migration will overwrite records with the same keys in the target storage\n   - Tool displays a warning if target storage already has data\n   - Data migration can be performed repeatedly\n   - Pre-existing data in target storage is handled correctly\n3. **Interrupt and Resume**\n   - Migration can be interrupted at any time (Ctrl+C)\n   - Already migrated data will remain in target storage\n   - Re-running will overwrite existing records\n   - Failed batches can be manually retried\n4. **Performance Considerations**\n   - Large data migration may take considerable time\n   - Recommend migrating during off-peak hours\n   - Ensure stable network connection (for remote databases)\n   - Memory usage stays constant regardless of dataset size\n\n## Storage Configuration\n\nThe tool supports multiple configuration methods with the following priority:\n\n1. **Environment variables** (highest priority)\n2. **config.ini file** (medium priority)\n3. **Default values** (lowest priority)\n\n#### Option A: Environment Variable Configuration\n\nConfigure storage settings in your `.env` file:\n\n#### Workspace Configuration (Optional)\n\n```bash\n# Generic workspace (shared by all storages)\nWORKSPACE=space1\n\n# Or configure independent workspace for specific storage\nPOSTGRES_WORKSPACE=pg_space\nMONGODB_WORKSPACE=mongo_space\nREDIS_WORKSPACE=redis_space\nOPENSEARCH_WORKSPACE=os_space\n```\n\n**Workspace Priority**: Storage-specific > Generic WORKSPACE > Empty string\n\n#### JsonKVStorage\n\n```bash\nWORKING_DIR=./rag_storage\n```\n\n#### RedisKVStorage\n\n```bash\nREDIS_URI=redis://localhost:6379\n```\n\n#### PGKVStorage\n\n```bash\nPOSTGRES_HOST=localhost\nPOSTGRES_PORT=5432\nPOSTGRES_USER=your_username\nPOSTGRES_PASSWORD=your_password\nPOSTGRES_DATABASE=your_database\n```\n\n#### MongoKVStorage\n\n```bash\nMONGO_URI=mongodb://root:root@localhost:27017/\nMONGO_DATABASE=LightRAG\n```\n\n#### OpenSearchKVStorage\n\n```bash\nOPENSEARCH_HOSTS=localhost:9200\nOPENSEARCH_WORKSPACE=os_space\n```\n\n#### Option B: config.ini Configuration\n\nAlternatively, create a `config.ini` file in the project root:\n\n```ini\n[redis]\nuri = redis://localhost:6379\n\n[postgres]\nhost = localhost\nport = 5432\nuser = postgres\npassword = yourpassword\ndatabase = lightrag\n\n[mongodb]\nuri = mongodb://root:root@localhost:27017/\ndatabase = LightRAG\n\n[opensearch]\nhosts = localhost:9200\n```\n\n**Note**: Environment variables take precedence over config.ini settings. JsonKVStorage uses `WORKING_DIR` environment variable or defaults to `./rag_storage`.\n\n## Troubleshooting\n\n### Missing Environment Variables\n```\n✗ Missing required environment variables: POSTGRES_USER, POSTGRES_PASSWORD\n```\n**Solution**: Add missing variables to your `.env` file\n\n### Connection Failed\n```\n✗ Initialization failed: Connection refused\n```\n**Solutions**:\n- Check if database service is running\n- Verify connection parameters (host, port, credentials)\n- Check firewall settings\n\n**Solutions**:\n- Check migration process for error logs\n- Re-run migration tool\n- Check target storage capacity and permissions\n\n## Example Scenarios\n\n### Scenario 1: JSON to MongoDB Migration\n\nUse case: Migrating from single-machine development to production\n\n```bash\n# 1. Configure environment variables\nWORKSPACE=production\nMONGO_URI=mongodb://user:pass@prod-server:27017/\nMONGO_DATABASE=LightRAG\n\n# 2. Run tool\npython -m lightrag.tools.migrate_llm_cache\n\n# 3. Select: 1 (JsonKVStorage) -> 1 (MongoKVStorage - renumbered from 4)\n```\n\n**Note**: After selecting JsonKVStorage as source, MongoKVStorage will be shown as option [1] in the target selection since options are renumbered after excluding the source.\n\n### Scenario 2: Redis to PostgreSQL\n\nUse case: Migrating from cache storage to relational database\n\n```bash\n# 1. Ensure both databases are accessible\nREDIS_URI=redis://old-redis:6379\nPOSTGRES_HOST=new-postgres-server\n# ... Other PostgreSQL configs\n\n# 2. Run tool\npython -m lightrag.tools.migrate_llm_cache\n\n# 3. Select: 2 (RedisKVStorage) -> 2 (PGKVStorage - renumbered from 3)\n```\n\n**Note**: After selecting RedisKVStorage as source, PGKVStorage will be shown as option [2] in the target selection.\n\n### Scenario 3: Different Workspaces Migration\n\nUse case: Migrating data between different workspace environments\n\n```bash\n# Configure separate workspaces for source and target\nPOSTGRES_WORKSPACE=dev_workspace  # For development environment\nMONGODB_WORKSPACE=prod_workspace  # For production environment\n\n# Run tool\npython -m lightrag.tools.migrate_llm_cache\n\n# Select: 3 (PGKVStorage with dev_workspace) -> 3 (MongoKVStorage with prod_workspace)\n```\n\n**Note**: This allows you to migrate between different logical data partitions while changing storage backends.\n\n## Tool Limitations\n\n1. **Same Storage Type Not Allowed**\n   - You cannot migrate between the same storage type (e.g., PostgreSQL to PostgreSQL)\n   - This is enforced by the tool automatically excluding the source storage type from target selection\n   - For same-storage migrations (e.g., database switches), use database-native tools instead\n2. **Only Default Mode Caches**\n   - Only migrates `default:extract:*` and `default:summary:*`\n   - Query caches are not included\n4. **Network Dependency**\n   - Tool requires stable network connection for remote databases\n   - Large datasets may fail if connection is interrupted\n\n## Best Practices\n\n1. **Backup Before Migration**\n   - Always backup your data before migration\n   - Test migration on non-production data first\n\n2. **Verify Results**\n   - Check the verification output after migration\n   - Manually verify a few cache entries if needed\n\n3. **Monitor Performance**\n   - Watch database resource usage during migration\n   - Consider migrating in smaller batches if needed\n\n4. **Clean Old Data**\n   - After successful migration, consider cleaning old cache data\n   - Keep backups for a reasonable period before deletion\n"
  },
  {
    "path": "lightrag/tools/__init__.py",
    "content": ""
  },
  {
    "path": "lightrag/tools/check_initialization.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nDiagnostic tool to check LightRAG initialization status.\n\nThis tool helps developers verify that their LightRAG instance is properly\ninitialized and ready to use. It should be called AFTER initialize_storages()\nto validate that all components are correctly set up.\n\nUsage:\n    # Basic usage in your code:\n    rag = LightRAG(...)\n    await rag.initialize_storages()\n    await check_lightrag_setup(rag, verbose=True)\n\n    # Run demo from command line:\n    python -m lightrag.tools.check_initialization --demo\n\"\"\"\n\nimport asyncio\nimport sys\nfrom pathlib import Path\n\n# Add parent directory to path for imports\nsys.path.insert(0, str(Path(__file__).parent.parent.parent))\n\nfrom lightrag import LightRAG\nfrom lightrag.base import StoragesStatus\n\n\nasync def check_lightrag_setup(rag_instance: LightRAG, verbose: bool = False) -> bool:\n    \"\"\"\n    Check if a LightRAG instance is properly initialized.\n\n    Args:\n        rag_instance: The LightRAG instance to check\n        verbose: If True, print detailed diagnostic information\n\n    Returns:\n        True if properly initialized, False otherwise\n    \"\"\"\n    issues = []\n    warnings = []\n\n    print(\"🔍 Checking LightRAG initialization status...\\n\")\n\n    # Check storage initialization status\n    if not hasattr(rag_instance, \"_storages_status\"):\n        issues.append(\"LightRAG instance missing _storages_status attribute\")\n    elif rag_instance._storages_status != StoragesStatus.INITIALIZED:\n        issues.append(\n            f\"Storages not initialized (status: {rag_instance._storages_status.name})\"\n        )\n    else:\n        print(\"✅ Storage status: INITIALIZED\")\n\n    # Check individual storage components\n    storage_components = [\n        (\"full_docs\", \"Document storage\"),\n        (\"text_chunks\", \"Text chunks storage\"),\n        (\"entities_vdb\", \"Entity vector database\"),\n        (\"relationships_vdb\", \"Relationship vector database\"),\n        (\"chunks_vdb\", \"Chunks vector database\"),\n        (\"doc_status\", \"Document status tracker\"),\n        (\"llm_response_cache\", \"LLM response cache\"),\n        (\"full_entities\", \"Entity storage\"),\n        (\"full_relations\", \"Relation storage\"),\n        (\"chunk_entity_relation_graph\", \"Graph storage\"),\n    ]\n\n    if verbose:\n        print(\"\\n📦 Storage Components:\")\n\n    for component, description in storage_components:\n        if not hasattr(rag_instance, component):\n            issues.append(f\"Missing storage component: {component} ({description})\")\n        else:\n            storage = getattr(rag_instance, component)\n            if storage is None:\n                warnings.append(f\"Storage {component} is None (might be optional)\")\n            elif hasattr(storage, \"_storage_lock\"):\n                if storage._storage_lock is None:\n                    issues.append(f\"Storage {component} not initialized (lock is None)\")\n                elif verbose:\n                    print(f\"  ✅ {description}: Ready\")\n            elif verbose:\n                print(f\"  ✅ {description}: Ready\")\n\n    # Check pipeline status\n    try:\n        from lightrag.kg.shared_storage import get_namespace_data\n\n        get_namespace_data(\"pipeline_status\", workspace=rag_instance.workspace)\n        print(\"✅ Pipeline status: INITIALIZED\")\n    except KeyError:\n        issues.append(\n            \"Pipeline status not initialized - call rag.initialize_storages() first\"\n        )\n    except Exception as e:\n        issues.append(f\"Error checking pipeline status: {str(e)}\")\n\n    # Print results\n    print(\"\\n\" + \"=\" * 50)\n\n    if issues:\n        print(\"❌ Issues found:\\n\")\n        for issue in issues:\n            print(f\"  • {issue}\")\n\n        print(\"\\n📝 To fix, run this initialization sequence:\\n\")\n        print(\"  await rag.initialize_storages()\")\n        print(\n            \"\\n📚 Documentation: https://github.com/HKUDS/LightRAG#important-initialization-requirements\"\n        )\n\n        if warnings and verbose:\n            print(\"\\n⚠️  Warnings (might be normal):\")\n            for warning in warnings:\n                print(f\"  • {warning}\")\n\n        return False\n    else:\n        print(\"✅ LightRAG is properly initialized and ready to use!\")\n\n        if warnings and verbose:\n            print(\"\\n⚠️  Warnings (might be normal):\")\n            for warning in warnings:\n                print(f\"  • {warning}\")\n\n        return True\n\n\nasync def demo():\n    \"\"\"Demonstrate the diagnostic tool with a test instance.\"\"\"\n    from lightrag.llm.openai import openai_embed, gpt_4o_mini_complete\n\n    print(\"=\" * 50)\n    print(\"LightRAG Initialization Diagnostic Tool\")\n    print(\"=\" * 50)\n\n    # Create test instance\n    rag = LightRAG(\n        working_dir=\"./test_diagnostic\",\n        embedding_func=openai_embed,\n        llm_model_func=gpt_4o_mini_complete,\n    )\n\n    print(\"\\n🔄 Initializing storages...\\n\")\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n\n    print(\"\\n🔍 Checking initialization status:\\n\")\n    await check_lightrag_setup(rag, verbose=True)\n\n    # Cleanup\n    import shutil\n\n    shutil.rmtree(\"./test_diagnostic\", ignore_errors=True)\n\n\nif __name__ == \"__main__\":\n    import argparse\n\n    parser = argparse.ArgumentParser(description=\"Check LightRAG initialization status\")\n    parser.add_argument(\n        \"--demo\", action=\"store_true\", help=\"Run a demonstration with a test instance\"\n    )\n    parser.add_argument(\n        \"--verbose\",\n        \"-v\",\n        action=\"store_true\",\n        help=\"Show detailed diagnostic information\",\n    )\n\n    args = parser.parse_args()\n\n    if args.demo:\n        asyncio.run(demo())\n    else:\n        print(\"Run with --demo to see the diagnostic tool in action\")\n        print(\"Or import this module and use check_lightrag_setup() with your instance\")\n"
  },
  {
    "path": "lightrag/tools/clean_llm_query_cache.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nLLM Query Cache Cleanup Tool for LightRAG\n\nThis tool cleans up LLM query cache (mix:*, hybrid:*, local:*, global:*)\nfrom KV storage implementations while preserving workspace isolation.\n\nUsage:\n    python -m lightrag.tools.clean_llm_query_cache\n    # or\n    python lightrag/tools/clean_llm_query_cache.py\n\nSupported KV Storage Types:\n    - JsonKVStorage\n    - RedisKVStorage\n    - PGKVStorage\n    - MongoKVStorage\n    - OpenSearchKVStorage\n\"\"\"\n\nimport asyncio\nimport os\nimport sys\nimport time\nfrom typing import Any, Dict, List\nfrom dataclasses import dataclass, field\nfrom dotenv import load_dotenv\n\n# Add project root to path for imports\nsys.path.insert(\n    0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\n)\n\nfrom lightrag.kg import STORAGE_ENV_REQUIREMENTS\nfrom lightrag.kg.shared_storage import set_all_update_flags\nfrom lightrag.namespace import NameSpace\nfrom lightrag.utils import setup_logger\n\n# Load environment variables\nload_dotenv(dotenv_path=\".env\", override=False)\n\n# Setup logger\nsetup_logger(\"lightrag\", level=\"INFO\")\n\n# Storage type configurations\nSTORAGE_TYPES = {\n    \"1\": \"JsonKVStorage\",\n    \"2\": \"RedisKVStorage\",\n    \"3\": \"PGKVStorage\",\n    \"4\": \"MongoKVStorage\",\n    \"5\": \"OpenSearchKVStorage\",\n}\n\n# Workspace environment variable mapping\nWORKSPACE_ENV_MAP = {\n    \"PGKVStorage\": \"POSTGRES_WORKSPACE\",\n    \"MongoKVStorage\": \"MONGODB_WORKSPACE\",\n    \"RedisKVStorage\": \"REDIS_WORKSPACE\",\n    \"OpenSearchKVStorage\": \"OPENSEARCH_WORKSPACE\",\n}\n\n# Query cache modes\nQUERY_MODES = [\"mix\", \"hybrid\", \"local\", \"global\"]\n\n# Query cache types\nCACHE_TYPES = [\"query\", \"keywords\"]\n\n# Default batch size for deletion\nDEFAULT_BATCH_SIZE = 1000\n\n# ANSI color codes for terminal output\nBOLD_CYAN = \"\\033[1;36m\"\nBOLD_RED = \"\\033[1;31m\"\nBOLD_GREEN = \"\\033[1;32m\"\nRESET = \"\\033[0m\"\n\n\n@dataclass\nclass CleanupStats:\n    \"\"\"Cleanup statistics and error tracking\"\"\"\n\n    # Count by mode and cache_type before cleanup\n    counts_before: Dict[str, Dict[str, int]] = field(default_factory=dict)\n\n    # Deletion statistics\n    total_to_delete: int = 0\n    total_batches: int = 0\n    successful_batches: int = 0\n    failed_batches: int = 0\n    successfully_deleted: int = 0\n    failed_to_delete: int = 0\n\n    # Count by mode and cache_type after cleanup\n    counts_after: Dict[str, Dict[str, int]] = field(default_factory=dict)\n\n    # Error tracking\n    errors: List[Dict[str, Any]] = field(default_factory=list)\n\n    def add_error(self, batch_idx: int, error: Exception, batch_size: int):\n        \"\"\"Record batch error\"\"\"\n        self.errors.append(\n            {\n                \"batch\": batch_idx,\n                \"error_type\": type(error).__name__,\n                \"error_msg\": str(error),\n                \"records_lost\": batch_size,\n                \"timestamp\": time.time(),\n            }\n        )\n        self.failed_batches += 1\n        self.failed_to_delete += batch_size\n\n    def initialize_counts(self):\n        \"\"\"Initialize count dictionaries\"\"\"\n        for mode in QUERY_MODES:\n            self.counts_before[mode] = {\"query\": 0, \"keywords\": 0}\n            self.counts_after[mode] = {\"query\": 0, \"keywords\": 0}\n\n\nclass CleanupTool:\n    \"\"\"LLM Query Cache Cleanup Tool\"\"\"\n\n    def __init__(self):\n        self.storage = None\n        self.workspace = \"\"\n        self.batch_size = DEFAULT_BATCH_SIZE\n\n    def get_workspace_for_storage(self, storage_name: str) -> str:\n        \"\"\"Get workspace for a specific storage type\n\n        Priority: Storage-specific env var > WORKSPACE env var > empty string\n\n        Args:\n            storage_name: Storage implementation name\n\n        Returns:\n            Workspace name\n        \"\"\"\n        # Check storage-specific workspace\n        if storage_name in WORKSPACE_ENV_MAP:\n            specific_workspace = os.getenv(WORKSPACE_ENV_MAP[storage_name])\n            if specific_workspace:\n                return specific_workspace\n\n        # Check generic WORKSPACE\n        workspace = os.getenv(\"WORKSPACE\", \"\")\n        return workspace\n\n    def check_config_ini_for_storage(self, storage_name: str) -> bool:\n        \"\"\"Check if config.ini has configuration for the storage type\n\n        Args:\n            storage_name: Storage implementation name\n\n        Returns:\n            True if config.ini has the necessary configuration\n        \"\"\"\n        try:\n            import configparser\n\n            config = configparser.ConfigParser()\n            config.read(\"config.ini\", \"utf-8\")\n\n            if storage_name == \"RedisKVStorage\":\n                return config.has_option(\"redis\", \"uri\")\n            elif storage_name == \"PGKVStorage\":\n                return (\n                    config.has_option(\"postgres\", \"user\")\n                    and config.has_option(\"postgres\", \"password\")\n                    and config.has_option(\"postgres\", \"database\")\n                )\n            elif storage_name == \"MongoKVStorage\":\n                return config.has_option(\"mongodb\", \"uri\") and config.has_option(\n                    \"mongodb\", \"database\"\n                )\n            elif storage_name == \"OpenSearchKVStorage\":\n                return config.has_option(\"opensearch\", \"hosts\")\n\n            return False\n        except Exception:\n            return False\n\n    def check_env_vars(self, storage_name: str) -> bool:\n        \"\"\"Check environment variables, show warnings if missing but don't fail\n\n        Args:\n            storage_name: Storage implementation name\n\n        Returns:\n            Always returns True (warnings only, no hard failure)\n        \"\"\"\n        required_vars = STORAGE_ENV_REQUIREMENTS.get(storage_name, [])\n\n        if not required_vars:\n            print(\"✓ No environment variables required\")\n            return True\n\n        missing_vars = [var for var in required_vars if var not in os.environ]\n\n        if missing_vars:\n            print(\n                f\"⚠️  Warning: Missing environment variables: {', '.join(missing_vars)}\"\n            )\n\n            # Check if config.ini has configuration\n            has_config = self.check_config_ini_for_storage(storage_name)\n            if has_config:\n                print(\"   ✓ Found configuration in config.ini\")\n            else:\n                print(f\"   Will attempt to use defaults for {storage_name}\")\n\n            return True\n\n        print(\"✓ All required environment variables are set\")\n        return True\n\n    def get_storage_class(self, storage_name: str):\n        \"\"\"Dynamically import and return storage class\n\n        Args:\n            storage_name: Storage implementation name\n\n        Returns:\n            Storage class\n        \"\"\"\n        if storage_name == \"JsonKVStorage\":\n            from lightrag.kg.json_kv_impl import JsonKVStorage\n\n            return JsonKVStorage\n        elif storage_name == \"RedisKVStorage\":\n            from lightrag.kg.redis_impl import RedisKVStorage\n\n            return RedisKVStorage\n        elif storage_name == \"PGKVStorage\":\n            from lightrag.kg.postgres_impl import PGKVStorage\n\n            return PGKVStorage\n        elif storage_name == \"MongoKVStorage\":\n            from lightrag.kg.mongo_impl import MongoKVStorage\n\n            return MongoKVStorage\n        elif storage_name == \"OpenSearchKVStorage\":\n            from lightrag.kg.opensearch_impl import OpenSearchKVStorage\n\n            return OpenSearchKVStorage\n        else:\n            raise ValueError(f\"Unsupported storage type: {storage_name}\")\n\n    async def initialize_storage(self, storage_name: str, workspace: str):\n        \"\"\"Initialize storage instance with fallback to config.ini and defaults\n\n        Args:\n            storage_name: Storage implementation name\n            workspace: Workspace name\n\n        Returns:\n            Initialized storage instance\n\n        Raises:\n            Exception: If initialization fails\n        \"\"\"\n        storage_class = self.get_storage_class(storage_name)\n\n        # Create global config\n        global_config = {\n            \"working_dir\": os.getenv(\"WORKING_DIR\", \"./rag_storage\"),\n            \"embedding_batch_num\": 10,\n        }\n\n        # Initialize storage\n        storage = storage_class(\n            namespace=NameSpace.KV_STORE_LLM_RESPONSE_CACHE,\n            workspace=workspace,\n            global_config=global_config,\n            embedding_func=None,\n        )\n\n        # Initialize the storage (may raise exception if connection fails)\n        await storage.initialize()\n\n        return storage\n\n    async def count_query_caches_json(self, storage) -> Dict[str, Dict[str, int]]:\n        \"\"\"Count query caches in JsonKVStorage by mode and cache_type\n\n        Args:\n            storage: JsonKVStorage instance\n\n        Returns:\n            Dictionary with counts for each mode and cache_type\n        \"\"\"\n        counts = {mode: {\"query\": 0, \"keywords\": 0} for mode in QUERY_MODES}\n\n        async with storage._storage_lock:\n            for key in storage._data.keys():\n                for mode in QUERY_MODES:\n                    if key.startswith(f\"{mode}:query:\"):\n                        counts[mode][\"query\"] += 1\n                    elif key.startswith(f\"{mode}:keywords:\"):\n                        counts[mode][\"keywords\"] += 1\n\n        return counts\n\n    async def count_query_caches_redis(self, storage) -> Dict[str, Dict[str, int]]:\n        \"\"\"Count query caches in RedisKVStorage by mode and cache_type\n\n        Args:\n            storage: RedisKVStorage instance\n\n        Returns:\n            Dictionary with counts for each mode and cache_type\n        \"\"\"\n        counts = {mode: {\"query\": 0, \"keywords\": 0} for mode in QUERY_MODES}\n\n        print(\"Scanning Redis keys...\", end=\"\", flush=True)\n\n        async with storage._get_redis_connection() as redis:\n            for mode in QUERY_MODES:\n                for cache_type in CACHE_TYPES:\n                    pattern = f\"{mode}:{cache_type}:*\"\n                    prefixed_pattern = f\"{storage.final_namespace}:{pattern}\"\n                    cursor = 0\n\n                    while True:\n                        cursor, keys = await redis.scan(\n                            cursor, match=prefixed_pattern, count=DEFAULT_BATCH_SIZE\n                        )\n                        counts[mode][cache_type] += len(keys)\n\n                        if cursor == 0:\n                            break\n\n        print()  # New line after progress\n        return counts\n\n    async def count_query_caches_pg(self, storage) -> Dict[str, Dict[str, int]]:\n        \"\"\"Count query caches in PostgreSQL by mode and cache_type\n\n        Args:\n            storage: PGKVStorage instance\n\n        Returns:\n            Dictionary with counts for each mode and cache_type\n        \"\"\"\n        from lightrag.kg.postgres_impl import namespace_to_table_name\n\n        counts = {mode: {\"query\": 0, \"keywords\": 0} for mode in QUERY_MODES}\n        table_name = namespace_to_table_name(storage.namespace)\n\n        print(\"Counting PostgreSQL records...\", end=\"\", flush=True)\n        start_time = time.time()\n\n        for mode in QUERY_MODES:\n            for cache_type in CACHE_TYPES:\n                query = f\"\"\"\n                    SELECT COUNT(*) as count\n                    FROM {table_name}\n                    WHERE workspace = $1\n                    AND id LIKE $2\n                \"\"\"\n                pattern = f\"{mode}:{cache_type}:%\"\n                result = await storage.db.query(query, [storage.workspace, pattern])\n                counts[mode][cache_type] = result[\"count\"] if result else 0\n\n        elapsed = time.time() - start_time\n        if elapsed > 1:\n            print(f\" (took {elapsed:.1f}s)\", end=\"\")\n        print()  # New line\n\n        return counts\n\n    async def count_query_caches_mongo(self, storage) -> Dict[str, Dict[str, int]]:\n        \"\"\"Count query caches in MongoDB by mode and cache_type\n\n        Args:\n            storage: MongoKVStorage instance\n\n        Returns:\n            Dictionary with counts for each mode and cache_type\n        \"\"\"\n        counts = {mode: {\"query\": 0, \"keywords\": 0} for mode in QUERY_MODES}\n\n        print(\"Counting MongoDB documents...\", end=\"\", flush=True)\n        start_time = time.time()\n\n        for mode in QUERY_MODES:\n            for cache_type in CACHE_TYPES:\n                pattern = f\"^{mode}:{cache_type}:\"\n                query = {\"_id\": {\"$regex\": pattern}}\n                count = await storage._data.count_documents(query)\n                counts[mode][cache_type] = count\n\n        elapsed = time.time() - start_time\n        if elapsed > 1:\n            print(f\" (took {elapsed:.1f}s)\", end=\"\")\n        print()  # New line\n\n        return counts\n\n    async def count_query_caches_opensearch(self, storage) -> Dict[str, Dict[str, int]]:\n        \"\"\"Count query caches in OpenSearch by mode and cache_type.\"\"\"\n        counts = {mode: {\"query\": 0, \"keywords\": 0} for mode in QUERY_MODES}\n\n        print(\"Scanning OpenSearch documents...\", end=\"\", flush=True)\n        start_time = time.time()\n\n        async for hits in storage._iter_raw_docs(batch_size=DEFAULT_BATCH_SIZE):\n            for hit in hits:\n                key = hit[\"_id\"]\n                for mode in QUERY_MODES:\n                    if key.startswith(f\"{mode}:query:\"):\n                        counts[mode][\"query\"] += 1\n                    elif key.startswith(f\"{mode}:keywords:\"):\n                        counts[mode][\"keywords\"] += 1\n\n        elapsed = time.time() - start_time\n        if elapsed > 1:\n            print(f\" (took {elapsed:.1f}s)\", end=\"\")\n        print()\n\n        return counts\n\n    async def count_query_caches(\n        self, storage, storage_name: str\n    ) -> Dict[str, Dict[str, int]]:\n        \"\"\"Count query caches from any storage type efficiently\n\n        Args:\n            storage: Storage instance\n            storage_name: Storage type name\n\n        Returns:\n            Dictionary with counts for each mode and cache_type\n        \"\"\"\n        if storage_name == \"JsonKVStorage\":\n            return await self.count_query_caches_json(storage)\n        elif storage_name == \"RedisKVStorage\":\n            return await self.count_query_caches_redis(storage)\n        elif storage_name == \"PGKVStorage\":\n            return await self.count_query_caches_pg(storage)\n        elif storage_name == \"MongoKVStorage\":\n            return await self.count_query_caches_mongo(storage)\n        elif storage_name == \"OpenSearchKVStorage\":\n            return await self.count_query_caches_opensearch(storage)\n        else:\n            raise ValueError(f\"Unsupported storage type: {storage_name}\")\n\n    async def delete_query_caches_json(\n        self, storage, cleanup_type: str, stats: CleanupStats\n    ):\n        \"\"\"Delete query caches from JsonKVStorage\n\n        Args:\n            storage: JsonKVStorage instance\n            cleanup_type: 'all', 'query', or 'keywords'\n            stats: CleanupStats object to track progress\n        \"\"\"\n        # Collect keys to delete\n        async with storage._storage_lock:\n            keys_to_delete = []\n            for key in storage._data.keys():\n                should_delete = False\n                for mode in QUERY_MODES:\n                    if cleanup_type == \"all\":\n                        if key.startswith(f\"{mode}:query:\") or key.startswith(\n                            f\"{mode}:keywords:\"\n                        ):\n                            should_delete = True\n                    elif cleanup_type == \"query\":\n                        if key.startswith(f\"{mode}:query:\"):\n                            should_delete = True\n                    elif cleanup_type == \"keywords\":\n                        if key.startswith(f\"{mode}:keywords:\"):\n                            should_delete = True\n\n                if should_delete:\n                    keys_to_delete.append(key)\n\n        # Delete in batches\n        total_keys = len(keys_to_delete)\n        stats.total_batches = (total_keys + self.batch_size - 1) // self.batch_size\n\n        print(\"\\n=== Starting Cleanup ===\")\n        print(\n            f\"💡 Processing {self.batch_size:,} records at a time from JsonKVStorage\\n\"\n        )\n\n        for batch_idx in range(stats.total_batches):\n            start_idx = batch_idx * self.batch_size\n            end_idx = min((batch_idx + 1) * self.batch_size, total_keys)\n            batch_keys = keys_to_delete[start_idx:end_idx]\n\n            try:\n                async with storage._storage_lock:\n                    for key in batch_keys:\n                        del storage._data[key]\n\n                # CRITICAL: Set update flag so changes persist to disk\n                # Without this, deletions remain in-memory only and are lost on exit\n                await set_all_update_flags(\n                    storage.namespace, workspace=storage.workspace\n                )\n\n                # Success\n                stats.successful_batches += 1\n                stats.successfully_deleted += len(batch_keys)\n\n                # Calculate progress\n                progress = (stats.successfully_deleted / total_keys) * 100\n                bar_length = 20\n                filled_length = int(\n                    bar_length * stats.successfully_deleted // total_keys\n                )\n                bar = \"█\" * filled_length + \"░\" * (bar_length - filled_length)\n\n                print(\n                    f\"Batch {batch_idx + 1}/{stats.total_batches}: {bar} \"\n                    f\"{stats.successfully_deleted:,}/{total_keys:,} ({progress:.1f}%) ✓\"\n                )\n\n            except Exception as e:\n                stats.add_error(batch_idx + 1, e, len(batch_keys))\n                print(\n                    f\"Batch {batch_idx + 1}/{stats.total_batches}: ✗ FAILED - \"\n                    f\"{type(e).__name__}: {str(e)}\"\n                )\n\n    async def delete_query_caches_redis(\n        self, storage, cleanup_type: str, stats: CleanupStats\n    ):\n        \"\"\"Delete query caches from RedisKVStorage\n\n        Args:\n            storage: RedisKVStorage instance\n            cleanup_type: 'all', 'query', or 'keywords'\n            stats: CleanupStats object to track progress\n        \"\"\"\n        # Build patterns to delete\n        patterns = []\n        for mode in QUERY_MODES:\n            if cleanup_type == \"all\":\n                patterns.append(f\"{mode}:query:*\")\n                patterns.append(f\"{mode}:keywords:*\")\n            elif cleanup_type == \"query\":\n                patterns.append(f\"{mode}:query:*\")\n            elif cleanup_type == \"keywords\":\n                patterns.append(f\"{mode}:keywords:*\")\n\n        print(\"\\n=== Starting Cleanup ===\")\n        print(f\"💡 Processing Redis keys in batches of {self.batch_size:,}\\n\")\n\n        batch_idx = 0\n        total_deleted = 0\n\n        async with storage._get_redis_connection() as redis:\n            for pattern in patterns:\n                prefixed_pattern = f\"{storage.final_namespace}:{pattern}\"\n                cursor = 0\n\n                while True:\n                    cursor, keys = await redis.scan(\n                        cursor, match=prefixed_pattern, count=self.batch_size\n                    )\n\n                    if keys:\n                        batch_idx += 1\n                        stats.total_batches += 1\n\n                        try:\n                            # Delete batch using pipeline\n                            pipe = redis.pipeline()\n                            for key in keys:\n                                pipe.delete(key)\n                            await pipe.execute()\n\n                            # Success\n                            stats.successful_batches += 1\n                            stats.successfully_deleted += len(keys)\n                            total_deleted += len(keys)\n\n                            # Progress\n                            print(\n                                f\"Batch {batch_idx}: Deleted {len(keys):,} keys \"\n                                f\"(Total: {total_deleted:,}) ✓\"\n                            )\n\n                        except Exception as e:\n                            stats.add_error(batch_idx, e, len(keys))\n                            print(\n                                f\"Batch {batch_idx}: ✗ FAILED - \"\n                                f\"{type(e).__name__}: {str(e)}\"\n                            )\n\n                    if cursor == 0:\n                        break\n\n                    await asyncio.sleep(0)\n\n    async def delete_query_caches_pg(\n        self, storage, cleanup_type: str, stats: CleanupStats\n    ):\n        \"\"\"Delete query caches from PostgreSQL\n\n        Args:\n            storage: PGKVStorage instance\n            cleanup_type: 'all', 'query', or 'keywords'\n            stats: CleanupStats object to track progress\n        \"\"\"\n        from lightrag.kg.postgres_impl import namespace_to_table_name\n\n        table_name = namespace_to_table_name(storage.namespace)\n\n        # Build WHERE conditions\n        conditions = []\n        for mode in QUERY_MODES:\n            if cleanup_type == \"all\":\n                conditions.append(f\"id LIKE '{mode}:query:%'\")\n                conditions.append(f\"id LIKE '{mode}:keywords:%'\")\n            elif cleanup_type == \"query\":\n                conditions.append(f\"id LIKE '{mode}:query:%'\")\n            elif cleanup_type == \"keywords\":\n                conditions.append(f\"id LIKE '{mode}:keywords:%'\")\n\n        where_clause = \" OR \".join(conditions)\n\n        print(\"\\n=== Starting Cleanup ===\")\n        print(\"💡 Executing PostgreSQL DELETE query\\n\")\n\n        try:\n            query = f\"\"\"\n                DELETE FROM {table_name}\n                WHERE workspace = $1\n                AND ({where_clause})\n            \"\"\"\n\n            start_time = time.time()\n            # Fix: Pass dict instead of list for execute() method\n            await storage.db.execute(query, {\"workspace\": storage.workspace})\n            elapsed = time.time() - start_time\n\n            # PostgreSQL returns deletion count\n            stats.total_batches = 1\n            stats.successful_batches = 1\n            stats.successfully_deleted = stats.total_to_delete\n\n            print(f\"✓ Deleted {stats.successfully_deleted:,} records in {elapsed:.2f}s\")\n\n        except Exception as e:\n            stats.add_error(1, e, stats.total_to_delete)\n            print(f\"✗ DELETE failed: {type(e).__name__}: {str(e)}\")\n\n    async def delete_query_caches_mongo(\n        self, storage, cleanup_type: str, stats: CleanupStats\n    ):\n        \"\"\"Delete query caches from MongoDB\n\n        Args:\n            storage: MongoKVStorage instance\n            cleanup_type: 'all', 'query', or 'keywords'\n            stats: CleanupStats object to track progress\n        \"\"\"\n        # Build regex patterns\n        patterns = []\n        for mode in QUERY_MODES:\n            if cleanup_type == \"all\":\n                patterns.append(f\"^{mode}:query:\")\n                patterns.append(f\"^{mode}:keywords:\")\n            elif cleanup_type == \"query\":\n                patterns.append(f\"^{mode}:query:\")\n            elif cleanup_type == \"keywords\":\n                patterns.append(f\"^{mode}:keywords:\")\n\n        print(\"\\n=== Starting Cleanup ===\")\n        print(\"💡 Executing MongoDB deleteMany operations\\n\")\n\n        total_deleted = 0\n        for idx, pattern in enumerate(patterns, 1):\n            try:\n                query = {\"_id\": {\"$regex\": pattern}}\n                result = await storage._data.delete_many(query)\n                deleted_count = result.deleted_count\n\n                stats.total_batches += 1\n                stats.successful_batches += 1\n                stats.successfully_deleted += deleted_count\n                total_deleted += deleted_count\n\n                print(\n                    f\"Pattern {idx}/{len(patterns)}: Deleted {deleted_count:,} records ✓\"\n                )\n\n            except Exception as e:\n                stats.add_error(idx, e, 0)\n                print(\n                    f\"Pattern {idx}/{len(patterns)}: ✗ FAILED - \"\n                    f\"{type(e).__name__}: {str(e)}\"\n                )\n\n        print(f\"\\nTotal deleted: {total_deleted:,} records\")\n\n    async def delete_query_caches_opensearch(\n        self, storage, cleanup_type: str, stats: CleanupStats\n    ):\n        \"\"\"Delete query caches from OpenSearchKVStorage.\"\"\"\n        keys_to_delete = []\n\n        async for hits in storage._iter_raw_docs(batch_size=self.batch_size):\n            for hit in hits:\n                key = hit[\"_id\"]\n                should_delete = False\n                for mode in QUERY_MODES:\n                    if cleanup_type == \"all\":\n                        if key.startswith(f\"{mode}:query:\") or key.startswith(\n                            f\"{mode}:keywords:\"\n                        ):\n                            should_delete = True\n                    elif cleanup_type == \"query\":\n                        if key.startswith(f\"{mode}:query:\"):\n                            should_delete = True\n                    elif cleanup_type == \"keywords\":\n                        if key.startswith(f\"{mode}:keywords:\"):\n                            should_delete = True\n\n                if should_delete:\n                    keys_to_delete.append(key)\n\n        total_keys = len(keys_to_delete)\n        stats.total_batches = (total_keys + self.batch_size - 1) // self.batch_size\n\n        print(\"\\n=== Starting Cleanup ===\")\n        print(\n            f\"💡 Processing {self.batch_size:,} records at a time from OpenSearchKVStorage\\n\"\n        )\n\n        for batch_idx in range(stats.total_batches):\n            start_idx = batch_idx * self.batch_size\n            end_idx = min((batch_idx + 1) * self.batch_size, total_keys)\n            batch_keys = keys_to_delete[start_idx:end_idx]\n\n            try:\n                await storage.delete(batch_keys)\n                stats.successful_batches += 1\n                stats.successfully_deleted += len(batch_keys)\n\n                progress = (stats.successfully_deleted / total_keys) * 100\n                bar_length = 20\n                filled_length = int(\n                    bar_length * stats.successfully_deleted // total_keys\n                )\n                bar = \"█\" * filled_length + \"░\" * (bar_length - filled_length)\n\n                print(\n                    f\"Batch {batch_idx + 1}/{stats.total_batches}: {bar} \"\n                    f\"{stats.successfully_deleted:,}/{total_keys:,} ({progress:.1f}%) ✓\"\n                )\n            except Exception as e:\n                stats.add_error(batch_idx + 1, e, len(batch_keys))\n                print(\n                    f\"Batch {batch_idx + 1}/{stats.total_batches}: ✗ FAILED - \"\n                    f\"{type(e).__name__}: {str(e)}\"\n                )\n\n    async def delete_query_caches(\n        self, storage, storage_name: str, cleanup_type: str, stats: CleanupStats\n    ):\n        \"\"\"Delete query caches from any storage type\n\n        Args:\n            storage: Storage instance\n            storage_name: Storage type name\n            cleanup_type: 'all', 'query', or 'keywords'\n            stats: CleanupStats object to track progress\n        \"\"\"\n        if storage_name == \"JsonKVStorage\":\n            await self.delete_query_caches_json(storage, cleanup_type, stats)\n        elif storage_name == \"RedisKVStorage\":\n            await self.delete_query_caches_redis(storage, cleanup_type, stats)\n        elif storage_name == \"PGKVStorage\":\n            await self.delete_query_caches_pg(storage, cleanup_type, stats)\n        elif storage_name == \"MongoKVStorage\":\n            await self.delete_query_caches_mongo(storage, cleanup_type, stats)\n        elif storage_name == \"OpenSearchKVStorage\":\n            await self.delete_query_caches_opensearch(storage, cleanup_type, stats)\n        else:\n            raise ValueError(f\"Unsupported storage type: {storage_name}\")\n\n    def print_header(self):\n        \"\"\"Print tool header\"\"\"\n        print(\"\\n\" + \"=\" * 60)\n        print(\"LLM Query Cache Cleanup Tool - LightRAG\")\n        print(\"=\" * 60)\n\n    def print_storage_types(self):\n        \"\"\"Print available storage types\"\"\"\n        print(\"\\nSupported KV Storage Types:\")\n        for key, value in STORAGE_TYPES.items():\n            print(f\"[{key}] {value}\")\n\n    def format_workspace(self, workspace: str) -> str:\n        \"\"\"Format workspace name with highlighting\n\n        Args:\n            workspace: Workspace name (may be empty)\n\n        Returns:\n            Formatted workspace string with ANSI color codes\n        \"\"\"\n        if workspace:\n            return f\"{BOLD_CYAN}{workspace}{RESET}\"\n        else:\n            return f\"{BOLD_CYAN}(default){RESET}\"\n\n    def print_cache_statistics(self, counts: Dict[str, Dict[str, int]], title: str):\n        \"\"\"Print cache statistics in a formatted table\n\n        Args:\n            counts: Dictionary with counts for each mode and cache_type\n            title: Title for the statistics display\n        \"\"\"\n        print(f\"\\n{title}\")\n        print(\"┌\" + \"─\" * 12 + \"┬\" + \"─\" * 12 + \"┬\" + \"─\" * 12 + \"┬\" + \"─\" * 12 + \"┐\")\n        print(f\"│ {'Mode':<10} │ {'Query':>10} │ {'Keywords':>10} │ {'Total':>10} │\")\n        print(\"├\" + \"─\" * 12 + \"┼\" + \"─\" * 12 + \"┼\" + \"─\" * 12 + \"┼\" + \"─\" * 12 + \"┤\")\n\n        total_query = 0\n        total_keywords = 0\n\n        for mode in QUERY_MODES:\n            query_count = counts[mode][\"query\"]\n            keywords_count = counts[mode][\"keywords\"]\n            mode_total = query_count + keywords_count\n\n            total_query += query_count\n            total_keywords += keywords_count\n\n            print(\n                f\"│ {mode:<10} │ {query_count:>10,} │ {keywords_count:>10,} │ {mode_total:>10,} │\"\n            )\n\n        print(\"├\" + \"─\" * 12 + \"┼\" + \"─\" * 12 + \"┼\" + \"─\" * 12 + \"┼\" + \"─\" * 12 + \"┤\")\n        grand_total = total_query + total_keywords\n        print(\n            f\"│ {'Total':<10} │ {total_query:>10,} │ {total_keywords:>10,} │ {grand_total:>10,} │\"\n        )\n        print(\"└\" + \"─\" * 12 + \"┴\" + \"─\" * 12 + \"┴\" + \"─\" * 12 + \"┴\" + \"─\" * 12 + \"┘\")\n\n    def calculate_total_to_delete(\n        self, counts: Dict[str, Dict[str, int]], cleanup_type: str\n    ) -> int:\n        \"\"\"Calculate total number of records to delete\n\n        Args:\n            counts: Dictionary with counts for each mode and cache_type\n            cleanup_type: 'all', 'query', or 'keywords'\n\n        Returns:\n            Total number of records to delete\n        \"\"\"\n        total = 0\n        for mode in QUERY_MODES:\n            if cleanup_type == \"all\":\n                total += counts[mode][\"query\"] + counts[mode][\"keywords\"]\n            elif cleanup_type == \"query\":\n                total += counts[mode][\"query\"]\n            elif cleanup_type == \"keywords\":\n                total += counts[mode][\"keywords\"]\n        return total\n\n    def print_cleanup_report(self, stats: CleanupStats):\n        \"\"\"Print comprehensive cleanup report\n\n        Args:\n            stats: CleanupStats object with cleanup results\n        \"\"\"\n        print(\"\\n\" + \"=\" * 60)\n        print(\"Cleanup Complete - Final Report\")\n        print(\"=\" * 60)\n\n        # Overall statistics\n        print(\"\\n📊 Statistics:\")\n        print(f\"  Total records to delete:  {stats.total_to_delete:,}\")\n        print(f\"  Total batches:            {stats.total_batches:,}\")\n        print(f\"  Successful batches:       {stats.successful_batches:,}\")\n        print(f\"  Failed batches:           {stats.failed_batches:,}\")\n        print(f\"  Successfully deleted:     {stats.successfully_deleted:,}\")\n        print(f\"  Failed to delete:         {stats.failed_to_delete:,}\")\n\n        # Success rate\n        success_rate = (\n            (stats.successfully_deleted / stats.total_to_delete * 100)\n            if stats.total_to_delete > 0\n            else 0\n        )\n        print(f\"  Success rate:             {success_rate:.2f}%\")\n\n        # Before/After comparison\n        print(\"\\n📈 Before/After Comparison:\")\n        total_before = sum(\n            counts[\"query\"] + counts[\"keywords\"]\n            for counts in stats.counts_before.values()\n        )\n        total_after = sum(\n            counts[\"query\"] + counts[\"keywords\"]\n            for counts in stats.counts_after.values()\n        )\n        print(f\"  Total caches before:      {total_before:,}\")\n        print(f\"  Total caches after:       {total_after:,}\")\n        print(f\"  Net reduction:            {total_before - total_after:,}\")\n\n        # Error details\n        if stats.errors:\n            print(f\"\\n⚠️  Errors encountered: {len(stats.errors)}\")\n            print(\"\\nError Details:\")\n            print(\"-\" * 60)\n\n            # Group errors by type\n            error_types = {}\n            for error in stats.errors:\n                err_type = error[\"error_type\"]\n                error_types[err_type] = error_types.get(err_type, 0) + 1\n\n            print(\"\\nError Summary:\")\n            for err_type, count in sorted(error_types.items(), key=lambda x: -x[1]):\n                print(f\"  - {err_type}: {count} occurrence(s)\")\n\n            print(\"\\nFirst 5 errors:\")\n            for i, error in enumerate(stats.errors[:5], 1):\n                print(f\"\\n  {i}. Batch {error['batch']}\")\n                print(f\"     Type: {error['error_type']}\")\n                print(f\"     Message: {error['error_msg']}\")\n                print(f\"     Records lost: {error['records_lost']:,}\")\n\n            if len(stats.errors) > 5:\n                print(f\"\\n  ... and {len(stats.errors) - 5} more errors\")\n\n            print(\"\\n\" + \"=\" * 60)\n            print(f\"{BOLD_RED}⚠️  WARNING: Cleanup completed with errors!{RESET}\")\n            print(\"   Please review the error details above.\")\n            print(\"=\" * 60)\n        else:\n            print(\"\\n\" + \"=\" * 60)\n            print(f\"{BOLD_GREEN}✓ SUCCESS: All records cleaned up successfully!{RESET}\")\n            print(\"=\" * 60)\n\n    async def setup_storage(self) -> tuple:\n        \"\"\"Setup and initialize storage\n\n        Returns:\n            Tuple of (storage_instance, storage_name, workspace)\n            Returns (None, None, None) if user chooses to exit\n        \"\"\"\n        print(\"\\n=== Storage Setup ===\")\n        self.print_storage_types()\n\n        num_options = len(STORAGE_TYPES)\n        prompt_range = \"1\" if num_options == 1 else f\"1-{num_options}\"\n\n        # Custom input handling with exit support\n        while True:\n            choice = input(\n                f\"\\nSelect storage type ({prompt_range}) (Press Enter to exit): \"\n            ).strip()\n\n            # Check for exit\n            if choice == \"\" or choice == \"0\":\n                print(\"\\n✓ Cleanup cancelled by user\")\n                return None, None, None\n\n            # Check if choice is valid\n            if choice in STORAGE_TYPES:\n                break\n\n            print(\n                f\"✗ Invalid choice. Please enter one of: {', '.join(STORAGE_TYPES.keys())}\"\n            )\n\n        storage_name = STORAGE_TYPES[choice]\n\n        # Special warning for JsonKVStorage about concurrent access\n        if storage_name == \"JsonKVStorage\":\n            print(\"\\n\" + \"=\" * 60)\n            print(f\"{BOLD_RED}⚠️  IMPORTANT WARNING - JsonKVStorage Concurrency{RESET}\")\n            print(\"=\" * 60)\n            print(\"\\nJsonKVStorage is an in-memory database that does NOT support\")\n            print(\"concurrent access to the same file by multiple programs.\")\n            print(\"\\nBefore proceeding, please ensure that:\")\n            print(\"  • LightRAG Server is completely shut down\")\n            print(\"  • No other programs are accessing the storage files\")\n            print(\"\\n\" + \"=\" * 60)\n\n            confirm = (\n                input(\"\\nHas LightRAG Server been shut down? (yes/no): \")\n                .strip()\n                .lower()\n            )\n            if confirm != \"yes\":\n                print(\n                    \"\\n✓ Operation cancelled - Please shut down LightRAG Server first\"\n                )\n                return None, None, None\n\n            print(\"✓ Proceeding with JsonKVStorage cleanup...\")\n\n        # Check configuration (warnings only, doesn't block)\n        print(\"\\nChecking configuration...\")\n        self.check_env_vars(storage_name)\n\n        # Get workspace\n        workspace = self.get_workspace_for_storage(storage_name)\n\n        # Initialize storage (real validation point)\n        print(\"\\nInitializing storage...\")\n        try:\n            storage = await self.initialize_storage(storage_name, workspace)\n            workspace = storage.workspace\n            print(f\"- Storage Type: {storage_name}\")\n            print(f\"- Workspace: {workspace if workspace else '(default)'}\")\n            print(\"- Connection Status: ✓ Success\")\n\n        except Exception as e:\n            print(f\"✗ Initialization failed: {e}\")\n            print(f\"\\nFor {storage_name}, you can configure using:\")\n            print(\"  1. Environment variables (highest priority)\")\n\n            # Show specific environment variable requirements\n            if storage_name in STORAGE_ENV_REQUIREMENTS:\n                for var in STORAGE_ENV_REQUIREMENTS[storage_name]:\n                    print(f\"     - {var}\")\n\n            print(\"  2. config.ini file (medium priority)\")\n            if storage_name == \"RedisKVStorage\":\n                print(\"     [redis]\")\n                print(\"     uri = redis://localhost:6379\")\n            elif storage_name == \"PGKVStorage\":\n                print(\"     [postgres]\")\n                print(\"     host = localhost\")\n                print(\"     port = 5432\")\n                print(\"     user = postgres\")\n                print(\"     password = yourpassword\")\n                print(\"     database = lightrag\")\n            elif storage_name == \"MongoKVStorage\":\n                print(\"     [mongodb]\")\n                print(\"     uri = mongodb://root:root@localhost:27017/\")\n                print(\"     database = LightRAG\")\n            elif storage_name == \"OpenSearchKVStorage\":\n                print(\"     [opensearch]\")\n                print(\"     hosts = localhost:9200\")\n\n            return None, None, None\n\n        return storage, storage_name, workspace\n\n    async def run(self):\n        \"\"\"Run the cleanup tool\"\"\"\n        try:\n            # Initialize shared storage (REQUIRED for storage classes to work)\n            from lightrag.kg.shared_storage import initialize_share_data\n\n            initialize_share_data(workers=1)\n\n            # Print header\n            self.print_header()\n\n            # Setup storage\n            self.storage, storage_name, self.workspace = await self.setup_storage()\n\n            # Check if user cancelled\n            if self.storage is None:\n                return\n\n            # Count query caches\n            print(\"\\nCounting query cache records...\")\n            try:\n                counts = await self.count_query_caches(self.storage, storage_name)\n            except Exception as e:\n                print(f\"✗ Counting failed: {e}\")\n                await self.storage.finalize()\n                return\n\n            # Initialize stats\n            stats = CleanupStats()\n            stats.initialize_counts()\n            stats.counts_before = counts\n\n            # Print statistics\n            self.print_cache_statistics(\n                counts, \"📊 Query Cache Statistics (Before Cleanup):\"\n            )\n\n            # Calculate total\n            total_caches = sum(\n                counts[mode][\"query\"] + counts[mode][\"keywords\"] for mode in QUERY_MODES\n            )\n\n            if total_caches == 0:\n                print(\"\\n⚠️  No query caches found in storage\")\n                await self.storage.finalize()\n                return\n\n            # Select cleanup type\n            print(\"\\n=== Cleanup Options ===\")\n            print(\"[1] Delete all query caches (both query and keywords)\")\n            print(\"[2] Delete query caches only (keep keywords)\")\n            print(\"[3] Delete keywords caches only (keep query)\")\n            print(\"[0] Cancel\")\n\n            while True:\n                choice = input(\"\\nSelect cleanup option (0-3): \").strip()\n\n                if choice == \"0\" or choice == \"\":\n                    print(\"\\n✓ Cleanup cancelled\")\n                    await self.storage.finalize()\n                    return\n                elif choice == \"1\":\n                    cleanup_type = \"all\"\n                elif choice == \"2\":\n                    cleanup_type = \"query\"\n                elif choice == \"3\":\n                    cleanup_type = \"keywords\"\n                else:\n                    print(\"✗ Invalid choice. Please enter 0, 1, 2, or 3\")\n                    continue\n\n                # Calculate total to delete for the selected type\n                stats.total_to_delete = self.calculate_total_to_delete(\n                    counts, cleanup_type\n                )\n\n                # Check if there are any records to delete\n                if stats.total_to_delete == 0:\n                    if cleanup_type == \"all\":\n                        print(f\"\\n{BOLD_RED}⚠️  No query caches found to delete!{RESET}\")\n                    elif cleanup_type == \"query\":\n                        print(\n                            f\"\\n{BOLD_RED}⚠️  No query caches found to delete! (Only keywords exist){RESET}\"\n                        )\n                    elif cleanup_type == \"keywords\":\n                        print(\n                            f\"\\n{BOLD_RED}⚠️  No keywords caches found to delete! (Only query caches exist){RESET}\"\n                        )\n                    print(\"   Please select a different cleanup option.\\n\")\n                    continue\n\n                # Valid selection with records to delete\n                break\n\n            # Confirm deletion\n            print(\"\\n\" + \"=\" * 60)\n            print(\"Cleanup Confirmation\")\n            print(\"=\" * 60)\n            print(\n                f\"Storage: {BOLD_CYAN}{storage_name}{RESET} \"\n                f\"(workspace: {self.format_workspace(self.workspace)})\"\n            )\n            print(f\"Cleanup Type: {BOLD_CYAN}{cleanup_type}{RESET}\")\n            print(\n                f\"Records to Delete: {BOLD_RED}{stats.total_to_delete:,}{RESET} / {total_caches:,}\"\n            )\n\n            if cleanup_type == \"all\":\n                print(\n                    f\"\\n{BOLD_RED}⚠️  WARNING: This will delete ALL query caches across all modes!{RESET}\"\n                )\n            elif cleanup_type == \"query\":\n                print(\"\\n⚠️  This will delete query caches only (keywords will be kept)\")\n            elif cleanup_type == \"keywords\":\n                print(\"\\n⚠️  This will delete keywords caches only (query will be kept)\")\n\n            confirm = input(\"\\nContinue with deletion? (y/n): \").strip().lower()\n            if confirm != \"y\":\n                print(\"\\n✓ Cleanup cancelled\")\n                await self.storage.finalize()\n                return\n\n            # Perform deletion\n            await self.delete_query_caches(\n                self.storage, storage_name, cleanup_type, stats\n            )\n\n            # Persist changes\n            print(\"\\nPersisting changes to storage...\")\n            try:\n                await self.storage.index_done_callback()\n                print(\"✓ Changes persisted successfully\")\n            except Exception as e:\n                print(f\"✗ Persist failed: {e}\")\n                stats.add_error(0, e, 0)\n\n            # Count again to verify\n            print(\"\\nVerifying cleanup results...\")\n            try:\n                stats.counts_after = await self.count_query_caches(\n                    self.storage, storage_name\n                )\n            except Exception as e:\n                print(f\"⚠️  Verification failed: {e}\")\n                # Use zero counts if verification fails\n                stats.counts_after = {\n                    mode: {\"query\": 0, \"keywords\": 0} for mode in QUERY_MODES\n                }\n\n            # Print final report\n            self.print_cleanup_report(stats)\n\n            # Print after statistics\n            self.print_cache_statistics(\n                stats.counts_after, \"\\n📊 Query Cache Statistics (After Cleanup):\"\n            )\n\n            # Cleanup\n            await self.storage.finalize()\n\n        except KeyboardInterrupt:\n            print(\"\\n\\n✗ Cleanup interrupted by user\")\n        except Exception as e:\n            print(f\"\\n✗ Cleanup failed: {e}\")\n            import traceback\n\n            traceback.print_exc()\n        finally:\n            # Ensure cleanup\n            if self.storage:\n                try:\n                    await self.storage.finalize()\n                except Exception:\n                    pass\n\n            # Finalize shared storage\n            try:\n                from lightrag.kg.shared_storage import finalize_share_data\n\n                finalize_share_data()\n            except Exception:\n                pass\n\n\nasync def async_main():\n    \"\"\"Async main entry point\"\"\"\n    tool = CleanupTool()\n    await tool.run()\n\n\ndef main():\n    \"\"\"Synchronous entry point for CLI command\"\"\"\n    asyncio.run(async_main())\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "lightrag/tools/download_cache.py",
    "content": "\"\"\"\nDownload all necessary cache files for offline deployment.\n\nThis module provides a CLI command to download tiktoken model cache files\nfor offline environments where internet access is not available.\n\"\"\"\n\nimport os\nimport sys\nfrom pathlib import Path\n\n\n# Known tiktoken encoding names (not model names)\n# These need to be loaded with tiktoken.get_encoding() instead of tiktoken.encoding_for_model()\nTIKTOKEN_ENCODING_NAMES = {\"cl100k_base\", \"p50k_base\", \"r50k_base\", \"o200k_base\"}\n\n\ndef download_tiktoken_cache(cache_dir: str = None, models: list = None):\n    \"\"\"Download tiktoken models to local cache\n\n    Args:\n        cache_dir: Directory to store the cache files. If None, uses tiktoken's default location.\n        models: List of model names or encoding names to download. If None, downloads common ones.\n\n    Returns:\n        Tuple of (success_count, failed_models, actual_cache_dir)\n    \"\"\"\n    # If user specified a cache directory, set it BEFORE importing tiktoken\n    # tiktoken reads TIKTOKEN_CACHE_DIR at import time\n    user_specified_cache = cache_dir is not None\n\n    if user_specified_cache:\n        cache_dir = os.path.abspath(cache_dir)\n        os.environ[\"TIKTOKEN_CACHE_DIR\"] = cache_dir\n        cache_path = Path(cache_dir)\n        cache_path.mkdir(parents=True, exist_ok=True)\n        print(f\"Using specified cache directory: {cache_dir}\")\n    else:\n        # Check if TIKTOKEN_CACHE_DIR is already set in environment\n        env_cache_dir = os.environ.get(\"TIKTOKEN_CACHE_DIR\")\n        if env_cache_dir:\n            cache_dir = env_cache_dir\n            print(f\"Using TIKTOKEN_CACHE_DIR from environment: {cache_dir}\")\n        else:\n            # Use tiktoken's default location (tempdir/data-gym-cache)\n            import tempfile\n\n            cache_dir = os.path.join(tempfile.gettempdir(), \"data-gym-cache\")\n            print(f\"Using tiktoken default cache directory: {cache_dir}\")\n\n    # Now import tiktoken (it will use the cache directory we determined)\n    try:\n        import tiktoken\n    except ImportError:\n        print(\"Error: tiktoken is not installed.\")\n        print(\"Install with: pip install tiktoken\")\n        sys.exit(1)\n\n    # Common models used by LightRAG and OpenAI\n    if models is None:\n        models = [\n            \"gpt-4o-mini\",  # Default model for LightRAG\n            \"gpt-4o\",  # GPT-4 Omni\n            \"gpt-4\",  # GPT-4\n            \"gpt-3.5-turbo\",  # GPT-3.5 Turbo\n            \"text-embedding-ada-002\",  # Legacy embedding model\n            \"text-embedding-3-small\",  # Small embedding model\n            \"text-embedding-3-large\",  # Large embedding model\n            \"cl100k_base\",  # Default encoding for LightRAG\n        ]\n\n    print(f\"\\nDownloading {len(models)} tiktoken models...\")\n    print(\"=\" * 70)\n\n    success_count = 0\n    failed_models = []\n\n    for i, model in enumerate(models, 1):\n        try:\n            print(f\"[{i}/{len(models)}] Downloading {model}...\", end=\" \", flush=True)\n            # Use get_encoding for encoding names, encoding_for_model for model names\n            if model in TIKTOKEN_ENCODING_NAMES:\n                encoding = tiktoken.get_encoding(model)\n            else:\n                encoding = tiktoken.encoding_for_model(model)\n            # Trigger download by encoding a test string\n            encoding.encode(\"test\")\n            print(\"✓ Done\")\n            success_count += 1\n        except KeyError as e:\n            print(f\"✗ Failed: Unknown model or encoding '{model}'\")\n            failed_models.append((model, str(e)))\n        except Exception as e:\n            print(f\"✗ Failed: {e}\")\n            failed_models.append((model, str(e)))\n\n    print(\"=\" * 70)\n    print(f\"\\n✓ Successfully cached {success_count}/{len(models)} models\")\n\n    if failed_models:\n        print(f\"\\n✗ Failed to download {len(failed_models)} models:\")\n        for model, error in failed_models:\n            print(f\"  - {model}: {error}\")\n\n    print(f\"\\nCache location: {cache_dir}\")\n    print(\"\\nFor offline deployment:\")\n    print(\"  1. Copy directory to offline server:\")\n    print(f\"     tar -czf tiktoken_cache.tar.gz {cache_dir}\")\n    print(\"     scp tiktoken_cache.tar.gz user@offline-server:/path/to/\")\n    print(\"\")\n    print(\"  2. On offline server, extract and set environment variable:\")\n    print(\"     tar -xzf tiktoken_cache.tar.gz\")\n    print(\"     export TIKTOKEN_CACHE_DIR=/path/to/tiktoken_cache\")\n    print(\"\")\n    print(\"  3. Or copy to default location:\")\n    print(f\"     cp -r {cache_dir} ~/.tiktoken_cache/\")\n\n    return success_count, failed_models\n\n\ndef main():\n    \"\"\"Main entry point for the CLI command\"\"\"\n    import argparse\n\n    parser = argparse.ArgumentParser(\n        prog=\"lightrag-download-cache\",\n        description=\"Download cache files for LightRAG offline deployment\",\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n        epilog=\"\"\"\nExamples:\n  # Download to default location (~/.tiktoken_cache)\n  lightrag-download-cache\n\n  # Download to specific directory\n  lightrag-download-cache --cache-dir ./offline_cache/tiktoken\n\n  # Download specific models only\n  lightrag-download-cache --models gpt-4o-mini gpt-4\n\nFor more information, visit: https://github.com/HKUDS/LightRAG\n        \"\"\",\n    )\n\n    parser.add_argument(\n        \"--cache-dir\",\n        help=\"Cache directory path (default: ~/.tiktoken_cache)\",\n        default=None,\n    )\n    parser.add_argument(\n        \"--models\",\n        nargs=\"+\",\n        help=\"Specific models to download (default: common models)\",\n        default=None,\n    )\n    parser.add_argument(\n        \"--version\", action=\"version\", version=\"%(prog)s (LightRAG cache downloader)\"\n    )\n\n    args = parser.parse_args()\n\n    print(\"=\" * 70)\n    print(\"LightRAG Offline Cache Downloader\")\n    print(\"=\" * 70)\n\n    try:\n        success_count, failed_models = download_tiktoken_cache(\n            args.cache_dir, args.models\n        )\n\n        print(\"\\n\" + \"=\" * 70)\n        print(\"Download Complete\")\n        print(\"=\" * 70)\n\n        # Exit with error code if all downloads failed\n        if success_count == 0:\n            print(\"\\n✗ All downloads failed. Please check your internet connection.\")\n            sys.exit(1)\n        # Exit with warning code if some downloads failed\n        elif failed_models:\n            print(\n                f\"\\n⚠ Some downloads failed ({len(failed_models)}/{success_count + len(failed_models)})\"\n            )\n            sys.exit(2)\n        else:\n            print(\"\\n✓ All cache files downloaded successfully!\")\n            sys.exit(0)\n\n    except KeyboardInterrupt:\n        print(\"\\n\\n✗ Download interrupted by user\")\n        sys.exit(130)\n    except Exception as e:\n        print(f\"\\n\\n✗ Error: {e}\")\n        import traceback\n\n        traceback.print_exc()\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "lightrag/tools/lightrag_visualizer/README-zh.md",
    "content": "# 3D GraphML Viewer\n\n一个基于 Dear ImGui 和 ModernGL 的交互式 3D 图可视化工具。\n\n## 功能特点\n\n- **3D 交互式可视化**: 使用 ModernGL 实现高性能的 3D 图形渲染\n- **多种布局算法**: 支持多种图布局方式\n  - Spring 布局\n  - Circular 布局\n  - Shell 布局\n  - Random 布局\n- **社区检测**: 支持图社区结构的自动检测和可视化\n- **交互控制**:\n  - WASD + QE 键控制相机移动\n  - 鼠标右键拖拽控制视角\n  - 节点选择和高亮\n  - 可调节节点大小和边宽度\n  - 可控制标签显示\n  - 可在节点的Connections间快速跳转\n- **社区检测**: 支持图社区结构的自动检测和可视化\n- **交互控制**:\n  - WASD + QE 键控制相机移动\n  - 鼠标右键拖拽控制视角\n  - 节点选择和高亮\n  - 可调节节点大小和边宽度\n  - 可控制标签显示\n\n## 技术栈\n\n- **imgui_bundle**: 用户界面\n- **ModernGL**: OpenGL 图形渲染\n- **NetworkX**: 图数据结构和算法\n- **NumPy**: 数值计算\n- **community**: 社区检测\n\n## 使用方法\n\n1. **启动程序**:\n   ```bash\n   pip install lightrag-hku[tools]\n   lightrag-viewer\n   ```\n\n2. **加载字体**:\n   - 将中文字体文件 `font.ttf` 放置在 `assets` 目录下\n   - 或者修改 `CUSTOM_FONT` 常量来使用其他字体文件\n\n3. **加载图文件**:\n   - 点击界面上的 \"Load GraphML\" 按钮\n   - 选择 GraphML 格式的图文件\n\n4. **交互控制**:\n   - **相机移动**:\n     - W: 前进\n     - S: 后退\n     - A: 左移\n     - D: 右移\n     - Q: 上升\n     - E: 下降\n   - **视角控制**:\n     - 按住鼠标右键拖动来旋转视角\n   - **节点交互**:\n     - 鼠标悬停可高亮节点\n     - 点击可选中节点\n\n5. **可视化设置**:\n   - 可通过 UI 控制面板调整:\n     - 布局类型\n     - 节点大小\n     - 边的宽度\n     - 标签显示\n     - 标签大小\n     - 背景颜色\n\n## 自定义设置\n\n- **节点缩放**: 通过 `node_scale` 参数调整节点大小\n- **边宽度**: 通过 `edge_width` 参数调整边的宽度\n- **标签显示**: 可通过 `show_labels` 开关标签显示\n- **标签大小**: 使用 `label_size` 调整标签大小\n- **标签颜色**: 通过 `label_color` 设置标签颜色\n- **视距控制**: 使用 `label_culling_distance` 控制标签显示的最大距离\n\n## 性能优化\n\n- 使用 ModernGL 进行高效的图形渲染\n- 视距裁剪优化标签显示\n- 社区检测算法优化大规模图的可视化效果\n\n## 系统要求\n\n- Python 3.10+\n- OpenGL 3.3+ 兼容的显卡\n- 支持的操作系统：Windows/Linux/MacOS\n"
  },
  {
    "path": "lightrag/tools/lightrag_visualizer/README.md",
    "content": "# LightRAG 3D Graph Viewer\n\nAn interactive 3D graph visualization tool included in the LightRAG package for visualizing and analyzing RAG (Retrieval-Augmented Generation) graphs and other graph structures.\n\n![image](https://github.com/user-attachments/assets/b0d86184-99fc-468c-96ed-c611f14292bf)\n\n## Installation\n\n### Quick Install\n```bash\npip install lightrag-hku[tools]  # Install with visualization tool only\n# or\npip install lightrag-hku[api,tools]  # Install with both API and visualization tools\n```\n\n## Launch the Viewer\n```bash\nlightrag-viewer\n```\n\n## Features\n\n- **3D Interactive Visualization**: High-performance 3D graphics rendering using ModernGL\n- **Multiple Layout Algorithms**: Support for various graph layouts\n  - Spring layout\n  - Circular layout\n  - Shell layout\n  - Random layout\n- **Community Detection**: Automatic detection and visualization of graph community structures\n- **Interactive Controls**:\n  - WASD + QE keys for camera movement\n  - Right mouse drag for view angle control\n  - Node selection and highlighting\n  - Adjustable node size and edge width\n  - Configurable label display\n  - Quick navigation between node connections\n\n## Tech Stack\n\n- **imgui_bundle**: User interface\n- **ModernGL**: OpenGL graphics rendering\n- **NetworkX**: Graph data structures and algorithms\n- **NumPy**: Numerical computations\n- **community**: Community detection\n\n## Interactive Controls\n\n### Camera Movement\n- W: Move forward\n- S: Move backward\n- A: Move left\n- D: Move right\n- Q: Move up\n- E: Move down\n\n### View Control\n- Hold right mouse button and drag to rotate view\n\n### Node Interaction\n- Hover mouse to highlight nodes\n- Click to select nodes\n\n## Visualization Settings\n\nAdjustable via UI control panel:\n- Layout type\n- Node size\n- Edge width\n- Label visibility\n- Label size\n- Background color\n\n## Customization Options\n\n- **Node Scaling**: Adjust node size via `node_scale` parameter\n- **Edge Width**: Modify edge width using `edge_width` parameter\n- **Label Display**: Toggle label visibility with `show_labels`\n- **Label Size**: Adjust label size using `label_size`\n- **Label Color**: Set label color through `label_color`\n- **View Distance**: Control maximum label display distance with `label_culling_distance`\n\n## System Requirements\n\n- Python 3.9+\n- Graphics card with OpenGL 3.3+ support\n- Supported Operating Systems: Windows/Linux/MacOS\n\n## Troubleshooting\n\n### Common Issues\n\n1. **Command Not Found**\n   ```bash\n   # Make sure you installed with the 'tools' option\n   pip install lightrag-hku[tools]\n\n   # Verify installation\n   pip list | grep lightrag-hku\n   ```\n\n2. **ModernGL Initialization Failed**\n   ```bash\n   # Check OpenGL version\n   glxinfo | grep \"OpenGL version\"\n\n   # Update graphics drivers if needed\n   ```\n\n3. **Font Loading Issues**\n   - The required fonts are included in the package\n   - If issues persist, check your graphics drivers\n\n## Usage with LightRAG\n\nThe viewer is particularly useful for:\n- Visualizing RAG knowledge graphs\n- Analyzing document relationships\n- Exploring semantic connections\n- Debugging retrieval patterns\n\n## Performance Optimizations\n\n- Efficient graphics rendering using ModernGL\n- View distance culling for label display optimization\n- Community detection algorithms for optimized visualization of large-scale graphs\n\n## Support\n\n- GitHub Issues: [LightRAG Repository](https://github.com/HKUDS/LightRAG)\n- Documentation: [LightRAG Docs](https://URL-to-docs)\n\n## License\n\nThis tool is part of LightRAG and is distributed under the MIT License. See `LICENSE` for more information.\n\nNote: This visualization tool is an optional component of the LightRAG package. Install with the [tools] option to access the viewer functionality.\n"
  },
  {
    "path": "lightrag/tools/lightrag_visualizer/__init__.py",
    "content": ""
  },
  {
    "path": "lightrag/tools/lightrag_visualizer/assets/LICENSE - Geist.txt",
    "content": "Copyright (c) 2023 Vercel, in collaboration with basement.studio\n\nThis Font Software is licensed under the SIL Open Font License, Version 1.1.\nThis license is copied below, and is also available with a FAQ at:\nhttp://scripts.sil.org/OFL\n\n-----------------------------------------------------------\nSIL OPEN FONT LICENSE Version 1.1 - 26 February 2007\n-----------------------------------------------------------\n\nPREAMBLE\nThe goals of the Open Font License (OFL) are to stimulate worldwide\ndevelopment of collaborative font projects, to support the font creation\nefforts of academic and linguistic communities, and to provide a free and\nopen framework in which fonts may be shared and improved in partnership\nwith others.\n\nThe OFL allows the licensed fonts to be used, studied, modified and\nredistributed freely as long as they are not sold by themselves. The\nfonts, including any derivative works, can be bundled, embedded,\nredistributed and/or sold with any software provided that any reserved\nnames are not used by derivative works. The fonts and derivatives,\nhowever, cannot be released under any other type of license. The\nrequirement for fonts to remain under this license does not apply\nto any document created using the fonts or their derivatives.\n\nDEFINITIONS\n\"Font Software\" refers to the set of files released by the Copyright\nHolder(s) under this license and clearly marked as such. This may\ninclude source files, build scripts and documentation.\n\n\"Reserved Font Name\" refers to any names specified as such after the\ncopyright statement(s).\n\n\"Original Version\" refers to the collection of Font Software components as\ndistributed by the Copyright Holder(s).\n\n\"Modified Version\" refers to any derivative made by adding to, deleting,\nor substituting -- in part or in whole -- any of the components of the\nOriginal Version, by changing formats or by porting the Font Software to a\nnew environment.\n\n\"Author\" refers to any designer, engineer, programmer, technical\nwriter or other person who contributed to the Font Software.\n\nPERMISSION AND CONDITIONS\nPermission is hereby granted, free of charge, to any person obtaining\na copy of the Font Software, to use, study, copy, merge, embed, modify,\nredistribute, and sell modified and unmodified copies of the Font\nSoftware, subject to the following conditions:\n\n1) Neither the Font Software nor any of its individual components,\nin Original or Modified Versions, may be sold by itself.\n\n2) Original or Modified Versions of the Font Software may be bundled,\nredistributed and/or sold with any software, provided that each copy\ncontains the above copyright notice and this license. These can be\nincluded either as stand-alone text files, human-readable headers or\nin the appropriate machine-readable metadata fields within text or\nbinary files as long as those fields can be easily viewed by the user.\n\n3) No Modified Version of the Font Software may use the Reserved Font\nName(s) unless explicit written permission is granted by the corresponding\nCopyright Holder. This restriction only applies to the primary font name as\npresented to the users.\n\n4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font\nSoftware shall not be used to promote, endorse or advertise any\nModified Version, except to acknowledge the contribution(s) of the\nCopyright Holder(s) and the Author(s) or with their explicit written\npermission.\n\n5) The Font Software, modified or unmodified, in part or in whole,\nmust be distributed entirely under this license, and must not be\ndistributed under any other license. The requirement for fonts to\nremain under this license does not apply to any document created\nusing the Font Software.\n\nTERMINATION\nThis license becomes null and void if any of the above conditions are\nnot met.\n\nDISCLAIMER\nTHE FONT SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT\nOF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE\nCOPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nINCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL\nDAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM\nOTHER DEALINGS IN THE FONT SOFTWARE.\n"
  },
  {
    "path": "lightrag/tools/lightrag_visualizer/assets/LICENSE - SmileySans.txt",
    "content": "Copyright (c) 2022--2024, atelierAnchor <https://atelier-anchor.com>,\nwith Reserved Font Name <Smiley> and <得意黑>.\n\nThis Font Software is licensed under the SIL Open Font License, Version 1.1.\nThis license is copied below, and is also available with a FAQ at:\nhttp://scripts.sil.org/OFL\n\n-----------------------------------------------------------\nSIL OPEN FONT LICENSE Version 1.1 - 26 February 2007\n-----------------------------------------------------------\n\nPREAMBLE\nThe goals of the Open Font License (OFL) are to stimulate worldwide\ndevelopment of collaborative font projects, to support the font creation\nefforts of academic and linguistic communities, and to provide a free and\nopen framework in which fonts may be shared and improved in partnership\nwith others.\n\nThe OFL allows the licensed fonts to be used, studied, modified and\nredistributed freely as long as they are not sold by themselves. The\nfonts, including any derivative works, can be bundled, embedded,\nredistributed and/or sold with any software provided that any reserved\nnames are not used by derivative works. The fonts and derivatives,\nhowever, cannot be released under any other type of license. The\nrequirement for fonts to remain under this license does not apply\nto any document created using the fonts or their derivatives.\n\nDEFINITIONS\n\"Font Software\" refers to the set of files released by the Copyright\nHolder(s) under this license and clearly marked as such. This may\ninclude source files, build scripts and documentation.\n\n\"Reserved Font Name\" refers to any names specified as such after the\ncopyright statement(s).\n\n\"Original Version\" refers to the collection of Font Software components as\ndistributed by the Copyright Holder(s).\n\n\"Modified Version\" refers to any derivative made by adding to, deleting,\nor substituting -- in part or in whole -- any of the components of the\nOriginal Version, by changing formats or by porting the Font Software to a\nnew environment.\n\n\"Author\" refers to any designer, engineer, programmer, technical\nwriter or other person who contributed to the Font Software.\n\nPERMISSION & CONDITIONS\nPermission is hereby granted, free of charge, to any person obtaining\na copy of the Font Software, to use, study, copy, merge, embed, modify,\nredistribute, and sell modified and unmodified copies of the Font\nSoftware, subject to the following conditions:\n\n1) Neither the Font Software nor any of its individual components,\nin Original or Modified Versions, may be sold by itself.\n\n2) Original or Modified Versions of the Font Software may be bundled,\nredistributed and/or sold with any software, provided that each copy\ncontains the above copyright notice and this license. These can be\nincluded either as stand-alone text files, human-readable headers or\nin the appropriate machine-readable metadata fields within text or\nbinary files as long as those fields can be easily viewed by the user.\n\n3) No Modified Version of the Font Software may use the Reserved Font\nName(s) unless explicit written permission is granted by the corresponding\nCopyright Holder. This restriction only applies to the primary font name as\npresented to the users.\n\n4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font\nSoftware shall not be used to promote, endorse or advertise any\nModified Version, except to acknowledge the contribution(s) of the\nCopyright Holder(s) and the Author(s) or with their explicit written\npermission.\n\n5) The Font Software, modified or unmodified, in part or in whole,\nmust be distributed entirely under this license, and must not be\ndistributed under any other license. The requirement for fonts to\nremain under this license does not apply to any document created\nusing the Font Software.\n\nTERMINATION\nThis license becomes null and void if any of the above conditions are\nnot met.\n\nDISCLAIMER\nTHE FONT SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT\nOF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE\nCOPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nINCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL\nDAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM\nOTHER DEALINGS IN THE FONT SOFTWARE.\n"
  },
  {
    "path": "lightrag/tools/lightrag_visualizer/assets/place_font_here",
    "content": ""
  },
  {
    "path": "lightrag/tools/lightrag_visualizer/graph_visualizer.py",
    "content": "from typing import Optional, Tuple, Dict, List\nimport numpy as np\nimport networkx as nx\nimport pipmaster as pm\n\n# Added automatic libraries install using pipmaster\nif not pm.is_installed(\"moderngl\"):\n    pm.install(\"moderngl\")\nif not pm.is_installed(\"imgui_bundle\"):\n    pm.install(\"imgui_bundle\")\nif not pm.is_installed(\"pyglm\"):\n    pm.install(\"pyglm\")\nif not pm.is_installed(\"python-louvain\"):\n    pm.install(\"python-louvain\")\n\nimport moderngl\nfrom imgui_bundle import imgui, immapp, hello_imgui\nimport community\nimport glm\nimport tkinter as tk\nfrom tkinter import filedialog\nimport traceback\nimport colorsys\nimport os\n\nCUSTOM_FONT = \"font.ttf\"\n\nDEFAULT_FONT_ENG = \"Geist-Regular.ttf\"\nDEFAULT_FONT_CHI = \"SmileySans-Oblique.ttf\"\n\n\nclass Node3D:\n    \"\"\"Class representing a 3D node in the graph\"\"\"\n\n    def __init__(\n        self, position: glm.vec3, color: glm.vec3, label: str, size: float, idx: int\n    ):\n        self.position = position\n        self.color = color\n        self.label = label\n        self.size = size\n        self.idx = idx\n\n\nclass GraphViewer:\n    \"\"\"Main class for 3D graph visualization\"\"\"\n\n    def __init__(self):\n        self.glctx = None  # ModernGL context\n        self.graph: Optional[nx.Graph] = None\n        self.nodes: List[Node3D] = []\n        self.id_node_map: Dict[str, Node3D] = {}\n        self.communities = None\n        self.community_colors = None\n\n        # Window dimensions\n        self.window_width = 1280\n        self.window_height = 720\n\n        # Camera parameters\n        self.position = glm.vec3(0.0, -10.0, 0.0)  # Initial camera position\n        self.front = glm.vec3(0.0, 1.0, 0.0)  # Direction camera is facing\n        self.up = glm.vec3(0.0, 0.0, 1.0)  # Up vector\n        self.yaw = 90.0  # Horizontal rotation (around Z axis)\n        self.pitch = 0.0  # Vertical rotation\n        self.move_speed = 0.05\n        self.mouse_sensitivity = 0.15\n\n        # Graph visualization settings\n        self.layout_type = \"Spring\"\n        self.node_scale = 0.2\n        self.edge_width = 0.5\n        self.show_labels = True\n        self.label_size = 2\n        self.label_color = (1.0, 1.0, 1.0, 1.0)\n        self.label_culling_distance = 10.0\n        self.available_layouts = (\"Spring\", \"Circular\", \"Shell\", \"Random\")\n        self.background_color = (0.05, 0.05, 0.05, 1.0)\n\n        # Mouse interaction\n        self.last_mouse_pos = None\n        self.mouse_pressed = False\n        self.mouse_button = -1\n        self.first_mouse = True\n\n        # File dialog state\n        self.show_load_error = False\n        self.error_message = \"\"\n\n        # Selection state\n        self.selected_node: Optional[Node3D] = None\n        self.highlighted_node: Optional[Node3D] = None\n\n        # Node id map\n        self.node_id_fbo = None\n        self.node_id_texture = None\n        self.node_id_depth = None\n        self.node_id_texture_np: np.ndarray = None\n\n        # Static data\n        self.sphere_data = create_sphere()\n\n        # Initialization flag\n        self.initialized = False\n\n    def setup(self):\n        self.setup_render_context()\n        self.setup_shaders()\n        self.setup_buffers()\n        self.initialized = True\n\n    def handle_keyboard_input(self):\n        \"\"\"Handle WASD keyboard input for camera movement\"\"\"\n        io = imgui.get_io()\n\n        if io.want_capture_keyboard:\n            return\n\n        # Calculate camera vectors\n        right = glm.normalize(glm.cross(self.front, self.up))\n\n        # Get movement direction from WASD keys\n        if imgui.is_key_down(imgui.Key.w):  # Forward\n            self.position += self.front * self.move_speed * 0.1\n        if imgui.is_key_down(imgui.Key.s):  # Backward\n            self.position -= self.front * self.move_speed * 0.1\n        if imgui.is_key_down(imgui.Key.a):  # Left\n            self.position -= right * self.move_speed * 0.1\n        if imgui.is_key_down(imgui.Key.d):  # Right\n            self.position += right * self.move_speed * 0.1\n        if imgui.is_key_down(imgui.Key.q):  # Up\n            self.position += self.up * self.move_speed * 0.1\n        if imgui.is_key_down(imgui.Key.e):  # Down\n            self.position -= self.up * self.move_speed * 0.1\n\n    def handle_mouse_interaction(self):\n        \"\"\"Handle mouse interaction for camera control and node selection\"\"\"\n        if (\n            imgui.is_any_item_active()\n            or imgui.is_any_item_hovered()\n            or imgui.is_any_item_focused()\n        ):\n            return\n\n        io = imgui.get_io()\n        mouse_pos = (io.mouse_pos.x, io.mouse_pos.y)\n        if (\n            mouse_pos[0] < 0\n            or mouse_pos[1] < 0\n            or mouse_pos[0] >= self.window_width\n            or mouse_pos[1] >= self.window_height\n        ):\n            return\n\n        # Handle first mouse input\n        if self.first_mouse:\n            self.last_mouse_pos = mouse_pos\n            self.first_mouse = False\n            return\n\n        # Handle mouse movement for camera rotation\n        if self.mouse_pressed and self.mouse_button == 1:  # Right mouse button\n            dx = self.last_mouse_pos[0] - mouse_pos[0]\n            dy = self.last_mouse_pos[1] - mouse_pos[1]  # Reversed for intuitive control\n\n            dx *= self.mouse_sensitivity\n            dy *= self.mouse_sensitivity\n\n            self.yaw += dx\n            self.pitch += dy\n\n            # Limit pitch to avoid flipping\n            self.pitch = np.clip(self.pitch, -89.0, 89.0)\n\n            # Update front vector\n            self.front = glm.normalize(\n                glm.vec3(\n                    np.cos(np.radians(self.yaw)) * np.cos(np.radians(self.pitch)),\n                    np.sin(np.radians(self.yaw)) * np.cos(np.radians(self.pitch)),\n                    np.sin(np.radians(self.pitch)),\n                )\n            )\n\n        if not imgui.is_window_hovered():\n            return\n\n        if io.mouse_wheel != 0:\n            self.move_speed += io.mouse_wheel * 0.05\n            self.move_speed = np.max([self.move_speed, 0.01])\n\n        # Handle mouse press/release\n        for button in range(3):\n            if imgui.is_mouse_clicked(button):\n                self.mouse_pressed = True\n                self.mouse_button = button\n                if button == 0 and self.highlighted_node:  # Left click for selection\n                    self.selected_node = self.highlighted_node\n\n            if imgui.is_mouse_released(button) and self.mouse_button == button:\n                self.mouse_pressed = False\n                self.mouse_button = -1\n\n        # Handle node hovering\n        if not self.mouse_pressed:\n            hovered = self.find_node_at((int(mouse_pos[0]), int(mouse_pos[1])))\n            self.highlighted_node = hovered\n\n        # Update last mouse position\n        self.last_mouse_pos = mouse_pos\n\n    def update_layout(self):\n        \"\"\"Update the graph layout\"\"\"\n        pos = nx.spring_layout(\n            self.graph,\n            dim=3,\n            pos={\n                node_id: list(node.position)\n                for node_id, node in self.id_node_map.items()\n            },\n            k=2.0,\n            iterations=100,\n            weight=None,\n        )\n\n        # Update node positions\n        for node_id, position in pos.items():\n            self.id_node_map[node_id].position = glm.vec3(position)\n        self.update_buffers()\n\n    def render_node_details(self):\n        \"\"\"Render node details window\"\"\"\n        if self.selected_node and imgui.begin(\"Node Details\"):\n            imgui.text(f\"ID: {self.selected_node.label}\")\n\n            if self.graph:\n                node_data = self.graph.nodes[self.selected_node.label]\n                imgui.text(f\"Type: {node_data.get('type', 'default')}\")\n\n                degree = self.graph.degree[self.selected_node.label]\n                imgui.text(f\"Degree: {degree}\")\n\n                for key, value in node_data.items():\n                    if key != \"type\":\n                        imgui.text(f\"{key}: {value}\")\n                        if value and imgui.is_item_hovered():\n                            imgui.set_tooltip(str(value))\n\n                imgui.separator()\n\n                connections = self.graph[self.selected_node.label]\n                if connections:\n                    imgui.text(\"Connections:\")\n                    keys = next(iter(connections.values())).keys()\n                    if imgui.begin_table(\n                        \"Connections\",\n                        len(keys) + 1,\n                        imgui.TableFlags_.borders\n                        | imgui.TableFlags_.row_bg\n                        | imgui.TableFlags_.resizable\n                        | imgui.TableFlags_.hideable,\n                    ):\n                        imgui.table_setup_column(\"Node\")\n                        for key in keys:\n                            imgui.table_setup_column(key)\n                        imgui.table_headers_row()\n\n                        for neighbor, edge_data in connections.items():\n                            imgui.table_next_row()\n                            imgui.table_set_column_index(0)\n                            if imgui.selectable(str(neighbor), True)[0]:\n                                # Select neighbor node\n                                self.selected_node = self.id_node_map[neighbor]\n                                self.position = self.selected_node.position - self.front\n                            for idx, key in enumerate(keys):\n                                imgui.table_set_column_index(idx + 1)\n                                value = str(edge_data.get(key, \"\"))\n                                imgui.text(value)\n                                if value and imgui.is_item_hovered():\n                                    imgui.set_tooltip(value)\n                        imgui.end_table()\n\n            imgui.end()\n\n    def setup_render_context(self):\n        \"\"\"Initialize ModernGL context\"\"\"\n        self.glctx = moderngl.create_context()\n        self.glctx.enable(moderngl.DEPTH_TEST | moderngl.CULL_FACE)\n        self.glctx.clear_color = self.background_color\n\n    def setup_shaders(self):\n        \"\"\"Setup vertex and fragment shaders for node and edge rendering\"\"\"\n        # Node shader program\n        self.node_prog = self.glctx.program(\n            vertex_shader=\"\"\"\n                #version 330\n\n                uniform mat4 mvp;\n                uniform vec3 camera;\n                uniform int selected_node;\n                uniform int highlighted_node;\n                uniform float scale;\n\n                in vec3 in_position;\n                in vec3 in_instance_position;\n                in vec3 in_instance_color;\n                in float in_instance_size;\n\n                out vec3 frag_color;\n                out vec3 frag_normal;\n                out vec3 frag_view_dir;\n\n                void main() {\n                    vec3 pos = in_position * in_instance_size * scale + in_instance_position;\n                    gl_Position = mvp * vec4(pos, 1.0);\n\n                    frag_normal = normalize(in_position);\n                    frag_view_dir = normalize(camera - pos);\n\n                    if (selected_node == gl_InstanceID) {\n                        frag_color = vec3(1.0, 0.5, 0.0);\n                    }\n                    else if (highlighted_node == gl_InstanceID) {\n                        frag_color = vec3(1.0, 0.8, 0.2);\n                    }\n                    else {\n                        frag_color = in_instance_color;\n                    }\n                }\n            \"\"\",\n            fragment_shader=\"\"\"\n                #version 330\n\n                in vec3 frag_color;\n                in vec3 frag_normal;\n                in vec3 frag_view_dir;\n\n                out vec4 outColor;\n\n                void main() {\n                    // Edge detection based on normal-view angle\n                    float edge = 1.0 - abs(dot(frag_normal, frag_view_dir));\n\n                    // Create sharp outline\n                    float outline = smoothstep(0.8, 0.9, edge);\n\n                    // Mix the sphere color with outline\n                    vec3 final_color = mix(frag_color, vec3(0.0), outline);\n\n                    outColor = vec4(final_color, 1.0);\n                }\n            \"\"\",\n        )\n\n        # Edge shader program with wide lines using geometry shader\n        self.edge_prog = self.glctx.program(\n            vertex_shader=\"\"\"\n                #version 330\n\n                uniform mat4 mvp;\n\n                in vec3 in_position;\n                in vec3 in_color;\n\n                out vec3 v_color;\n                out vec4 v_position;\n\n                void main() {\n                    v_position = mvp * vec4(in_position, 1.0);\n                    gl_Position = v_position;\n                    v_color = in_color;\n                }\n            \"\"\",\n            geometry_shader=\"\"\"\n                #version 330\n\n                layout(lines) in;\n                layout(triangle_strip, max_vertices = 4) out;\n\n                uniform float edge_width;\n                uniform vec2 viewport_size;\n\n                in vec3 v_color[];\n                in vec4 v_position[];\n                out vec3 g_color;\n                out float edge_coord;\n\n                void main() {\n                    // Get the two vertices of the line\n                    vec4 p1 = v_position[0];\n                    vec4 p2 = v_position[1];\n\n                    // Perspective division\n                    vec4 p1_ndc = p1 / p1.w;\n                    vec4 p2_ndc = p2 / p2.w;\n\n                    // Calculate line direction in screen space\n                    vec2 dir = normalize((p2_ndc.xy - p1_ndc.xy) * viewport_size);\n                    vec2 normal = vec2(-dir.y, dir.x);\n\n                    // Calculate half width based on screen space\n                    float half_width = edge_width * 0.5;\n                    vec2 offset = normal * (half_width / viewport_size);\n\n                    // Emit vertices with proper depth\n                    gl_Position = vec4(p1_ndc.xy + offset, p1_ndc.z, 1.0);\n                    gl_Position *= p1.w;  // Restore perspective\n                    g_color = v_color[0];\n                    edge_coord = 1.0;\n                    EmitVertex();\n\n                    gl_Position = vec4(p1_ndc.xy - offset, p1_ndc.z, 1.0);\n                    gl_Position *= p1.w;\n                    g_color = v_color[0];\n                    edge_coord = -1.0;\n                    EmitVertex();\n\n                    gl_Position = vec4(p2_ndc.xy + offset, p2_ndc.z, 1.0);\n                    gl_Position *= p2.w;\n                    g_color = v_color[1];\n                    edge_coord = 1.0;\n                    EmitVertex();\n\n                    gl_Position = vec4(p2_ndc.xy - offset, p2_ndc.z, 1.0);\n                    gl_Position *= p2.w;\n                    g_color = v_color[1];\n                    edge_coord = -1.0;\n                    EmitVertex();\n\n                    EndPrimitive();\n                }\n            \"\"\",\n            fragment_shader=\"\"\"\n                #version 330\n\n                in vec3 g_color;\n                in float edge_coord;\n\n                out vec4 fragColor;\n\n                void main() {\n                    // Edge outline parameters\n                    float outline_width = 0.2;  // Width of the outline relative to edge\n                    float edge_softness = 0.1;  // Softness of the edge\n                    float edge_dist = abs(edge_coord);\n\n                    // Calculate outline\n                    float outline_factor = smoothstep(1.0 - outline_width - edge_softness,\n                                                    1.0 - outline_width,\n                                                    edge_dist);\n\n                    // Mix edge color with outline (black)\n                    vec3 final_color = mix(g_color, vec3(0.0), outline_factor);\n\n                    // Calculate alpha for anti-aliasing\n                    float alpha = 1.0 - smoothstep(1.0 - edge_softness, 1.0, edge_dist);\n\n                    fragColor = vec4(final_color, alpha);\n                }\n            \"\"\",\n        )\n\n        # Id framebuffer shader program\n        self.node_id_prog = self.glctx.program(\n            vertex_shader=\"\"\"\n                #version 330\n\n                uniform mat4 mvp;\n                uniform float scale;\n\n                in vec3 in_position;\n                in vec3 in_instance_position;\n                in float in_instance_size;\n\n                out vec3 frag_color;\n\n                vec3 int_to_rgb(int value) {\n                    float R = float((value >> 16) & 0xFF);\n                    float G = float((value >> 8) & 0xFF);\n                    float B = float(value & 0xFF);\n                    // normalize to [0, 1]\n                    return vec3(R / 255.0, G / 255.0, B / 255.0);\n                }\n\n                void main() {\n                    vec3 pos = in_position * in_instance_size * scale + in_instance_position;\n                    gl_Position = mvp * vec4(pos, 1.0);\n                    frag_color = int_to_rgb(gl_InstanceID);\n                }\n                \"\"\",\n            fragment_shader=\"\"\"\n                    #version 330\n                    in vec3 frag_color;\n                    out vec4 outColor;\n                    void main() {\n                        outColor = vec4(frag_color, 1.0);\n                    }\n                \"\"\",\n        )\n\n    def setup_buffers(self):\n        \"\"\"Setup vertex buffers for nodes and edges\"\"\"\n        # We'll create these when loading the graph\n        self.node_vbo = None\n        self.node_color_vbo = None\n        self.node_size_vbo = None\n        self.edge_vbo = None\n        self.edge_color_vbo = None\n        self.node_vao = None\n        self.edge_vao = None\n        self.node_id_vao = None\n        self.sphere_pos_vbo = None\n        self.sphere_index_buffer = None\n\n    def load_file(self, filepath: str):\n        \"\"\"Load a GraphML file with error handling\"\"\"\n        try:\n            # Clear existing data\n            self.id_node_map.clear()\n            self.nodes.clear()\n            self.selected_node = None\n            self.highlighted_node = None\n            self.setup_buffers()\n\n            # Load new graph\n            self.graph = nx.read_graphml(filepath)\n            self.calculate_layout()\n            self.update_buffers()\n            self.show_load_error = False\n            self.error_message = \"\"\n        except Exception as _:\n            self.show_load_error = True\n            self.error_message = traceback.format_exc()\n            print(self.error_message)\n\n    def calculate_layout(self):\n        \"\"\"Calculate 3D layout for the graph\"\"\"\n        if not self.graph:\n            return\n\n        # Detect communities for coloring\n        self.communities = community.best_partition(self.graph)\n        num_communities = len(set(self.communities.values()))\n        self.community_colors = generate_colors(num_communities)\n\n        # Calculate layout based on selected type\n        if self.layout_type == \"Spring\":\n            pos = nx.spring_layout(\n                self.graph, dim=3, k=2.0, iterations=100, weight=None\n            )\n        elif self.layout_type == \"Circular\":\n            pos_2d = nx.circular_layout(self.graph)\n            pos = {node: np.array((x, 0.0, y)) for node, (x, y) in pos_2d.items()}\n        elif self.layout_type == \"Shell\":\n            # Group nodes by community for shell layout\n            comm_lists = [[] for _ in range(num_communities)]\n            for node, comm in self.communities.items():\n                comm_lists[comm].append(node)\n            pos_2d = nx.shell_layout(self.graph, comm_lists)\n            pos = {node: np.array((x, 0.0, y)) for node, (x, y) in pos_2d.items()}\n        else:  # Random\n            pos = {node: np.random.rand(3) * 2 - 1 for node in self.graph.nodes()}\n\n        # Scale positions\n        positions = np.array(list(pos.values()))\n        if len(positions) > 0:\n            scale = 10.0 / max(1.0, np.max(np.abs(positions)))\n            pos = {node: coords * scale for node, coords in pos.items()}\n\n        # Calculate degree-based sizes\n        degrees = dict(self.graph.degree())\n        max_degree = max(degrees.values()) if degrees else 1\n        min_degree = min(degrees.values()) if degrees else 1\n\n        idx = 0\n        # Create nodes with community colors\n        for node_id in self.graph.nodes():\n            position = glm.vec3(pos[node_id])\n            color = self.get_node_color(node_id)\n\n            # Normalize sizes between 0.5 and 2.0\n            size = 1.0\n            if max_degree != min_degree:\n                # Normalize and scale size\n                normalized = (degrees[node_id] - min_degree) / (max_degree - min_degree)\n                size = 0.5 + normalized * 1.5\n\n            if node_id in self.id_node_map:\n                node = self.id_node_map[node_id]\n                node.position = position\n                node.base_color = color\n                node.color = color\n                node.size = size\n            else:\n                node = Node3D(position, color, str(node_id), size, idx)\n                self.id_node_map[node_id] = node\n                self.nodes.append(node)\n                idx += 1\n\n        self.update_buffers()\n\n    def get_node_color(self, node_id: str) -> glm.vec3:\n        \"\"\"Get RGBA color based on community\"\"\"\n        if self.communities and node_id in self.communities:\n            comm_id = self.communities[node_id]\n            color = self.community_colors[comm_id]\n            return color\n        return glm.vec3(0.5, 0.5, 0.5)\n\n    def update_buffers(self):\n        \"\"\"Update vertex buffers with current node and edge data using batch rendering\"\"\"\n        if not self.graph:\n            return\n\n        # Update node buffers\n        node_positions = []\n        node_colors = []\n        node_sizes = []\n\n        for node in self.nodes:\n            node_positions.append(node.position)\n            node_colors.append(node.color)  # Only use RGB components\n            node_sizes.append(node.size)\n\n        if node_positions:\n            node_positions = np.array(node_positions, dtype=np.float32)\n            node_colors = np.array(node_colors, dtype=np.float32)\n            node_sizes = np.array(node_sizes, dtype=np.float32)\n\n            self.node_vbo = self.glctx.buffer(node_positions.tobytes())\n            self.node_color_vbo = self.glctx.buffer(node_colors.tobytes())\n            self.node_size_vbo = self.glctx.buffer(node_sizes.tobytes())\n            self.sphere_pos_vbo = self.glctx.buffer(self.sphere_data[0].tobytes())\n            self.sphere_index_buffer = self.glctx.buffer(self.sphere_data[1].tobytes())\n\n            self.node_vao = self.glctx.vertex_array(\n                self.node_prog,\n                [\n                    (self.sphere_pos_vbo, \"3f\", \"in_position\"),\n                    (self.node_vbo, \"3f /i\", \"in_instance_position\"),\n                    (self.node_color_vbo, \"3f /i\", \"in_instance_color\"),\n                    (self.node_size_vbo, \"f /i\", \"in_instance_size\"),\n                ],\n                index_buffer=self.sphere_index_buffer,\n                index_element_size=4,\n            )\n            self.node_vao.instances = len(self.nodes)\n\n            self.node_id_vao = self.glctx.vertex_array(\n                self.node_id_prog,\n                [\n                    (self.sphere_pos_vbo, \"3f\", \"in_position\"),\n                    (self.node_vbo, \"3f /i\", \"in_instance_position\"),\n                    (self.node_size_vbo, \"f /i\", \"in_instance_size\"),\n                ],\n                index_buffer=self.sphere_index_buffer,\n                index_element_size=4,\n            )\n            self.node_id_vao.instances = len(self.nodes)\n\n        # Update edge buffers\n        edge_positions = []\n        edge_colors = []\n\n        for edge in self.graph.edges():\n            start_node = self.id_node_map[edge[0]]\n            end_node = self.id_node_map[edge[1]]\n\n            edge_positions.append(start_node.position)\n            edge_colors.append(start_node.color)\n\n            edge_positions.append(end_node.position)\n            edge_colors.append(end_node.color)\n\n        if edge_positions:\n            edge_positions = np.array(edge_positions, dtype=np.float32)\n            edge_colors = np.array(edge_colors, dtype=np.float32)\n\n            self.edge_vbo = self.glctx.buffer(edge_positions.tobytes())\n            self.edge_color_vbo = self.glctx.buffer(edge_colors.tobytes())\n\n            self.edge_vao = self.glctx.vertex_array(\n                self.edge_prog,\n                [\n                    (self.edge_vbo, \"3f\", \"in_position\"),\n                    (self.edge_color_vbo, \"3f\", \"in_color\"),\n                ],\n            )\n\n    def update_view_proj_matrix(self):\n        \"\"\"Update view matrix based on camera parameters\"\"\"\n        self.view_matrix = glm.lookAt(\n            self.position, self.position + self.front, self.up\n        )\n\n        aspect_ratio = self.window_width / self.window_height\n        self.proj_matrix = glm.perspective(\n            glm.radians(60.0),  # FOV\n            aspect_ratio,  # Aspect ratio\n            0.001,  # Near plane\n            1000.0,  # Far plane\n        )\n\n    def find_node_at(self, screen_pos: Tuple[int, int]) -> Optional[Node3D]:\n        \"\"\"Find the node at a specific screen position\"\"\"\n        if (\n            self.node_id_texture_np is None\n            or self.node_id_texture_np.shape[1] != self.window_width\n            or self.node_id_texture_np.shape[0] != self.window_height\n            or screen_pos[0] < 0\n            or screen_pos[1] < 0\n            or screen_pos[0] >= self.window_width\n            or screen_pos[1] >= self.window_height\n        ):\n            return None\n\n        x = screen_pos[0]\n        y = self.window_height - screen_pos[1] - 1\n        pixel = self.node_id_texture_np[y, x]\n\n        if pixel[3] == 0:\n            return None\n\n        R = int(round(pixel[0] * 255))\n        G = int(round(pixel[1] * 255))\n        B = int(round(pixel[2] * 255))\n        index = (R << 16) | (G << 8) | B\n\n        if index > len(self.nodes):\n            return None\n        return self.nodes[index]\n\n    def is_node_visible_at(self, screen_pos: Tuple[int, int], node_idx: int) -> bool:\n        \"\"\"Check if a node exists at a specific screen position\"\"\"\n        node = self.find_node_at(screen_pos)\n        return node is not None and node.idx == node_idx\n\n    def render_settings(self):\n        \"\"\"Render settings window\"\"\"\n        if imgui.begin(\"Graph Settings\"):\n            # Layout type combo\n            changed, value = imgui.combo(\n                \"Layout\",\n                self.available_layouts.index(self.layout_type),\n                self.available_layouts,\n            )\n            if changed:\n                self.layout_type = self.available_layouts[value]\n                self.calculate_layout()  # Recalculate layout when changed\n\n            # Node size slider\n            changed, value = imgui.slider_float(\"Node Scale\", self.node_scale, 0.01, 10)\n            if changed:\n                self.node_scale = value\n\n            # Edge width slider\n            changed, value = imgui.slider_float(\"Edge Width\", self.edge_width, 0, 20)\n            if changed:\n                self.edge_width = value\n\n            # Show labels checkbox\n            changed, value = imgui.checkbox(\"Show Labels\", self.show_labels)\n\n            if changed:\n                self.show_labels = value\n\n            if self.show_labels:\n                # Label size slider\n                changed, value = imgui.slider_float(\n                    \"Label Size\", self.label_size, 0.5, 10.0\n                )\n                if changed:\n                    self.label_size = value\n\n                # Label color picker\n                changed, value = imgui.color_edit4(\n                    \"Label Color\",\n                    self.label_color,\n                    imgui.ColorEditFlags_.picker_hue_wheel,\n                )\n                if changed:\n                    self.label_color = (value[0], value[1], value[2], value[3])\n\n                # Label culling distance slider\n                changed, value = imgui.slider_float(\n                    \"Label Culling Distance\", self.label_culling_distance, 0.1, 100.0\n                )\n                if changed:\n                    self.label_culling_distance = value\n\n            # Background color picker\n            changed, value = imgui.color_edit4(\n                \"Background Color\",\n                self.background_color,\n                imgui.ColorEditFlags_.picker_hue_wheel,\n            )\n            if changed:\n                self.background_color = (value[0], value[1], value[2], value[3])\n\n            imgui.end()\n\n    def save_node_id_texture_to_png(self, filename):\n        # Convert to a PIL Image and save as PNG\n        from PIL import Image\n\n        scaled_array = self.node_id_texture_np * 255\n        img = Image.fromarray(\n            scaled_array.astype(np.uint8),\n            \"RGBA\",\n        )\n        img = img.transpose(method=Image.FLIP_TOP_BOTTOM)\n        img.save(filename)\n\n    def render_id_map(self, mvp: glm.mat4):\n        \"\"\"Render an offscreen id map where each node is drawn with a unique id color.\"\"\"\n        # Lazy initialization of id framebuffer\n        if self.node_id_texture is not None:\n            if (\n                self.node_id_texture.width != self.window_width\n                or self.node_id_texture.height != self.window_height\n            ):\n                self.node_id_fbo = None\n                self.node_id_texture = None\n                self.node_id_texture_np = None\n                self.node_id_depth = None\n\n        if self.node_id_texture is None:\n            self.node_id_texture = self.glctx.texture(\n                (self.window_width, self.window_height), components=4, dtype=\"f4\"\n            )\n            self.node_id_depth = self.glctx.depth_renderbuffer(\n                size=(self.window_width, self.window_height)\n            )\n            self.node_id_fbo = self.glctx.framebuffer(\n                color_attachments=[self.node_id_texture],\n                depth_attachment=self.node_id_depth,\n            )\n            self.node_id_texture_np = np.zeros(\n                (self.window_height, self.window_width, 4), dtype=np.float32\n            )\n\n        # Bind the offscreen framebuffer\n        self.node_id_fbo.use()\n        self.glctx.clear(0, 0, 0, 0)\n\n        # Render nodes\n        if self.node_id_vao:\n            self.node_id_prog[\"mvp\"].write(mvp.to_bytes())\n            self.node_id_prog[\"scale\"].write(np.float32(self.node_scale).tobytes())\n            self.node_id_vao.render(moderngl.TRIANGLES)\n\n        # Revert to default framebuffer\n        self.glctx.screen.use()\n        self.node_id_texture.read_into(self.node_id_texture_np.data)\n\n    def render(self):\n        \"\"\"Render the graph\"\"\"\n        # Clear screen\n        self.glctx.clear(*self.background_color, depth=1)\n\n        if not self.graph:\n            return\n\n        # Enable blending for transparency\n        self.glctx.enable(moderngl.BLEND)\n        self.glctx.blend_func = moderngl.SRC_ALPHA, moderngl.ONE_MINUS_SRC_ALPHA\n\n        # Update view and projection matrices\n        self.update_view_proj_matrix()\n        mvp = self.proj_matrix * self.view_matrix\n\n        # Render edges first (under nodes)\n        if self.edge_vao:\n            self.edge_prog[\"mvp\"].write(mvp.to_bytes())\n            self.edge_prog[\"edge_width\"].value = (\n                float(self.edge_width) * 2.0\n            )  # Double the width for better visibility\n            self.edge_prog[\"viewport_size\"].value = (\n                float(self.window_width),\n                float(self.window_height),\n            )\n            self.edge_vao.render(moderngl.LINES)\n\n        # Render nodes\n        if self.node_vao:\n            self.node_prog[\"mvp\"].write(mvp.to_bytes())\n            self.node_prog[\"camera\"].write(self.position.to_bytes())\n            self.node_prog[\"selected_node\"].write(\n                np.int32(self.selected_node.idx).tobytes()\n                if self.selected_node\n                else np.int32(-1).tobytes()\n            )\n            self.node_prog[\"highlighted_node\"].write(\n                np.int32(self.highlighted_node.idx).tobytes()\n                if self.highlighted_node\n                else np.int32(-1).tobytes()\n            )\n            self.node_prog[\"scale\"].write(np.float32(self.node_scale).tobytes())\n            self.node_vao.render(moderngl.TRIANGLES)\n\n        self.glctx.disable(moderngl.BLEND)\n\n        # Render id map\n        self.render_id_map(mvp)\n\n    def render_labels(self):\n        # Render labels if enabled\n        if self.show_labels and self.nodes:\n            # Save current font scale\n            original_scale = imgui.get_font_size()\n\n            self.update_view_proj_matrix()\n            mvp = self.proj_matrix * self.view_matrix\n\n            for node in self.nodes:\n                # Project node position to screen space\n                pos = mvp * glm.vec4(\n                    node.position[0], node.position[1], node.position[2], 1.0\n                )\n\n                # Check if node is behind camera\n                if pos.w > 0 and pos.w < self.label_culling_distance:\n                    screen_x = (pos.x / pos.w + 1) * self.window_width / 2\n                    screen_y = (-pos.y / pos.w + 1) * self.window_height / 2\n\n                    if self.is_node_visible_at(\n                        (int(screen_x), int(screen_y)), node.idx\n                    ):\n                        # Set font scale\n                        imgui.set_window_font_scale(float(self.label_size) * node.size)\n\n                        # Calculate label size\n                        label_size = imgui.calc_text_size(node.label)\n\n                        # Adjust position to center the label\n                        screen_x -= label_size.x / 2\n                        screen_y -= label_size.y / 2\n\n                        # Set text color with calculated alpha\n                        imgui.push_style_color(imgui.Col_.text, self.label_color)\n\n                        # Draw label using ImGui\n                        imgui.set_cursor_pos((screen_x, screen_y))\n                        imgui.text(node.label)\n\n                        # Restore text color\n                        imgui.pop_style_color()\n\n            # Restore original font scale\n            imgui.set_window_font_scale(original_scale)\n\n    def reset_view(self):\n        \"\"\"Reset camera view to default\"\"\"\n        self.position = glm.vec3(0.0, -10.0, 0.0)\n        self.front = glm.vec3(0.0, 1.0, 0.0)\n        self.yaw = 90.0\n        self.pitch = 0.0\n\n\ndef generate_colors(n: int) -> List[glm.vec3]:\n    \"\"\"Generate n distinct colors using HSV color space\"\"\"\n    colors = []\n    for i in range(n):\n        # Use golden ratio to generate well-distributed hues\n        hue = (i * 0.618033988749895) % 1.0\n        # Fixed saturation and value for vibrant colors\n        saturation = 0.8\n        value = 0.95\n        # Convert HSV to RGB\n        rgb = colorsys.hsv_to_rgb(hue, saturation, value)\n        # Add alpha channel\n        colors.append(glm.vec3(rgb))\n    return colors\n\n\ndef show_file_dialog() -> Optional[str]:\n    \"\"\"Show a file dialog for selecting GraphML files\"\"\"\n    file_path = filedialog.askopenfilename(\n        title=\"Select GraphML File\",\n        filetypes=[(\"GraphML files\", \"*.graphml\"), (\"All files\", \"*.*\")],\n    )\n    return file_path if file_path else None\n\n\ndef create_sphere(sectors: int = 32, rings: int = 16) -> Tuple:\n    \"\"\"\n    Creates a sphere.\n    \"\"\"\n    R = 1.0 / (rings - 1)\n    S = 1.0 / (sectors - 1)\n\n    # Use those names as normals and uvs are part of the API\n    vertices_l = [0.0] * (rings * sectors * 3)\n    # normals_l = [0.0] * (rings * sectors * 3)\n    uvs_l = [0.0] * (rings * sectors * 2)\n\n    v, n, t = 0, 0, 0\n    for r in range(rings):\n        for s in range(sectors):\n            y = np.sin(-np.pi / 2 + np.pi * r * R)\n            x = np.cos(2 * np.pi * s * S) * np.sin(np.pi * r * R)\n            z = np.sin(2 * np.pi * s * S) * np.sin(np.pi * r * R)\n\n            uvs_l[t] = s * S\n            uvs_l[t + 1] = r * R\n\n            vertices_l[v] = x\n            vertices_l[v + 1] = y\n            vertices_l[v + 2] = z\n\n            t += 2\n            v += 3\n            n += 3\n\n    indices = [0] * rings * sectors * 6\n    i = 0\n    for r in range(rings - 1):\n        for s in range(sectors - 1):\n            indices[i] = r * sectors + s\n            indices[i + 1] = (r + 1) * sectors + (s + 1)\n            indices[i + 2] = r * sectors + (s + 1)\n\n            indices[i + 3] = r * sectors + s\n            indices[i + 4] = (r + 1) * sectors + s\n            indices[i + 5] = (r + 1) * sectors + (s + 1)\n            i += 6\n\n    vbo_vertices = np.array(vertices_l, dtype=np.float32)\n    vbo_elements = np.array(indices, dtype=np.uint32)\n\n    return (vbo_vertices, vbo_elements)\n\n\ndef draw_text_with_bg(\n    text: str,\n    text_pos: imgui.ImVec2Like,\n    text_size: imgui.ImVec2Like,\n    bg_color: int,\n):\n    imgui.get_window_draw_list().add_rect_filled(\n        (text_pos[0] - 5, text_pos[1] - 5),\n        (text_pos[0] + text_size[0] + 5, text_pos[1] + text_size[1] + 5),\n        bg_color,\n        3.0,\n    )\n    imgui.set_cursor_pos(text_pos)\n    imgui.text(text)\n\n\ndef main():\n    \"\"\"Main application entry point\"\"\"\n    viewer = GraphViewer()\n\n    show_fps = True\n    text_bg_color = imgui.IM_COL32(0, 0, 0, 100)\n\n    def gui():\n        if not viewer.initialized:\n            viewer.setup()\n            # # Change the theme\n            # tweaked_theme = hello_imgui.get_runner_params().imgui_window_params.tweaked_theme\n            # tweaked_theme.theme = hello_imgui.ImGuiTheme_.darcula_darker\n            # hello_imgui.apply_tweaked_theme(tweaked_theme)\n\n        viewer.window_width = int(imgui.get_window_width())\n        viewer.window_height = int(imgui.get_window_height())\n\n        # Handle keyboard and mouse input\n        viewer.handle_keyboard_input()\n        viewer.handle_mouse_interaction()\n\n        style = imgui.get_style()\n        window_bg_color = style.color_(imgui.Col_.window_bg.value)\n\n        window_bg_color.w = 0.8\n        style.set_color_(imgui.Col_.window_bg.value, window_bg_color)\n\n        # Main control window\n        imgui.begin(\"Graph Controls\")\n\n        if imgui.button(\"Load GraphML\"):\n            filepath = show_file_dialog()\n            if filepath:\n                viewer.load_file(filepath)\n\n        # Show error message if loading failed\n        if viewer.show_load_error:\n            imgui.push_style_color(imgui.Col_.text, (1.0, 0.0, 0.0, 1.0))\n            imgui.text(f\"Error loading file: {viewer.error_message}\")\n            imgui.pop_style_color()\n\n        imgui.separator()\n\n        # Camera controls help\n        imgui.text(\"Camera Controls:\")\n        imgui.bullet_text(\"Hold Right Mouse - Look around\")\n        imgui.bullet_text(\"W/S - Move forward/backward\")\n        imgui.bullet_text(\"A/D - Move left/right\")\n        imgui.bullet_text(\"Q/E - Move up/down\")\n        imgui.bullet_text(\"Left Mouse - Select node\")\n        imgui.bullet_text(\"Wheel - Change the movement speed\")\n\n        imgui.separator()\n\n        # Camera settings\n        _, viewer.move_speed = imgui.slider_float(\n            \"Movement Speed\", viewer.move_speed, 0.01, 2.0\n        )\n        _, viewer.mouse_sensitivity = imgui.slider_float(\n            \"Mouse Sensitivity\", viewer.mouse_sensitivity, 0.01, 0.5\n        )\n\n        imgui.separator()\n\n        imgui.begin_horizontal(\"buttons\")\n\n        if imgui.button(\"Reset Camera\"):\n            viewer.reset_view()\n\n        if imgui.button(\"Update Layout\") and viewer.graph:\n            viewer.update_layout()\n\n        # if imgui.button(\"Save Node ID Texture\"):\n        #     viewer.save_node_id_texture_to_png(\"node_id_texture.png\")\n\n        imgui.end_horizontal()\n\n        imgui.end()\n\n        # Render node details window if a node is selected\n        viewer.render_node_details()\n\n        # Render graph settings window\n        viewer.render_settings()\n\n        # Render FPS\n        if show_fps:\n            imgui.set_window_font_scale(1)\n            fps_text = f\"FPS: {hello_imgui.frame_rate():.1f}\"\n            text_size = imgui.calc_text_size(fps_text)\n            cursor_pos = (10, viewer.window_height - text_size.y - 10)\n            draw_text_with_bg(fps_text, cursor_pos, text_size, text_bg_color)\n\n        # Render highlighted node ID\n        if viewer.highlighted_node:\n            imgui.set_window_font_scale(1)\n            node_text = f\"Node ID: {viewer.highlighted_node.label}\"\n            text_size = imgui.calc_text_size(node_text)\n            cursor_pos = (\n                viewer.window_width - text_size.x - 10,\n                viewer.window_height - text_size.y - 10,\n            )\n            draw_text_with_bg(node_text, cursor_pos, text_size, text_bg_color)\n\n        window_bg_color.w = 0\n        style.set_color_(imgui.Col_.window_bg.value, window_bg_color)\n\n        # Render labels\n        viewer.render_labels()\n\n    def custom_background():\n        if viewer.initialized:\n            viewer.render()\n\n    runner_params = hello_imgui.RunnerParams()\n    runner_params.app_window_params.window_geometry.size = (\n        viewer.window_width,\n        viewer.window_height,\n    )\n    runner_params.app_window_params.window_title = \"3D GraphML Viewer\"\n    runner_params.callbacks.show_gui = gui\n    runner_params.callbacks.custom_background = custom_background\n\n    def load_font():\n        # You will need to provide it yourself, or use another font.\n        font_filename = CUSTOM_FONT\n\n        io = imgui.get_io()\n        io.fonts.tex_desired_width = 4096  # Larger texture for better CJK font quality\n        font_size_pixels = 14\n        asset_dir = os.path.join(os.path.dirname(__file__), \"assets\")\n\n        # Try to load custom font\n        if not os.path.isfile(font_filename):\n            font_filename = os.path.join(asset_dir, font_filename)\n        if os.path.isfile(font_filename):\n            custom_font = io.fonts.add_font_from_file_ttf(\n                filename=font_filename,\n                size_pixels=font_size_pixels,\n                glyph_ranges_as_int_list=io.fonts.get_glyph_ranges_chinese_full(),\n            )\n            io.font_default = custom_font\n            return\n\n        # Load default fonts\n        io.fonts.add_font_from_file_ttf(\n            filename=os.path.join(asset_dir, DEFAULT_FONT_ENG),\n            size_pixels=font_size_pixels,\n        )\n\n        font_config = imgui.ImFontConfig()\n        font_config.merge_mode = True\n\n        io.font_default = io.fonts.add_font_from_file_ttf(\n            filename=os.path.join(asset_dir, DEFAULT_FONT_CHI),\n            size_pixels=font_size_pixels,\n            font_cfg=font_config,\n            glyph_ranges_as_int_list=io.fonts.get_glyph_ranges_chinese_full(),\n        )\n\n    runner_params.callbacks.load_additional_fonts = load_font\n\n    tk_root = tk.Tk()\n    tk_root.withdraw()  # Hide the main window\n\n    immapp.run(runner_params)\n\n    tk_root.destroy()  # Destroy the main window\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "lightrag/tools/lightrag_visualizer/requirements.txt",
    "content": "imgui_bundle\nmoderngl\nnetworkx\nnumpy\npyglm\npython-louvain\nscipy\ntk\n"
  },
  {
    "path": "lightrag/tools/migrate_llm_cache.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nLLM Cache Migration Tool for LightRAG\n\nThis tool migrates LLM response cache (default:extract:* and default:summary:*)\nbetween different KV storage implementations while preserving workspace isolation.\n\nUsage:\n    python -m lightrag.tools.migrate_llm_cache\n    # or\n    python lightrag/tools/migrate_llm_cache.py\n\nSupported KV Storage Types:\n    - JsonKVStorage\n    - RedisKVStorage\n    - PGKVStorage\n    - MongoKVStorage\n    - OpenSearchKVStorage\n\"\"\"\n\nimport asyncio\nimport os\nimport sys\nimport time\nfrom typing import Any, Dict, List\nfrom dataclasses import dataclass, field\nfrom dotenv import load_dotenv\n\n# Add project root to path for imports\nsys.path.insert(\n    0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\n)\n\nfrom lightrag.kg import STORAGE_ENV_REQUIREMENTS\nfrom lightrag.namespace import NameSpace\nfrom lightrag.utils import setup_logger\n\n# Load environment variables\n# use the .env that is inside the current folder\n# allows to use different .env file for each lightrag instance\n# the OS environment variables take precedence over the .env file\nload_dotenv(dotenv_path=\".env\", override=False)\n\n# Setup logger\nsetup_logger(\"lightrag\", level=\"INFO\")\n\n# Storage type configurations\nSTORAGE_TYPES = {\n    \"1\": \"JsonKVStorage\",\n    \"2\": \"RedisKVStorage\",\n    \"3\": \"PGKVStorage\",\n    \"4\": \"MongoKVStorage\",\n    \"5\": \"OpenSearchKVStorage\",\n}\n\n# Workspace environment variable mapping\nWORKSPACE_ENV_MAP = {\n    \"PGKVStorage\": \"POSTGRES_WORKSPACE\",\n    \"MongoKVStorage\": \"MONGODB_WORKSPACE\",\n    \"RedisKVStorage\": \"REDIS_WORKSPACE\",\n    \"OpenSearchKVStorage\": \"OPENSEARCH_WORKSPACE\",\n}\n\n# Default batch size for migration\nDEFAULT_BATCH_SIZE = 1000\n\n\n# Default count batch size for efficient counting\nDEFAULT_COUNT_BATCH_SIZE = 1000\n\n# ANSI color codes for terminal output\nBOLD_CYAN = \"\\033[1;36m\"\nRESET = \"\\033[0m\"\n\n\n@dataclass\nclass MigrationStats:\n    \"\"\"Migration statistics and error tracking\"\"\"\n\n    total_source_records: int = 0\n    total_batches: int = 0\n    successful_batches: int = 0\n    failed_batches: int = 0\n    successful_records: int = 0\n    failed_records: int = 0\n    errors: List[Dict[str, Any]] = field(default_factory=list)\n\n    def add_error(self, batch_idx: int, error: Exception, batch_size: int):\n        \"\"\"Record batch error\"\"\"\n        self.errors.append(\n            {\n                \"batch\": batch_idx,\n                \"error_type\": type(error).__name__,\n                \"error_msg\": str(error),\n                \"records_lost\": batch_size,\n                \"timestamp\": time.time(),\n            }\n        )\n        self.failed_batches += 1\n        self.failed_records += batch_size\n\n\nclass MigrationTool:\n    \"\"\"LLM Cache Migration Tool\"\"\"\n\n    def __init__(self):\n        self.source_storage = None\n        self.target_storage = None\n        self.source_workspace = \"\"\n        self.target_workspace = \"\"\n        self.batch_size = DEFAULT_BATCH_SIZE\n\n    def get_workspace_for_storage(self, storage_name: str) -> str:\n        \"\"\"Get workspace for a specific storage type\n\n        Priority: Storage-specific env var > WORKSPACE env var > empty string\n\n        Args:\n            storage_name: Storage implementation name\n\n        Returns:\n            Workspace name\n        \"\"\"\n        # Check storage-specific workspace\n        if storage_name in WORKSPACE_ENV_MAP:\n            specific_workspace = os.getenv(WORKSPACE_ENV_MAP[storage_name])\n            if specific_workspace:\n                return specific_workspace\n\n        # Check generic WORKSPACE\n        workspace = os.getenv(\"WORKSPACE\", \"\")\n        return workspace\n\n    def check_config_ini_for_storage(self, storage_name: str) -> bool:\n        \"\"\"Check if config.ini has configuration for the storage type\n\n        Args:\n            storage_name: Storage implementation name\n\n        Returns:\n            True if config.ini has the necessary configuration\n        \"\"\"\n        try:\n            import configparser\n\n            config = configparser.ConfigParser()\n            config.read(\"config.ini\", \"utf-8\")\n\n            if storage_name == \"RedisKVStorage\":\n                return config.has_option(\"redis\", \"uri\")\n            elif storage_name == \"PGKVStorage\":\n                return (\n                    config.has_option(\"postgres\", \"user\")\n                    and config.has_option(\"postgres\", \"password\")\n                    and config.has_option(\"postgres\", \"database\")\n                )\n            elif storage_name == \"MongoKVStorage\":\n                return config.has_option(\"mongodb\", \"uri\") and config.has_option(\n                    \"mongodb\", \"database\"\n                )\n            elif storage_name == \"OpenSearchKVStorage\":\n                return config.has_option(\"opensearch\", \"hosts\")\n\n            return False\n        except Exception:\n            return False\n\n    def check_env_vars(self, storage_name: str) -> bool:\n        \"\"\"Check environment variables, show warnings if missing but don't fail\n\n        Args:\n            storage_name: Storage implementation name\n\n        Returns:\n            Always returns True (warnings only, no hard failure)\n        \"\"\"\n        required_vars = STORAGE_ENV_REQUIREMENTS.get(storage_name, [])\n\n        if not required_vars:\n            print(\"✓ No environment variables required\")\n            return True\n\n        missing_vars = [var for var in required_vars if var not in os.environ]\n\n        if missing_vars:\n            print(\n                f\"⚠️  Warning: Missing environment variables: {', '.join(missing_vars)}\"\n            )\n\n            # Check if config.ini has configuration\n            has_config = self.check_config_ini_for_storage(storage_name)\n            if has_config:\n                print(\"   ✓ Found configuration in config.ini\")\n            else:\n                print(f\"   Will attempt to use defaults for {storage_name}\")\n\n            return True\n\n        print(\"✓ All required environment variables are set\")\n        return True\n\n    def count_available_storage_types(self) -> int:\n        \"\"\"Count available storage types (with env vars, config.ini, or defaults)\n\n        Returns:\n            Number of available storage types\n        \"\"\"\n        available_count = 0\n\n        for storage_name in STORAGE_TYPES.values():\n            # Check if storage requires configuration\n            required_vars = STORAGE_ENV_REQUIREMENTS.get(storage_name, [])\n\n            if not required_vars:\n                # JsonKVStorage, MongoKVStorage etc. - no config needed\n                available_count += 1\n            else:\n                # Check if has environment variables\n                has_env = all(var in os.environ for var in required_vars)\n                if has_env:\n                    available_count += 1\n                else:\n                    # Check if has config.ini configuration\n                    has_config = self.check_config_ini_for_storage(storage_name)\n                    if has_config:\n                        available_count += 1\n\n        return available_count\n\n    def get_storage_class(self, storage_name: str):\n        \"\"\"Dynamically import and return storage class\n\n        Args:\n            storage_name: Storage implementation name\n\n        Returns:\n            Storage class\n        \"\"\"\n        if storage_name == \"JsonKVStorage\":\n            from lightrag.kg.json_kv_impl import JsonKVStorage\n\n            return JsonKVStorage\n        elif storage_name == \"RedisKVStorage\":\n            from lightrag.kg.redis_impl import RedisKVStorage\n\n            return RedisKVStorage\n        elif storage_name == \"PGKVStorage\":\n            from lightrag.kg.postgres_impl import PGKVStorage\n\n            return PGKVStorage\n        elif storage_name == \"MongoKVStorage\":\n            from lightrag.kg.mongo_impl import MongoKVStorage\n\n            return MongoKVStorage\n        elif storage_name == \"OpenSearchKVStorage\":\n            from lightrag.kg.opensearch_impl import OpenSearchKVStorage\n\n            return OpenSearchKVStorage\n        else:\n            raise ValueError(f\"Unsupported storage type: {storage_name}\")\n\n    async def initialize_storage(self, storage_name: str, workspace: str):\n        \"\"\"Initialize storage instance with fallback to config.ini and defaults\n\n        Args:\n            storage_name: Storage implementation name\n            workspace: Workspace name\n\n        Returns:\n            Initialized storage instance\n\n        Raises:\n            Exception: If initialization fails\n        \"\"\"\n        storage_class = self.get_storage_class(storage_name)\n\n        # Create global config\n        global_config = {\n            \"working_dir\": os.getenv(\"WORKING_DIR\", \"./rag_storage\"),\n            \"embedding_batch_num\": 10,\n        }\n\n        # Initialize storage\n        storage = storage_class(\n            namespace=NameSpace.KV_STORE_LLM_RESPONSE_CACHE,\n            workspace=workspace,\n            global_config=global_config,\n            embedding_func=None,\n        )\n\n        # Initialize the storage (may raise exception if connection fails)\n        await storage.initialize()\n\n        return storage\n\n    async def get_default_caches_json(self, storage) -> Dict[str, Any]:\n        \"\"\"Get default caches from JsonKVStorage\n\n        Args:\n            storage: JsonKVStorage instance\n\n        Returns:\n            Dictionary of cache entries with default:extract:* or default:summary:* keys\n        \"\"\"\n        # Access _data directly - it's a dict from shared_storage\n        async with storage._storage_lock:\n            filtered = {}\n            for key, value in storage._data.items():\n                if key.startswith(\"default:extract:\") or key.startswith(\n                    \"default:summary:\"\n                ):\n                    filtered[key] = value.copy()\n            return filtered\n\n    async def get_default_caches_redis(\n        self, storage, batch_size: int = 1000\n    ) -> Dict[str, Any]:\n        \"\"\"Get default caches from RedisKVStorage with pagination\n\n        Args:\n            storage: RedisKVStorage instance\n            batch_size: Number of keys to process per batch\n\n        Returns:\n            Dictionary of cache entries with default:extract:* or default:summary:* keys\n        \"\"\"\n        import json\n\n        cache_data = {}\n\n        # Use _get_redis_connection() context manager\n        async with storage._get_redis_connection() as redis:\n            for pattern in [\"default:extract:*\", \"default:summary:*\"]:\n                # Add namespace prefix to pattern\n                prefixed_pattern = f\"{storage.final_namespace}:{pattern}\"\n                cursor = 0\n\n                while True:\n                    # SCAN already implements cursor-based pagination\n                    cursor, keys = await redis.scan(\n                        cursor, match=prefixed_pattern, count=batch_size\n                    )\n\n                    if keys:\n                        # Process this batch using pipeline with error handling\n                        try:\n                            pipe = redis.pipeline()\n                            for key in keys:\n                                pipe.get(key)\n                            values = await pipe.execute()\n\n                            for key, value in zip(keys, values):\n                                if value:\n                                    key_str = (\n                                        key.decode() if isinstance(key, bytes) else key\n                                    )\n                                    # Remove namespace prefix to get original key\n                                    original_key = key_str.replace(\n                                        f\"{storage.final_namespace}:\", \"\", 1\n                                    )\n                                    cache_data[original_key] = json.loads(value)\n\n                        except Exception as e:\n                            # Pipeline execution failed, fall back to individual gets\n                            print(\n                                f\"⚠️  Pipeline execution failed for batch, using individual gets: {e}\"\n                            )\n                            for key in keys:\n                                try:\n                                    value = await redis.get(key)\n                                    if value:\n                                        key_str = (\n                                            key.decode()\n                                            if isinstance(key, bytes)\n                                            else key\n                                        )\n                                        original_key = key_str.replace(\n                                            f\"{storage.final_namespace}:\", \"\", 1\n                                        )\n                                        cache_data[original_key] = json.loads(value)\n                                except Exception as individual_error:\n                                    print(\n                                        f\"⚠️  Failed to get individual key {key}: {individual_error}\"\n                                    )\n                                    continue\n\n                    if cursor == 0:\n                        break\n\n                    # Yield control periodically to avoid blocking\n                    await asyncio.sleep(0)\n\n        return cache_data\n\n    async def get_default_caches_pg(\n        self, storage, batch_size: int = 1000\n    ) -> Dict[str, Any]:\n        \"\"\"Get default caches from PGKVStorage with pagination\n\n        Args:\n            storage: PGKVStorage instance\n            batch_size: Number of records to fetch per batch\n\n        Returns:\n            Dictionary of cache entries with default:extract:* or default:summary:* keys\n        \"\"\"\n        from lightrag.kg.postgres_impl import namespace_to_table_name\n\n        cache_data = {}\n        table_name = namespace_to_table_name(storage.namespace)\n        offset = 0\n\n        while True:\n            # Use LIMIT and OFFSET for pagination\n            query = f\"\"\"\n                SELECT id as key, original_prompt, return_value, chunk_id, cache_type, queryparam,\n                       EXTRACT(EPOCH FROM create_time)::BIGINT as create_time,\n                       EXTRACT(EPOCH FROM update_time)::BIGINT as update_time\n                FROM {table_name}\n                WHERE workspace = $1\n                AND (id LIKE 'default:extract:%' OR id LIKE 'default:summary:%')\n                ORDER BY id\n                LIMIT $2 OFFSET $3\n            \"\"\"\n\n            results = await storage.db.query(\n                query, [storage.workspace, batch_size, offset], multirows=True\n            )\n\n            if not results:\n                break\n\n            for row in results:\n                # Map PostgreSQL fields to cache format\n                cache_entry = {\n                    \"return\": row.get(\"return_value\", \"\"),\n                    \"cache_type\": row.get(\"cache_type\"),\n                    \"original_prompt\": row.get(\"original_prompt\", \"\"),\n                    \"chunk_id\": row.get(\"chunk_id\"),\n                    \"queryparam\": row.get(\"queryparam\"),\n                    \"create_time\": row.get(\"create_time\", 0),\n                    \"update_time\": row.get(\"update_time\", 0),\n                }\n                cache_data[row[\"key\"]] = cache_entry\n\n            # If we got fewer results than batch_size, we're done\n            if len(results) < batch_size:\n                break\n\n            offset += batch_size\n\n            # Yield control periodically\n            await asyncio.sleep(0)\n\n        return cache_data\n\n    async def get_default_caches_mongo(\n        self, storage, batch_size: int = 1000\n    ) -> Dict[str, Any]:\n        \"\"\"Get default caches from MongoKVStorage with cursor-based pagination\n\n        Args:\n            storage: MongoKVStorage instance\n            batch_size: Number of documents to process per batch\n\n        Returns:\n            Dictionary of cache entries with default:extract:* or default:summary:* keys\n        \"\"\"\n        cache_data = {}\n\n        # MongoDB query with regex - use _data not collection\n        query = {\"_id\": {\"$regex\": \"^default:(extract|summary):\"}}\n\n        # Use cursor without to_list() - process in batches\n        cursor = storage._data.find(query).batch_size(batch_size)\n\n        async for doc in cursor:\n            # Process each document as it comes\n            doc_copy = doc.copy()\n            key = doc_copy.pop(\"_id\")\n\n            # Filter ALL MongoDB/database-specific fields\n            # Following .clinerules: \"Always filter deprecated/incompatible fields during deserialization\"\n            for field_name in [\"namespace\", \"workspace\", \"_id\", \"content\"]:\n                doc_copy.pop(field_name, None)\n\n            cache_data[key] = doc_copy.copy()\n\n            # Periodically yield control (every batch_size documents)\n            if len(cache_data) % batch_size == 0:\n                await asyncio.sleep(0)\n\n        return cache_data\n\n    async def get_default_caches_opensearch(\n        self, storage, batch_size: int = 1000\n    ) -> Dict[str, Any]:\n        \"\"\"Get default caches from OpenSearchKVStorage.\"\"\"\n        cache_data = {}\n\n        async for hits in storage._iter_raw_docs(batch_size=batch_size):\n            for hit in hits:\n                key = hit[\"_id\"]\n                if key.startswith(\"default:extract:\") or key.startswith(\n                    \"default:summary:\"\n                ):\n                    cache_data[key] = hit[\"_source\"].copy()\n\n        return cache_data\n\n    async def get_default_caches(self, storage, storage_name: str) -> Dict[str, Any]:\n        \"\"\"Get default caches from any storage type\n\n        Args:\n            storage: Storage instance\n            storage_name: Storage type name\n\n        Returns:\n            Dictionary of cache entries\n        \"\"\"\n        if storage_name == \"JsonKVStorage\":\n            return await self.get_default_caches_json(storage)\n        elif storage_name == \"RedisKVStorage\":\n            return await self.get_default_caches_redis(storage)\n        elif storage_name == \"PGKVStorage\":\n            return await self.get_default_caches_pg(storage)\n        elif storage_name == \"MongoKVStorage\":\n            return await self.get_default_caches_mongo(storage)\n        elif storage_name == \"OpenSearchKVStorage\":\n            return await self.get_default_caches_opensearch(storage)\n        else:\n            raise ValueError(f\"Unsupported storage type: {storage_name}\")\n\n    async def count_default_caches_json(self, storage) -> int:\n        \"\"\"Count default caches in JsonKVStorage - O(N) but very fast in-memory\n\n        Args:\n            storage: JsonKVStorage instance\n\n        Returns:\n            Total count of cache records\n        \"\"\"\n        async with storage._storage_lock:\n            return sum(\n                1\n                for key in storage._data.keys()\n                if key.startswith(\"default:extract:\")\n                or key.startswith(\"default:summary:\")\n            )\n\n    async def count_default_caches_redis(self, storage) -> int:\n        \"\"\"Count default caches in RedisKVStorage using SCAN with progress display\n\n        Args:\n            storage: RedisKVStorage instance\n\n        Returns:\n            Total count of cache records\n        \"\"\"\n        count = 0\n        print(\"Scanning Redis keys...\", end=\"\", flush=True)\n\n        async with storage._get_redis_connection() as redis:\n            for pattern in [\"default:extract:*\", \"default:summary:*\"]:\n                prefixed_pattern = f\"{storage.final_namespace}:{pattern}\"\n                cursor = 0\n                while True:\n                    cursor, keys = await redis.scan(\n                        cursor, match=prefixed_pattern, count=DEFAULT_COUNT_BATCH_SIZE\n                    )\n                    count += len(keys)\n\n                    # Show progress\n                    print(\n                        f\"\\rScanning Redis keys... found {count:,} records\",\n                        end=\"\",\n                        flush=True,\n                    )\n\n                    if cursor == 0:\n                        break\n\n        print()  # New line after progress\n        return count\n\n    async def count_default_caches_pg(self, storage) -> int:\n        \"\"\"Count default caches in PostgreSQL using COUNT(*) with progress indicator\n\n        Args:\n            storage: PGKVStorage instance\n\n        Returns:\n            Total count of cache records\n        \"\"\"\n        from lightrag.kg.postgres_impl import namespace_to_table_name\n\n        table_name = namespace_to_table_name(storage.namespace)\n\n        query = f\"\"\"\n            SELECT COUNT(*) as count\n            FROM {table_name}\n            WHERE workspace = $1\n            AND (id LIKE 'default:extract:%' OR id LIKE 'default:summary:%')\n        \"\"\"\n\n        print(\"Counting PostgreSQL records...\", end=\"\", flush=True)\n        start_time = time.time()\n\n        result = await storage.db.query(query, [storage.workspace])\n\n        elapsed = time.time() - start_time\n        if elapsed > 1:\n            print(f\" (took {elapsed:.1f}s)\", end=\"\")\n        print()  # New line\n\n        return result[\"count\"] if result else 0\n\n    async def count_default_caches_mongo(self, storage) -> int:\n        \"\"\"Count default caches in MongoDB using count_documents with progress indicator\n\n        Args:\n            storage: MongoKVStorage instance\n\n        Returns:\n            Total count of cache records\n        \"\"\"\n        query = {\"_id\": {\"$regex\": \"^default:(extract|summary):\"}}\n\n        print(\"Counting MongoDB documents...\", end=\"\", flush=True)\n        start_time = time.time()\n\n        count = await storage._data.count_documents(query)\n\n        elapsed = time.time() - start_time\n        if elapsed > 1:\n            print(f\" (took {elapsed:.1f}s)\", end=\"\")\n        print()  # New line\n\n        return count\n\n    async def count_default_caches_opensearch(self, storage) -> int:\n        \"\"\"Count default caches in OpenSearch using PIT pagination.\"\"\"\n        count = 0\n        print(\"Scanning OpenSearch documents...\", end=\"\", flush=True)\n        start_time = time.time()\n\n        async for hits in storage._iter_raw_docs(batch_size=DEFAULT_COUNT_BATCH_SIZE):\n            for hit in hits:\n                key = hit[\"_id\"]\n                if key.startswith(\"default:extract:\") or key.startswith(\n                    \"default:summary:\"\n                ):\n                    count += 1\n\n        elapsed = time.time() - start_time\n        if elapsed > 1:\n            print(f\" (took {elapsed:.1f}s)\", end=\"\")\n        print()\n\n        return count\n\n    async def count_default_caches(self, storage, storage_name: str) -> int:\n        \"\"\"Count default caches from any storage type efficiently\n\n        Args:\n            storage: Storage instance\n            storage_name: Storage type name\n\n        Returns:\n            Total count of cache records\n        \"\"\"\n        if storage_name == \"JsonKVStorage\":\n            return await self.count_default_caches_json(storage)\n        elif storage_name == \"RedisKVStorage\":\n            return await self.count_default_caches_redis(storage)\n        elif storage_name == \"PGKVStorage\":\n            return await self.count_default_caches_pg(storage)\n        elif storage_name == \"MongoKVStorage\":\n            return await self.count_default_caches_mongo(storage)\n        elif storage_name == \"OpenSearchKVStorage\":\n            return await self.count_default_caches_opensearch(storage)\n        else:\n            raise ValueError(f\"Unsupported storage type: {storage_name}\")\n\n    async def stream_default_caches_json(self, storage, batch_size: int):\n        \"\"\"Stream default caches from JsonKVStorage - yields batches\n\n        Args:\n            storage: JsonKVStorage instance\n            batch_size: Size of each batch to yield\n\n        Yields:\n            Dictionary batches of cache entries\n\n        Note:\n            This method creates a snapshot of matching items while holding the lock,\n            then releases the lock before yielding batches. This prevents deadlock\n            when the target storage (also JsonKVStorage) tries to acquire the same\n            lock during upsert operations.\n        \"\"\"\n        # Create a snapshot of matching items while holding the lock\n        async with storage._storage_lock:\n            matching_items = [\n                (key, value)\n                for key, value in storage._data.items()\n                if key.startswith(\"default:extract:\")\n                or key.startswith(\"default:summary:\")\n            ]\n\n        # Now iterate over snapshot without holding lock\n        batch = {}\n        for key, value in matching_items:\n            batch[key] = value.copy()\n            if len(batch) >= batch_size:\n                yield batch\n                batch = {}\n\n        # Yield remaining items\n        if batch:\n            yield batch\n\n    async def stream_default_caches_redis(self, storage, batch_size: int):\n        \"\"\"Stream default caches from RedisKVStorage - yields batches\n\n        Args:\n            storage: RedisKVStorage instance\n            batch_size: Size of each batch to yield\n\n        Yields:\n            Dictionary batches of cache entries\n        \"\"\"\n        import json\n\n        async with storage._get_redis_connection() as redis:\n            for pattern in [\"default:extract:*\", \"default:summary:*\"]:\n                prefixed_pattern = f\"{storage.final_namespace}:{pattern}\"\n                cursor = 0\n\n                while True:\n                    cursor, keys = await redis.scan(\n                        cursor, match=prefixed_pattern, count=batch_size\n                    )\n\n                    if keys:\n                        try:\n                            pipe = redis.pipeline()\n                            for key in keys:\n                                pipe.get(key)\n                            values = await pipe.execute()\n\n                            batch = {}\n                            for key, value in zip(keys, values):\n                                if value:\n                                    key_str = (\n                                        key.decode() if isinstance(key, bytes) else key\n                                    )\n                                    original_key = key_str.replace(\n                                        f\"{storage.final_namespace}:\", \"\", 1\n                                    )\n                                    batch[original_key] = json.loads(value)\n\n                            if batch:\n                                yield batch\n\n                        except Exception as e:\n                            print(f\"⚠️  Pipeline execution failed for batch: {e}\")\n                            # Fall back to individual gets\n                            batch = {}\n                            for key in keys:\n                                try:\n                                    value = await redis.get(key)\n                                    if value:\n                                        key_str = (\n                                            key.decode()\n                                            if isinstance(key, bytes)\n                                            else key\n                                        )\n                                        original_key = key_str.replace(\n                                            f\"{storage.final_namespace}:\", \"\", 1\n                                        )\n                                        batch[original_key] = json.loads(value)\n                                except Exception as individual_error:\n                                    print(\n                                        f\"⚠️  Failed to get individual key {key}: {individual_error}\"\n                                    )\n                                    continue\n\n                            if batch:\n                                yield batch\n\n                    if cursor == 0:\n                        break\n\n                    await asyncio.sleep(0)\n\n    async def stream_default_caches_pg(self, storage, batch_size: int):\n        \"\"\"Stream default caches from PostgreSQL - yields batches\n\n        Args:\n            storage: PGKVStorage instance\n            batch_size: Size of each batch to yield\n\n        Yields:\n            Dictionary batches of cache entries\n        \"\"\"\n        from lightrag.kg.postgres_impl import namespace_to_table_name\n\n        table_name = namespace_to_table_name(storage.namespace)\n        offset = 0\n\n        while True:\n            query = f\"\"\"\n                SELECT id as key, original_prompt, return_value, chunk_id, cache_type, queryparam,\n                       EXTRACT(EPOCH FROM create_time)::BIGINT as create_time,\n                       EXTRACT(EPOCH FROM update_time)::BIGINT as update_time\n                FROM {table_name}\n                WHERE workspace = $1\n                AND (id LIKE 'default:extract:%' OR id LIKE 'default:summary:%')\n                ORDER BY id\n                LIMIT $2 OFFSET $3\n            \"\"\"\n\n            results = await storage.db.query(\n                query, [storage.workspace, batch_size, offset], multirows=True\n            )\n\n            if not results:\n                break\n\n            batch = {}\n            for row in results:\n                cache_entry = {\n                    \"return\": row.get(\"return_value\", \"\"),\n                    \"cache_type\": row.get(\"cache_type\"),\n                    \"original_prompt\": row.get(\"original_prompt\", \"\"),\n                    \"chunk_id\": row.get(\"chunk_id\"),\n                    \"queryparam\": row.get(\"queryparam\"),\n                    \"create_time\": row.get(\"create_time\", 0),\n                    \"update_time\": row.get(\"update_time\", 0),\n                }\n                batch[row[\"key\"]] = cache_entry\n\n            if batch:\n                yield batch\n\n            if len(results) < batch_size:\n                break\n\n            offset += batch_size\n            await asyncio.sleep(0)\n\n    async def stream_default_caches_mongo(self, storage, batch_size: int):\n        \"\"\"Stream default caches from MongoDB - yields batches\n\n        Args:\n            storage: MongoKVStorage instance\n            batch_size: Size of each batch to yield\n\n        Yields:\n            Dictionary batches of cache entries\n        \"\"\"\n        query = {\"_id\": {\"$regex\": \"^default:(extract|summary):\"}}\n        cursor = storage._data.find(query).batch_size(batch_size)\n\n        batch = {}\n        async for doc in cursor:\n            doc_copy = doc.copy()\n            key = doc_copy.pop(\"_id\")\n\n            # Filter MongoDB/database-specific fields\n            for field_name in [\"namespace\", \"workspace\", \"_id\", \"content\"]:\n                doc_copy.pop(field_name, None)\n\n            batch[key] = doc_copy.copy()\n\n            if len(batch) >= batch_size:\n                yield batch\n                batch = {}\n\n        # Yield remaining items\n        if batch:\n            yield batch\n\n    async def stream_default_caches_opensearch(self, storage, batch_size: int):\n        \"\"\"Stream default caches from OpenSearchKVStorage - yields batches.\"\"\"\n        batch = {}\n\n        async for hits in storage._iter_raw_docs(batch_size=batch_size):\n            for hit in hits:\n                key = hit[\"_id\"]\n                if key.startswith(\"default:extract:\") or key.startswith(\n                    \"default:summary:\"\n                ):\n                    batch[key] = hit[\"_source\"].copy()\n\n                if len(batch) >= batch_size:\n                    yield batch\n                    batch = {}\n\n        if batch:\n            yield batch\n\n    async def stream_default_caches(\n        self, storage, storage_name: str, batch_size: int = None\n    ):\n        \"\"\"Stream default caches from any storage type - unified interface\n\n        Args:\n            storage: Storage instance\n            storage_name: Storage type name\n            batch_size: Size of each batch to yield (defaults to self.batch_size)\n\n        Yields:\n            Dictionary batches of cache entries\n        \"\"\"\n        if batch_size is None:\n            batch_size = self.batch_size\n\n        if storage_name == \"JsonKVStorage\":\n            async for batch in self.stream_default_caches_json(storage, batch_size):\n                yield batch\n        elif storage_name == \"RedisKVStorage\":\n            async for batch in self.stream_default_caches_redis(storage, batch_size):\n                yield batch\n        elif storage_name == \"PGKVStorage\":\n            async for batch in self.stream_default_caches_pg(storage, batch_size):\n                yield batch\n        elif storage_name == \"MongoKVStorage\":\n            async for batch in self.stream_default_caches_mongo(storage, batch_size):\n                yield batch\n        elif storage_name == \"OpenSearchKVStorage\":\n            async for batch in self.stream_default_caches_opensearch(\n                storage, batch_size\n            ):\n                yield batch\n        else:\n            raise ValueError(f\"Unsupported storage type: {storage_name}\")\n\n    async def count_cache_types(self, cache_data: Dict[str, Any]) -> Dict[str, int]:\n        \"\"\"Count cache entries by type\n\n        Args:\n            cache_data: Dictionary of cache entries\n\n        Returns:\n            Dictionary with counts for each cache type\n        \"\"\"\n        counts = {\n            \"extract\": 0,\n            \"summary\": 0,\n        }\n\n        for key in cache_data.keys():\n            if key.startswith(\"default:extract:\"):\n                counts[\"extract\"] += 1\n            elif key.startswith(\"default:summary:\"):\n                counts[\"summary\"] += 1\n\n        return counts\n\n    def print_header(self):\n        \"\"\"Print tool header\"\"\"\n        print(\"\\n\" + \"=\" * 50)\n        print(\"LLM Cache Migration Tool - LightRAG\")\n        print(\"=\" * 50)\n\n    def print_storage_types(self):\n        \"\"\"Print available storage types\"\"\"\n        print(\"\\nSupported KV Storage Types:\")\n        for key, value in STORAGE_TYPES.items():\n            print(f\"[{key}] {value}\")\n\n    def format_workspace(self, workspace: str) -> str:\n        \"\"\"Format workspace name with highlighting\n\n        Args:\n            workspace: Workspace name (may be empty)\n\n        Returns:\n            Formatted workspace string with ANSI color codes\n        \"\"\"\n        if workspace:\n            return f\"{BOLD_CYAN}{workspace}{RESET}\"\n        else:\n            return f\"{BOLD_CYAN}(default){RESET}\"\n\n    def format_storage_name(self, storage_name: str) -> str:\n        \"\"\"Format storage type name with highlighting\n\n        Args:\n            storage_name: Storage type name\n\n        Returns:\n            Formatted storage name string with ANSI color codes\n        \"\"\"\n        return f\"{BOLD_CYAN}{storage_name}{RESET}\"\n\n    async def setup_storage(\n        self,\n        storage_type: str,\n        use_streaming: bool = False,\n        exclude_storage_name: str = None,\n    ) -> tuple:\n        \"\"\"Setup and initialize storage with config.ini fallback support\n\n        Args:\n            storage_type: Type label (source/target)\n            use_streaming: If True, only count records without loading. If False, load all data (legacy mode)\n            exclude_storage_name: Storage type to exclude from selection (e.g., to prevent selecting same as source)\n\n        Returns:\n            Tuple of (storage_instance, storage_name, workspace, total_count)\n            Returns (None, None, None, 0) if user chooses to exit\n        \"\"\"\n        print(f\"\\n=== {storage_type} Storage Setup ===\")\n\n        # Filter and remap available storage types if exclusion is specified\n        if exclude_storage_name:\n            # Get available storage types (excluding source)\n            available_list = [\n                (k, v) for k, v in STORAGE_TYPES.items() if v != exclude_storage_name\n            ]\n\n            # Remap to sequential numbering (1, 2, 3...)\n            remapped_types = {\n                str(i + 1): name for i, (_, name) in enumerate(available_list)\n            }\n\n            # Print available types with new sequential numbers\n            print(\n                f\"\\nAvailable Storage Types for Target (source: {exclude_storage_name} excluded):\"\n            )\n            for key, value in remapped_types.items():\n                print(f\"[{key}] {value}\")\n\n            available_types = remapped_types\n        else:\n            # For source storage, use original numbering\n            available_types = STORAGE_TYPES.copy()\n            self.print_storage_types()\n\n        # Generate dynamic prompt based on number of options\n        num_options = len(available_types)\n        if num_options == 1:\n            prompt_range = \"1\"\n        else:\n            prompt_range = f\"1-{num_options}\"\n\n        # Custom input handling with exit support\n        while True:\n            choice = input(\n                f\"\\nSelect {storage_type} storage type ({prompt_range}) (Press Enter to exit): \"\n            ).strip()\n\n            # Check for exit\n            if choice == \"\" or choice == \"0\":\n                print(\"\\n✓ Migration cancelled by user\")\n                return None, None, None, 0\n\n            # Check if choice is valid\n            if choice in available_types:\n                break\n\n            print(\n                f\"✗ Invalid choice. Please enter one of: {', '.join(available_types.keys())}\"\n            )\n\n        storage_name = available_types[choice]\n\n        # Check configuration (warnings only, doesn't block)\n        print(\"\\nChecking configuration...\")\n        self.check_env_vars(storage_name)\n\n        # Get workspace\n        workspace = self.get_workspace_for_storage(storage_name)\n\n        # Initialize storage (real validation point)\n        print(f\"\\nInitializing {storage_type} storage...\")\n        try:\n            storage = await self.initialize_storage(storage_name, workspace)\n            workspace = storage.workspace\n            print(f\"- Storage Type: {storage_name}\")\n            print(f\"- Workspace: {workspace if workspace else '(default)'}\")\n            print(\"- Connection Status: ✓ Success\")\n\n            # Show configuration source for transparency\n            if storage_name == \"RedisKVStorage\":\n                config_source = (\n                    \"environment variable\"\n                    if \"REDIS_URI\" in os.environ\n                    else \"config.ini or default\"\n                )\n                print(f\"- Configuration Source: {config_source}\")\n            elif storage_name == \"PGKVStorage\":\n                config_source = (\n                    \"environment variables\"\n                    if all(\n                        var in os.environ\n                        for var in STORAGE_ENV_REQUIREMENTS[storage_name]\n                    )\n                    else \"config.ini or defaults\"\n                )\n                print(f\"- Configuration Source: {config_source}\")\n            elif storage_name == \"MongoKVStorage\":\n                config_source = (\n                    \"environment variables\"\n                    if all(\n                        var in os.environ\n                        for var in STORAGE_ENV_REQUIREMENTS[storage_name]\n                    )\n                    else \"config.ini or defaults\"\n                )\n                print(f\"- Configuration Source: {config_source}\")\n            elif storage_name == \"OpenSearchKVStorage\":\n                config_source = (\n                    \"environment variables\"\n                    if all(\n                        var in os.environ\n                        for var in STORAGE_ENV_REQUIREMENTS[storage_name]\n                    )\n                    else \"config.ini or defaults\"\n                )\n                print(f\"- Configuration Source: {config_source}\")\n\n        except Exception as e:\n            print(f\"✗ Initialization failed: {e}\")\n            print(f\"\\nFor {storage_name}, you can configure using:\")\n            print(\"  1. Environment variables (highest priority)\")\n\n            # Show specific environment variable requirements\n            if storage_name in STORAGE_ENV_REQUIREMENTS:\n                for var in STORAGE_ENV_REQUIREMENTS[storage_name]:\n                    print(f\"     - {var}\")\n\n            print(\"  2. config.ini file (medium priority)\")\n            if storage_name == \"RedisKVStorage\":\n                print(\"     [redis]\")\n                print(\"     uri = redis://localhost:6379\")\n            elif storage_name == \"PGKVStorage\":\n                print(\"     [postgres]\")\n                print(\"     host = localhost\")\n                print(\"     port = 5432\")\n                print(\"     user = postgres\")\n                print(\"     password = yourpassword\")\n                print(\"     database = lightrag\")\n            elif storage_name == \"MongoKVStorage\":\n                print(\"     [mongodb]\")\n                print(\"     uri = mongodb://root:root@localhost:27017/\")\n                print(\"     database = LightRAG\")\n            elif storage_name == \"OpenSearchKVStorage\":\n                print(\"     [opensearch]\")\n                print(\"     hosts = localhost:9200\")\n\n            return None, None, None, 0\n\n        # Count cache records efficiently\n        print(f\"\\n{'Counting' if use_streaming else 'Loading'} cache records...\")\n        try:\n            if use_streaming:\n                # Use efficient counting without loading data\n                total_count = await self.count_default_caches(storage, storage_name)\n                print(f\"- Total: {total_count:,} records\")\n            else:\n                # Legacy mode: load all data\n                cache_data = await self.get_default_caches(storage, storage_name)\n                counts = await self.count_cache_types(cache_data)\n                total_count = len(cache_data)\n\n                print(f\"- default:extract: {counts['extract']:,} records\")\n                print(f\"- default:summary: {counts['summary']:,} records\")\n                print(f\"- Total: {total_count:,} records\")\n        except Exception as e:\n            print(f\"✗ {'Counting' if use_streaming else 'Loading'} failed: {e}\")\n            return None, None, None, 0\n\n        return storage, storage_name, workspace, total_count\n\n    async def migrate_caches(\n        self, source_data: Dict[str, Any], target_storage, target_storage_name: str\n    ) -> MigrationStats:\n        \"\"\"Migrate caches in batches with error tracking (Legacy mode - loads all data)\n\n        Args:\n            source_data: Source cache data\n            target_storage: Target storage instance\n            target_storage_name: Target storage type name\n\n        Returns:\n            MigrationStats object with migration results and errors\n        \"\"\"\n        stats = MigrationStats()\n        stats.total_source_records = len(source_data)\n\n        if stats.total_source_records == 0:\n            print(\"\\nNo records to migrate\")\n            return stats\n\n        # Convert to list for batching\n        items = list(source_data.items())\n        stats.total_batches = (\n            stats.total_source_records + self.batch_size - 1\n        ) // self.batch_size\n\n        print(\"\\n=== Starting Migration ===\")\n\n        for batch_idx in range(stats.total_batches):\n            start_idx = batch_idx * self.batch_size\n            end_idx = min((batch_idx + 1) * self.batch_size, stats.total_source_records)\n            batch_items = items[start_idx:end_idx]\n            batch_data = dict(batch_items)\n\n            # Determine current cache type for display\n            current_key = batch_items[0][0]\n            cache_type = \"extract\" if \"extract\" in current_key else \"summary\"\n\n            try:\n                # Attempt to write batch\n                await target_storage.upsert(batch_data)\n\n                # Success - update stats\n                stats.successful_batches += 1\n                stats.successful_records += len(batch_data)\n\n                # Calculate progress\n                progress = (end_idx / stats.total_source_records) * 100\n                bar_length = 20\n                filled_length = int(bar_length * end_idx // stats.total_source_records)\n                bar = \"█\" * filled_length + \"░\" * (bar_length - filled_length)\n\n                print(\n                    f\"Batch {batch_idx + 1}/{stats.total_batches}: {bar} \"\n                    f\"{end_idx:,}/{stats.total_source_records:,} ({progress:.0f}%) - \"\n                    f\"default:{cache_type} ✓\"\n                )\n\n            except Exception as e:\n                # Error - record and continue\n                stats.add_error(batch_idx + 1, e, len(batch_data))\n\n                print(\n                    f\"Batch {batch_idx + 1}/{stats.total_batches}: ✗ FAILED - \"\n                    f\"{type(e).__name__}: {str(e)}\"\n                )\n\n        # Final persist\n        print(\"\\nPersisting data to disk...\")\n        try:\n            await target_storage.index_done_callback()\n            print(\"✓ Data persisted successfully\")\n        except Exception as e:\n            print(f\"✗ Persist failed: {e}\")\n            stats.add_error(0, e, 0)  # batch 0 = persist error\n\n        return stats\n\n    async def migrate_caches_streaming(\n        self,\n        source_storage,\n        source_storage_name: str,\n        target_storage,\n        target_storage_name: str,\n        total_records: int,\n    ) -> MigrationStats:\n        \"\"\"Migrate caches using streaming approach - minimal memory footprint\n\n        Args:\n            source_storage: Source storage instance\n            source_storage_name: Source storage type name\n            target_storage: Target storage instance\n            target_storage_name: Target storage type name\n            total_records: Total number of records to migrate\n\n        Returns:\n            MigrationStats object with migration results and errors\n        \"\"\"\n        stats = MigrationStats()\n        stats.total_source_records = total_records\n\n        if stats.total_source_records == 0:\n            print(\"\\nNo records to migrate\")\n            return stats\n\n        # Calculate total batches\n        stats.total_batches = (total_records + self.batch_size - 1) // self.batch_size\n\n        print(\"\\n=== Starting Streaming Migration ===\")\n        print(\n            f\"💡 Memory-optimized mode: Processing {self.batch_size:,} records at a time\\n\"\n        )\n\n        batch_idx = 0\n\n        # Stream batches from source and write to target immediately\n        async for batch in self.stream_default_caches(\n            source_storage, source_storage_name\n        ):\n            batch_idx += 1\n\n            # Determine current cache type for display\n            if batch:\n                first_key = next(iter(batch.keys()))\n                cache_type = \"extract\" if \"extract\" in first_key else \"summary\"\n            else:\n                cache_type = \"unknown\"\n\n            try:\n                # Write batch to target storage\n                await target_storage.upsert(batch)\n\n                # Success - update stats\n                stats.successful_batches += 1\n                stats.successful_records += len(batch)\n\n                # Calculate progress with known total\n                progress = (stats.successful_records / total_records) * 100\n                bar_length = 20\n                filled_length = int(\n                    bar_length * stats.successful_records // total_records\n                )\n                bar = \"█\" * filled_length + \"░\" * (bar_length - filled_length)\n\n                print(\n                    f\"Batch {batch_idx}/{stats.total_batches}: {bar} \"\n                    f\"{stats.successful_records:,}/{total_records:,} ({progress:.1f}%) - \"\n                    f\"default:{cache_type} ✓\"\n                )\n\n            except Exception as e:\n                # Error - record and continue\n                stats.add_error(batch_idx, e, len(batch))\n\n                print(\n                    f\"Batch {batch_idx}/{stats.total_batches}: ✗ FAILED - \"\n                    f\"{type(e).__name__}: {str(e)}\"\n                )\n\n        # Final persist\n        print(\"\\nPersisting data to disk...\")\n        try:\n            await target_storage.index_done_callback()\n            print(\"✓ Data persisted successfully\")\n        except Exception as e:\n            print(f\"✗ Persist failed: {e}\")\n            stats.add_error(0, e, 0)  # batch 0 = persist error\n\n        return stats\n\n    def print_migration_report(self, stats: MigrationStats):\n        \"\"\"Print comprehensive migration report\n\n        Args:\n            stats: MigrationStats object with migration results\n        \"\"\"\n        print(\"\\n\" + \"=\" * 60)\n        print(\"Migration Complete - Final Report\")\n        print(\"=\" * 60)\n\n        # Overall statistics\n        print(\"\\n📊 Statistics:\")\n        print(f\"  Total source records:    {stats.total_source_records:,}\")\n        print(f\"  Total batches:           {stats.total_batches:,}\")\n        print(f\"  Successful batches:      {stats.successful_batches:,}\")\n        print(f\"  Failed batches:          {stats.failed_batches:,}\")\n        print(f\"  Successfully migrated:   {stats.successful_records:,}\")\n        print(f\"  Failed to migrate:       {stats.failed_records:,}\")\n\n        # Success rate\n        success_rate = (\n            (stats.successful_records / stats.total_source_records * 100)\n            if stats.total_source_records > 0\n            else 0\n        )\n        print(f\"  Success rate:            {success_rate:.2f}%\")\n\n        # Error details\n        if stats.errors:\n            print(f\"\\n⚠️  Errors encountered: {len(stats.errors)}\")\n            print(\"\\nError Details:\")\n            print(\"-\" * 60)\n\n            # Group errors by type\n            error_types = {}\n            for error in stats.errors:\n                err_type = error[\"error_type\"]\n                error_types[err_type] = error_types.get(err_type, 0) + 1\n\n            print(\"\\nError Summary:\")\n            for err_type, count in sorted(error_types.items(), key=lambda x: -x[1]):\n                print(f\"  - {err_type}: {count} occurrence(s)\")\n\n            print(\"\\nFirst 5 errors:\")\n            for i, error in enumerate(stats.errors[:5], 1):\n                print(f\"\\n  {i}. Batch {error['batch']}\")\n                print(f\"     Type: {error['error_type']}\")\n                print(f\"     Message: {error['error_msg']}\")\n                print(f\"     Records lost: {error['records_lost']:,}\")\n\n            if len(stats.errors) > 5:\n                print(f\"\\n  ... and {len(stats.errors) - 5} more errors\")\n\n            print(\"\\n\" + \"=\" * 60)\n            print(\"⚠️  WARNING: Migration completed with errors!\")\n            print(\"   Please review the error details above.\")\n            print(\"=\" * 60)\n        else:\n            print(\"\\n\" + \"=\" * 60)\n            print(\"✓ SUCCESS: All records migrated successfully!\")\n            print(\"=\" * 60)\n\n    async def run(self):\n        \"\"\"Run the migration tool with streaming approach and early validation\"\"\"\n        try:\n            # Initialize shared storage (REQUIRED for storage classes to work)\n            from lightrag.kg.shared_storage import initialize_share_data\n\n            initialize_share_data(workers=1)\n\n            # Print header\n            self.print_header()\n\n            # Setup source storage with streaming (only count, don't load all data)\n            (\n                self.source_storage,\n                source_storage_name,\n                self.source_workspace,\n                source_count,\n            ) = await self.setup_storage(\"Source\", use_streaming=True)\n\n            # Check if user cancelled (setup_storage returns None for all fields)\n            if self.source_storage is None:\n                return\n\n            # Check if there are at least 2 storage types available\n            available_count = self.count_available_storage_types()\n            if available_count <= 1:\n                print(\"\\n\" + \"=\" * 60)\n                print(\"⚠️  Warning: Migration Not Possible\")\n                print(\"=\" * 60)\n                print(f\"Only {available_count} storage type(s) available.\")\n                print(\"Migration requires at least 2 different storage types.\")\n                print(\"\\nTo enable migration, configure additional storage:\")\n                print(\"  1. Set environment variables, OR\")\n                print(\"  2. Update config.ini file\")\n                print(\"\\nSupported storage types:\")\n                for name in STORAGE_TYPES.values():\n                    if name != source_storage_name:\n                        print(f\"  - {name}\")\n                        if name in STORAGE_ENV_REQUIREMENTS:\n                            for var in STORAGE_ENV_REQUIREMENTS[name]:\n                                print(f\"    Required: {var}\")\n                print(\"=\" * 60)\n\n                # Cleanup\n                await self.source_storage.finalize()\n                return\n\n            if source_count == 0:\n                print(\"\\n⚠️  Source storage has no cache records to migrate\")\n                # Cleanup\n                await self.source_storage.finalize()\n                return\n\n            # Setup target storage with streaming (only count, don't load all data)\n            # Exclude source storage type from target selection\n            (\n                self.target_storage,\n                target_storage_name,\n                self.target_workspace,\n                target_count,\n            ) = await self.setup_storage(\n                \"Target\", use_streaming=True, exclude_storage_name=source_storage_name\n            )\n\n            if not self.target_storage:\n                print(\"\\n✗ Target storage setup failed\")\n                # Cleanup source\n                await self.source_storage.finalize()\n                return\n\n            # Show migration summary\n            print(\"\\n\" + \"=\" * 50)\n            print(\"Migration Confirmation\")\n            print(\"=\" * 50)\n            print(\n                f\"Source: {self.format_storage_name(source_storage_name)} (workspace: {self.format_workspace(self.source_workspace)}) - {source_count:,} records\"\n            )\n            print(\n                f\"Target: {self.format_storage_name(target_storage_name)} (workspace: {self.format_workspace(self.target_workspace)}) - {target_count:,} records\"\n            )\n            print(f\"Batch Size: {self.batch_size:,} records/batch\")\n            print(\"Memory Mode: Streaming (memory-optimized)\")\n\n            if target_count > 0:\n                print(\n                    f\"\\n⚠️ Warning: Target storage already has {target_count:,} records\"\n                )\n                print(\"Migration will overwrite records with the same keys\")\n\n            # Confirm migration\n            confirm = input(\"\\nContinue? (y/n): \").strip().lower()\n            if confirm != \"y\":\n                print(\"\\n✗ Migration cancelled\")\n                # Cleanup\n                await self.source_storage.finalize()\n                await self.target_storage.finalize()\n                return\n\n            # Perform streaming migration with error tracking\n            stats = await self.migrate_caches_streaming(\n                self.source_storage,\n                source_storage_name,\n                self.target_storage,\n                target_storage_name,\n                source_count,\n            )\n\n            # Print comprehensive migration report\n            self.print_migration_report(stats)\n\n            # Cleanup\n            await self.source_storage.finalize()\n            await self.target_storage.finalize()\n\n        except KeyboardInterrupt:\n            print(\"\\n\\n✗ Migration interrupted by user\")\n        except Exception as e:\n            print(f\"\\n✗ Migration failed: {e}\")\n            import traceback\n\n            traceback.print_exc()\n        finally:\n            # Ensure cleanup\n            if self.source_storage:\n                try:\n                    await self.source_storage.finalize()\n                except Exception:\n                    pass\n            if self.target_storage:\n                try:\n                    await self.target_storage.finalize()\n                except Exception:\n                    pass\n\n            # Finalize shared storage\n            try:\n                from lightrag.kg.shared_storage import finalize_share_data\n\n                finalize_share_data()\n            except Exception:\n                pass\n\n\nasync def main():\n    \"\"\"Main entry point\"\"\"\n    tool = MigrationTool()\n    await tool.run()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "lightrag/tools/prepare_qdrant_legacy_data.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nQdrant Legacy Data Preparation Tool for LightRAG\n\nThis tool copies data from new collections to legacy collections for testing\nthe data migration logic in setup_collection function.\n\nNew Collections (with workspace_id):\n    - lightrag_vdb_chunks\n    - lightrag_vdb_entities\n    - lightrag_vdb_relationships\n\nLegacy Collections (without workspace_id, dynamically named as {workspace}_{suffix}):\n    - {workspace}_chunks (e.g., space1_chunks)\n    - {workspace}_entities (e.g., space1_entities)\n    - {workspace}_relationships (e.g., space1_relationships)\n\nThe tool:\n    1. Filters source data by workspace_id\n    2. Verifies workspace data exists before creating legacy collections\n    3. Removes workspace_id field to simulate legacy data format\n    4. Copies only the specified workspace's data to legacy collections\n\nUsage:\n    python -m lightrag.tools.prepare_qdrant_legacy_data\n    # or\n    python lightrag/tools/prepare_qdrant_legacy_data.py\n\n    # Specify custom workspace\n    python -m lightrag.tools.prepare_qdrant_legacy_data --workspace space1\n\n    # Process specific collection types only\n    python -m lightrag.tools.prepare_qdrant_legacy_data --types chunks,entities\n\n    # Dry run (preview only, no actual changes)\n    python -m lightrag.tools.prepare_qdrant_legacy_data --dry-run\n\"\"\"\n\nimport argparse\nimport asyncio\nimport configparser\nimport os\nimport sys\nimport time\nfrom dataclasses import dataclass, field\nfrom typing import Any, Dict, List, Optional\n\nimport pipmaster as pm\nfrom dotenv import load_dotenv\nfrom qdrant_client import QdrantClient, models  # type: ignore\n\n# Add project root to path for imports\nsys.path.insert(\n    0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\n)\n\n# Load environment variables\nload_dotenv(dotenv_path=\".env\", override=False)\n\n# Ensure qdrant-client is installed\nif not pm.is_installed(\"qdrant-client\"):\n    pm.install(\"qdrant-client\")\n\n# Collection namespace mapping: new collection pattern -> legacy suffix\n# Legacy collection will be named as: {workspace}_{suffix}\nCOLLECTION_NAMESPACES = {\n    \"chunks\": {\n        \"new\": \"lightrag_vdb_chunks\",\n        \"suffix\": \"chunks\",\n    },\n    \"entities\": {\n        \"new\": \"lightrag_vdb_entities\",\n        \"suffix\": \"entities\",\n    },\n    \"relationships\": {\n        \"new\": \"lightrag_vdb_relationships\",\n        \"suffix\": \"relationships\",\n    },\n}\n\n# Default batch size for copy operations\nDEFAULT_BATCH_SIZE = 500\n\n# Field to remove from legacy data\nWORKSPACE_ID_FIELD = \"workspace_id\"\n\n# ANSI color codes for terminal output\nBOLD_CYAN = \"\\033[1;36m\"\nBOLD_GREEN = \"\\033[1;32m\"\nBOLD_YELLOW = \"\\033[1;33m\"\nBOLD_RED = \"\\033[1;31m\"\nRESET = \"\\033[0m\"\n\n\n@dataclass\nclass CopyStats:\n    \"\"\"Copy operation statistics\"\"\"\n\n    collection_type: str\n    source_collection: str\n    target_collection: str\n    total_records: int = 0\n    copied_records: int = 0\n    failed_records: int = 0\n    errors: List[Dict[str, Any]] = field(default_factory=list)\n    elapsed_time: float = 0.0\n\n    def add_error(self, batch_idx: int, error: Exception, batch_size: int):\n        \"\"\"Record batch error\"\"\"\n        self.errors.append(\n            {\n                \"batch\": batch_idx,\n                \"error_type\": type(error).__name__,\n                \"error_msg\": str(error),\n                \"records_lost\": batch_size,\n                \"timestamp\": time.time(),\n            }\n        )\n        self.failed_records += batch_size\n\n\nclass QdrantLegacyDataPreparationTool:\n    \"\"\"Tool for preparing legacy data in Qdrant for migration testing\"\"\"\n\n    def __init__(\n        self,\n        workspace: str = \"space1\",\n        batch_size: int = DEFAULT_BATCH_SIZE,\n        dry_run: bool = False,\n        clear_target: bool = False,\n    ):\n        \"\"\"\n        Initialize the tool.\n\n        Args:\n            workspace: Workspace to use for filtering new collection data\n            batch_size: Number of records to process per batch\n            dry_run: If True, only preview operations without making changes\n            clear_target: If True, delete target collection before copying data\n        \"\"\"\n        self.workspace = workspace\n        self.batch_size = batch_size\n        self.dry_run = dry_run\n        self.clear_target = clear_target\n        self._client: Optional[QdrantClient] = None\n\n    def _get_client(self) -> QdrantClient:\n        \"\"\"Get or create QdrantClient instance\"\"\"\n        if self._client is None:\n            config = configparser.ConfigParser()\n            config.read(\"config.ini\", \"utf-8\")\n\n            self._client = QdrantClient(\n                url=os.environ.get(\n                    \"QDRANT_URL\", config.get(\"qdrant\", \"uri\", fallback=None)\n                ),\n                api_key=os.environ.get(\n                    \"QDRANT_API_KEY\",\n                    config.get(\"qdrant\", \"apikey\", fallback=None),\n                ),\n            )\n        return self._client\n\n    def print_header(self):\n        \"\"\"Print tool header\"\"\"\n        print(\"\\n\" + \"=\" * 60)\n        print(\"Qdrant Legacy Data Preparation Tool - LightRAG\")\n        print(\"=\" * 60)\n        if self.dry_run:\n            print(f\"{BOLD_YELLOW}⚠️  DRY RUN MODE - No changes will be made{RESET}\")\n        if self.clear_target:\n            print(\n                f\"{BOLD_RED}⚠️  CLEAR TARGET MODE - Target collections will be deleted first{RESET}\"\n            )\n        print(f\"Workspace: {BOLD_CYAN}{self.workspace}{RESET}\")\n        print(f\"Batch Size: {self.batch_size}\")\n        print(\"=\" * 60)\n\n    def check_connection(self) -> bool:\n        \"\"\"Check Qdrant connection\"\"\"\n        try:\n            client = self._get_client()\n            # Try to list collections to verify connection\n            client.get_collections()\n            print(f\"{BOLD_GREEN}✓{RESET} Qdrant connection successful\")\n            return True\n        except Exception as e:\n            print(f\"{BOLD_RED}✗{RESET} Qdrant connection failed: {e}\")\n            return False\n\n    def get_collection_info(self, collection_name: str) -> Optional[Dict[str, Any]]:\n        \"\"\"\n        Get collection information.\n\n        Args:\n            collection_name: Name of the collection\n\n        Returns:\n            Dictionary with collection info (vector_size, count) or None if not exists\n        \"\"\"\n        client = self._get_client()\n\n        if not client.collection_exists(collection_name):\n            return None\n\n        info = client.get_collection(collection_name)\n        count = client.count(collection_name=collection_name, exact=True).count\n\n        # Handle both object and dict formats for vectors config\n        vectors_config = info.config.params.vectors\n        if isinstance(vectors_config, dict):\n            # Named vectors format or dict format\n            if vectors_config:\n                first_key = next(iter(vectors_config.keys()), None)\n                if first_key and hasattr(vectors_config[first_key], \"size\"):\n                    vector_size = vectors_config[first_key].size\n                    distance = vectors_config[first_key].distance\n                else:\n                    # Try to get from dict values\n                    first_val = next(iter(vectors_config.values()), {})\n                    vector_size = (\n                        first_val.get(\"size\")\n                        if isinstance(first_val, dict)\n                        else getattr(first_val, \"size\", None)\n                    )\n                    distance = (\n                        first_val.get(\"distance\")\n                        if isinstance(first_val, dict)\n                        else getattr(first_val, \"distance\", None)\n                    )\n            else:\n                vector_size = None\n                distance = None\n        else:\n            # Standard single vector format\n            vector_size = vectors_config.size\n            distance = vectors_config.distance\n\n        return {\n            \"name\": collection_name,\n            \"vector_size\": vector_size,\n            \"count\": count,\n            \"distance\": distance,\n        }\n\n    def delete_collection(self, collection_name: str) -> bool:\n        \"\"\"\n        Delete a collection if it exists.\n\n        Args:\n            collection_name: Name of the collection to delete\n\n        Returns:\n            True if deleted or doesn't exist\n        \"\"\"\n        client = self._get_client()\n\n        if not client.collection_exists(collection_name):\n            return True\n\n        if self.dry_run:\n            target_info = self.get_collection_info(collection_name)\n            count = target_info[\"count\"] if target_info else 0\n            print(\n                f\"  {BOLD_YELLOW}[DRY RUN]{RESET} Would delete collection '{collection_name}' ({count:,} records)\"\n            )\n            return True\n\n        try:\n            target_info = self.get_collection_info(collection_name)\n            count = target_info[\"count\"] if target_info else 0\n            client.delete_collection(collection_name=collection_name)\n            print(\n                f\"  {BOLD_RED}✗{RESET} Deleted collection '{collection_name}' ({count:,} records)\"\n            )\n            return True\n        except Exception as e:\n            print(f\"  {BOLD_RED}✗{RESET} Failed to delete collection: {e}\")\n            return False\n\n    def create_legacy_collection(\n        self, collection_name: str, vector_size: int, distance: models.Distance\n    ) -> bool:\n        \"\"\"\n        Create legacy collection if it doesn't exist.\n\n        Args:\n            collection_name: Name of the collection to create\n            vector_size: Dimension of vectors\n            distance: Distance metric\n\n        Returns:\n            True if created or already exists\n        \"\"\"\n        client = self._get_client()\n\n        if client.collection_exists(collection_name):\n            print(f\"  Collection '{collection_name}' already exists\")\n            return True\n\n        if self.dry_run:\n            print(\n                f\"  {BOLD_YELLOW}[DRY RUN]{RESET} Would create collection '{collection_name}' with {vector_size}d vectors\"\n            )\n            return True\n\n        try:\n            client.create_collection(\n                collection_name=collection_name,\n                vectors_config=models.VectorParams(\n                    size=vector_size,\n                    distance=distance,\n                ),\n                hnsw_config=models.HnswConfigDiff(\n                    payload_m=16,\n                    m=0,\n                ),\n            )\n            print(\n                f\"  {BOLD_GREEN}✓{RESET} Created collection '{collection_name}' with {vector_size}d vectors\"\n            )\n            return True\n        except Exception as e:\n            print(f\"  {BOLD_RED}✗{RESET} Failed to create collection: {e}\")\n            return False\n\n    def _get_workspace_filter(self) -> models.Filter:\n        \"\"\"Create workspace filter for Qdrant queries\"\"\"\n        return models.Filter(\n            must=[\n                models.FieldCondition(\n                    key=WORKSPACE_ID_FIELD,\n                    match=models.MatchValue(value=self.workspace),\n                )\n            ]\n        )\n\n    def get_workspace_count(self, collection_name: str) -> int:\n        \"\"\"\n        Get count of records for the current workspace in a collection.\n\n        Args:\n            collection_name: Name of the collection\n\n        Returns:\n            Count of records for the workspace\n        \"\"\"\n        client = self._get_client()\n        return client.count(\n            collection_name=collection_name,\n            count_filter=self._get_workspace_filter(),\n            exact=True,\n        ).count\n\n    def copy_collection_data(\n        self,\n        source_collection: str,\n        target_collection: str,\n        collection_type: str,\n        workspace_count: int,\n    ) -> CopyStats:\n        \"\"\"\n        Copy data from source to target collection.\n\n        This filters by workspace_id and removes it from payload to simulate legacy data format.\n\n        Args:\n            source_collection: Source collection name\n            target_collection: Target collection name\n            collection_type: Type of collection (chunks, entities, relationships)\n            workspace_count: Pre-computed count of workspace records\n\n        Returns:\n            CopyStats with operation results\n        \"\"\"\n        client = self._get_client()\n        stats = CopyStats(\n            collection_type=collection_type,\n            source_collection=source_collection,\n            target_collection=target_collection,\n        )\n\n        start_time = time.time()\n        stats.total_records = workspace_count\n\n        if workspace_count == 0:\n            print(f\"  No records for workspace '{self.workspace}', skipping\")\n            stats.elapsed_time = time.time() - start_time\n            return stats\n\n        print(f\"  Workspace records: {workspace_count:,}\")\n\n        if self.dry_run:\n            print(\n                f\"  {BOLD_YELLOW}[DRY RUN]{RESET} Would copy {workspace_count:,} records to '{target_collection}'\"\n            )\n            stats.copied_records = workspace_count\n            stats.elapsed_time = time.time() - start_time\n            return stats\n\n        # Batch copy using scroll with workspace filter\n        workspace_filter = self._get_workspace_filter()\n        offset = None\n        batch_idx = 0\n\n        while True:\n            # Scroll source collection with workspace filter\n            result = client.scroll(\n                collection_name=source_collection,\n                scroll_filter=workspace_filter,\n                limit=self.batch_size,\n                offset=offset,\n                with_vectors=True,\n                with_payload=True,\n            )\n            points, next_offset = result\n\n            if not points:\n                break\n\n            batch_idx += 1\n\n            # Transform points: remove workspace_id from payload\n            new_points = []\n            for point in points:\n                new_payload = dict(point.payload or {})\n                # Remove workspace_id to simulate legacy format\n                new_payload.pop(WORKSPACE_ID_FIELD, None)\n\n                # Use original id from payload if available, otherwise use point.id\n                original_id = new_payload.get(\"id\")\n                if original_id:\n                    # Generate a simple deterministic id for legacy format\n                    # Use original id directly (legacy format didn't have workspace prefix)\n                    import hashlib\n                    import uuid\n\n                    hashed = hashlib.sha256(original_id.encode(\"utf-8\")).digest()\n                    point_id = uuid.UUID(bytes=hashed[:16], version=4).hex\n                else:\n                    point_id = str(point.id)\n\n                new_points.append(\n                    models.PointStruct(\n                        id=point_id,\n                        vector=point.vector,\n                        payload=new_payload,\n                    )\n                )\n\n            try:\n                # Upsert to target collection\n                client.upsert(\n                    collection_name=target_collection, points=new_points, wait=True\n                )\n                stats.copied_records += len(new_points)\n\n                # Progress bar\n                progress = (stats.copied_records / workspace_count) * 100\n                bar_length = 30\n                filled = int(bar_length * stats.copied_records // workspace_count)\n                bar = \"█\" * filled + \"░\" * (bar_length - filled)\n\n                print(\n                    f\"\\r  Copying: {bar} {stats.copied_records:,}/{workspace_count:,} ({progress:.1f}%) \",\n                    end=\"\",\n                    flush=True,\n                )\n\n            except Exception as e:\n                stats.add_error(batch_idx, e, len(new_points))\n                print(\n                    f\"\\n  {BOLD_RED}✗{RESET} Batch {batch_idx} failed: {type(e).__name__}: {e}\"\n                )\n\n            if next_offset is None:\n                break\n            offset = next_offset\n\n        print()  # New line after progress bar\n        stats.elapsed_time = time.time() - start_time\n\n        return stats\n\n    def process_collection_type(self, collection_type: str) -> Optional[CopyStats]:\n        \"\"\"\n        Process a single collection type.\n\n        Args:\n            collection_type: Type of collection (chunks, entities, relationships)\n\n        Returns:\n            CopyStats or None if error\n        \"\"\"\n        namespace_config = COLLECTION_NAMESPACES.get(collection_type)\n        if not namespace_config:\n            print(f\"{BOLD_RED}✗{RESET} Unknown collection type: {collection_type}\")\n            return None\n\n        source = namespace_config[\"new\"]\n        # Generate legacy collection name dynamically: {workspace}_{suffix}\n        target = f\"{self.workspace}_{namespace_config['suffix']}\"\n\n        print(f\"\\n{'=' * 50}\")\n        print(f\"Processing: {BOLD_CYAN}{collection_type}{RESET}\")\n        print(f\"{'=' * 50}\")\n        print(f\"  Source: {source}\")\n        print(f\"  Target: {target}\")\n\n        # Check source collection\n        source_info = self.get_collection_info(source)\n        if source_info is None:\n            print(\n                f\"  {BOLD_YELLOW}⚠{RESET} Source collection '{source}' does not exist, skipping\"\n            )\n            return None\n\n        print(f\"  Source vector dimension: {source_info['vector_size']}d\")\n        print(f\"  Source distance metric: {source_info['distance']}\")\n        print(f\"  Source total records: {source_info['count']:,}\")\n\n        # Check workspace data exists BEFORE creating legacy collection\n        workspace_count = self.get_workspace_count(source)\n        print(f\"  Workspace '{self.workspace}' records: {workspace_count:,}\")\n\n        if workspace_count == 0:\n            print(\n                f\"  {BOLD_YELLOW}⚠{RESET} No data found for workspace '{self.workspace}' in '{source}', skipping\"\n            )\n            return None\n\n        # Clear target collection if requested\n        if self.clear_target:\n            if not self.delete_collection(target):\n                return None\n\n        # Create target collection only after confirming workspace data exists\n        if not self.create_legacy_collection(\n            target, source_info[\"vector_size\"], source_info[\"distance\"]\n        ):\n            return None\n\n        # Copy data with workspace filter\n        stats = self.copy_collection_data(\n            source, target, collection_type, workspace_count\n        )\n\n        # Print result\n        if stats.failed_records == 0:\n            print(\n                f\"  {BOLD_GREEN}✓{RESET} Copied {stats.copied_records:,} records in {stats.elapsed_time:.2f}s\"\n            )\n        else:\n            print(\n                f\"  {BOLD_YELLOW}⚠{RESET} Copied {stats.copied_records:,} records, \"\n                f\"{BOLD_RED}{stats.failed_records:,} failed{RESET} in {stats.elapsed_time:.2f}s\"\n            )\n\n        return stats\n\n    def print_summary(self, all_stats: List[CopyStats]):\n        \"\"\"Print summary of all operations\"\"\"\n        print(\"\\n\" + \"=\" * 60)\n        print(\"Summary\")\n        print(\"=\" * 60)\n\n        total_copied = sum(s.copied_records for s in all_stats)\n        total_failed = sum(s.failed_records for s in all_stats)\n        total_time = sum(s.elapsed_time for s in all_stats)\n\n        for stats in all_stats:\n            status = (\n                f\"{BOLD_GREEN}✓{RESET}\"\n                if stats.failed_records == 0\n                else f\"{BOLD_YELLOW}⚠{RESET}\"\n            )\n            print(\n                f\"  {status} {stats.collection_type}: {stats.copied_records:,}/{stats.total_records:,} \"\n                f\"({stats.source_collection} → {stats.target_collection})\"\n            )\n\n        print(\"-\" * 60)\n        print(f\"  Total records copied: {BOLD_CYAN}{total_copied:,}{RESET}\")\n        if total_failed > 0:\n            print(f\"  Total records failed: {BOLD_RED}{total_failed:,}{RESET}\")\n        print(f\"  Total time: {total_time:.2f}s\")\n\n        if self.dry_run:\n            print(f\"\\n{BOLD_YELLOW}⚠️  DRY RUN - No actual changes were made{RESET}\")\n\n        # Print error details if any\n        all_errors = []\n        for stats in all_stats:\n            all_errors.extend(stats.errors)\n\n        if all_errors:\n            print(f\"\\n{BOLD_RED}Errors ({len(all_errors)}){RESET}\")\n            for i, error in enumerate(all_errors[:5], 1):\n                print(\n                    f\"  {i}. Batch {error['batch']}: {error['error_type']}: {error['error_msg']}\"\n                )\n            if len(all_errors) > 5:\n                print(f\"  ... and {len(all_errors) - 5} more errors\")\n\n        print(\"=\" * 60)\n\n    async def run(self, collection_types: Optional[List[str]] = None):\n        \"\"\"\n        Run the data preparation tool.\n\n        Args:\n            collection_types: List of collection types to process (default: all)\n        \"\"\"\n        self.print_header()\n\n        # Check connection\n        if not self.check_connection():\n            return\n\n        # Determine which collection types to process\n        if collection_types:\n            types_to_process = [t.strip() for t in collection_types]\n            invalid_types = [\n                t for t in types_to_process if t not in COLLECTION_NAMESPACES\n            ]\n            if invalid_types:\n                print(\n                    f\"{BOLD_RED}✗{RESET} Invalid collection types: {', '.join(invalid_types)}\"\n                )\n                print(f\"  Valid types: {', '.join(COLLECTION_NAMESPACES.keys())}\")\n                return\n        else:\n            types_to_process = list(COLLECTION_NAMESPACES.keys())\n\n        print(f\"\\nCollection types to process: {', '.join(types_to_process)}\")\n\n        # Process each collection type\n        all_stats = []\n        for ctype in types_to_process:\n            stats = self.process_collection_type(ctype)\n            if stats:\n                all_stats.append(stats)\n\n        # Print summary\n        if all_stats:\n            self.print_summary(all_stats)\n        else:\n            print(f\"\\n{BOLD_YELLOW}⚠{RESET} No collections were processed\")\n\n\ndef parse_args():\n    \"\"\"Parse command line arguments\"\"\"\n    parser = argparse.ArgumentParser(\n        description=\"Prepare legacy data in Qdrant for migration testing\",\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n        epilog=\"\"\"\nExamples:\n    python -m lightrag.tools.prepare_qdrant_legacy_data\n    python -m lightrag.tools.prepare_qdrant_legacy_data --workspace space1\n    python -m lightrag.tools.prepare_qdrant_legacy_data --types chunks,entities\n    python -m lightrag.tools.prepare_qdrant_legacy_data --dry-run\n        \"\"\",\n    )\n\n    parser.add_argument(\n        \"--workspace\",\n        type=str,\n        default=\"space1\",\n        help=\"Workspace name (default: space1)\",\n    )\n\n    parser.add_argument(\n        \"--types\",\n        type=str,\n        default=None,\n        help=\"Comma-separated list of collection types (chunks, entities, relationships)\",\n    )\n\n    parser.add_argument(\n        \"--batch-size\",\n        type=int,\n        default=DEFAULT_BATCH_SIZE,\n        help=f\"Batch size for copy operations (default: {DEFAULT_BATCH_SIZE})\",\n    )\n\n    parser.add_argument(\n        \"--dry-run\",\n        action=\"store_true\",\n        help=\"Preview operations without making changes\",\n    )\n\n    parser.add_argument(\n        \"--clear-target\",\n        action=\"store_true\",\n        help=\"Delete target collections before copying (for clean test environment)\",\n    )\n\n    return parser.parse_args()\n\n\nasync def main():\n    \"\"\"Main entry point\"\"\"\n    args = parse_args()\n\n    collection_types = None\n    if args.types:\n        collection_types = [t.strip() for t in args.types.split(\",\")]\n\n    tool = QdrantLegacyDataPreparationTool(\n        workspace=args.workspace,\n        batch_size=args.batch_size,\n        dry_run=args.dry_run,\n        clear_target=args.clear_target,\n    )\n\n    await tool.run(collection_types=collection_types)\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "lightrag/types.py",
    "content": "from __future__ import annotations\n\nfrom pydantic import BaseModel\nfrom typing import Any, Optional\n\n\nclass GPTKeywordExtractionFormat(BaseModel):\n    high_level_keywords: list[str]\n    low_level_keywords: list[str]\n\n\nclass KnowledgeGraphNode(BaseModel):\n    id: str\n    labels: list[str]\n    properties: dict[str, Any]  # anything else goes here\n\n\nclass KnowledgeGraphEdge(BaseModel):\n    id: str\n    type: Optional[str]\n    source: str  # id of source node\n    target: str  # id of target node\n    properties: dict[str, Any]  # anything else goes here\n\n\nclass KnowledgeGraph(BaseModel):\n    nodes: list[KnowledgeGraphNode] = []\n    edges: list[KnowledgeGraphEdge] = []\n    is_truncated: bool = False\n"
  },
  {
    "path": "lightrag/utils.py",
    "content": "from __future__ import annotations\nimport weakref\n\nimport sys\n\nimport asyncio\nimport html\nimport csv\nimport inspect\nimport json\nimport logging\nimport logging.handlers\nimport os\nimport re\nimport time\nimport uuid\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom functools import wraps\nfrom hashlib import md5\nfrom typing import (\n    Any,\n    Protocol,\n    Callable,\n    TYPE_CHECKING,\n    List,\n    Optional,\n    Iterable,\n    Sequence,\n    Collection,\n)\nimport numpy as np\nfrom dotenv import load_dotenv\n\nfrom lightrag.constants import (\n    DEFAULT_LOG_MAX_BYTES,\n    DEFAULT_LOG_BACKUP_COUNT,\n    DEFAULT_LOG_FILENAME,\n    GRAPH_FIELD_SEP,\n    DEFAULT_MAX_TOTAL_TOKENS,\n    DEFAULT_SOURCE_IDS_LIMIT_METHOD,\n    VALID_SOURCE_IDS_LIMIT_METHODS,\n    SOURCE_IDS_LIMIT_METHOD_FIFO,\n)\n\n# Precompile regex pattern for JSON sanitization (module-level, compiled once)\n_SURROGATE_PATTERN = re.compile(r\"[\\uD800-\\uDFFF\\uFFFE\\uFFFF]\")\n\n\nclass SafeStreamHandler(logging.StreamHandler):\n    \"\"\"StreamHandler that gracefully handles closed streams during shutdown.\n\n    This handler prevents \"ValueError: I/O operation on closed file\" errors\n    that can occur when pytest or other test frameworks close stdout/stderr\n    before Python's logging cleanup runs.\n    \"\"\"\n\n    def flush(self):\n        \"\"\"Flush the stream, ignoring errors if the stream is closed.\"\"\"\n        try:\n            super().flush()\n        except (ValueError, OSError):\n            # Stream is closed or otherwise unavailable, silently ignore\n            pass\n\n    def close(self):\n        \"\"\"Close the handler, ignoring errors if the stream is already closed.\"\"\"\n        try:\n            super().close()\n        except (ValueError, OSError):\n            # Stream is closed or otherwise unavailable, silently ignore\n            pass\n\n\n# Initialize logger with basic configuration\nlogger = logging.getLogger(\"lightrag\")\nlogger.propagate = False  # prevent log message send to root logger\nlogger.setLevel(logging.INFO)\n\n# Add console handler if no handlers exist\nif not logger.handlers:\n    console_handler = SafeStreamHandler()\n    console_handler.setLevel(logging.INFO)\n    formatter = logging.Formatter(\"%(levelname)s: %(message)s\")\n    console_handler.setFormatter(formatter)\n    logger.addHandler(console_handler)\n\n# Set httpx logging level to WARNING\nlogging.getLogger(\"httpx\").setLevel(logging.WARNING)\n\n\ndef _patch_ascii_colors_console_handler() -> None:\n    \"\"\"Prevent ascii_colors from printing flush errors during interpreter exit.\"\"\"\n\n    try:\n        from ascii_colors import ConsoleHandler\n    except ImportError:\n        return\n\n    if getattr(ConsoleHandler, \"_lightrag_patched\", False):\n        return\n\n    original_handle_error = ConsoleHandler.handle_error\n\n    def _safe_handle_error(self, message: str) -> None:  # type: ignore[override]\n        exc_type, _, _ = sys.exc_info()\n        if exc_type in (ValueError, OSError) and \"close\" in message.lower():\n            return\n        original_handle_error(self, message)\n\n    ConsoleHandler.handle_error = _safe_handle_error  # type: ignore[assignment]\n    ConsoleHandler._lightrag_patched = True  # type: ignore[attr-defined]\n\n\n_patch_ascii_colors_console_handler()\n\n\n# Global import for pypinyin with startup-time logging\ntry:\n    import pypinyin\n\n    _PYPINYIN_AVAILABLE = True\n    # logger.info(\"pypinyin loaded successfully for Chinese pinyin sorting\")\nexcept ImportError:\n    pypinyin = None\n    _PYPINYIN_AVAILABLE = False\n    logger.warning(\n        \"pypinyin is not installed. Chinese pinyin sorting will use simple string sorting.\"\n    )\n\n\nasync def safe_vdb_operation_with_exception(\n    operation: Callable,\n    operation_name: str,\n    entity_name: str = \"\",\n    max_retries: int = 3,\n    retry_delay: float = 0.2,\n    logger_func: Optional[Callable] = None,\n) -> None:\n    \"\"\"\n    Safely execute vector database operations with retry mechanism and exception handling.\n\n    This function ensures that VDB operations are executed with proper error handling\n    and retry logic. If all retries fail, it raises an exception to maintain data consistency.\n\n    Args:\n        operation: The async operation to execute\n        operation_name: Operation name for logging purposes\n        entity_name: Entity name for logging purposes\n        max_retries: Maximum number of retry attempts\n        retry_delay: Delay between retries in seconds\n        logger_func: Logger function to use for error messages\n\n    Raises:\n        Exception: When operation fails after all retry attempts\n    \"\"\"\n    log_func = logger_func or logger.warning\n\n    for attempt in range(max_retries):\n        try:\n            await operation()\n            return  # Success, return immediately\n        except Exception as e:\n            if attempt >= max_retries - 1:\n                error_msg = f\"VDB {operation_name} failed for {entity_name} after {max_retries} attempts: {e}\"\n                log_func(error_msg)\n                raise Exception(error_msg) from e\n            else:\n                log_func(\n                    f\"VDB {operation_name} attempt {attempt + 1} failed for {entity_name}: {e}, retrying...\"\n                )\n                if retry_delay > 0:\n                    await asyncio.sleep(retry_delay)\n\n\ndef get_env_value(\n    env_key: str, default: any, value_type: type = str, special_none: bool = False\n) -> any:\n    \"\"\"\n    Get value from environment variable with type conversion\n\n    Args:\n        env_key (str): Environment variable key\n        default (any): Default value if env variable is not set\n        value_type (type): Type to convert the value to\n        special_none (bool): If True, return None when value is \"None\"\n\n    Returns:\n        any: Converted value from environment or default\n    \"\"\"\n    value = os.getenv(env_key)\n    if value is None:\n        return default\n\n    # Handle special case for \"None\" string\n    if special_none and value == \"None\":\n        return None\n\n    if value_type is bool:\n        return value.lower() in (\"true\", \"1\", \"yes\", \"t\", \"on\")\n\n    # Handle list type with JSON parsing\n    if value_type is list:\n        try:\n            import json\n\n            parsed_value = json.loads(value)\n            # Ensure the parsed value is actually a list\n            if isinstance(parsed_value, list):\n                return parsed_value\n            else:\n                logger.warning(\n                    f\"Environment variable {env_key} is not a valid JSON list, using default\"\n                )\n                return default\n        except (json.JSONDecodeError, ValueError) as e:\n            logger.warning(\n                f\"Failed to parse {env_key} as JSON list: {e}, using default\"\n            )\n            return default\n\n    try:\n        return value_type(value)\n    except (ValueError, TypeError):\n        return default\n\n\n# Use TYPE_CHECKING to avoid circular imports\nif TYPE_CHECKING:\n    from lightrag.base import BaseKVStorage, BaseVectorStorage, QueryParam\n\n# use the .env that is inside the current folder\n# allows to use different .env file for each lightrag instance\n# the OS environment variables take precedence over the .env file\nload_dotenv(dotenv_path=\".env\", override=False)\n\nVERBOSE_DEBUG = os.getenv(\"VERBOSE\", \"false\").lower() == \"true\"\n\n\ndef verbose_debug(msg: str, *args, **kwargs):\n    \"\"\"Function for outputting detailed debug information.\n    When VERBOSE_DEBUG=True, outputs the complete message.\n    When VERBOSE_DEBUG=False, outputs only the first 50 characters.\n\n    Args:\n        msg: The message format string\n        *args: Arguments to be formatted into the message\n        **kwargs: Keyword arguments passed to logger.debug()\n    \"\"\"\n    if VERBOSE_DEBUG:\n        logger.debug(msg, *args, **kwargs)\n    else:\n        # Format the message with args first\n        if args:\n            formatted_msg = msg % args\n        else:\n            formatted_msg = msg\n        # Then truncate the formatted message\n        truncated_msg = (\n            formatted_msg[:150] + \"...\" if len(formatted_msg) > 150 else formatted_msg\n        )\n        # Remove consecutive newlines\n        truncated_msg = re.sub(r\"\\n+\", \"\\n\", truncated_msg)\n        logger.debug(truncated_msg, **kwargs)\n\n\ndef set_verbose_debug(enabled: bool):\n    \"\"\"Enable or disable verbose debug output\"\"\"\n    global VERBOSE_DEBUG\n    VERBOSE_DEBUG = enabled\n\n\nstatistic_data = {\"llm_call\": 0, \"llm_cache\": 0, \"embed_call\": 0}\n\n\nclass LightragPathFilter(logging.Filter):\n    \"\"\"Filter for lightrag logger to filter out frequent path access logs\"\"\"\n\n    def __init__(self):\n        super().__init__()\n        # Define paths to be filtered\n        self.filtered_paths = [\n            \"/documents\",\n            \"/documents/paginated\",\n            \"/health\",\n            \"/webui/\",\n            \"/documents/pipeline_status\",\n        ]\n        # self.filtered_paths = [\"/health\", \"/webui/\"]\n\n    def filter(self, record):\n        try:\n            # Check if record has the required attributes for an access log\n            if not hasattr(record, \"args\") or not isinstance(record.args, tuple):\n                return True\n            if len(record.args) < 5:\n                return True\n\n            # Extract method, path and status from the record args\n            method = record.args[1]\n            path = record.args[2]\n            status = record.args[4]\n\n            # Filter out successful GET/POST requests to filtered paths\n            if (\n                (method == \"GET\" or method == \"POST\")\n                and (status == 200 or status == 304)\n                and path in self.filtered_paths\n            ):\n                return False\n\n            return True\n        except Exception:\n            # In case of any error, let the message through\n            return True\n\n\ndef setup_logger(\n    logger_name: str,\n    level: str = \"INFO\",\n    add_filter: bool = False,\n    log_file_path: str | None = None,\n    enable_file_logging: bool = True,\n):\n    \"\"\"Set up a logger with console and optionally file handlers\n\n    Args:\n        logger_name: Name of the logger to set up\n        level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)\n        add_filter: Whether to add LightragPathFilter to the logger\n        log_file_path: Path to the log file. If None and file logging is enabled, defaults to lightrag.log in LOG_DIR or cwd\n        enable_file_logging: Whether to enable logging to a file (defaults to True)\n    \"\"\"\n    # Configure formatters\n    detailed_formatter = logging.Formatter(\n        \"%(asctime)s - %(name)s - %(levelname)s - %(message)s\"\n    )\n    simple_formatter = logging.Formatter(\"%(levelname)s: %(message)s\")\n\n    logger_instance = logging.getLogger(logger_name)\n    logger_instance.setLevel(level)\n    logger_instance.handlers = []  # Clear existing handlers\n    logger_instance.propagate = False\n\n    # Add console handler with safe stream handling\n    console_handler = SafeStreamHandler()\n    console_handler.setFormatter(simple_formatter)\n    console_handler.setLevel(level)\n    logger_instance.addHandler(console_handler)\n\n    # Add file handler by default unless explicitly disabled\n    if enable_file_logging:\n        # Get log file path\n        if log_file_path is None:\n            log_dir = os.getenv(\"LOG_DIR\", os.getcwd())\n            log_file_path = os.path.abspath(os.path.join(log_dir, DEFAULT_LOG_FILENAME))\n\n        # Ensure log directory exists\n        os.makedirs(os.path.dirname(log_file_path), exist_ok=True)\n\n        # Get log file max size and backup count from environment variables\n        log_max_bytes = get_env_value(\"LOG_MAX_BYTES\", DEFAULT_LOG_MAX_BYTES, int)\n        log_backup_count = get_env_value(\n            \"LOG_BACKUP_COUNT\", DEFAULT_LOG_BACKUP_COUNT, int\n        )\n\n        try:\n            # Add file handler\n            file_handler = logging.handlers.RotatingFileHandler(\n                filename=log_file_path,\n                maxBytes=log_max_bytes,\n                backupCount=log_backup_count,\n                encoding=\"utf-8\",\n            )\n            file_handler.setFormatter(detailed_formatter)\n            file_handler.setLevel(level)\n            logger_instance.addHandler(file_handler)\n        except PermissionError as e:\n            logger.warning(f\"Could not create log file at {log_file_path}: {str(e)}\")\n            logger.warning(\"Continuing with console logging only\")\n\n    # Add path filter if requested\n    if add_filter:\n        path_filter = LightragPathFilter()\n        logger_instance.addFilter(path_filter)\n\n\nclass UnlimitedSemaphore:\n    \"\"\"A context manager that allows unlimited access.\"\"\"\n\n    async def __aenter__(self):\n        pass\n\n    async def __aexit__(self, exc_type, exc, tb):\n        pass\n\n\n@dataclass\nclass TaskState:\n    \"\"\"Task state tracking for priority queue management\"\"\"\n\n    future: asyncio.Future\n    start_time: float\n    execution_start_time: float = None\n    worker_started: bool = False\n    cancellation_requested: bool = False\n    cleanup_done: bool = False\n\n\n@dataclass\nclass EmbeddingFunc:\n    \"\"\"Embedding function wrapper with dimension validation\n\n    This class wraps an embedding function to ensure that the output embeddings have the correct dimension.\n    If wrapped multiple times, the inner wrappers will be automatically unwrapped to prevent\n    configuration conflicts where inner wrapper settings would override outer wrapper settings.\n\n    Using functools.partial for parameter binding:\n        A common pattern is to use functools.partial to pre-bind model and host parameters\n        to an embedding function. When the base embedding function is already decorated with\n        @wrap_embedding_func_with_attrs (e.g., ollama_embed), use `.func` to access the\n        original unwrapped function to avoid double wrapping:\n\n        Example:\n            from functools import partial\n\n            # ❌ Wrong - causes double wrapping (inner EmbeddingFunc still executes)\n            func=partial(ollama_embed, embed_model=\"bge-m3:latest\", host=\"http://localhost:11434\")\n\n            # ✅ Correct - access the unwrapped function via .func\n            func=partial(ollama_embed.func, embed_model=\"bge-m3:latest\", host=\"http://localhost:11434\")\n\n    Args:\n        embedding_dim: Expected dimension of the embeddings(For dimension checking and workspace data isolation in vector DB)\n        func: The actual embedding function to wrap\n        max_token_size: Enable embedding token limit checking for description summarization(Set embedding_token_limit in LightRAG)\n        send_dimensions: Whether to inject embedding_dim argument to underlying function\n        model_name: Model name for implementing workspace data isolation in vector DB\n    \"\"\"\n\n    embedding_dim: int\n    func: callable\n    max_token_size: int | None = None\n    send_dimensions: bool = False\n    model_name: str | None = (\n        None  # Model name for implementing workspace data isolation in vector DB\n    )\n\n    def __post_init__(self):\n        \"\"\"Unwrap nested EmbeddingFunc to prevent double wrapping issues.\n\n        When an EmbeddingFunc wraps another EmbeddingFunc, the inner wrapper's\n        __call__ preprocessing would override the outer wrapper's settings.\n        This method detects and unwraps nested EmbeddingFunc instances to ensure\n        that only the outermost wrapper's configuration is applied.\n        \"\"\"\n        # Check if func is already an EmbeddingFunc instance and unwrap it\n        max_unwrap_depth = 3  # Safety limit to prevent infinite loops\n        unwrap_count = 0\n        while isinstance(self.func, EmbeddingFunc):\n            unwrap_count += 1\n            if unwrap_count > max_unwrap_depth:\n                raise ValueError(\n                    f\"EmbeddingFunc unwrap depth exceeded {max_unwrap_depth}. \"\n                    \"Possible circular reference detected.\"\n                )\n            # Unwrap to get the original function\n            self.func = self.func.func\n\n        if unwrap_count > 0:\n            logger.warning(\n                f\"Detected nested EmbeddingFunc wrapping (depth: {unwrap_count}), \"\n                \"auto-unwrapped to prevent configuration conflicts. \"\n                \"Consider using .func to access the unwrapped function directly.\"\n            )\n\n    async def __call__(self, *args, **kwargs) -> np.ndarray:\n        # Only inject embedding_dim when send_dimensions is True\n        if self.send_dimensions:\n            # Check if user provided embedding_dim parameter\n            if \"embedding_dim\" in kwargs:\n                user_provided_dim = kwargs[\"embedding_dim\"]\n                # If user's value differs from class attribute, output warning\n                if (\n                    user_provided_dim is not None\n                    and user_provided_dim != self.embedding_dim\n                ):\n                    logger.warning(\n                        f\"Ignoring user-provided embedding_dim={user_provided_dim}, \"\n                        f\"using declared embedding_dim={self.embedding_dim} from decorator\"\n                    )\n\n            # Inject embedding_dim from decorator\n            kwargs[\"embedding_dim\"] = self.embedding_dim\n\n        # Check if underlying function supports max_token_size and inject if not provided\n        if self.max_token_size is not None and \"max_token_size\" not in kwargs:\n            sig = inspect.signature(self.func)\n            if \"max_token_size\" in sig.parameters:\n                kwargs[\"max_token_size\"] = self.max_token_size\n\n        # Call the actual embedding function\n        result = await self.func(*args, **kwargs)\n\n        # Validate embedding dimensions using total element count\n        total_elements = result.size  # Total number of elements in the numpy array\n        expected_dim = self.embedding_dim\n\n        # Check if total elements can be evenly divided by embedding_dim\n        if total_elements % expected_dim != 0:\n            raise ValueError(\n                f\"Embedding dimension mismatch detected: \"\n                f\"total elements ({total_elements}) cannot be evenly divided by \"\n                f\"expected dimension ({expected_dim}). \"\n            )\n\n        # Optional: Verify vector count matches input text count\n        actual_vectors = total_elements // expected_dim\n        if args and isinstance(args[0], (list, tuple)):\n            expected_vectors = len(args[0])\n            if actual_vectors != expected_vectors:\n                raise ValueError(\n                    f\"Vector count mismatch: \"\n                    f\"expected {expected_vectors} vectors but got {actual_vectors} vectors (from embedding result).\"\n                )\n\n        return result\n\n\ndef compute_args_hash(*args: Any) -> str:\n    \"\"\"Compute a hash for the given arguments with safe Unicode handling.\n\n    Args:\n        *args: Arguments to hash\n    Returns:\n        str: Hash string\n    \"\"\"\n    # Convert all arguments to strings and join them\n    args_str = \"\".join([str(arg) for arg in args])\n\n    # Use 'replace' error handling to safely encode problematic Unicode characters\n    # This replaces invalid characters with Unicode replacement character (U+FFFD)\n    try:\n        return md5(args_str.encode(\"utf-8\")).hexdigest()\n    except UnicodeEncodeError:\n        # Handle surrogate characters and other encoding issues\n        safe_bytes = args_str.encode(\"utf-8\", errors=\"replace\")\n        return md5(safe_bytes).hexdigest()\n\n\ndef compute_mdhash_id(content: str, prefix: str = \"\") -> str:\n    \"\"\"\n    Compute a unique ID for a given content string.\n\n    The ID is a combination of the given prefix and the MD5 hash of the content string.\n    \"\"\"\n    return prefix + compute_args_hash(content)\n\n\ndef generate_cache_key(mode: str, cache_type: str, hash_value: str) -> str:\n    \"\"\"Generate a flattened cache key in the format {mode}:{cache_type}:{hash}\n\n    Args:\n        mode: Cache mode (e.g., 'default', 'local', 'global')\n        cache_type: Type of cache (e.g., 'extract', 'query', 'keywords')\n        hash_value: Hash value from compute_args_hash\n\n    Returns:\n        str: Flattened cache key\n    \"\"\"\n    return f\"{mode}:{cache_type}:{hash_value}\"\n\n\ndef parse_cache_key(cache_key: str) -> tuple[str, str, str] | None:\n    \"\"\"Parse a flattened cache key back into its components\n\n    Args:\n        cache_key: Flattened cache key in format {mode}:{cache_type}:{hash}\n\n    Returns:\n        tuple[str, str, str] | None: (mode, cache_type, hash) or None if invalid format\n    \"\"\"\n    parts = cache_key.split(\":\", 2)\n    if len(parts) == 3:\n        return parts[0], parts[1], parts[2]\n    return None\n\n\n# Custom exception classes\nclass QueueFullError(Exception):\n    \"\"\"Raised when the queue is full and the wait times out\"\"\"\n\n    pass\n\n\nclass WorkerTimeoutError(Exception):\n    \"\"\"Worker-level timeout exception with specific timeout information\"\"\"\n\n    def __init__(self, timeout_value: float, timeout_type: str = \"execution\"):\n        self.timeout_value = timeout_value\n        self.timeout_type = timeout_type\n        super().__init__(f\"Worker {timeout_type} timeout after {timeout_value}s\")\n\n\nclass HealthCheckTimeoutError(Exception):\n    \"\"\"Health Check-level timeout exception\"\"\"\n\n    def __init__(self, timeout_value: float, execution_duration: float):\n        self.timeout_value = timeout_value\n        self.execution_duration = execution_duration\n        super().__init__(\n            f\"Task forcefully terminated due to execution timeout (>{timeout_value}s, actual: {execution_duration:.1f}s)\"\n        )\n\n\ndef priority_limit_async_func_call(\n    max_size: int,\n    llm_timeout: float = None,\n    max_execution_timeout: float = None,\n    max_task_duration: float = None,\n    max_queue_size: int = 1000,\n    cleanup_timeout: float = 2.0,\n    queue_name: str = \"limit_async\",\n):\n    \"\"\"\n    Enhanced priority-limited asynchronous function call decorator with robust timeout handling\n\n    This decorator provides a comprehensive solution for managing concurrent LLM requests with:\n    - Multi-layer timeout protection (LLM -> Worker -> Health Check -> User)\n    - Task state tracking to prevent race conditions\n    - Enhanced health check system with stuck task detection\n    - Proper resource cleanup and error recovery\n\n    Args:\n        max_size: Maximum number of concurrent calls\n        max_queue_size: Maximum queue capacity to prevent memory overflow\n        llm_timeout: LLM provider timeout (from global config), used to calculate other timeouts\n        max_execution_timeout: Maximum time for worker to execute function (defaults to llm_timeout + 30s)\n        max_task_duration: Maximum time before health check intervenes (defaults to llm_timeout + 60s)\n        cleanup_timeout: Maximum time to wait for cleanup operations (defaults to 2.0s)\n        queue_name: Optional queue name for logging identification (defaults to \"limit_async\")\n\n    Returns:\n        Decorator function\n    \"\"\"\n\n    def final_decro(func):\n        # Ensure func is callable\n        if not callable(func):\n            raise TypeError(f\"Expected a callable object, got {type(func)}\")\n\n        # Calculate timeout hierarchy if llm_timeout is provided (Dynamic Timeout Calculation)\n        if llm_timeout is not None:\n            nonlocal max_execution_timeout, max_task_duration\n            if max_execution_timeout is None:\n                max_execution_timeout = (\n                    llm_timeout * 2\n                )  # Reserved timeout buffer for low-level retry\n            if max_task_duration is None:\n                max_task_duration = (\n                    llm_timeout * 2 + 15\n                )  # Reserved timeout buffer for health check phase\n\n        queue = asyncio.PriorityQueue(maxsize=max_queue_size)\n        tasks = set()\n        initialization_lock = asyncio.Lock()\n        counter = 0\n        shutdown_event = asyncio.Event()\n        initialized = False\n        worker_health_check_task = None\n\n        # Enhanced task state management\n        task_states = {}  # task_id -> TaskState\n        task_states_lock = asyncio.Lock()\n        active_futures = weakref.WeakSet()\n        reinit_count = 0\n\n        async def worker():\n            \"\"\"Enhanced worker that processes tasks with proper timeout and state management\"\"\"\n            try:\n                while not shutdown_event.is_set():\n                    try:\n                        # Get task from queue with timeout for shutdown checking\n                        try:\n                            (\n                                priority,\n                                count,\n                                task_id,\n                                args,\n                                kwargs,\n                            ) = await asyncio.wait_for(queue.get(), timeout=1.0)\n                        except asyncio.TimeoutError:\n                            continue\n\n                        # Get task state and mark worker as started\n                        async with task_states_lock:\n                            if task_id not in task_states:\n                                queue.task_done()\n                                continue\n                            task_state = task_states[task_id]\n                            task_state.worker_started = True\n                            # Record execution start time when worker actually begins processing\n                            task_state.execution_start_time = (\n                                asyncio.get_event_loop().time()\n                            )\n\n                        # Check if task was cancelled before worker started\n                        if (\n                            task_state.cancellation_requested\n                            or task_state.future.cancelled()\n                        ):\n                            async with task_states_lock:\n                                task_states.pop(task_id, None)\n                            queue.task_done()\n                            continue\n\n                        try:\n                            # Execute function with timeout protection\n                            if max_execution_timeout is not None:\n                                result = await asyncio.wait_for(\n                                    func(*args, **kwargs), timeout=max_execution_timeout\n                                )\n                            else:\n                                result = await func(*args, **kwargs)\n\n                            # Set result if future is still valid\n                            if not task_state.future.done():\n                                task_state.future.set_result(result)\n\n                        except asyncio.TimeoutError:\n                            # Worker-level timeout (max_execution_timeout exceeded)\n                            logger.warning(\n                                f\"{queue_name}: Worker timeout for task {task_id} after {max_execution_timeout}s\"\n                            )\n                            if not task_state.future.done():\n                                task_state.future.set_exception(\n                                    WorkerTimeoutError(\n                                        max_execution_timeout, \"execution\"\n                                    )\n                                )\n                        except asyncio.CancelledError:\n                            # Task was cancelled during execution\n                            if not task_state.future.done():\n                                task_state.future.cancel()\n                            logger.debug(\n                                f\"{queue_name}: Task {task_id} cancelled during execution\"\n                            )\n                        except Exception as e:\n                            # Function execution error\n                            logger.error(\n                                f\"{queue_name}: Error in decorated function for task {task_id}: {str(e)}\"\n                            )\n                            if not task_state.future.done():\n                                task_state.future.set_exception(e)\n                        finally:\n                            # Clean up task state\n                            async with task_states_lock:\n                                task_states.pop(task_id, None)\n                            queue.task_done()\n\n                    except Exception as e:\n                        # Critical error in worker loop\n                        logger.error(\n                            f\"{queue_name}: Critical error in worker: {str(e)}\"\n                        )\n                        await asyncio.sleep(0.1)\n            finally:\n                logger.debug(f\"{queue_name}: Worker exiting\")\n\n        async def enhanced_health_check():\n            \"\"\"Enhanced health check with stuck task detection and recovery\"\"\"\n            nonlocal initialized\n            try:\n                while not shutdown_event.is_set():\n                    await asyncio.sleep(5)  # Check every 5 seconds\n\n                    current_time = asyncio.get_event_loop().time()\n\n                    # Detect and handle stuck tasks based on execution start time\n                    if max_task_duration is not None:\n                        stuck_tasks = []\n                        async with task_states_lock:\n                            for task_id, task_state in list(task_states.items()):\n                                # Only check tasks that have started execution\n                                if (\n                                    task_state.worker_started\n                                    and task_state.execution_start_time is not None\n                                    and current_time - task_state.execution_start_time\n                                    > max_task_duration\n                                ):\n                                    stuck_tasks.append(\n                                        (\n                                            task_id,\n                                            current_time\n                                            - task_state.execution_start_time,\n                                        )\n                                    )\n\n                        # Force cleanup of stuck tasks\n                        for task_id, execution_duration in stuck_tasks:\n                            logger.warning(\n                                f\"{queue_name}: Detected stuck task {task_id} (execution time: {execution_duration:.1f}s), forcing cleanup\"\n                            )\n                            async with task_states_lock:\n                                if task_id in task_states:\n                                    task_state = task_states[task_id]\n                                    if not task_state.future.done():\n                                        task_state.future.set_exception(\n                                            HealthCheckTimeoutError(\n                                                max_task_duration, execution_duration\n                                            )\n                                        )\n                                    task_states.pop(task_id, None)\n\n                    # Worker recovery logic\n                    current_tasks = set(tasks)\n                    done_tasks = {t for t in current_tasks if t.done()}\n                    tasks.difference_update(done_tasks)\n\n                    active_tasks_count = len(tasks)\n                    workers_needed = max_size - active_tasks_count\n\n                    if workers_needed > 0:\n                        logger.info(\n                            f\"{queue_name}: Creating {workers_needed} new workers\"\n                        )\n                        new_tasks = set()\n                        for _ in range(workers_needed):\n                            task = asyncio.create_task(worker())\n                            new_tasks.add(task)\n                            task.add_done_callback(tasks.discard)\n                        tasks.update(new_tasks)\n\n            except Exception as e:\n                logger.error(f\"{queue_name}: Error in enhanced health check: {str(e)}\")\n            finally:\n                logger.debug(f\"{queue_name}: Enhanced health check task exiting\")\n                initialized = False\n\n        async def ensure_workers():\n            \"\"\"Ensure worker system is initialized with enhanced error handling\"\"\"\n            nonlocal initialized, worker_health_check_task, tasks, reinit_count\n\n            if initialized:\n                return\n\n            async with initialization_lock:\n                if initialized:\n                    return\n\n                if reinit_count > 0:\n                    reinit_count += 1\n                    logger.warning(\n                        f\"{queue_name}: Reinitializing system (count: {reinit_count})\"\n                    )\n                else:\n                    reinit_count = 1\n\n                # Clean up completed tasks\n                current_tasks = set(tasks)\n                done_tasks = {t for t in current_tasks if t.done()}\n                tasks.difference_update(done_tasks)\n\n                active_tasks_count = len(tasks)\n                if active_tasks_count > 0 and reinit_count > 1:\n                    logger.warning(\n                        f\"{queue_name}: {active_tasks_count} tasks still running during reinitialization\"\n                    )\n\n                # Create worker tasks\n                workers_needed = max_size - active_tasks_count\n                for _ in range(workers_needed):\n                    task = asyncio.create_task(worker())\n                    tasks.add(task)\n                    task.add_done_callback(tasks.discard)\n\n                # Start enhanced health check\n                worker_health_check_task = asyncio.create_task(enhanced_health_check())\n\n                initialized = True\n                # Log dynamic timeout configuration\n                timeout_info = []\n                if llm_timeout is not None:\n                    timeout_info.append(f\"Func: {llm_timeout}s\")\n                if max_execution_timeout is not None:\n                    timeout_info.append(f\"Worker: {max_execution_timeout}s\")\n                if max_task_duration is not None:\n                    timeout_info.append(f\"Health Check: {max_task_duration}s\")\n\n                timeout_str = (\n                    f\"(Timeouts: {', '.join(timeout_info)})\" if timeout_info else \"\"\n                )\n                logger.info(\n                    f\"{queue_name}: {workers_needed} new workers initialized {timeout_str}\"\n                )\n\n        async def shutdown():\n            \"\"\"Gracefully shut down all workers and cleanup resources\"\"\"\n            logger.info(f\"{queue_name}: Shutting down priority queue workers\")\n\n            shutdown_event.set()\n\n            # Cancel all active futures\n            for future in list(active_futures):\n                if not future.done():\n                    future.cancel()\n\n            # Cancel all pending tasks\n            async with task_states_lock:\n                for task_id, task_state in list(task_states.items()):\n                    if not task_state.future.done():\n                        task_state.future.cancel()\n                task_states.clear()\n\n            # Wait for queue to empty with timeout\n            try:\n                await asyncio.wait_for(queue.join(), timeout=5.0)\n            except asyncio.TimeoutError:\n                logger.warning(\n                    f\"{queue_name}: Timeout waiting for queue to empty during shutdown\"\n                )\n\n            # Cancel worker tasks\n            for task in list(tasks):\n                if not task.done():\n                    task.cancel()\n\n            # Wait for all tasks to complete\n            if tasks:\n                await asyncio.gather(*tasks, return_exceptions=True)\n\n            # Cancel health check task\n            if worker_health_check_task and not worker_health_check_task.done():\n                worker_health_check_task.cancel()\n                try:\n                    await worker_health_check_task\n                except asyncio.CancelledError:\n                    pass\n\n            logger.info(f\"{queue_name}: Priority queue workers shutdown complete\")\n\n        @wraps(func)\n        async def wait_func(\n            *args, _priority=10, _timeout=None, _queue_timeout=None, **kwargs\n        ):\n            \"\"\"\n            Execute function with enhanced priority-based concurrency control and timeout handling\n\n            Args:\n                *args: Positional arguments passed to the function\n                _priority: Call priority (lower values have higher priority)\n                _timeout: Maximum time to wait for completion (in seconds, none means determinded by max_execution_timeout of the queue)\n                _queue_timeout: Maximum time to wait for entering the queue (in seconds)\n                **kwargs: Keyword arguments passed to the function\n\n            Returns:\n                The result of the function call\n\n            Raises:\n                TimeoutError: If the function call times out at any level\n                QueueFullError: If the queue is full and waiting times out\n                Any exception raised by the decorated function\n            \"\"\"\n            await ensure_workers()\n\n            # Generate unique task ID\n            task_id = f\"{id(asyncio.current_task())}_{asyncio.get_event_loop().time()}\"\n            future = asyncio.Future()\n\n            # Create task state\n            task_state = TaskState(\n                future=future, start_time=asyncio.get_event_loop().time()\n            )\n\n            try:\n                # Register task state\n                async with task_states_lock:\n                    task_states[task_id] = task_state\n\n                active_futures.add(future)\n\n                # Get counter for FIFO ordering\n                nonlocal counter\n                async with initialization_lock:\n                    current_count = counter\n                    counter += 1\n\n                # Queue the task with timeout handling\n                try:\n                    if _queue_timeout is not None:\n                        await asyncio.wait_for(\n                            queue.put(\n                                (_priority, current_count, task_id, args, kwargs)\n                            ),\n                            timeout=_queue_timeout,\n                        )\n                    else:\n                        await queue.put(\n                            (_priority, current_count, task_id, args, kwargs)\n                        )\n                except asyncio.TimeoutError:\n                    raise QueueFullError(\n                        f\"{queue_name}: Queue full, timeout after {_queue_timeout} seconds\"\n                    )\n                except Exception as e:\n                    # Clean up on queue error\n                    if not future.done():\n                        future.set_exception(e)\n                    raise\n\n                # Wait for result with timeout handling\n                try:\n                    if _timeout is not None:\n                        return await asyncio.wait_for(future, _timeout)\n                    else:\n                        return await future\n                except asyncio.TimeoutError:\n                    # This is user-level timeout (asyncio.wait_for caused)\n                    # Mark cancellation request\n                    async with task_states_lock:\n                        if task_id in task_states:\n                            task_states[task_id].cancellation_requested = True\n\n                    # Cancel future\n                    if not future.done():\n                        future.cancel()\n\n                    # Wait for worker cleanup with timeout\n                    cleanup_start = asyncio.get_event_loop().time()\n                    while (\n                        task_id in task_states\n                        and asyncio.get_event_loop().time() - cleanup_start\n                        < cleanup_timeout\n                    ):\n                        await asyncio.sleep(0.1)\n\n                    raise TimeoutError(\n                        f\"{queue_name}: User timeout after {_timeout} seconds\"\n                    )\n                except WorkerTimeoutError as e:\n                    # This is Worker-level timeout, directly propagate exception information\n                    raise TimeoutError(f\"{queue_name}: {str(e)}\")\n                except HealthCheckTimeoutError as e:\n                    # This is Health Check-level timeout, directly propagate exception information\n                    raise TimeoutError(f\"{queue_name}: {str(e)}\")\n\n            finally:\n                # Ensure cleanup\n                active_futures.discard(future)\n                async with task_states_lock:\n                    task_states.pop(task_id, None)\n\n        # Add shutdown method to decorated function\n        wait_func.shutdown = shutdown\n\n        return wait_func\n\n    return final_decro\n\n\ndef wrap_embedding_func_with_attrs(**kwargs):\n    \"\"\"Decorator to add embedding dimension and token limit attributes to embedding functions.\n\n    This decorator wraps an async embedding function and returns an EmbeddingFunc instance\n    that automatically handles dimension parameter injection and attribute management.\n\n    WARNING: DO NOT apply this decorator to wrapper functions that call other\n    decorated embedding functions. This will cause double decoration and parameter\n    injection conflicts.\n\n    Correct usage patterns:\n\n    1. Direct decoration:\n        ```python\n        @wrap_embedding_func_with_attrs(embedding_dim=1536, max_token_size=8192, model_name=\"my_embedding_model\")\n        async def my_embed(texts, embedding_dim=None):\n            # Direct implementation\n            return embeddings\n        ```\n    2. Double decoration:\n        ```python\n        @wrap_embedding_func_with_attrs(embedding_dim=1536, max_token_size=8192, model_name=\"my_embedding_model\")\n        @retry(...)\n        async def my_embed(texts, ...):\n            # Base implementation\n            pass\n\n        @wrap_embedding_func_with_attrs(embedding_dim=1024, max_token_size=4096, model_name=\"another_embedding_model\")\n        # Note: No @retry here!\n        async def my_new_embed(texts, ...):\n            # CRITICAL: Call .func to access unwrapped function\n            return await my_embed.func(texts, ...)  # ✅ Correct\n            # return await my_embed(texts, ...)     # ❌ Wrong - double decoration!\n        ```\n\n    The decorated function becomes an EmbeddingFunc instance with:\n    - embedding_dim: The embedding dimension\n    - max_token_size: Maximum token limit (optional)\n    - model_name: Model name (optional)\n    - func: The original unwrapped function (access via .func)\n    - __call__: Wrapper that injects embedding_dim parameter\n\n    Args:\n        embedding_dim: The dimension of embedding vectors\n        max_token_size: Maximum number of tokens (optional)\n        send_dimensions: Whether to pass embedding_dim as a keyword argument (for models with configurable embedding dimensions).\n\n    Returns:\n        A decorator that wraps the function as an EmbeddingFunc instance\n    \"\"\"\n\n    def final_decro(func) -> EmbeddingFunc:\n        new_func = EmbeddingFunc(**kwargs, func=func)\n        return new_func\n\n    return final_decro\n\n\ndef load_json(file_name):\n    if not os.path.exists(file_name):\n        return None\n    with open(file_name, encoding=\"utf-8-sig\") as f:\n        return json.load(f)\n\n\ndef _sanitize_string_for_json(text: str) -> str:\n    \"\"\"Remove characters that cannot be encoded in UTF-8 for JSON serialization.\n\n    Uses regex for optimal performance with zero-copy optimization for clean strings.\n    Fast detection path for clean strings (99% of cases) with efficient removal for dirty strings.\n\n    Args:\n        text: String to sanitize\n\n    Returns:\n        Original string if clean (zero-copy), sanitized string if dirty\n    \"\"\"\n    if not text:\n        return text\n\n    # Fast path: Check if sanitization is needed using C-level regex search\n    if not _SURROGATE_PATTERN.search(text):\n        return text  # Zero-copy for clean strings - most common case\n\n    # Slow path: Remove problematic characters using C-level regex substitution\n    return _SURROGATE_PATTERN.sub(\"\", text)\n\n\nclass SanitizingJSONEncoder(json.JSONEncoder):\n    \"\"\"\n    Custom JSON encoder that sanitizes data during serialization.\n\n    This encoder cleans strings during the encoding process without creating\n    a full copy of the data structure, making it memory-efficient for large datasets.\n    \"\"\"\n\n    def encode(self, o):\n        \"\"\"Override encode method to handle simple string cases\"\"\"\n        if isinstance(o, str):\n            return json.encoder.encode_basestring(_sanitize_string_for_json(o))\n        return super().encode(o)\n\n    def iterencode(self, o, _one_shot=False):\n        \"\"\"\n        Override iterencode to sanitize strings during serialization.\n        This is the core method that handles complex nested structures.\n        \"\"\"\n        # Preprocess: sanitize all strings in the object\n        sanitized = self._sanitize_for_encoding(o)\n\n        # Call parent's iterencode with sanitized data\n        for chunk in super().iterencode(sanitized, _one_shot):\n            yield chunk\n\n    def _sanitize_for_encoding(self, obj):\n        \"\"\"\n        Recursively sanitize strings in an object.\n        Creates new objects only when necessary to avoid deep copies.\n\n        Args:\n            obj: Object to sanitize\n\n        Returns:\n            Sanitized object with cleaned strings\n        \"\"\"\n        if isinstance(obj, str):\n            return _sanitize_string_for_json(obj)\n\n        elif isinstance(obj, dict):\n            # Create new dict with sanitized keys and values\n            new_dict = {}\n            for k, v in obj.items():\n                clean_k = _sanitize_string_for_json(k) if isinstance(k, str) else k\n                clean_v = self._sanitize_for_encoding(v)\n                new_dict[clean_k] = clean_v\n            return new_dict\n\n        elif isinstance(obj, (list, tuple)):\n            # Sanitize list/tuple elements\n            cleaned = [self._sanitize_for_encoding(item) for item in obj]\n            return type(obj)(cleaned) if isinstance(obj, tuple) else cleaned\n\n        else:\n            # Numbers, booleans, None, etc. remain unchanged\n            return obj\n\n\ndef write_json(json_obj, file_name):\n    \"\"\"\n    Write JSON data to file with optimized sanitization strategy.\n\n    This function uses a two-stage approach:\n    1. Fast path: Try direct serialization (works for clean data ~99% of time)\n    2. Slow path: Use custom encoder that sanitizes during serialization\n\n    The custom encoder approach avoids creating a deep copy of the data,\n    making it memory-efficient. When sanitization occurs, the caller should\n    reload the cleaned data from the file to update shared memory.\n\n    Args:\n        json_obj: Object to serialize (may be a shallow copy from shared memory)\n        file_name: Output file path\n\n    Returns:\n        bool: True if sanitization was applied (caller should reload data),\n              False if direct write succeeded (no reload needed)\n    \"\"\"\n    try:\n        # Strategy 1: Fast path - try direct serialization\n        with open(file_name, \"w\", encoding=\"utf-8\") as f:\n            json.dump(json_obj, f, indent=2, ensure_ascii=False)\n        return False  # No sanitization needed, no reload required\n\n    except (UnicodeEncodeError, UnicodeDecodeError) as e:\n        logger.debug(f\"Direct JSON write failed, using sanitizing encoder: {e}\")\n\n    # Strategy 2: Use custom encoder (sanitizes during serialization, zero memory copy)\n    with open(file_name, \"w\", encoding=\"utf-8\") as f:\n        json.dump(json_obj, f, indent=2, ensure_ascii=False, cls=SanitizingJSONEncoder)\n\n    logger.info(f\"JSON sanitization applied during write: {file_name}\")\n    return True  # Sanitization applied, reload recommended\n\n\nclass TokenizerInterface(Protocol):\n    \"\"\"\n    Defines the interface for a tokenizer, requiring encode and decode methods.\n    \"\"\"\n\n    def encode(self, content: str) -> List[int]:\n        \"\"\"Encodes a string into a list of tokens.\"\"\"\n        ...\n\n    def decode(self, tokens: List[int]) -> str:\n        \"\"\"Decodes a list of tokens into a string.\"\"\"\n        ...\n\n\nclass Tokenizer:\n    \"\"\"\n    A wrapper around a tokenizer to provide a consistent interface for encoding and decoding.\n    \"\"\"\n\n    def __init__(self, model_name: str, tokenizer: TokenizerInterface):\n        \"\"\"\n        Initializes the Tokenizer with a tokenizer model name and a tokenizer instance.\n\n        Args:\n            model_name: The associated model name for the tokenizer.\n            tokenizer: An instance of a class implementing the TokenizerInterface.\n        \"\"\"\n        self.model_name: str = model_name\n        self.tokenizer: TokenizerInterface = tokenizer\n\n    def encode(self, content: str) -> List[int]:\n        \"\"\"\n        Encodes a string into a list of tokens using the underlying tokenizer.\n\n        Args:\n            content: The string to encode.\n\n        Returns:\n            A list of integer tokens.\n        \"\"\"\n        return self.tokenizer.encode(content)\n\n    def decode(self, tokens: List[int]) -> str:\n        \"\"\"\n        Decodes a list of tokens into a string using the underlying tokenizer.\n\n        Args:\n            tokens: A list of integer tokens to decode.\n\n        Returns:\n            The decoded string.\n        \"\"\"\n        return self.tokenizer.decode(tokens)\n\n\nclass TiktokenTokenizer(Tokenizer):\n    \"\"\"\n    A Tokenizer implementation using the tiktoken library.\n    \"\"\"\n\n    def __init__(self, model_name: str = \"gpt-4o-mini\"):\n        \"\"\"\n        Initializes the TiktokenTokenizer with a specified model name.\n\n        Args:\n            model_name: The model name for the tiktoken tokenizer to use.  Defaults to \"gpt-4o-mini\".\n\n        Raises:\n            ImportError: If tiktoken is not installed.\n            ValueError: If the model_name is invalid.\n        \"\"\"\n        try:\n            import tiktoken\n        except ImportError:\n            raise ImportError(\n                \"tiktoken is not installed. Please install it with `pip install tiktoken` or define custom `tokenizer_func`.\"\n            )\n\n        try:\n            tokenizer = tiktoken.encoding_for_model(model_name)\n            super().__init__(model_name=model_name, tokenizer=tokenizer)\n        except KeyError:\n            raise ValueError(f\"Invalid model_name: {model_name}.\")\n\n\ndef pack_user_ass_to_openai_messages(*args: str):\n    roles = [\"user\", \"assistant\"]\n    return [\n        {\"role\": roles[i % 2], \"content\": content} for i, content in enumerate(args)\n    ]\n\n\ndef split_string_by_multi_markers(content: str, markers: list[str]) -> list[str]:\n    \"\"\"Split a string by multiple markers\"\"\"\n    if not markers:\n        return [content]\n    content = content if content is not None else \"\"\n    results = re.split(\"|\".join(re.escape(marker) for marker in markers), content)\n    return [r.strip() for r in results if r.strip()]\n\n\ndef is_float_regex(value: str) -> bool:\n    return bool(re.match(r\"^[-+]?[0-9]*\\.?[0-9]+$\", value))\n\n\ndef truncate_list_by_token_size(\n    list_data: list[Any],\n    key: Callable[[Any], str],\n    max_token_size: int,\n    tokenizer: Tokenizer,\n) -> list[int]:\n    \"\"\"Truncate a list of data by token size\"\"\"\n    if max_token_size <= 0:\n        return []\n    tokens = 0\n    for i, data in enumerate(list_data):\n        tokens += len(tokenizer.encode(key(data)))\n        if tokens > max_token_size:\n            return list_data[:i]\n    return list_data\n\n\ndef cosine_similarity(v1, v2):\n    \"\"\"Calculate cosine similarity between two vectors\"\"\"\n    dot_product = np.dot(v1, v2)\n    norm1 = np.linalg.norm(v1)\n    norm2 = np.linalg.norm(v2)\n    return dot_product / (norm1 * norm2)\n\n\nasync def handle_cache(\n    hashing_kv,\n    args_hash,\n    prompt,\n    mode=\"default\",\n    cache_type=\"unknown\",\n) -> tuple[str, int] | None:\n    \"\"\"Generic cache handling function with flattened cache keys\n\n    Returns:\n        tuple[str, int] | None: (content, create_time) if cache hit, None if cache miss\n    \"\"\"\n    if hashing_kv is None:\n        return None\n\n    if mode != \"default\":  # handle cache for all type of query\n        if not hashing_kv.global_config.get(\"enable_llm_cache\"):\n            return None\n    else:  # handle cache for entity extraction\n        if not hashing_kv.global_config.get(\"enable_llm_cache_for_entity_extract\"):\n            return None\n\n    # Use flattened cache key format: {mode}:{cache_type}:{hash}\n    flattened_key = generate_cache_key(mode, cache_type, args_hash)\n    cache_entry = await hashing_kv.get_by_id(flattened_key)\n    if cache_entry:\n        logger.debug(f\"Flattened cache hit(key:{flattened_key})\")\n        content = cache_entry[\"return\"]\n        timestamp = cache_entry.get(\"create_time\", 0)\n        return content, timestamp\n\n    logger.debug(f\"Cache missed(mode:{mode} type:{cache_type})\")\n    return None\n\n\n@dataclass\nclass CacheData:\n    args_hash: str\n    content: str\n    prompt: str\n    mode: str = \"default\"\n    cache_type: str = \"query\"\n    chunk_id: str | None = None\n    queryparam: dict | None = None\n\n\nasync def save_to_cache(hashing_kv, cache_data: CacheData):\n    \"\"\"Save data to cache using flattened key structure.\n\n    Args:\n        hashing_kv: The key-value storage for caching\n        cache_data: The cache data to save\n    \"\"\"\n    # Skip if storage is None or content is a streaming response\n    if hashing_kv is None or not cache_data.content:\n        return\n\n    # If content is a streaming response, don't cache it\n    if hasattr(cache_data.content, \"__aiter__\"):\n        logger.debug(\"Streaming response detected, skipping cache\")\n        return\n\n    # Use flattened cache key format: {mode}:{cache_type}:{hash}\n    flattened_key = generate_cache_key(\n        cache_data.mode, cache_data.cache_type, cache_data.args_hash\n    )\n\n    # Check if we already have identical content cached\n    existing_cache = await hashing_kv.get_by_id(flattened_key)\n    if existing_cache:\n        existing_content = existing_cache.get(\"return\")\n        if existing_content == cache_data.content:\n            logger.warning(\n                f\"Cache duplication detected for {flattened_key}, skipping update\"\n            )\n            return\n\n    # Create cache entry with flattened structure\n    cache_entry = {\n        \"return\": cache_data.content,\n        \"cache_type\": cache_data.cache_type,\n        \"chunk_id\": cache_data.chunk_id if cache_data.chunk_id is not None else None,\n        \"original_prompt\": cache_data.prompt,\n        \"queryparam\": cache_data.queryparam\n        if cache_data.queryparam is not None\n        else None,\n    }\n\n    logger.info(f\" == LLM cache == saving: {flattened_key}\")\n\n    # Save using flattened key\n    await hashing_kv.upsert({flattened_key: cache_entry})\n\n\ndef safe_unicode_decode(content):\n    # Regular expression to find all Unicode escape sequences of the form \\uXXXX\n    unicode_escape_pattern = re.compile(r\"\\\\u([0-9a-fA-F]{4})\")\n\n    # Function to replace the Unicode escape with the actual character\n    def replace_unicode_escape(match):\n        # Convert the matched hexadecimal value into the actual Unicode character\n        return chr(int(match.group(1), 16))\n\n    # Perform the substitution\n    decoded_content = unicode_escape_pattern.sub(\n        replace_unicode_escape, content.decode(\"utf-8\")\n    )\n\n    return decoded_content\n\n\ndef exists_func(obj, func_name: str) -> bool:\n    \"\"\"Check if a function exists in an object or not.\n    :param obj:\n    :param func_name:\n    :return: True / False\n    \"\"\"\n    if callable(getattr(obj, func_name, None)):\n        return True\n    else:\n        return False\n\n\ndef always_get_an_event_loop() -> asyncio.AbstractEventLoop:\n    \"\"\"\n    Ensure that there is always an event loop available.\n\n    This function tries to get the current event loop. If the current event loop is closed or does not exist,\n    it creates a new event loop and sets it as the current event loop.\n\n    Returns:\n        asyncio.AbstractEventLoop: The current or newly created event loop.\n    \"\"\"\n    try:\n        # Try to get the current event loop\n        current_loop = asyncio.get_event_loop()\n        if current_loop.is_closed():\n            raise RuntimeError(\"Event loop is closed.\")\n        return current_loop\n\n    except RuntimeError:\n        # If no event loop exists or it is closed, create a new one\n        logger.info(\"Creating a new event loop in main thread.\")\n        new_loop = asyncio.new_event_loop()\n        asyncio.set_event_loop(new_loop)\n        return new_loop\n\n\nasync def aexport_data(\n    chunk_entity_relation_graph,\n    entities_vdb,\n    relationships_vdb,\n    output_path: str,\n    file_format: str = \"csv\",\n    include_vector_data: bool = False,\n) -> None:\n    \"\"\"\n    Asynchronously exports all entities, relations, and relationships to various formats.\n\n    Args:\n        chunk_entity_relation_graph: Graph storage instance for entities and relations\n        entities_vdb: Vector database storage for entities\n        relationships_vdb: Vector database storage for relationships\n        output_path: The path to the output file (including extension).\n        file_format: Output format - \"csv\", \"excel\", \"md\", \"txt\".\n            - csv: Comma-separated values file\n            - excel: Microsoft Excel file with multiple sheets\n            - md: Markdown tables\n            - txt: Plain text formatted output\n        include_vector_data: Whether to include data from the vector database.\n    \"\"\"\n    # Collect data\n    entities_data = []\n    relations_data = []\n    relationships_data = []\n\n    # --- Entities ---\n    all_entities = await chunk_entity_relation_graph.get_all_labels()\n    for entity_name in all_entities:\n        # Get entity information from graph\n        node_data = await chunk_entity_relation_graph.get_node(entity_name)\n        source_id = node_data.get(\"source_id\") if node_data else None\n\n        entity_info = {\n            \"graph_data\": node_data,\n            \"source_id\": source_id,\n        }\n\n        # Optional: Get vector database information\n        if include_vector_data:\n            entity_id = compute_mdhash_id(entity_name, prefix=\"ent-\")\n            vector_data = await entities_vdb.get_by_id(entity_id)\n            entity_info[\"vector_data\"] = vector_data\n\n        entity_row = {\n            \"entity_name\": entity_name,\n            \"source_id\": source_id,\n            \"graph_data\": str(\n                entity_info[\"graph_data\"]\n            ),  # Convert to string to ensure compatibility\n        }\n        if include_vector_data and \"vector_data\" in entity_info:\n            entity_row[\"vector_data\"] = str(entity_info[\"vector_data\"])\n        entities_data.append(entity_row)\n\n    # --- Relations ---\n    for src_entity in all_entities:\n        for tgt_entity in all_entities:\n            if src_entity == tgt_entity:\n                continue\n\n            edge_exists = await chunk_entity_relation_graph.has_edge(\n                src_entity, tgt_entity\n            )\n            if edge_exists:\n                # Get edge information from graph\n                edge_data = await chunk_entity_relation_graph.get_edge(\n                    src_entity, tgt_entity\n                )\n                source_id = edge_data.get(\"source_id\") if edge_data else None\n\n                relation_info = {\n                    \"graph_data\": edge_data,\n                    \"source_id\": source_id,\n                }\n\n                # Optional: Get vector database information\n                if include_vector_data:\n                    rel_id = compute_mdhash_id(src_entity + tgt_entity, prefix=\"rel-\")\n                    vector_data = await relationships_vdb.get_by_id(rel_id)\n                    relation_info[\"vector_data\"] = vector_data\n\n                relation_row = {\n                    \"src_entity\": src_entity,\n                    \"tgt_entity\": tgt_entity,\n                    \"source_id\": relation_info[\"source_id\"],\n                    \"graph_data\": str(relation_info[\"graph_data\"]),  # Convert to string\n                }\n                if include_vector_data and \"vector_data\" in relation_info:\n                    relation_row[\"vector_data\"] = str(relation_info[\"vector_data\"])\n                relations_data.append(relation_row)\n\n    # --- Relationships (from VectorDB) ---\n    all_relationships = await relationships_vdb.client_storage\n    for rel in all_relationships[\"data\"]:\n        relationships_data.append(\n            {\n                \"relationship_id\": rel[\"__id__\"],\n                \"data\": str(rel),  # Convert to string for compatibility\n            }\n        )\n\n    # Export based on format\n    if file_format == \"csv\":\n        # CSV export\n        with open(output_path, \"w\", newline=\"\", encoding=\"utf-8\") as csvfile:\n            # Entities\n            if entities_data:\n                csvfile.write(\"# ENTITIES\\n\")\n                writer = csv.DictWriter(csvfile, fieldnames=entities_data[0].keys())\n                writer.writeheader()\n                writer.writerows(entities_data)\n                csvfile.write(\"\\n\\n\")\n\n            # Relations\n            if relations_data:\n                csvfile.write(\"# RELATIONS\\n\")\n                writer = csv.DictWriter(csvfile, fieldnames=relations_data[0].keys())\n                writer.writeheader()\n                writer.writerows(relations_data)\n                csvfile.write(\"\\n\\n\")\n\n            # Relationships\n            if relationships_data:\n                csvfile.write(\"# RELATIONSHIPS\\n\")\n                writer = csv.DictWriter(\n                    csvfile, fieldnames=relationships_data[0].keys()\n                )\n                writer.writeheader()\n                writer.writerows(relationships_data)\n\n    elif file_format == \"excel\":\n        # Excel export\n        import pandas as pd\n\n        entities_df = pd.DataFrame(entities_data) if entities_data else pd.DataFrame()\n        relations_df = (\n            pd.DataFrame(relations_data) if relations_data else pd.DataFrame()\n        )\n        relationships_df = (\n            pd.DataFrame(relationships_data) if relationships_data else pd.DataFrame()\n        )\n\n        with pd.ExcelWriter(output_path, engine=\"xlsxwriter\") as writer:\n            if not entities_df.empty:\n                entities_df.to_excel(writer, sheet_name=\"Entities\", index=False)\n            if not relations_df.empty:\n                relations_df.to_excel(writer, sheet_name=\"Relations\", index=False)\n            if not relationships_df.empty:\n                relationships_df.to_excel(\n                    writer, sheet_name=\"Relationships\", index=False\n                )\n\n    elif file_format == \"md\":\n        # Markdown export\n        with open(output_path, \"w\", encoding=\"utf-8\") as mdfile:\n            mdfile.write(\"# LightRAG Data Export\\n\\n\")\n\n            # Entities\n            mdfile.write(\"## Entities\\n\\n\")\n            if entities_data:\n                # Write header\n                mdfile.write(\"| \" + \" | \".join(entities_data[0].keys()) + \" |\\n\")\n                mdfile.write(\n                    \"| \" + \" | \".join([\"---\"] * len(entities_data[0].keys())) + \" |\\n\"\n                )\n\n                # Write rows\n                for entity in entities_data:\n                    mdfile.write(\n                        \"| \" + \" | \".join(str(v) for v in entity.values()) + \" |\\n\"\n                    )\n                mdfile.write(\"\\n\\n\")\n            else:\n                mdfile.write(\"*No entity data available*\\n\\n\")\n\n            # Relations\n            mdfile.write(\"## Relations\\n\\n\")\n            if relations_data:\n                # Write header\n                mdfile.write(\"| \" + \" | \".join(relations_data[0].keys()) + \" |\\n\")\n                mdfile.write(\n                    \"| \" + \" | \".join([\"---\"] * len(relations_data[0].keys())) + \" |\\n\"\n                )\n\n                # Write rows\n                for relation in relations_data:\n                    mdfile.write(\n                        \"| \" + \" | \".join(str(v) for v in relation.values()) + \" |\\n\"\n                    )\n                mdfile.write(\"\\n\\n\")\n            else:\n                mdfile.write(\"*No relation data available*\\n\\n\")\n\n            # Relationships\n            mdfile.write(\"## Relationships\\n\\n\")\n            if relationships_data:\n                # Write header\n                mdfile.write(\"| \" + \" | \".join(relationships_data[0].keys()) + \" |\\n\")\n                mdfile.write(\n                    \"| \"\n                    + \" | \".join([\"---\"] * len(relationships_data[0].keys()))\n                    + \" |\\n\"\n                )\n\n                # Write rows\n                for relationship in relationships_data:\n                    mdfile.write(\n                        \"| \"\n                        + \" | \".join(str(v) for v in relationship.values())\n                        + \" |\\n\"\n                    )\n            else:\n                mdfile.write(\"*No relationship data available*\\n\\n\")\n\n    elif file_format == \"txt\":\n        # Plain text export\n        with open(output_path, \"w\", encoding=\"utf-8\") as txtfile:\n            txtfile.write(\"LIGHTRAG DATA EXPORT\\n\")\n            txtfile.write(\"=\" * 80 + \"\\n\\n\")\n\n            # Entities\n            txtfile.write(\"ENTITIES\\n\")\n            txtfile.write(\"-\" * 80 + \"\\n\")\n            if entities_data:\n                # Create fixed width columns\n                col_widths = {\n                    k: max(len(k), max(len(str(e[k])) for e in entities_data))\n                    for k in entities_data[0]\n                }\n                header = \"  \".join(k.ljust(col_widths[k]) for k in entities_data[0])\n                txtfile.write(header + \"\\n\")\n                txtfile.write(\"-\" * len(header) + \"\\n\")\n\n                # Write rows\n                for entity in entities_data:\n                    row = \"  \".join(\n                        str(v).ljust(col_widths[k]) for k, v in entity.items()\n                    )\n                    txtfile.write(row + \"\\n\")\n                txtfile.write(\"\\n\\n\")\n            else:\n                txtfile.write(\"No entity data available\\n\\n\")\n\n            # Relations\n            txtfile.write(\"RELATIONS\\n\")\n            txtfile.write(\"-\" * 80 + \"\\n\")\n            if relations_data:\n                # Create fixed width columns\n                col_widths = {\n                    k: max(len(k), max(len(str(r[k])) for r in relations_data))\n                    for k in relations_data[0]\n                }\n                header = \"  \".join(k.ljust(col_widths[k]) for k in relations_data[0])\n                txtfile.write(header + \"\\n\")\n                txtfile.write(\"-\" * len(header) + \"\\n\")\n\n                # Write rows\n                for relation in relations_data:\n                    row = \"  \".join(\n                        str(v).ljust(col_widths[k]) for k, v in relation.items()\n                    )\n                    txtfile.write(row + \"\\n\")\n                txtfile.write(\"\\n\\n\")\n            else:\n                txtfile.write(\"No relation data available\\n\\n\")\n\n            # Relationships\n            txtfile.write(\"RELATIONSHIPS\\n\")\n            txtfile.write(\"-\" * 80 + \"\\n\")\n            if relationships_data:\n                # Create fixed width columns\n                col_widths = {\n                    k: max(len(k), max(len(str(r[k])) for r in relationships_data))\n                    for k in relationships_data[0]\n                }\n                header = \"  \".join(\n                    k.ljust(col_widths[k]) for k in relationships_data[0]\n                )\n                txtfile.write(header + \"\\n\")\n                txtfile.write(\"-\" * len(header) + \"\\n\")\n\n                # Write rows\n                for relationship in relationships_data:\n                    row = \"  \".join(\n                        str(v).ljust(col_widths[k]) for k, v in relationship.items()\n                    )\n                    txtfile.write(row + \"\\n\")\n            else:\n                txtfile.write(\"No relationship data available\\n\\n\")\n\n    else:\n        raise ValueError(\n            f\"Unsupported file format: {file_format}. Choose from: csv, excel, md, txt\"\n        )\n    if file_format is not None:\n        print(f\"Data exported to: {output_path} with format: {file_format}\")\n    else:\n        print(\"Data displayed as table format\")\n\n\ndef export_data(\n    chunk_entity_relation_graph,\n    entities_vdb,\n    relationships_vdb,\n    output_path: str,\n    file_format: str = \"csv\",\n    include_vector_data: bool = False,\n) -> None:\n    \"\"\"\n    Synchronously exports all entities, relations, and relationships to various formats.\n\n    Args:\n        chunk_entity_relation_graph: Graph storage instance for entities and relations\n        entities_vdb: Vector database storage for entities\n        relationships_vdb: Vector database storage for relationships\n        output_path: The path to the output file (including extension).\n        file_format: Output format - \"csv\", \"excel\", \"md\", \"txt\".\n            - csv: Comma-separated values file\n            - excel: Microsoft Excel file with multiple sheets\n            - md: Markdown tables\n            - txt: Plain text formatted output\n        include_vector_data: Whether to include data from the vector database.\n    \"\"\"\n    try:\n        loop = asyncio.get_event_loop()\n    except RuntimeError:\n        loop = asyncio.new_event_loop()\n        asyncio.set_event_loop(loop)\n\n    loop.run_until_complete(\n        aexport_data(\n            chunk_entity_relation_graph,\n            entities_vdb,\n            relationships_vdb,\n            output_path,\n            file_format,\n            include_vector_data,\n        )\n    )\n\n\ndef lazy_external_import(module_name: str, class_name: str) -> Callable[..., Any]:\n    \"\"\"Lazily import a class from an external module based on the package of the caller.\"\"\"\n    # Get the caller's module and package\n    import inspect\n\n    caller_frame = inspect.currentframe().f_back\n    module = inspect.getmodule(caller_frame)\n    package = module.__package__ if module else None\n\n    def import_class(*args: Any, **kwargs: Any):\n        import importlib\n\n        module = importlib.import_module(module_name, package=package)\n        cls = getattr(module, class_name)\n        return cls(*args, **kwargs)\n\n    return import_class\n\n\nasync def update_chunk_cache_list(\n    chunk_id: str,\n    text_chunks_storage: \"BaseKVStorage\",\n    cache_keys: list[str],\n    cache_scenario: str = \"batch_update\",\n) -> None:\n    \"\"\"Update chunk's llm_cache_list with the given cache keys\n\n    Args:\n        chunk_id: Chunk identifier\n        text_chunks_storage: Text chunks storage instance\n        cache_keys: List of cache keys to add to the list\n        cache_scenario: Description of the cache scenario for logging\n    \"\"\"\n    if not cache_keys:\n        return\n\n    try:\n        chunk_data = await text_chunks_storage.get_by_id(chunk_id)\n        if chunk_data:\n            # Ensure llm_cache_list exists\n            if \"llm_cache_list\" not in chunk_data:\n                chunk_data[\"llm_cache_list\"] = []\n\n            # Add cache keys to the list if not already present\n            existing_keys = set(chunk_data[\"llm_cache_list\"])\n            new_keys = [key for key in cache_keys if key not in existing_keys]\n\n            if new_keys:\n                chunk_data[\"llm_cache_list\"].extend(new_keys)\n\n                # Update the chunk in storage\n                await text_chunks_storage.upsert({chunk_id: chunk_data})\n                logger.debug(\n                    f\"Updated chunk {chunk_id} with {len(new_keys)} cache keys ({cache_scenario})\"\n                )\n    except Exception as e:\n        logger.warning(\n            f\"Failed to update chunk {chunk_id} with cache references on {cache_scenario}: {e}\"\n        )\n\n\ndef remove_think_tags(text: str) -> str:\n    \"\"\"Remove <think>...</think> tags from the text\n    Remove  orphon ...</think> tags from the text also\"\"\"\n    return re.sub(\n        r\"^(<think>.*?</think>|.*</think>)\", \"\", text, flags=re.DOTALL\n    ).strip()\n\n\nasync def use_llm_func_with_cache(\n    user_prompt: str,\n    use_llm_func: callable,\n    llm_response_cache: \"BaseKVStorage | None\" = None,\n    system_prompt: str | None = None,\n    max_tokens: int = None,\n    history_messages: list[dict[str, str]] = None,\n    cache_type: str = \"extract\",\n    chunk_id: str | None = None,\n    cache_keys_collector: list = None,\n) -> tuple[str, int]:\n    \"\"\"Call LLM function with cache support and text sanitization\n\n    If cache is available and enabled (determined by handle_cache based on mode),\n    retrieve result from cache; otherwise call LLM function and save result to cache.\n\n    This function applies text sanitization to prevent UTF-8 encoding errors for all LLM providers.\n\n    Args:\n        input_text: Input text to send to LLM\n        use_llm_func: LLM function with higher priority\n        llm_response_cache: Cache storage instance\n        max_tokens: Maximum tokens for generation\n        history_messages: History messages list\n        cache_type: Type of cache\n        chunk_id: Chunk identifier to store in cache\n        text_chunks_storage: Text chunks storage to update llm_cache_list\n        cache_keys_collector: Optional list to collect cache keys for batch processing\n\n    Returns:\n        tuple[str, int]: (LLM response text, timestamp)\n            - For cache hits: (content, cache_create_time)\n            - For cache misses: (content, current_timestamp)\n    \"\"\"\n    # Sanitize input text to prevent UTF-8 encoding errors for all LLM providers\n    safe_user_prompt = sanitize_text_for_encoding(user_prompt)\n    safe_system_prompt = (\n        sanitize_text_for_encoding(system_prompt) if system_prompt else None\n    )\n\n    # Sanitize history messages if provided\n    safe_history_messages = None\n    if history_messages:\n        safe_history_messages = []\n        for i, msg in enumerate(history_messages):\n            safe_msg = msg.copy()\n            if \"content\" in safe_msg:\n                safe_msg[\"content\"] = sanitize_text_for_encoding(safe_msg[\"content\"])\n            safe_history_messages.append(safe_msg)\n        history = json.dumps(safe_history_messages, ensure_ascii=False)\n    else:\n        history = None\n\n    if llm_response_cache:\n        prompt_parts = []\n        if safe_user_prompt:\n            prompt_parts.append(safe_user_prompt)\n        if safe_system_prompt:\n            prompt_parts.append(safe_system_prompt)\n        if history:\n            prompt_parts.append(history)\n        _prompt = \"\\n\".join(prompt_parts)\n\n        arg_hash = compute_args_hash(_prompt)\n        # Generate cache key for this LLM call\n        cache_key = generate_cache_key(\"default\", cache_type, arg_hash)\n\n        cached_result = await handle_cache(\n            llm_response_cache,\n            arg_hash,\n            _prompt,\n            \"default\",\n            cache_type=cache_type,\n        )\n        if cached_result:\n            content, timestamp = cached_result\n            logger.debug(f\"Found cache for {arg_hash}\")\n            statistic_data[\"llm_cache\"] += 1\n\n            # Add cache key to collector if provided\n            if cache_keys_collector is not None:\n                cache_keys_collector.append(cache_key)\n\n            return content, timestamp\n        statistic_data[\"llm_call\"] += 1\n\n        # Call LLM with sanitized input\n        kwargs = {}\n        if safe_history_messages:\n            kwargs[\"history_messages\"] = safe_history_messages\n        if max_tokens is not None:\n            kwargs[\"max_tokens\"] = max_tokens\n\n        res: str = await use_llm_func(\n            safe_user_prompt, system_prompt=safe_system_prompt, **kwargs\n        )\n\n        res = remove_think_tags(res)\n\n        # Generate timestamp for cache miss (LLM call completion time)\n        current_timestamp = int(time.time())\n\n        if llm_response_cache.global_config.get(\"enable_llm_cache_for_entity_extract\"):\n            await save_to_cache(\n                llm_response_cache,\n                CacheData(\n                    args_hash=arg_hash,\n                    content=res,\n                    prompt=_prompt,\n                    cache_type=cache_type,\n                    chunk_id=chunk_id,\n                ),\n            )\n\n            # Add cache key to collector if provided\n            if cache_keys_collector is not None:\n                cache_keys_collector.append(cache_key)\n\n        return res, current_timestamp\n\n    # When cache is disabled, directly call LLM with sanitized input\n    kwargs = {}\n    if safe_history_messages:\n        kwargs[\"history_messages\"] = safe_history_messages\n    if max_tokens is not None:\n        kwargs[\"max_tokens\"] = max_tokens\n\n    try:\n        res = await use_llm_func(\n            safe_user_prompt, system_prompt=safe_system_prompt, **kwargs\n        )\n    except Exception as e:\n        # Add [LLM func] prefix to error message\n        error_msg = f\"[LLM func] {str(e)}\"\n        # Re-raise with the same exception type but modified message\n        raise type(e)(error_msg) from e\n\n    # Generate timestamp for non-cached LLM call\n    current_timestamp = int(time.time())\n    return remove_think_tags(res), current_timestamp\n\n\ndef get_content_summary(content: str, max_length: int = 250) -> str:\n    \"\"\"Get summary of document content\n\n    Args:\n        content: Original document content\n        max_length: Maximum length of summary\n\n    Returns:\n        Truncated content with ellipsis if needed\n    \"\"\"\n    content = content.strip()\n    if len(content) <= max_length:\n        return content\n    return content[:max_length] + \"...\"\n\n\ndef sanitize_and_normalize_extracted_text(\n    input_text: str, remove_inner_quotes=False\n) -> str:\n    \"\"\"Santitize and normalize extracted text\n    Args:\n        input_text: text string to be processed\n        is_name: whether the input text is a entity or relation name\n\n    Returns:\n        Santitized and normalized text string\n    \"\"\"\n    safe_input_text = sanitize_text_for_encoding(input_text)\n    if safe_input_text:\n        normalized_text = normalize_extracted_info(\n            safe_input_text, remove_inner_quotes=remove_inner_quotes\n        )\n        return normalized_text\n    return \"\"\n\n\ndef normalize_extracted_info(name: str, remove_inner_quotes=False) -> str:\n    \"\"\"Normalize entity/relation names and description with the following rules:\n    - Clean HTML tags (paragraph and line break tags)\n    - Convert Chinese symbols to English symbols\n    - Remove spaces between Chinese characters\n    - Remove spaces between Chinese characters and English letters/numbers\n    - Preserve spaces within English text and numbers\n    - Replace Chinese parentheses with English parentheses\n    - Replace Chinese dash with English dash\n    - Remove English quotation marks from the beginning and end of the text\n    - Remove English quotation marks in and around chinese\n    - Remove Chinese quotation marks\n    - Filter out short numeric-only text (length < 3 and only digits/dots)\n    - remove_inner_quotes = True\n        remove Chinese quotes\n        remove English quotes in and around chinese\n        Convert non-breaking spaces to regular spaces\n        Convert narrow non-breaking spaces after non-digits to regular spaces\n\n    Args:\n        name: Entity name to normalize\n        is_entity: Whether this is an entity name (affects quote handling)\n\n    Returns:\n        Normalized entity name\n    \"\"\"\n    # Clean HTML tags - remove paragraph and line break tags\n    name = re.sub(r\"</p\\s*>|<p\\s*>|<p/>\", \"\", name, flags=re.IGNORECASE)\n    name = re.sub(r\"</br\\s*>|<br\\s*>|<br/>\", \"\", name, flags=re.IGNORECASE)\n\n    # Chinese full-width letters to half-width (A-Z, a-z)\n    name = name.translate(\n        str.maketrans(\n            \"ＡＢＣＤＥＦＧＨＩＪＫＬＭＮＯＰＱＲＳＴＵＶＷＸＹＺａｂｃｄｅｆｇｈｉｊｋｌｍｎｏｐｑｒｓｔｕｖｗｘｙｚ\",\n            \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\",\n        )\n    )\n\n    # Chinese full-width numbers to half-width\n    name = name.translate(str.maketrans(\"０１２３４５６７８９\", \"0123456789\"))\n\n    # Chinese full-width symbols to half-width\n    name = name.replace(\"－\", \"-\")  # Chinese minus\n    name = name.replace(\"＋\", \"+\")  # Chinese plus\n    name = name.replace(\"／\", \"/\")  # Chinese slash\n    name = name.replace(\"＊\", \"*\")  # Chinese asterisk\n\n    # Replace Chinese parentheses with English parentheses\n    name = name.replace(\"（\", \"(\").replace(\"）\", \")\")\n\n    # Replace Chinese dash with English dash (additional patterns)\n    name = name.replace(\"—\", \"-\").replace(\"－\", \"-\")\n\n    # Chinese full-width space to regular space (after other replacements)\n    name = name.replace(\"　\", \" \")\n\n    # Use regex to remove spaces between Chinese characters\n    # Regex explanation:\n    # (?<=[\\u4e00-\\u9fa5]): Positive lookbehind for Chinese character\n    # \\s+: One or more whitespace characters\n    # (?=[\\u4e00-\\u9fa5]): Positive lookahead for Chinese character\n    name = re.sub(r\"(?<=[\\u4e00-\\u9fa5])\\s+(?=[\\u4e00-\\u9fa5])\", \"\", name)\n\n    # Remove spaces between Chinese and English/numbers/symbols\n    name = re.sub(\n        r\"(?<=[\\u4e00-\\u9fa5])\\s+(?=[a-zA-Z0-9\\(\\)\\[\\]@#$%!&\\*\\-=+_])\", \"\", name\n    )\n    name = re.sub(\n        r\"(?<=[a-zA-Z0-9\\(\\)\\[\\]@#$%!&\\*\\-=+_])\\s+(?=[\\u4e00-\\u9fa5])\", \"\", name\n    )\n\n    # Remove outer quotes\n    if len(name) >= 2:\n        # Handle double quotes\n        if name.startswith('\"') and name.endswith('\"'):\n            inner_content = name[1:-1]\n            if '\"' not in inner_content:  # No double quotes inside\n                name = inner_content\n\n        # Handle single quotes\n        if name.startswith(\"'\") and name.endswith(\"'\"):\n            inner_content = name[1:-1]\n            if \"'\" not in inner_content:  # No single quotes inside\n                name = inner_content\n\n        # Handle Chinese-style double quotes\n        if name.startswith(\"“\") and name.endswith(\"”\"):\n            inner_content = name[1:-1]\n            if \"“\" not in inner_content and \"”\" not in inner_content:\n                name = inner_content\n        if name.startswith(\"‘\") and name.endswith(\"’\"):\n            inner_content = name[1:-1]\n            if \"‘\" not in inner_content and \"’\" not in inner_content:\n                name = inner_content\n\n        # Handle Chinese-style book title mark\n        if name.startswith(\"《\") and name.endswith(\"》\"):\n            inner_content = name[1:-1]\n            if \"《\" not in inner_content and \"》\" not in inner_content:\n                name = inner_content\n\n    if remove_inner_quotes:\n        # Remove Chinese quotes\n        name = name.replace(\"“\", \"\").replace(\"”\", \"\").replace(\"‘\", \"\").replace(\"’\", \"\")\n        # Remove English queotes in and around chinese\n        name = re.sub(r\"['\\\"]+(?=[\\u4e00-\\u9fa5])\", \"\", name)\n        name = re.sub(r\"(?<=[\\u4e00-\\u9fa5])['\\\"]+\", \"\", name)\n        # Convert non-breaking space to regular space\n        name = name.replace(\"\\u00a0\", \" \")\n        # Convert narrow non-breaking space to regular space when after non-digits\n        name = re.sub(r\"(?<=[^\\d])\\u202F\", \" \", name)\n\n    # Remove spaces from the beginning and end of the text\n    name = name.strip()\n\n    # Filter out pure numeric content with length < 3\n    if len(name) < 3 and re.match(r\"^[0-9]+$\", name):\n        return \"\"\n\n    def should_filter_by_dots(text):\n        \"\"\"\n        Check if the string consists only of dots and digits, with at least one dot\n        Filter cases include: 1.2.3, 12.3, .123, 123., 12.3., .1.23 etc.\n        \"\"\"\n        return all(c.isdigit() or c == \".\" for c in text) and \".\" in text\n\n    if len(name) < 6 and should_filter_by_dots(name):\n        # Filter out mixed numeric and dot content with length < 6, requiring at least one dot\n        return \"\"\n\n    return name\n\n\ndef sanitize_text_for_encoding(text: str, replacement_char: str = \"\") -> str:\n    \"\"\"Sanitize text to ensure safe UTF-8 encoding by removing or replacing problematic characters.\n\n    This function handles:\n    - Surrogate characters (the main cause of encoding errors)\n    - Other invalid Unicode sequences\n    - Control characters that might cause issues\n    - Unescape HTML escapes\n    - Remove control characters\n    - Whitespace trimming\n\n    Args:\n        text: Input text to sanitize\n        replacement_char: Character to use for replacing invalid sequences\n\n    Returns:\n        Sanitized text that can be safely encoded as UTF-8\n\n    Raises:\n        ValueError: When text contains uncleanable encoding issues that cannot be safely processed\n    \"\"\"\n    if not text:\n        return text\n\n    try:\n        # First, strip whitespace\n        text = text.strip()\n\n        # Early return if text is empty after basic cleaning\n        if not text:\n            return text\n\n        # Try to encode/decode to catch any encoding issues early\n        text.encode(\"utf-8\")\n\n        # Remove or replace surrogate characters (U+D800 to U+DFFF)\n        # These are the main cause of the encoding error\n        sanitized = \"\"\n        for char in text:\n            code_point = ord(char)\n            # Check for surrogate characters\n            if 0xD800 <= code_point <= 0xDFFF:\n                # Replace surrogate with replacement character\n                sanitized += replacement_char\n                continue\n            # Check for other problematic characters\n            elif code_point == 0xFFFE or code_point == 0xFFFF:\n                # These are non-characters in Unicode\n                sanitized += replacement_char\n                continue\n            else:\n                sanitized += char\n\n        # Additional cleanup: remove null bytes and other control characters that might cause issues\n        # (but preserve common whitespace like \\t, \\n, \\r)\n        sanitized = re.sub(\n            r\"[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]\", replacement_char, sanitized\n        )\n\n        # Test final encoding to ensure it's safe\n        sanitized.encode(\"utf-8\")\n\n        # Unescape HTML escapes\n        sanitized = html.unescape(sanitized)\n\n        # Remove control characters but preserve common whitespace (\\t, \\n, \\r)\n        sanitized = re.sub(r\"[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F-\\x9F]\", \"\", sanitized)\n\n        return sanitized.strip()\n\n    except UnicodeEncodeError as e:\n        # Critical change: Don't return placeholder, raise exception for caller to handle\n        error_msg = f\"Text contains uncleanable UTF-8 encoding issues: {str(e)[:100]}\"\n        logger.error(f\"Text sanitization failed: {error_msg}\")\n        raise ValueError(error_msg) from e\n\n    except Exception as e:\n        logger.error(f\"Text sanitization: Unexpected error: {str(e)}\")\n        # For other exceptions, if no encoding issues detected, return original text\n        try:\n            text.encode(\"utf-8\")\n            return text\n        except UnicodeEncodeError:\n            raise ValueError(\n                f\"Text sanitization failed with unexpected error: {str(e)}\"\n            ) from e\n\n\ndef check_storage_env_vars(storage_name: str) -> None:\n    \"\"\"Check if all required environment variables for storage implementation exist\n\n    Args:\n        storage_name: Storage implementation name\n\n    Raises:\n        ValueError: If required environment variables are missing\n    \"\"\"\n    from lightrag.kg import STORAGE_ENV_REQUIREMENTS\n\n    required_vars = STORAGE_ENV_REQUIREMENTS.get(storage_name, [])\n    missing_vars = [var for var in required_vars if var not in os.environ]\n\n    if missing_vars:\n        raise ValueError(\n            f\"Storage implementation '{storage_name}' requires the following \"\n            f\"environment variables: {', '.join(missing_vars)}\"\n        )\n\n\ndef pick_by_weighted_polling(\n    entities_or_relations: list[dict],\n    max_related_chunks: int,\n    min_related_chunks: int = 1,\n) -> list[str]:\n    \"\"\"\n    Linear gradient weighted polling algorithm for text chunk selection.\n\n    This algorithm ensures that entities/relations with higher importance get more text chunks,\n    forming a linear decreasing allocation pattern.\n\n    Args:\n        entities_or_relations: List of entities or relations sorted by importance (high to low)\n        max_related_chunks: Expected number of text chunks for the highest importance entity/relation\n        min_related_chunks: Expected number of text chunks for the lowest importance entity/relation\n\n    Returns:\n        List of selected text chunk IDs\n    \"\"\"\n    if not entities_or_relations:\n        return []\n\n    n = len(entities_or_relations)\n    if n == 1:\n        # Only one entity/relation, return its first max_related_chunks text chunks\n        entity_chunks = entities_or_relations[0].get(\"sorted_chunks\", [])\n        return entity_chunks[:max_related_chunks]\n\n    # Calculate expected text chunk count for each position (linear decrease)\n    expected_counts = []\n    for i in range(n):\n        # Linear interpolation: from max_related_chunks to min_related_chunks\n        ratio = i / (n - 1) if n > 1 else 0\n        expected = max_related_chunks - ratio * (\n            max_related_chunks - min_related_chunks\n        )\n        expected_counts.append(int(round(expected)))\n\n    # First round allocation: allocate by expected values\n    selected_chunks = []\n    used_counts = []  # Track number of chunks used by each entity\n    total_remaining = 0  # Accumulate remaining quotas\n\n    for i, entity_rel in enumerate(entities_or_relations):\n        entity_chunks = entity_rel.get(\"sorted_chunks\", [])\n        expected = expected_counts[i]\n\n        # Actual allocatable count\n        actual = min(expected, len(entity_chunks))\n        selected_chunks.extend(entity_chunks[:actual])\n        used_counts.append(actual)\n\n        # Accumulate remaining quota\n        remaining = expected - actual\n        if remaining > 0:\n            total_remaining += remaining\n\n    # Second round allocation: multi-round scanning to allocate remaining quotas\n    for _ in range(total_remaining):\n        allocated = False\n\n        # Scan entities one by one, allocate one chunk when finding unused chunks\n        for i, entity_rel in enumerate(entities_or_relations):\n            entity_chunks = entity_rel.get(\"sorted_chunks\", [])\n\n            # Check if there are still unused chunks\n            if used_counts[i] < len(entity_chunks):\n                # Allocate one chunk\n                selected_chunks.append(entity_chunks[used_counts[i]])\n                used_counts[i] += 1\n                allocated = True\n                break\n\n        # If no chunks were allocated in this round, all entities are exhausted\n        if not allocated:\n            break\n\n    return selected_chunks\n\n\nasync def pick_by_vector_similarity(\n    query: str,\n    text_chunks_storage: \"BaseKVStorage\",\n    chunks_vdb: \"BaseVectorStorage\",\n    num_of_chunks: int,\n    entity_info: list[dict[str, Any]],\n    embedding_func: callable,\n    query_embedding=None,\n) -> list[str]:\n    \"\"\"\n    Vector similarity-based text chunk selection algorithm.\n\n    This algorithm selects text chunks based on cosine similarity between\n    the query embedding and text chunk embeddings.\n\n    Args:\n        query: User's original query string\n        text_chunks_storage: Text chunks storage instance\n        chunks_vdb: Vector database storage for chunks\n        num_of_chunks: Number of chunks to select\n        entity_info: List of entity information containing chunk IDs\n        embedding_func: Embedding function to compute query embedding\n\n    Returns:\n        List of selected text chunk IDs sorted by similarity (highest first)\n    \"\"\"\n    logger.debug(\n        f\"Vector similarity chunk selection: num_of_chunks={num_of_chunks}, entity_info_count={len(entity_info) if entity_info else 0}\"\n    )\n\n    if not entity_info or num_of_chunks <= 0:\n        return []\n\n    # Collect all unique chunk IDs from entity info\n    all_chunk_ids = set()\n    for i, entity in enumerate(entity_info):\n        chunk_ids = entity.get(\"sorted_chunks\", [])\n        all_chunk_ids.update(chunk_ids)\n\n    if not all_chunk_ids:\n        logger.warning(\n            \"Vector similarity chunk selection:  no chunk IDs found in entity_info\"\n        )\n        return []\n\n    logger.debug(\n        f\"Vector similarity chunk selection: {len(all_chunk_ids)} unique chunk IDs collected\"\n    )\n\n    all_chunk_ids = list(all_chunk_ids)\n\n    try:\n        # Use pre-computed query embedding if provided, otherwise compute it\n        if query_embedding is None:\n            query_embedding = await embedding_func([query])\n            query_embedding = query_embedding[\n                0\n            ]  # Extract first embedding from batch result\n            logger.debug(\n                \"Computed query embedding for vector similarity chunk selection\"\n            )\n        else:\n            logger.debug(\n                \"Using pre-computed query embedding for vector similarity chunk selection\"\n            )\n\n        # Get chunk embeddings from vector database\n        chunk_vectors = await chunks_vdb.get_vectors_by_ids(all_chunk_ids)\n        logger.debug(\n            f\"Vector similarity chunk selection: {len(chunk_vectors)} chunk vectors Retrieved\"\n        )\n\n        if not chunk_vectors or len(chunk_vectors) != len(all_chunk_ids):\n            if not chunk_vectors:\n                logger.warning(\n                    \"Vector similarity chunk selection: no vectors retrieved from chunks_vdb\"\n                )\n            else:\n                logger.warning(\n                    f\"Vector similarity chunk selection: found {len(chunk_vectors)} but expecting {len(all_chunk_ids)}\"\n                )\n            return []\n\n        # Calculate cosine similarities\n        similarities = []\n        valid_vectors = 0\n        for chunk_id in all_chunk_ids:\n            if chunk_id in chunk_vectors:\n                chunk_embedding = chunk_vectors[chunk_id]\n                try:\n                    # Calculate cosine similarity\n                    similarity = cosine_similarity(query_embedding, chunk_embedding)\n                    similarities.append((chunk_id, similarity))\n                    valid_vectors += 1\n                except Exception as e:\n                    logger.warning(\n                        f\"Vector similarity chunk selection: failed to calculate similarity for chunk {chunk_id}: {e}\"\n                    )\n            else:\n                logger.warning(\n                    f\"Vector similarity chunk selection:  no vector found for chunk {chunk_id}\"\n                )\n\n        # Sort by similarity (highest first) and select top num_of_chunks\n        similarities.sort(key=lambda x: x[1], reverse=True)\n        selected_chunks = [chunk_id for chunk_id, _ in similarities[:num_of_chunks]]\n\n        logger.debug(\n            f\"Vector similarity chunk selection: {len(selected_chunks)} chunks from {len(all_chunk_ids)} candidates\"\n        )\n\n        return selected_chunks\n\n    except Exception as e:\n        logger.error(f\"[VECTOR_SIMILARITY] Error in vector similarity sorting: {e}\")\n        import traceback\n\n        logger.error(f\"[VECTOR_SIMILARITY] Traceback: {traceback.format_exc()}\")\n        # Fallback to simple truncation\n        logger.debug(\"[VECTOR_SIMILARITY] Falling back to simple truncation\")\n        return all_chunk_ids[:num_of_chunks]\n\n\nclass TokenTracker:\n    \"\"\"Track token usage for LLM calls.\"\"\"\n\n    def __init__(self):\n        self.reset()\n\n    def __enter__(self):\n        self.reset()\n        return self\n\n    def __exit__(self, exc_type, exc_val, exc_tb):\n        print(self)\n\n    def reset(self):\n        self.prompt_tokens = 0\n        self.completion_tokens = 0\n        self.total_tokens = 0\n        self.call_count = 0\n\n    def add_usage(self, token_counts):\n        \"\"\"Add token usage from one LLM call.\n\n        Args:\n            token_counts: A dictionary containing prompt_tokens, completion_tokens, total_tokens\n        \"\"\"\n        self.prompt_tokens += token_counts.get(\"prompt_tokens\", 0)\n        self.completion_tokens += token_counts.get(\"completion_tokens\", 0)\n\n        # If total_tokens is provided, use it directly; otherwise calculate the sum\n        if \"total_tokens\" in token_counts:\n            self.total_tokens += token_counts[\"total_tokens\"]\n        else:\n            self.total_tokens += token_counts.get(\n                \"prompt_tokens\", 0\n            ) + token_counts.get(\"completion_tokens\", 0)\n\n        self.call_count += 1\n\n    def get_usage(self):\n        \"\"\"Get current usage statistics.\"\"\"\n        return {\n            \"prompt_tokens\": self.prompt_tokens,\n            \"completion_tokens\": self.completion_tokens,\n            \"total_tokens\": self.total_tokens,\n            \"call_count\": self.call_count,\n        }\n\n    def __str__(self):\n        usage = self.get_usage()\n        return (\n            f\"LLM call count: {usage['call_count']}, \"\n            f\"Prompt tokens: {usage['prompt_tokens']}, \"\n            f\"Completion tokens: {usage['completion_tokens']}, \"\n            f\"Total tokens: {usage['total_tokens']}\"\n        )\n\n\nasync def apply_rerank_if_enabled(\n    query: str,\n    retrieved_docs: list[dict],\n    global_config: dict,\n    enable_rerank: bool = True,\n    top_n: int = None,\n) -> list[dict]:\n    \"\"\"\n    Apply reranking to retrieved documents if rerank is enabled.\n\n    Args:\n        query: The search query\n        retrieved_docs: List of retrieved documents\n        global_config: Global configuration containing rerank settings\n        enable_rerank: Whether to enable reranking from query parameter\n        top_n: Number of top documents to return after reranking\n\n    Returns:\n        Reranked documents if rerank is enabled, otherwise original documents\n    \"\"\"\n    if not enable_rerank or not retrieved_docs:\n        return retrieved_docs\n\n    rerank_func = global_config.get(\"rerank_model_func\")\n    if not rerank_func:\n        logger.warning(\n            \"Rerank is enabled but no rerank model is configured. Please set up a rerank model or set enable_rerank=False in query parameters.\"\n        )\n        return retrieved_docs\n\n    try:\n        # Extract document content for reranking\n        document_texts = []\n        for doc in retrieved_docs:\n            # Try multiple possible content fields\n            content = (\n                doc.get(\"content\")\n                or doc.get(\"text\")\n                or doc.get(\"chunk_content\")\n                or doc.get(\"document\")\n                or str(doc)\n            )\n            document_texts.append(content)\n\n        # Call the new rerank function that returns index-based results\n        rerank_results = await rerank_func(\n            query=query,\n            documents=document_texts,\n            top_n=top_n,\n        )\n\n        # Process rerank results based on return format\n        if rerank_results and len(rerank_results) > 0:\n            # Check if results are in the new index-based format\n            if isinstance(rerank_results[0], dict) and \"index\" in rerank_results[0]:\n                # New format: [{\"index\": 0, \"relevance_score\": 0.85}, ...]\n                reranked_docs = []\n                for result in rerank_results:\n                    index = result[\"index\"]\n                    relevance_score = result[\"relevance_score\"]\n\n                    # Get original document and add rerank score\n                    if 0 <= index < len(retrieved_docs):\n                        doc = retrieved_docs[index].copy()\n                        doc[\"rerank_score\"] = relevance_score\n                        reranked_docs.append(doc)\n\n                logger.info(\n                    f\"Successfully reranked: {len(reranked_docs)} chunks from {len(retrieved_docs)} original chunks\"\n                )\n                return reranked_docs\n            else:\n                # Legacy format: assume it's already reranked documents\n                logger.info(f\"Using legacy rerank format: {len(rerank_results)} chunks\")\n                return rerank_results[:top_n] if top_n else rerank_results\n        else:\n            logger.warning(\"Rerank returned empty results, using original chunks\")\n            return retrieved_docs\n\n    except Exception as e:\n        logger.error(f\"Error during reranking: {e}, using original chunks\")\n        return retrieved_docs\n\n\nasync def process_chunks_unified(\n    query: str,\n    unique_chunks: list[dict],\n    query_param: \"QueryParam\",\n    global_config: dict,\n    source_type: str = \"mixed\",\n    chunk_token_limit: int = None,  # Add parameter for dynamic token limit\n) -> list[dict]:\n    \"\"\"\n    Unified processing for text chunks: deduplication, chunk_top_k limiting, reranking, and token truncation.\n\n    Args:\n        query: Search query for reranking\n        chunks: List of text chunks to process\n        query_param: Query parameters containing configuration\n        global_config: Global configuration dictionary\n        source_type: Source type for logging (\"vector\", \"entity\", \"relationship\", \"mixed\")\n        chunk_token_limit: Dynamic token limit for chunks (if None, uses default)\n\n    Returns:\n        Processed and filtered list of text chunks\n    \"\"\"\n    if not unique_chunks:\n        return []\n\n    origin_count = len(unique_chunks)\n\n    # 1. Apply reranking if enabled and query is provided\n    if query_param.enable_rerank and query and unique_chunks:\n        rerank_top_k = query_param.chunk_top_k or len(unique_chunks)\n        unique_chunks = await apply_rerank_if_enabled(\n            query=query,\n            retrieved_docs=unique_chunks,\n            global_config=global_config,\n            enable_rerank=query_param.enable_rerank,\n            top_n=rerank_top_k,\n        )\n\n    # 2. Filter by minimum rerank score if reranking is enabled\n    if query_param.enable_rerank and unique_chunks:\n        min_rerank_score = global_config.get(\"min_rerank_score\", 0.5)\n        if min_rerank_score > 0.0:\n            original_count = len(unique_chunks)\n\n            # Filter chunks with score below threshold\n            filtered_chunks = []\n            for chunk in unique_chunks:\n                rerank_score = chunk.get(\n                    \"rerank_score\", 1.0\n                )  # Default to 1.0 if no score\n                if rerank_score >= min_rerank_score:\n                    filtered_chunks.append(chunk)\n\n            unique_chunks = filtered_chunks\n            filtered_count = original_count - len(unique_chunks)\n\n            if filtered_count > 0:\n                logger.info(\n                    f\"Rerank filtering: {len(unique_chunks)} chunks remained (min rerank score: {min_rerank_score})\"\n                )\n            if not unique_chunks:\n                return []\n\n    # 3. Apply chunk_top_k limiting if specified\n    if query_param.chunk_top_k is not None and query_param.chunk_top_k > 0:\n        if len(unique_chunks) > query_param.chunk_top_k:\n            unique_chunks = unique_chunks[: query_param.chunk_top_k]\n        logger.debug(\n            f\"Kept chunk_top-k: {len(unique_chunks)} chunks (deduplicated original: {origin_count})\"\n        )\n\n    # 4. Token-based final truncation\n    tokenizer = global_config.get(\"tokenizer\")\n    if tokenizer and unique_chunks:\n        # Set default chunk_token_limit if not provided\n        if chunk_token_limit is None:\n            # Get default from query_param or global_config\n            chunk_token_limit = getattr(\n                query_param,\n                \"max_total_tokens\",\n                global_config.get(\"MAX_TOTAL_TOKENS\", DEFAULT_MAX_TOTAL_TOKENS),\n            )\n\n        original_count = len(unique_chunks)\n\n        unique_chunks = truncate_list_by_token_size(\n            unique_chunks,\n            key=lambda x: \"\\n\".join(\n                json.dumps(item, ensure_ascii=False) for item in [x]\n            ),\n            max_token_size=chunk_token_limit,\n            tokenizer=tokenizer,\n        )\n\n        logger.debug(\n            f\"Token truncation: {len(unique_chunks)} chunks from {original_count} \"\n            f\"(chunk available tokens: {chunk_token_limit}, source: {source_type})\"\n        )\n\n    # 5. add id field to each chunk\n    final_chunks = []\n    for i, chunk in enumerate(unique_chunks):\n        chunk_with_id = chunk.copy()\n        chunk_with_id[\"id\"] = f\"DC{i + 1}\"\n        final_chunks.append(chunk_with_id)\n\n    return final_chunks\n\n\ndef normalize_source_ids_limit_method(method: str | None) -> str:\n    \"\"\"Normalize the source ID limiting strategy and fall back to default when invalid.\"\"\"\n\n    if not method:\n        return DEFAULT_SOURCE_IDS_LIMIT_METHOD\n\n    normalized = method.upper()\n    if normalized not in VALID_SOURCE_IDS_LIMIT_METHODS:\n        logger.warning(\n            \"Unknown SOURCE_IDS_LIMIT_METHOD '%s', falling back to %s\",\n            method,\n            DEFAULT_SOURCE_IDS_LIMIT_METHOD,\n        )\n        return DEFAULT_SOURCE_IDS_LIMIT_METHOD\n\n    return normalized\n\n\ndef merge_source_ids(\n    existing_ids: Iterable[str] | None, new_ids: Iterable[str] | None\n) -> list[str]:\n    \"\"\"Merge two iterables of source IDs while preserving order and removing duplicates.\"\"\"\n\n    merged: list[str] = []\n    seen: set[str] = set()\n\n    for sequence in (existing_ids, new_ids):\n        if not sequence:\n            continue\n        for source_id in sequence:\n            if not source_id:\n                continue\n            if source_id not in seen:\n                seen.add(source_id)\n                merged.append(source_id)\n\n    return merged\n\n\ndef apply_source_ids_limit(\n    source_ids: Sequence[str],\n    limit: int,\n    method: str,\n    *,\n    identifier: str | None = None,\n) -> list[str]:\n    \"\"\"Apply a limit strategy to a sequence of source IDs.\"\"\"\n\n    if limit <= 0:\n        return []\n\n    source_ids_list = list(source_ids)\n    if len(source_ids_list) <= limit:\n        return source_ids_list\n\n    normalized_method = normalize_source_ids_limit_method(method)\n\n    if normalized_method == SOURCE_IDS_LIMIT_METHOD_FIFO:\n        truncated = source_ids_list[-limit:]\n    else:  # IGNORE_NEW\n        truncated = source_ids_list[:limit]\n\n    if identifier and len(truncated) < len(source_ids_list):\n        logger.debug(\n            \"Source_id truncated: %s | %s keeping %s of %s entries\",\n            identifier,\n            normalized_method,\n            len(truncated),\n            len(source_ids_list),\n        )\n\n    return truncated\n\n\ndef compute_incremental_chunk_ids(\n    existing_full_chunk_ids: list[str],\n    old_chunk_ids: list[str],\n    new_chunk_ids: list[str],\n) -> list[str]:\n    \"\"\"\n    Compute incrementally updated chunk IDs based on changes.\n\n    This function applies delta changes (additions and removals) to an existing\n    list of chunk IDs while maintaining order and ensuring deduplication.\n    Delta additions from new_chunk_ids are placed at the end.\n\n    Args:\n        existing_full_chunk_ids: Complete list of existing chunk IDs from storage\n        old_chunk_ids: Previous chunk IDs from source_id (chunks being replaced)\n        new_chunk_ids: New chunk IDs from updated source_id (chunks being added)\n\n    Returns:\n        Updated list of chunk IDs with deduplication\n\n    Example:\n        >>> existing = ['chunk-1', 'chunk-2', 'chunk-3']\n        >>> old = ['chunk-1', 'chunk-2']\n        >>> new = ['chunk-2', 'chunk-4']\n        >>> compute_incremental_chunk_ids(existing, old, new)\n        ['chunk-3', 'chunk-2', 'chunk-4']\n    \"\"\"\n    # Calculate changes\n    chunks_to_remove = set(old_chunk_ids) - set(new_chunk_ids)\n    chunks_to_add = set(new_chunk_ids) - set(old_chunk_ids)\n\n    # Apply changes to full chunk_ids\n    # Step 1: Remove chunks that are no longer needed\n    updated_chunk_ids = [\n        cid for cid in existing_full_chunk_ids if cid not in chunks_to_remove\n    ]\n\n    # Step 2: Add new chunks (preserving order from new_chunk_ids)\n    # Note: 'cid not in updated_chunk_ids' check ensures deduplication\n    for cid in new_chunk_ids:\n        if cid in chunks_to_add and cid not in updated_chunk_ids:\n            updated_chunk_ids.append(cid)\n\n    return updated_chunk_ids\n\n\ndef subtract_source_ids(\n    source_ids: Iterable[str],\n    ids_to_remove: Collection[str],\n) -> list[str]:\n    \"\"\"Remove a collection of IDs from an ordered iterable while preserving order.\"\"\"\n\n    removal_set = set(ids_to_remove)\n    if not removal_set:\n        return [source_id for source_id in source_ids if source_id]\n\n    return [\n        source_id\n        for source_id in source_ids\n        if source_id and source_id not in removal_set\n    ]\n\n\ndef make_relation_chunk_key(src: str, tgt: str) -> str:\n    \"\"\"Create a deterministic storage key for relation chunk tracking.\"\"\"\n\n    return GRAPH_FIELD_SEP.join(sorted((src, tgt)))\n\n\ndef parse_relation_chunk_key(key: str) -> tuple[str, str]:\n    \"\"\"Parse a relation chunk storage key back into its entity pair.\"\"\"\n\n    parts = key.split(GRAPH_FIELD_SEP)\n    if len(parts) != 2:\n        raise ValueError(f\"Invalid relation chunk key: {key}\")\n    return parts[0], parts[1]\n\n\ndef generate_track_id(prefix: str = \"upload\") -> str:\n    \"\"\"Generate a unique tracking ID with timestamp and UUID\n\n    Args:\n        prefix: Prefix for the track ID (e.g., 'upload', 'insert')\n\n    Returns:\n        str: Unique tracking ID in format: {prefix}_{timestamp}_{uuid}\n    \"\"\"\n    timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n    unique_id = str(uuid.uuid4())[:8]  # Use first 8 characters of UUID\n    return f\"{prefix}_{timestamp}_{unique_id}\"\n\n\ndef get_pinyin_sort_key(text: str) -> str:\n    \"\"\"Generate sort key for Chinese pinyin sorting\n\n    This function uses pypinyin for true Chinese pinyin sorting.\n    If pypinyin is not available, it falls back to simple lowercase string sorting.\n\n    Args:\n        text: Text to generate sort key for\n\n    Returns:\n        str: Sort key that can be used for comparison and sorting\n    \"\"\"\n    if not text:\n        return \"\"\n\n    if _PYPINYIN_AVAILABLE:\n        try:\n            # Convert Chinese characters to pinyin, keep non-Chinese as-is\n            pinyin_list = pypinyin.lazy_pinyin(text, style=pypinyin.Style.NORMAL)\n            return \"\".join(pinyin_list).lower()\n        except Exception:\n            # Silently fall back to simple string sorting on any error\n            return text.lower()\n    else:\n        # pypinyin not available, use simple string sorting\n        return text.lower()\n\n\ndef fix_tuple_delimiter_corruption(\n    record: str, delimiter_core: str, tuple_delimiter: str\n) -> str:\n    \"\"\"\n    Fix various forms of tuple_delimiter corruption from LLM output.\n\n    This function handles missing or replaced characters around the core delimiter.\n    It fixes common corruption patterns where the LLM output doesn't match the expected\n    tuple_delimiter format.\n\n    Args:\n        record: The text record to fix\n        delimiter_core: The core delimiter (e.g., \"S\" from \"<|#|>\")\n        tuple_delimiter: The complete tuple delimiter (e.g., \"<|#|>\")\n\n    Returns:\n        The corrected record with proper tuple_delimiter format\n    \"\"\"\n    if not record or not delimiter_core or not tuple_delimiter:\n        return record\n\n    # Escape the delimiter core for regex use\n    escaped_delimiter_core = re.escape(delimiter_core)\n\n    # Fix: <|##|> -> <|#|>, <|#||#|> -> <|#|>, <|#|||#|> -> <|#|>\n    record = re.sub(\n        rf\"<\\|{escaped_delimiter_core}\\|*?{escaped_delimiter_core}\\|>\",\n        tuple_delimiter,\n        record,\n    )\n\n    # Fix: <|\\#|> -> <|#|>\n    record = re.sub(\n        rf\"<\\|\\\\{escaped_delimiter_core}\\|>\",\n        tuple_delimiter,\n        record,\n    )\n\n    # Fix: <|> -> <|#|>, <||> -> <|#|>\n    record = re.sub(\n        r\"<\\|+>\",\n        tuple_delimiter,\n        record,\n    )\n\n    # Fix: <X|#|> -> <|#|>, <|#|Y> -> <|#|>, <X|#|Y> -> <|#|>, <||#||> -> <|#|> (one extra characters outside pipes)\n    record = re.sub(\n        rf\"<.?\\|{escaped_delimiter_core}\\|.?>\",\n        tuple_delimiter,\n        record,\n    )\n\n    # Fix: <#>, <#|>, <|#> -> <|#|> (missing one or both pipes)\n    record = re.sub(\n        rf\"<\\|?{escaped_delimiter_core}\\|?>\",\n        tuple_delimiter,\n        record,\n    )\n\n    # Fix: <X#|> -> <|#|>, <|#X> -> <|#|> (one pipe is replaced by other character)\n    record = re.sub(\n        rf\"<[^|]{escaped_delimiter_core}\\|>|<\\|{escaped_delimiter_core}[^|]>\",\n        tuple_delimiter,\n        record,\n    )\n\n    # Fix: <|#| -> <|#|>, <|#|| -> <|#|> (missing closing >)\n    record = re.sub(\n        rf\"<\\|{escaped_delimiter_core}\\|+(?!>)\",\n        tuple_delimiter,\n        record,\n    )\n\n    # Fix <|#: -> <|#|> (missing closing >)\n    record = re.sub(\n        rf\"<\\|{escaped_delimiter_core}:(?!>)\",\n        tuple_delimiter,\n        record,\n    )\n\n    # Fix: <||#> -> <|#|> (double pipe at start, missing pipe at end)\n    record = re.sub(\n        rf\"<\\|+{escaped_delimiter_core}>\",\n        tuple_delimiter,\n        record,\n    )\n\n    # Fix: <|| -> <|#|>\n    record = re.sub(\n        r\"<\\|\\|(?!>)\",\n        tuple_delimiter,\n        record,\n    )\n\n    # Fix: |#|> -> <|#|> (missing opening <)\n    record = re.sub(\n        rf\"(?<!<)\\|{escaped_delimiter_core}\\|>\",\n        tuple_delimiter,\n        record,\n    )\n\n    # Fix: <|#|>| -> <|#|>  ( this is a fix for: <|#|| -> <|#|> )\n    record = re.sub(\n        rf\"<\\|{escaped_delimiter_core}\\|>\\|\",\n        tuple_delimiter,\n        record,\n    )\n\n    # Fix: ||#|| -> <|#|> (double pipes on both sides without angle brackets)\n    record = re.sub(\n        rf\"\\|\\|{escaped_delimiter_core}\\|\\|\",\n        tuple_delimiter,\n        record,\n    )\n\n    return record\n\n\ndef create_prefixed_exception(original_exception: Exception, prefix: str) -> Exception:\n    \"\"\"\n    Safely create a prefixed exception that adapts to all error types.\n\n    Args:\n        original_exception: The original exception.\n        prefix: The prefix to add.\n\n    Returns:\n        A new exception with the prefix, maintaining the original exception type if possible.\n    \"\"\"\n    try:\n        # Method 1: Try to reconstruct using original arguments.\n        if hasattr(original_exception, \"args\") and original_exception.args:\n            args = list(original_exception.args)\n            # Find the first string argument and prefix it. This is safer for\n            # exceptions like OSError where the first arg is an integer (errno).\n            found_str = False\n            for i, arg in enumerate(args):\n                if isinstance(arg, str):\n                    args[i] = f\"{prefix}: {arg}\"\n                    found_str = True\n                    break\n\n            # If no string argument is found, prefix the first argument's string representation.\n            if not found_str:\n                args[0] = f\"{prefix}: {args[0]}\"\n\n            return type(original_exception)(*args)\n        else:\n            # Method 2: If no args, try single parameter construction.\n            return type(original_exception)(f\"{prefix}: {str(original_exception)}\")\n    except (TypeError, ValueError, AttributeError) as construct_error:\n        # Method 3: If reconstruction fails, wrap it in a RuntimeError.\n        # This is the safest fallback, as attempting to create the same type\n        # with a single string can fail if the constructor requires multiple arguments.\n        return RuntimeError(\n            f\"{prefix}: {type(original_exception).__name__}: {str(original_exception)} \"\n            f\"(Original exception could not be reconstructed: {construct_error})\"\n        )\n\n\ndef convert_to_user_format(\n    entities_context: list[dict],\n    relations_context: list[dict],\n    chunks: list[dict],\n    references: list[dict],\n    query_mode: str,\n    entity_id_to_original: dict = None,\n    relation_id_to_original: dict = None,\n) -> dict[str, Any]:\n    \"\"\"Convert internal data format to user-friendly format using original database data\"\"\"\n\n    # Convert entities format using original data when available\n    formatted_entities = []\n    for entity in entities_context:\n        entity_name = entity.get(\"entity\", \"\")\n\n        # Try to get original data first\n        original_entity = None\n        if entity_id_to_original and entity_name in entity_id_to_original:\n            original_entity = entity_id_to_original[entity_name]\n\n        if original_entity:\n            # Use original database data\n            formatted_entities.append(\n                {\n                    \"entity_name\": original_entity.get(\"entity_name\", entity_name),\n                    \"entity_type\": original_entity.get(\"entity_type\", \"UNKNOWN\"),\n                    \"description\": original_entity.get(\"description\", \"\"),\n                    \"source_id\": original_entity.get(\"source_id\", \"\"),\n                    \"file_path\": original_entity.get(\"file_path\", \"unknown_source\"),\n                    \"created_at\": original_entity.get(\"created_at\", \"\"),\n                }\n            )\n        else:\n            # Fallback to LLM context data (for backward compatibility)\n            formatted_entities.append(\n                {\n                    \"entity_name\": entity_name,\n                    \"entity_type\": entity.get(\"type\", \"UNKNOWN\"),\n                    \"description\": entity.get(\"description\", \"\"),\n                    \"source_id\": entity.get(\"source_id\", \"\"),\n                    \"file_path\": entity.get(\"file_path\", \"unknown_source\"),\n                    \"created_at\": entity.get(\"created_at\", \"\"),\n                }\n            )\n\n    # Convert relationships format using original data when available\n    formatted_relationships = []\n    for relation in relations_context:\n        entity1 = relation.get(\"entity1\", \"\")\n        entity2 = relation.get(\"entity2\", \"\")\n        relation_key = (entity1, entity2)\n\n        # Try to get original data first\n        original_relation = None\n        if relation_id_to_original and relation_key in relation_id_to_original:\n            original_relation = relation_id_to_original[relation_key]\n\n        if original_relation:\n            # Use original database data\n            formatted_relationships.append(\n                {\n                    \"src_id\": original_relation.get(\"src_id\", entity1),\n                    \"tgt_id\": original_relation.get(\"tgt_id\", entity2),\n                    \"description\": original_relation.get(\"description\", \"\"),\n                    \"keywords\": original_relation.get(\"keywords\", \"\"),\n                    \"weight\": original_relation.get(\"weight\", 1.0),\n                    \"source_id\": original_relation.get(\"source_id\", \"\"),\n                    \"file_path\": original_relation.get(\"file_path\", \"unknown_source\"),\n                    \"created_at\": original_relation.get(\"created_at\", \"\"),\n                }\n            )\n        else:\n            # Fallback to LLM context data (for backward compatibility)\n            formatted_relationships.append(\n                {\n                    \"src_id\": entity1,\n                    \"tgt_id\": entity2,\n                    \"description\": relation.get(\"description\", \"\"),\n                    \"keywords\": relation.get(\"keywords\", \"\"),\n                    \"weight\": relation.get(\"weight\", 1.0),\n                    \"source_id\": relation.get(\"source_id\", \"\"),\n                    \"file_path\": relation.get(\"file_path\", \"unknown_source\"),\n                    \"created_at\": relation.get(\"created_at\", \"\"),\n                }\n            )\n\n    # Convert chunks format (chunks already contain complete data)\n    formatted_chunks = []\n    for i, chunk in enumerate(chunks):\n        chunk_data = {\n            \"reference_id\": chunk.get(\"reference_id\", \"\"),\n            \"content\": chunk.get(\"content\", \"\"),\n            \"file_path\": chunk.get(\"file_path\", \"unknown_source\"),\n            \"chunk_id\": chunk.get(\"chunk_id\", \"\"),\n        }\n        formatted_chunks.append(chunk_data)\n\n    logger.debug(\n        f\"[convert_to_user_format] Formatted {len(formatted_chunks)}/{len(chunks)} chunks\"\n    )\n\n    # Build basic metadata (metadata details will be added by calling functions)\n    metadata = {\n        \"query_mode\": query_mode,\n        \"keywords\": {\n            \"high_level\": [],\n            \"low_level\": [],\n        },  # Placeholder, will be set by calling functions\n    }\n\n    return {\n        \"status\": \"success\",\n        \"message\": \"Query processed successfully\",\n        \"data\": {\n            \"entities\": formatted_entities,\n            \"relationships\": formatted_relationships,\n            \"chunks\": formatted_chunks,\n            \"references\": references,\n        },\n        \"metadata\": metadata,\n    }\n\n\ndef generate_reference_list_from_chunks(\n    chunks: list[dict],\n) -> tuple[list[dict], list[dict]]:\n    \"\"\"\n    Generate reference list from chunks, prioritizing by occurrence frequency.\n\n    This function extracts file_paths from chunks, counts their occurrences,\n    sorts by frequency and first appearance order, creates reference_id mappings,\n    and builds a reference_list structure.\n\n    Args:\n        chunks: List of chunk dictionaries with file_path information\n\n    Returns:\n        tuple: (reference_list, updated_chunks_with_reference_ids)\n            - reference_list: List of dicts with reference_id and file_path\n            - updated_chunks_with_reference_ids: Original chunks with reference_id field added\n    \"\"\"\n    if not chunks:\n        return [], []\n\n    # 1. Extract all valid file_paths and count their occurrences\n    file_path_counts = {}\n    for chunk in chunks:\n        file_path = chunk.get(\"file_path\", \"\")\n        if file_path and file_path != \"unknown_source\":\n            file_path_counts[file_path] = file_path_counts.get(file_path, 0) + 1\n\n    # 2. Sort file paths by frequency (descending), then by first appearance order\n    # Create a list of (file_path, count, first_index) tuples\n    file_path_with_indices = []\n    seen_paths = set()\n    for i, chunk in enumerate(chunks):\n        file_path = chunk.get(\"file_path\", \"\")\n        if file_path and file_path != \"unknown_source\" and file_path not in seen_paths:\n            file_path_with_indices.append((file_path, file_path_counts[file_path], i))\n            seen_paths.add(file_path)\n\n    # Sort by count (descending), then by first appearance index (ascending)\n    sorted_file_paths = sorted(file_path_with_indices, key=lambda x: (-x[1], x[2]))\n    unique_file_paths = [item[0] for item in sorted_file_paths]\n\n    # 3. Create mapping from file_path to reference_id (prioritized by frequency)\n    file_path_to_ref_id = {}\n    for i, file_path in enumerate(unique_file_paths):\n        file_path_to_ref_id[file_path] = str(i + 1)\n\n    # 4. Add reference_id field to each chunk\n    updated_chunks = []\n    for chunk in chunks:\n        chunk_copy = chunk.copy()\n        file_path = chunk_copy.get(\"file_path\", \"\")\n        if file_path and file_path != \"unknown_source\":\n            chunk_copy[\"reference_id\"] = file_path_to_ref_id[file_path]\n        else:\n            chunk_copy[\"reference_id\"] = \"\"\n        updated_chunks.append(chunk_copy)\n\n    # 5. Build reference_list\n    reference_list = []\n    for i, file_path in enumerate(unique_file_paths):\n        reference_list.append({\"reference_id\": str(i + 1), \"file_path\": file_path})\n\n    return reference_list, updated_chunks\n"
  },
  {
    "path": "lightrag/utils_graph.py",
    "content": "from __future__ import annotations\n\nimport time\nimport asyncio\nfrom typing import Any, cast\n\nfrom .base import DeletionResult\nfrom .kg.shared_storage import get_storage_keyed_lock\nfrom .constants import GRAPH_FIELD_SEP\nfrom .utils import compute_mdhash_id, logger\nfrom .base import StorageNameSpace\n\n\ndef _require_non_empty_description(\n    description: Any, *, operation: str, object_type: str\n) -> None:\n    if description is None or not str(description).strip():\n        raise ValueError(\n            f\"{object_type.capitalize()} description cannot be empty for {operation} operation\"\n        )\n\n\nasync def _persist_graph_updates(\n    entities_vdb=None,\n    relationships_vdb=None,\n    chunk_entity_relation_graph=None,\n    entity_chunks_storage=None,\n    relation_chunks_storage=None,\n) -> None:\n    \"\"\"Unified callback to persist updates after graph operations.\n\n    Ensures all relevant storage instances are properly persisted after\n    operations like delete, edit, create, or merge.\n\n    Args:\n        entities_vdb: Entity vector database storage (optional)\n        relationships_vdb: Relationship vector database storage (optional)\n        chunk_entity_relation_graph: Graph storage instance (optional)\n        entity_chunks_storage: Entity-chunk tracking storage (optional)\n        relation_chunks_storage: Relation-chunk tracking storage (optional)\n    \"\"\"\n    storages = []\n\n    # Collect all non-None storage instances\n    if entities_vdb is not None:\n        storages.append(entities_vdb)\n    if relationships_vdb is not None:\n        storages.append(relationships_vdb)\n    if chunk_entity_relation_graph is not None:\n        storages.append(chunk_entity_relation_graph)\n    if entity_chunks_storage is not None:\n        storages.append(entity_chunks_storage)\n    if relation_chunks_storage is not None:\n        storages.append(relation_chunks_storage)\n\n    # Persist all storage instances in parallel\n    if storages:\n        await asyncio.gather(\n            *[\n                cast(StorageNameSpace, storage_inst).index_done_callback()\n                for storage_inst in storages  # type: ignore\n            ]\n        )\n\n\nasync def adelete_by_entity(\n    chunk_entity_relation_graph,\n    entities_vdb,\n    relationships_vdb,\n    entity_name: str,\n    entity_chunks_storage=None,\n    relation_chunks_storage=None,\n) -> DeletionResult:\n    \"\"\"Asynchronously delete an entity and all its relationships.\n\n    Also cleans up entity_chunks_storage and relation_chunks_storage to remove chunk tracking.\n\n    Args:\n        chunk_entity_relation_graph: Graph storage instance\n        entities_vdb: Vector database storage for entities\n        relationships_vdb: Vector database storage for relationships\n        entity_name: Name of the entity to delete\n        entity_chunks_storage: Optional KV storage for tracking chunks that reference this entity\n        relation_chunks_storage: Optional KV storage for tracking chunks that reference relations\n    \"\"\"\n    # Use keyed lock for entity to ensure atomic graph and vector db operations\n    workspace = entities_vdb.global_config.get(\"workspace\", \"\")\n    namespace = f\"{workspace}:GraphDB\" if workspace else \"GraphDB\"\n    async with get_storage_keyed_lock(\n        [entity_name], namespace=namespace, enable_logging=False\n    ):\n        try:\n            # Check if the entity exists\n            if not await chunk_entity_relation_graph.has_node(entity_name):\n                logger.warning(f\"Entity '{entity_name}' not found.\")\n                return DeletionResult(\n                    status=\"not_found\",\n                    doc_id=entity_name,\n                    message=f\"Entity '{entity_name}' not found.\",\n                    status_code=404,\n                )\n            # Retrieve related relationships before deleting the node\n            edges = await chunk_entity_relation_graph.get_node_edges(entity_name)\n            related_relations_count = len(edges) if edges else 0\n\n            # Clean up chunk tracking storages before deletion\n            if entity_chunks_storage is not None:\n                # Delete entity's entry from entity_chunks_storage\n                await entity_chunks_storage.delete([entity_name])\n                logger.info(\n                    f\"Entity Delete: removed chunk tracking for `{entity_name}`\"\n                )\n\n            if relation_chunks_storage is not None and edges:\n                # Delete all related relationships from relation_chunks_storage\n                from .utils import make_relation_chunk_key\n\n                relation_keys_to_delete = []\n                for src, tgt in edges:\n                    # Normalize entity order for consistent key generation\n                    normalized_src, normalized_tgt = sorted([src, tgt])\n                    storage_key = make_relation_chunk_key(\n                        normalized_src, normalized_tgt\n                    )\n                    relation_keys_to_delete.append(storage_key)\n\n                if relation_keys_to_delete:\n                    await relation_chunks_storage.delete(relation_keys_to_delete)\n                    logger.info(\n                        f\"Entity Delete: removed chunk tracking for {len(relation_keys_to_delete)} relations\"\n                    )\n\n            await entities_vdb.delete_entity(entity_name)\n            await relationships_vdb.delete_entity_relation(entity_name)\n            await chunk_entity_relation_graph.delete_node(entity_name)\n\n            message = f\"Entity Delete: remove '{entity_name}' and its {related_relations_count} relations\"\n            logger.info(message)\n            await _persist_graph_updates(\n                entities_vdb=entities_vdb,\n                relationships_vdb=relationships_vdb,\n                chunk_entity_relation_graph=chunk_entity_relation_graph,\n                entity_chunks_storage=entity_chunks_storage,\n                relation_chunks_storage=relation_chunks_storage,\n            )\n            return DeletionResult(\n                status=\"success\",\n                doc_id=entity_name,\n                message=message,\n                status_code=200,\n            )\n        except Exception as e:\n            error_message = f\"Error while deleting entity '{entity_name}': {e}\"\n            logger.error(error_message)\n            return DeletionResult(\n                status=\"fail\",\n                doc_id=entity_name,\n                message=error_message,\n                status_code=500,\n            )\n\n\nasync def adelete_by_relation(\n    chunk_entity_relation_graph,\n    relationships_vdb,\n    source_entity: str,\n    target_entity: str,\n    relation_chunks_storage=None,\n) -> DeletionResult:\n    \"\"\"Asynchronously delete a relation between two entities.\n\n    Also cleans up relation_chunks_storage to remove chunk tracking.\n\n    Args:\n        chunk_entity_relation_graph: Graph storage instance\n        relationships_vdb: Vector database storage for relationships\n        source_entity: Name of the source entity\n        target_entity: Name of the target entity\n        relation_chunks_storage: Optional KV storage for tracking chunks that reference this relation\n    \"\"\"\n    relation_str = f\"{source_entity} -> {target_entity}\"\n    # Normalize entity order for undirected graph (ensures consistent key generation)\n    if source_entity > target_entity:\n        source_entity, target_entity = target_entity, source_entity\n\n    # Use keyed lock for relation to ensure atomic graph and vector db operations\n    workspace = relationships_vdb.global_config.get(\"workspace\", \"\")\n    namespace = f\"{workspace}:GraphDB\" if workspace else \"GraphDB\"\n    sorted_edge_key = sorted([source_entity, target_entity])\n    async with get_storage_keyed_lock(\n        sorted_edge_key, namespace=namespace, enable_logging=False\n    ):\n        try:\n            # Check if the relation exists\n            edge_exists = await chunk_entity_relation_graph.has_edge(\n                source_entity, target_entity\n            )\n            if not edge_exists:\n                message = f\"Relation from '{source_entity}' to '{target_entity}' does not exist\"\n                logger.warning(message)\n                return DeletionResult(\n                    status=\"not_found\",\n                    doc_id=relation_str,\n                    message=message,\n                    status_code=404,\n                )\n\n            # Clean up chunk tracking storage before deletion\n            if relation_chunks_storage is not None:\n                from .utils import make_relation_chunk_key\n\n                # Normalize entity order for consistent key generation\n                normalized_src, normalized_tgt = sorted([source_entity, target_entity])\n                storage_key = make_relation_chunk_key(normalized_src, normalized_tgt)\n\n                await relation_chunks_storage.delete([storage_key])\n                logger.info(\n                    f\"Relation Delete: removed chunk tracking for `{source_entity}`~`{target_entity}`\"\n                )\n\n            # Delete relation from vector database\n            rel_ids_to_delete = [\n                compute_mdhash_id(source_entity + target_entity, prefix=\"rel-\"),\n                compute_mdhash_id(target_entity + source_entity, prefix=\"rel-\"),\n            ]\n\n            await relationships_vdb.delete(rel_ids_to_delete)\n\n            # Delete relation from knowledge graph\n            await chunk_entity_relation_graph.remove_edges(\n                [(source_entity, target_entity)]\n            )\n\n            message = f\"Relation Delete: `{source_entity}`~`{target_entity}` deleted successfully\"\n            logger.info(message)\n            await _persist_graph_updates(\n                relationships_vdb=relationships_vdb,\n                chunk_entity_relation_graph=chunk_entity_relation_graph,\n                relation_chunks_storage=relation_chunks_storage,\n            )\n            return DeletionResult(\n                status=\"success\",\n                doc_id=relation_str,\n                message=message,\n                status_code=200,\n            )\n        except Exception as e:\n            error_message = f\"Error while deleting relation from '{source_entity}' to '{target_entity}': {e}\"\n            logger.error(error_message)\n            return DeletionResult(\n                status=\"fail\",\n                doc_id=relation_str,\n                message=error_message,\n                status_code=500,\n            )\n\n\nasync def _edit_entity_impl(\n    chunk_entity_relation_graph,\n    entities_vdb,\n    relationships_vdb,\n    entity_name: str,\n    updated_data: dict[str, str],\n    *,\n    entity_chunks_storage=None,\n    relation_chunks_storage=None,\n) -> dict[str, Any]:\n    \"\"\"Internal helper that edits an entity without acquiring storage locks.\n\n    This function performs the actual entity edit operations without lock management.\n    It should only be called by public APIs that have already acquired necessary locks.\n\n    Args:\n        chunk_entity_relation_graph: Graph storage instance\n        entities_vdb: Vector database storage for entities\n        relationships_vdb: Vector database storage for relationships\n        entity_name: Name of the entity to edit\n        updated_data: Dictionary containing updated attributes (including optional entity_name for renaming)\n        entity_chunks_storage: Optional KV storage for tracking chunks\n        relation_chunks_storage: Optional KV storage for tracking relation chunks\n\n    Returns:\n        Dictionary containing updated entity information\n\n    Note:\n        Caller must acquire appropriate locks before calling this function.\n        If renaming (entity_name in updated_data), this function will check if the new name exists.\n    \"\"\"\n    new_entity_name = updated_data.get(\"entity_name\", entity_name)\n    is_renaming = new_entity_name != entity_name\n\n    original_entity_name = entity_name\n\n    node_exists = await chunk_entity_relation_graph.has_node(entity_name)\n    if not node_exists:\n        raise ValueError(f\"Entity '{entity_name}' does not exist\")\n    node_data = await chunk_entity_relation_graph.get_node(entity_name)\n\n    if is_renaming:\n        existing_node = await chunk_entity_relation_graph.has_node(new_entity_name)\n        if existing_node:\n            raise ValueError(\n                f\"Entity name '{new_entity_name}' already exists, cannot rename\"\n            )\n\n    new_node_data = {**node_data, **updated_data}\n    new_node_data[\"entity_id\"] = new_entity_name\n\n    if \"entity_name\" in new_node_data:\n        del new_node_data[\n            \"entity_name\"\n        ]  # Node data should not contain entity_name field\n\n    if is_renaming:\n        logger.info(f\"Entity Edit: renaming `{entity_name}` to `{new_entity_name}`\")\n\n        await chunk_entity_relation_graph.upsert_node(new_entity_name, new_node_data)\n\n        relations_to_update = []\n        relations_to_delete = []\n        edges = await chunk_entity_relation_graph.get_node_edges(entity_name)\n        if edges:\n            for source, target in edges:\n                edge_data = await chunk_entity_relation_graph.get_edge(source, target)\n                if edge_data:\n                    relations_to_delete.append(\n                        compute_mdhash_id(source + target, prefix=\"rel-\")\n                    )\n                    relations_to_delete.append(\n                        compute_mdhash_id(target + source, prefix=\"rel-\")\n                    )\n                    if source == entity_name:\n                        await chunk_entity_relation_graph.upsert_edge(\n                            new_entity_name, target, edge_data\n                        )\n                        relations_to_update.append((new_entity_name, target, edge_data))\n                    else:  # target == entity_name\n                        await chunk_entity_relation_graph.upsert_edge(\n                            source, new_entity_name, edge_data\n                        )\n                        relations_to_update.append((source, new_entity_name, edge_data))\n\n        await chunk_entity_relation_graph.delete_node(entity_name)\n\n        old_entity_id = compute_mdhash_id(entity_name, prefix=\"ent-\")\n        await entities_vdb.delete([old_entity_id])\n\n        await relationships_vdb.delete(relations_to_delete)\n\n        for src, tgt, edge_data in relations_to_update:\n            normalized_src, normalized_tgt = sorted([src, tgt])\n\n            description = edge_data.get(\"description\", \"\")\n            keywords = edge_data.get(\"keywords\", \"\")\n            source_id = edge_data.get(\"source_id\", \"\")\n            weight = float(edge_data.get(\"weight\", 1.0))\n\n            content = f\"{normalized_src}\\t{normalized_tgt}\\n{keywords}\\n{description}\"\n\n            relation_id = compute_mdhash_id(\n                normalized_src + normalized_tgt, prefix=\"rel-\"\n            )\n\n            relation_data = {\n                relation_id: {\n                    \"content\": content,\n                    \"src_id\": normalized_src,\n                    \"tgt_id\": normalized_tgt,\n                    \"source_id\": source_id,\n                    \"description\": description,\n                    \"keywords\": keywords,\n                    \"weight\": weight,\n                }\n            }\n\n            await relationships_vdb.upsert(relation_data)\n\n        entity_name = new_entity_name\n    else:\n        await chunk_entity_relation_graph.upsert_node(entity_name, new_node_data)\n\n    description = new_node_data.get(\"description\", \"\")\n    source_id = new_node_data.get(\"source_id\", \"\")\n    entity_type = new_node_data.get(\"entity_type\", \"\")\n    content = entity_name + \"\\n\" + description\n\n    entity_id = compute_mdhash_id(entity_name, prefix=\"ent-\")\n\n    entity_data = {\n        entity_id: {\n            \"content\": content,\n            \"entity_name\": entity_name,\n            \"source_id\": source_id,\n            \"description\": description,\n            \"entity_type\": entity_type,\n        }\n    }\n\n    await entities_vdb.upsert(entity_data)\n\n    if entity_chunks_storage is not None or relation_chunks_storage is not None:\n        from .utils import make_relation_chunk_key, compute_incremental_chunk_ids\n\n        if entity_chunks_storage is not None:\n            storage_key = original_entity_name if is_renaming else entity_name\n            stored_data = await entity_chunks_storage.get_by_id(storage_key)\n            has_stored_data = (\n                stored_data\n                and isinstance(stored_data, dict)\n                and stored_data.get(\"chunk_ids\")\n            )\n\n            old_source_id = node_data.get(\"source_id\", \"\")\n            old_chunk_ids = [cid for cid in old_source_id.split(GRAPH_FIELD_SEP) if cid]\n\n            new_source_id = new_node_data.get(\"source_id\", \"\")\n            new_chunk_ids = [cid for cid in new_source_id.split(GRAPH_FIELD_SEP) if cid]\n\n            source_id_changed = set(new_chunk_ids) != set(old_chunk_ids)\n\n            if source_id_changed or not has_stored_data or is_renaming:\n                existing_full_chunk_ids = []\n                if has_stored_data:\n                    existing_full_chunk_ids = [\n                        cid for cid in stored_data.get(\"chunk_ids\", []) if cid\n                    ]\n\n                if not existing_full_chunk_ids:\n                    existing_full_chunk_ids = old_chunk_ids.copy()\n\n                updated_chunk_ids = compute_incremental_chunk_ids(\n                    existing_full_chunk_ids, old_chunk_ids, new_chunk_ids\n                )\n\n                if is_renaming:\n                    await entity_chunks_storage.delete([original_entity_name])\n                    await entity_chunks_storage.upsert(\n                        {\n                            entity_name: {\n                                \"chunk_ids\": updated_chunk_ids,\n                                \"count\": len(updated_chunk_ids),\n                            }\n                        }\n                    )\n                else:\n                    await entity_chunks_storage.upsert(\n                        {\n                            entity_name: {\n                                \"chunk_ids\": updated_chunk_ids,\n                                \"count\": len(updated_chunk_ids),\n                            }\n                        }\n                    )\n\n                logger.info(\n                    f\"Entity Edit: find {len(updated_chunk_ids)} chunks related to `{entity_name}`\"\n                )\n\n        if is_renaming and relation_chunks_storage is not None and relations_to_update:\n            for src, tgt, edge_data in relations_to_update:\n                old_src = original_entity_name if src == entity_name else src\n                old_tgt = original_entity_name if tgt == entity_name else tgt\n\n                old_normalized_src, old_normalized_tgt = sorted([old_src, old_tgt])\n                new_normalized_src, new_normalized_tgt = sorted([src, tgt])\n\n                old_storage_key = make_relation_chunk_key(\n                    old_normalized_src, old_normalized_tgt\n                )\n                new_storage_key = make_relation_chunk_key(\n                    new_normalized_src, new_normalized_tgt\n                )\n\n                if old_storage_key != new_storage_key:\n                    old_stored_data = await relation_chunks_storage.get_by_id(\n                        old_storage_key\n                    )\n                    relation_chunk_ids = []\n\n                    if old_stored_data and isinstance(old_stored_data, dict):\n                        relation_chunk_ids = [\n                            cid for cid in old_stored_data.get(\"chunk_ids\", []) if cid\n                        ]\n                    else:\n                        relation_source_id = edge_data.get(\"source_id\", \"\")\n                        relation_chunk_ids = [\n                            cid\n                            for cid in relation_source_id.split(GRAPH_FIELD_SEP)\n                            if cid\n                        ]\n\n                    await relation_chunks_storage.delete([old_storage_key])\n\n                    if relation_chunk_ids:\n                        await relation_chunks_storage.upsert(\n                            {\n                                new_storage_key: {\n                                    \"chunk_ids\": relation_chunk_ids,\n                                    \"count\": len(relation_chunk_ids),\n                                }\n                            }\n                        )\n            logger.info(\n                f\"Entity Edit: migrate {len(relations_to_update)} relations after rename\"\n            )\n\n    await _persist_graph_updates(\n        entities_vdb=entities_vdb,\n        relationships_vdb=relationships_vdb,\n        chunk_entity_relation_graph=chunk_entity_relation_graph,\n        entity_chunks_storage=entity_chunks_storage,\n        relation_chunks_storage=relation_chunks_storage,\n    )\n\n    logger.info(f\"Entity Edit: `{entity_name}` successfully updated\")\n    return await get_entity_info(\n        chunk_entity_relation_graph,\n        entities_vdb,\n        entity_name,\n        include_vector_data=True,\n    )\n\n\nasync def aedit_entity(\n    chunk_entity_relation_graph,\n    entities_vdb,\n    relationships_vdb,\n    entity_name: str,\n    updated_data: dict[str, str],\n    allow_rename: bool = True,\n    allow_merge: bool = False,\n    entity_chunks_storage=None,\n    relation_chunks_storage=None,\n) -> dict[str, Any]:\n    \"\"\"Asynchronously edit entity information.\n\n    Updates entity information in the knowledge graph and re-embeds the entity in the vector database.\n    Also synchronizes entity_chunks_storage and relation_chunks_storage to track chunk references.\n\n    Args:\n        chunk_entity_relation_graph: Graph storage instance\n        entities_vdb: Vector database storage for entities\n        relationships_vdb: Vector database storage for relationships\n        entity_name: Name of the entity to edit\n        updated_data: Dictionary containing updated attributes, e.g. {\"description\": \"new description\", \"entity_type\": \"new type\"}\n        allow_rename: Whether to allow entity renaming, defaults to True\n        allow_merge: Whether to merge into an existing entity when renaming to an existing name, defaults to False\n        entity_chunks_storage: Optional KV storage for tracking chunks that reference this entity\n        relation_chunks_storage: Optional KV storage for tracking chunks that reference relations\n\n    Returns:\n        Dictionary containing updated entity information and operation summary with the following structure:\n        {\n            \"entity_name\": str,           # Name of the entity\n            \"description\": str,           # Entity description\n            \"entity_type\": str,           # Entity type\n            \"source_id\": str,            # Source chunk IDs\n            ...                          # Other entity properties\n            \"operation_summary\": {\n                \"merged\": bool,          # Whether entity was merged\n                \"merge_status\": str,     # \"success\" | \"failed\" | \"not_attempted\"\n                \"merge_error\": str | None,  # Error message if merge failed\n                \"operation_status\": str, # \"success\" | \"partial_success\" | \"failure\"\n                \"target_entity\": str | None,  # Target entity name if renaming/merging\n                \"final_entity\": str,     # Final entity name after operation\n                \"renamed\": bool          # Whether entity was renamed\n            }\n        }\n\n        operation_status values:\n            - \"success\": Operation completed successfully (update/rename/merge all succeeded)\n            - \"partial_success\": Non-name updates succeeded but merge failed\n            - \"failure\": Operation failed completely\n\n        merge_status values:\n            - \"success\": Entity successfully merged into target\n            - \"failed\": Merge operation failed\n            - \"not_attempted\": No merge was attempted (normal update/rename)\n    \"\"\"\n    if \"description\" in updated_data:\n        _require_non_empty_description(\n            updated_data.get(\"description\"), operation=\"edit\", object_type=\"entity\"\n        )\n\n    new_entity_name = updated_data.get(\"entity_name\", entity_name)\n    is_renaming = new_entity_name != entity_name\n\n    lock_keys = sorted({entity_name, new_entity_name}) if is_renaming else [entity_name]\n\n    workspace = entities_vdb.global_config.get(\"workspace\", \"\")\n    namespace = f\"{workspace}:GraphDB\" if workspace else \"GraphDB\"\n\n    operation_summary: dict[str, Any] = {\n        \"merged\": False,\n        \"merge_status\": \"not_attempted\",\n        \"merge_error\": None,\n        \"operation_status\": \"success\",\n        \"target_entity\": None,\n        \"final_entity\": new_entity_name if is_renaming else entity_name,\n        \"renamed\": is_renaming,\n    }\n    async with get_storage_keyed_lock(\n        lock_keys, namespace=namespace, enable_logging=False\n    ):\n        try:\n            if is_renaming and not allow_rename:\n                raise ValueError(\n                    \"Entity renaming is not allowed. Set allow_rename=True to enable this feature\"\n                )\n\n            if is_renaming:\n                target_exists = await chunk_entity_relation_graph.has_node(\n                    new_entity_name\n                )\n                if target_exists:\n                    if not allow_merge:\n                        raise ValueError(\n                            f\"Entity name '{new_entity_name}' already exists, cannot rename\"\n                        )\n\n                    logger.info(\n                        f\"Entity Edit: `{entity_name}` will be merged into `{new_entity_name}`\"\n                    )\n\n                    # Track whether non-name updates were applied\n                    non_name_updates_applied = False\n                    non_name_updates = {\n                        key: value\n                        for key, value in updated_data.items()\n                        if key != \"entity_name\"\n                    }\n\n                    # Apply non-name updates first\n                    if non_name_updates:\n                        try:\n                            logger.info(\n                                \"Entity Edit: applying non-name updates before merge\"\n                            )\n                            await _edit_entity_impl(\n                                chunk_entity_relation_graph,\n                                entities_vdb,\n                                relationships_vdb,\n                                entity_name,\n                                non_name_updates,\n                                entity_chunks_storage=entity_chunks_storage,\n                                relation_chunks_storage=relation_chunks_storage,\n                            )\n                            non_name_updates_applied = True\n                        except Exception as update_error:\n                            # If update fails, re-raise immediately\n                            logger.error(\n                                f\"Entity Edit: non-name updates failed: {update_error}\"\n                            )\n                            raise\n\n                    # Attempt to merge entities\n                    try:\n                        merge_result = await _merge_entities_impl(\n                            chunk_entity_relation_graph,\n                            entities_vdb,\n                            relationships_vdb,\n                            [entity_name],\n                            new_entity_name,\n                            merge_strategy=None,\n                            target_entity_data=None,\n                            entity_chunks_storage=entity_chunks_storage,\n                            relation_chunks_storage=relation_chunks_storage,\n                        )\n\n                        # Merge succeeded\n                        operation_summary.update(\n                            {\n                                \"merged\": True,\n                                \"merge_status\": \"success\",\n                                \"merge_error\": None,\n                                \"operation_status\": \"success\",\n                                \"target_entity\": new_entity_name,\n                                \"final_entity\": new_entity_name,\n                            }\n                        )\n                        return {**merge_result, \"operation_summary\": operation_summary}\n\n                    except Exception as merge_error:\n                        # Merge failed, but update may have succeeded\n                        logger.error(f\"Entity Edit: merge failed: {merge_error}\")\n\n                        # Return partial success status (update succeeded but merge failed)\n                        operation_summary.update(\n                            {\n                                \"merged\": False,\n                                \"merge_status\": \"failed\",\n                                \"merge_error\": str(merge_error),\n                                \"operation_status\": \"partial_success\"\n                                if non_name_updates_applied\n                                else \"failure\",\n                                \"target_entity\": new_entity_name,\n                                \"final_entity\": entity_name,  # Keep source entity name\n                            }\n                        )\n\n                        # Get current entity info (with applied updates if any)\n                        entity_info = await get_entity_info(\n                            chunk_entity_relation_graph,\n                            entities_vdb,\n                            entity_name,\n                            include_vector_data=True,\n                        )\n                        return {**entity_info, \"operation_summary\": operation_summary}\n\n            # Normal edit flow (no merge involved)\n            edit_result = await _edit_entity_impl(\n                chunk_entity_relation_graph,\n                entities_vdb,\n                relationships_vdb,\n                entity_name,\n                updated_data,\n                entity_chunks_storage=entity_chunks_storage,\n                relation_chunks_storage=relation_chunks_storage,\n            )\n            operation_summary[\"operation_status\"] = \"success\"\n            return {**edit_result, \"operation_summary\": operation_summary}\n\n        except Exception as e:\n            logger.error(f\"Error while editing entity '{entity_name}': {e}\")\n            raise\n\n\nasync def aedit_relation(\n    chunk_entity_relation_graph,\n    entities_vdb,\n    relationships_vdb,\n    source_entity: str,\n    target_entity: str,\n    updated_data: dict[str, Any],\n    relation_chunks_storage=None,\n) -> dict[str, Any]:\n    \"\"\"Asynchronously edit relation information.\n\n    Updates relation (edge) information in the knowledge graph and re-embeds the relation in the vector database.\n    Also synchronizes the relation_chunks_storage to track which chunks reference this relation.\n\n    Args:\n        chunk_entity_relation_graph: Graph storage instance\n        entities_vdb: Vector database storage for entities\n        relationships_vdb: Vector database storage for relationships\n        source_entity: Name of the source entity\n        target_entity: Name of the target entity\n        updated_data: Dictionary containing updated attributes, e.g. {\"description\": \"new description\", \"keywords\": \"new keywords\"}\n        relation_chunks_storage: Optional KV storage for tracking chunks that reference this relation\n\n    Returns:\n        Dictionary containing updated relation information\n    \"\"\"\n    if \"description\" in updated_data:\n        _require_non_empty_description(\n            updated_data.get(\"description\"), operation=\"edit\", object_type=\"relation\"\n        )\n\n    # Normalize entity order for undirected graph (ensures consistent key generation)\n    if source_entity > target_entity:\n        source_entity, target_entity = target_entity, source_entity\n\n    # Use keyed lock for relation to ensure atomic graph and vector db operations\n    workspace = relationships_vdb.global_config.get(\"workspace\", \"\")\n    namespace = f\"{workspace}:GraphDB\" if workspace else \"GraphDB\"\n    sorted_edge_key = sorted([source_entity, target_entity])\n    async with get_storage_keyed_lock(\n        sorted_edge_key, namespace=namespace, enable_logging=False\n    ):\n        try:\n            # 1. Get current relation information\n            edge_exists = await chunk_entity_relation_graph.has_edge(\n                source_entity, target_entity\n            )\n            if not edge_exists:\n                raise ValueError(\n                    f\"Relation from '{source_entity}' to '{target_entity}' does not exist\"\n                )\n            edge_data = await chunk_entity_relation_graph.get_edge(\n                source_entity, target_entity\n            )\n            # Important: First delete the old relation record from the vector database\n            # Delete both permutations to handle relationships created before normalization\n            rel_ids_to_delete = [\n                compute_mdhash_id(source_entity + target_entity, prefix=\"rel-\"),\n                compute_mdhash_id(target_entity + source_entity, prefix=\"rel-\"),\n            ]\n            await relationships_vdb.delete(rel_ids_to_delete)\n            logger.debug(\n                f\"Relation Delete: delete vdb for `{source_entity}`~`{target_entity}`\"\n            )\n\n            # 2. Update relation information in the graph\n            new_edge_data = {**edge_data, **updated_data}\n            await chunk_entity_relation_graph.upsert_edge(\n                source_entity, target_entity, new_edge_data\n            )\n\n            # 3. Recalculate relation's vector representation and update vector database\n            description = new_edge_data.get(\"description\", \"\")\n            keywords = new_edge_data.get(\"keywords\", \"\")\n            source_id = new_edge_data.get(\"source_id\", \"\")\n            weight = float(new_edge_data.get(\"weight\", 1.0))\n\n            # Create content for embedding\n            content = f\"{source_entity}\\t{target_entity}\\n{keywords}\\n{description}\"\n\n            # Calculate relation ID\n            relation_id = compute_mdhash_id(\n                source_entity + target_entity, prefix=\"rel-\"\n            )\n\n            # Prepare data for vector database update\n            relation_data = {\n                relation_id: {\n                    \"content\": content,\n                    \"src_id\": source_entity,\n                    \"tgt_id\": target_entity,\n                    \"source_id\": source_id,\n                    \"description\": description,\n                    \"keywords\": keywords,\n                    \"weight\": weight,\n                }\n            }\n\n            # Update vector database\n            await relationships_vdb.upsert(relation_data)\n\n            # 4. Update relation_chunks_storage in two scenarios:\n            #    - source_id has changed (edit scenario)\n            #    - relation_chunks_storage has no existing data (migration/initialization scenario)\n            if relation_chunks_storage is not None:\n                from .utils import (\n                    make_relation_chunk_key,\n                    compute_incremental_chunk_ids,\n                )\n\n                storage_key = make_relation_chunk_key(source_entity, target_entity)\n\n                # Check if storage has existing data\n                stored_data = await relation_chunks_storage.get_by_id(storage_key)\n                has_stored_data = (\n                    stored_data\n                    and isinstance(stored_data, dict)\n                    and stored_data.get(\"chunk_ids\")\n                )\n\n                # Get old and new source_id\n                old_source_id = edge_data.get(\"source_id\", \"\")\n                old_chunk_ids = [\n                    cid for cid in old_source_id.split(GRAPH_FIELD_SEP) if cid\n                ]\n\n                new_source_id = new_edge_data.get(\"source_id\", \"\")\n                new_chunk_ids = [\n                    cid for cid in new_source_id.split(GRAPH_FIELD_SEP) if cid\n                ]\n\n                source_id_changed = set(new_chunk_ids) != set(old_chunk_ids)\n\n                # Update if: source_id changed OR storage has no data\n                if source_id_changed or not has_stored_data:\n                    # Get existing full chunk_ids from storage\n                    existing_full_chunk_ids = []\n                    if has_stored_data:\n                        existing_full_chunk_ids = [\n                            cid for cid in stored_data.get(\"chunk_ids\", []) if cid\n                        ]\n\n                    # If no stored data exists, use old source_id as baseline\n                    if not existing_full_chunk_ids:\n                        existing_full_chunk_ids = old_chunk_ids.copy()\n\n                    # Use utility function to compute incremental updates\n                    updated_chunk_ids = compute_incremental_chunk_ids(\n                        existing_full_chunk_ids, old_chunk_ids, new_chunk_ids\n                    )\n\n                    # Update storage (Update even if updated_chunk_ids is empty)\n                    await relation_chunks_storage.upsert(\n                        {\n                            storage_key: {\n                                \"chunk_ids\": updated_chunk_ids,\n                                \"count\": len(updated_chunk_ids),\n                            }\n                        }\n                    )\n\n                    logger.info(\n                        f\"Relation Delete: update chunk tracking for `{source_entity}`~`{target_entity}`\"\n                    )\n\n            # 5. Save changes\n            await _persist_graph_updates(\n                relationships_vdb=relationships_vdb,\n                chunk_entity_relation_graph=chunk_entity_relation_graph,\n                relation_chunks_storage=relation_chunks_storage,\n            )\n\n            logger.info(\n                f\"Relation Delete: `{source_entity}`~`{target_entity}`' successfully updated\"\n            )\n            return await get_relation_info(\n                chunk_entity_relation_graph,\n                relationships_vdb,\n                source_entity,\n                target_entity,\n                include_vector_data=True,\n            )\n        except Exception as e:\n            logger.error(\n                f\"Error while editing relation from '{source_entity}' to '{target_entity}': {e}\"\n            )\n            raise\n\n\nasync def acreate_entity(\n    chunk_entity_relation_graph,\n    entities_vdb,\n    relationships_vdb,\n    entity_name: str,\n    entity_data: dict[str, Any],\n    entity_chunks_storage=None,\n    relation_chunks_storage=None,\n) -> dict[str, Any]:\n    \"\"\"Asynchronously create a new entity.\n\n    Creates a new entity in the knowledge graph and adds it to the vector database.\n    Also synchronizes entity_chunks_storage to track chunk references.\n\n    Args:\n        chunk_entity_relation_graph: Graph storage instance\n        entities_vdb: Vector database storage for entities\n        relationships_vdb: Vector database storage for relationships\n        entity_name: Name of the new entity\n        entity_data: Dictionary containing entity attributes, e.g. {\"description\": \"description\", \"entity_type\": \"type\"}\n        entity_chunks_storage: Optional KV storage for tracking chunks that reference this entity\n        relation_chunks_storage: Optional KV storage for tracking chunks that reference relations\n\n    Returns:\n        Dictionary containing created entity information\n    \"\"\"\n    _require_non_empty_description(\n        entity_data.get(\"description\"), operation=\"create\", object_type=\"entity\"\n    )\n\n    # Use keyed lock for entity to ensure atomic graph and vector db operations\n    workspace = entities_vdb.global_config.get(\"workspace\", \"\")\n    namespace = f\"{workspace}:GraphDB\" if workspace else \"GraphDB\"\n    async with get_storage_keyed_lock(\n        [entity_name], namespace=namespace, enable_logging=False\n    ):\n        try:\n            # Check if entity already exists\n            existing_node = await chunk_entity_relation_graph.has_node(entity_name)\n            if existing_node:\n                raise ValueError(f\"Entity '{entity_name}' already exists\")\n\n            # Prepare node data with defaults if missing\n            node_data = {\n                \"entity_id\": entity_name,\n                \"entity_type\": entity_data.get(\"entity_type\", \"UNKNOWN\"),\n                \"description\": entity_data.get(\"description\", \"\"),\n                \"source_id\": entity_data.get(\"source_id\", \"manual_creation\"),\n                \"file_path\": entity_data.get(\"file_path\", \"manual_creation\"),\n                \"created_at\": int(time.time()),\n            }\n\n            # Add entity to knowledge graph\n            await chunk_entity_relation_graph.upsert_node(entity_name, node_data)\n\n            # Prepare content for entity\n            description = node_data.get(\"description\", \"\")\n            source_id = node_data.get(\"source_id\", \"\")\n            entity_type = node_data.get(\"entity_type\", \"\")\n            content = entity_name + \"\\n\" + description\n\n            # Calculate entity ID\n            entity_id = compute_mdhash_id(entity_name, prefix=\"ent-\")\n\n            # Prepare data for vector database update\n            entity_data_for_vdb = {\n                entity_id: {\n                    \"content\": content,\n                    \"entity_name\": entity_name,\n                    \"source_id\": source_id,\n                    \"description\": description,\n                    \"entity_type\": entity_type,\n                    \"file_path\": entity_data.get(\"file_path\", \"manual_creation\"),\n                }\n            }\n\n            # Update vector database\n            await entities_vdb.upsert(entity_data_for_vdb)\n\n            # Update entity_chunks_storage to track chunk references\n            if entity_chunks_storage is not None:\n                source_id = node_data.get(\"source_id\", \"\")\n                chunk_ids = [cid for cid in source_id.split(GRAPH_FIELD_SEP) if cid]\n\n                if chunk_ids:\n                    await entity_chunks_storage.upsert(\n                        {\n                            entity_name: {\n                                \"chunk_ids\": chunk_ids,\n                                \"count\": len(chunk_ids),\n                            }\n                        }\n                    )\n                    logger.info(\n                        f\"Entity Create: tracked {len(chunk_ids)} chunks for `{entity_name}`\"\n                    )\n\n            # Save changes\n            await _persist_graph_updates(\n                entities_vdb=entities_vdb,\n                relationships_vdb=relationships_vdb,\n                chunk_entity_relation_graph=chunk_entity_relation_graph,\n                entity_chunks_storage=entity_chunks_storage,\n                relation_chunks_storage=relation_chunks_storage,\n            )\n\n            logger.info(f\"Entity Create: '{entity_name}' successfully created\")\n            return await get_entity_info(\n                chunk_entity_relation_graph,\n                entities_vdb,\n                entity_name,\n                include_vector_data=True,\n            )\n        except Exception as e:\n            logger.error(f\"Error while creating entity '{entity_name}': {e}\")\n            raise\n\n\nasync def acreate_relation(\n    chunk_entity_relation_graph,\n    entities_vdb,\n    relationships_vdb,\n    source_entity: str,\n    target_entity: str,\n    relation_data: dict[str, Any],\n    relation_chunks_storage=None,\n) -> dict[str, Any]:\n    \"\"\"Asynchronously create a new relation between entities.\n\n    Creates a new relation (edge) in the knowledge graph and adds it to the vector database.\n    Also synchronizes relation_chunks_storage to track chunk references.\n\n    Args:\n        chunk_entity_relation_graph: Graph storage instance\n        entities_vdb: Vector database storage for entities\n        relationships_vdb: Vector database storage for relationships\n        source_entity: Name of the source entity\n        target_entity: Name of the target entity\n        relation_data: Dictionary containing relation attributes, e.g. {\"description\": \"description\", \"keywords\": \"keywords\"}\n        relation_chunks_storage: Optional KV storage for tracking chunks that reference this relation\n\n    Returns:\n        Dictionary containing created relation information\n    \"\"\"\n    _require_non_empty_description(\n        relation_data.get(\"description\"), operation=\"create\", object_type=\"relation\"\n    )\n\n    # Use keyed lock for relation to ensure atomic graph and vector db operations\n    workspace = relationships_vdb.global_config.get(\"workspace\", \"\")\n    namespace = f\"{workspace}:GraphDB\" if workspace else \"GraphDB\"\n    sorted_edge_key = sorted([source_entity, target_entity])\n    async with get_storage_keyed_lock(\n        sorted_edge_key, namespace=namespace, enable_logging=False\n    ):\n        try:\n            # Check if both entities exist\n            source_exists = await chunk_entity_relation_graph.has_node(source_entity)\n            target_exists = await chunk_entity_relation_graph.has_node(target_entity)\n\n            if not source_exists:\n                raise ValueError(f\"Source entity '{source_entity}' does not exist\")\n            if not target_exists:\n                raise ValueError(f\"Target entity '{target_entity}' does not exist\")\n\n            # Check if relation already exists\n            existing_edge = await chunk_entity_relation_graph.has_edge(\n                source_entity, target_entity\n            )\n            if existing_edge:\n                raise ValueError(\n                    f\"Relation from '{source_entity}' to '{target_entity}' already exists\"\n                )\n\n            # Prepare edge data with defaults if missing\n            edge_data = {\n                \"description\": relation_data.get(\"description\", \"\"),\n                \"keywords\": relation_data.get(\"keywords\", \"\"),\n                \"source_id\": relation_data.get(\"source_id\", \"manual_creation\"),\n                \"weight\": float(relation_data.get(\"weight\", 1.0)),\n                \"file_path\": relation_data.get(\"file_path\", \"manual_creation\"),\n                \"created_at\": int(time.time()),\n            }\n\n            # Add relation to knowledge graph\n            await chunk_entity_relation_graph.upsert_edge(\n                source_entity, target_entity, edge_data\n            )\n\n            # Normalize entity order for undirected relation vector (ensures consistent key generation)\n            if source_entity > target_entity:\n                source_entity, target_entity = target_entity, source_entity\n\n            # Prepare content for embedding\n            description = edge_data.get(\"description\", \"\")\n            keywords = edge_data.get(\"keywords\", \"\")\n            source_id = edge_data.get(\"source_id\", \"\")\n            weight = edge_data.get(\"weight\", 1.0)\n\n            # Create content for embedding\n            content = f\"{keywords}\\t{source_entity}\\n{target_entity}\\n{description}\"\n\n            # Calculate relation ID\n            relation_id = compute_mdhash_id(\n                source_entity + target_entity, prefix=\"rel-\"\n            )\n\n            # Prepare data for vector database update\n            relation_data_for_vdb = {\n                relation_id: {\n                    \"content\": content,\n                    \"src_id\": source_entity,\n                    \"tgt_id\": target_entity,\n                    \"source_id\": source_id,\n                    \"description\": description,\n                    \"keywords\": keywords,\n                    \"weight\": weight,\n                    \"file_path\": relation_data.get(\"file_path\", \"manual_creation\"),\n                }\n            }\n\n            # Update vector database\n            await relationships_vdb.upsert(relation_data_for_vdb)\n\n            # Update relation_chunks_storage to track chunk references\n            if relation_chunks_storage is not None:\n                from .utils import make_relation_chunk_key\n\n                # Normalize entity order for consistent key generation\n                normalized_src, normalized_tgt = sorted([source_entity, target_entity])\n                storage_key = make_relation_chunk_key(normalized_src, normalized_tgt)\n\n                source_id = edge_data.get(\"source_id\", \"\")\n                chunk_ids = [cid for cid in source_id.split(GRAPH_FIELD_SEP) if cid]\n\n                if chunk_ids:\n                    await relation_chunks_storage.upsert(\n                        {\n                            storage_key: {\n                                \"chunk_ids\": chunk_ids,\n                                \"count\": len(chunk_ids),\n                            }\n                        }\n                    )\n                    logger.info(\n                        f\"Relation Create: tracked {len(chunk_ids)} chunks for `{source_entity}`~`{target_entity}`\"\n                    )\n\n            # Save changes\n            await _persist_graph_updates(\n                relationships_vdb=relationships_vdb,\n                chunk_entity_relation_graph=chunk_entity_relation_graph,\n                relation_chunks_storage=relation_chunks_storage,\n            )\n\n            logger.info(\n                f\"Relation Create: `{source_entity}`~`{target_entity}` successfully created\"\n            )\n            return await get_relation_info(\n                chunk_entity_relation_graph,\n                relationships_vdb,\n                source_entity,\n                target_entity,\n                include_vector_data=True,\n            )\n        except Exception as e:\n            logger.error(\n                f\"Error while creating relation from '{source_entity}' to '{target_entity}': {e}\"\n            )\n            raise\n\n\nasync def _merge_entities_impl(\n    chunk_entity_relation_graph,\n    entities_vdb,\n    relationships_vdb,\n    source_entities: list[str],\n    target_entity: str,\n    *,\n    merge_strategy: dict[str, str] = None,\n    target_entity_data: dict[str, Any] = None,\n    entity_chunks_storage=None,\n    relation_chunks_storage=None,\n) -> dict[str, Any]:\n    \"\"\"Internal helper that merges entities without acquiring storage locks.\n\n    This function performs the actual entity merge operations without lock management.\n    It should only be called by public APIs that have already acquired necessary locks.\n\n    Args:\n        chunk_entity_relation_graph: Graph storage instance\n        entities_vdb: Vector database storage for entities\n        relationships_vdb: Vector database storage for relationships\n        source_entities: List of source entity names to merge\n        target_entity: Name of the target entity after merging\n        merge_strategy: Deprecated. Merge strategy for each field (optional)\n        target_entity_data: Dictionary of specific values to set for target entity (optional)\n        entity_chunks_storage: Optional KV storage for tracking chunks\n        relation_chunks_storage: Optional KV storage for tracking relation chunks\n\n    Returns:\n        Dictionary containing the merged entity information\n\n    Note:\n        Caller must acquire appropriate locks before calling this function.\n        All source entities and the target entity should be locked together.\n    \"\"\"\n    # Default merge strategy for entities\n    default_entity_merge_strategy = {\n        \"description\": \"concatenate\",\n        \"entity_type\": \"keep_first\",\n        \"source_id\": \"join_unique\",\n        \"file_path\": \"join_unique\",\n    }\n    effective_entity_merge_strategy = default_entity_merge_strategy\n    if merge_strategy:\n        logger.warning(\n            \"Entity Merge: merge_strategy parameter is deprecated and will be ignored in a future release.\"\n        )\n        effective_entity_merge_strategy = {\n            **default_entity_merge_strategy,\n            **merge_strategy,\n        }\n    target_entity_data = {} if target_entity_data is None else target_entity_data\n\n    # 1. Check if all source entities exist\n    source_entities_data = {}\n    for entity_name in source_entities:\n        node_exists = await chunk_entity_relation_graph.has_node(entity_name)\n        if not node_exists:\n            raise ValueError(f\"Source entity '{entity_name}' does not exist\")\n        node_data = await chunk_entity_relation_graph.get_node(entity_name)\n        source_entities_data[entity_name] = node_data\n\n    # 2. Check if target entity exists and get its data if it does\n    target_exists = await chunk_entity_relation_graph.has_node(target_entity)\n    existing_target_entity_data = {}\n    if target_exists:\n        existing_target_entity_data = await chunk_entity_relation_graph.get_node(\n            target_entity\n        )\n\n    # 3. Merge entity data\n    merged_entity_data = _merge_attributes(\n        list(source_entities_data.values())\n        + ([existing_target_entity_data] if target_exists else []),\n        effective_entity_merge_strategy,\n        filter_none_only=False,  # Use entity behavior: filter falsy values\n    )\n\n    # Apply any explicitly provided target entity data (overrides merged data)\n    for key, value in target_entity_data.items():\n        merged_entity_data[key] = value\n\n    # 4. Get all relationships of the source entities and target entity (if exists)\n    all_relations = []\n    entities_to_collect = source_entities.copy()\n\n    # If target entity exists and not already in source_entities, add it\n    if target_exists and target_entity not in source_entities:\n        entities_to_collect.append(target_entity)\n\n    for entity_name in entities_to_collect:\n        # Get all relationships of the entities\n        edges = await chunk_entity_relation_graph.get_node_edges(entity_name)\n        if edges:\n            for src, tgt in edges:\n                # Ensure src is the current entity\n                if src == entity_name:\n                    edge_data = await chunk_entity_relation_graph.get_edge(src, tgt)\n                    all_relations.append((src, tgt, edge_data))\n\n    # 5. Create or update the target entity\n    merged_entity_data[\"entity_id\"] = target_entity\n    if not target_exists:\n        await chunk_entity_relation_graph.upsert_node(target_entity, merged_entity_data)\n        logger.info(f\"Entity Merge: created target '{target_entity}'\")\n    else:\n        await chunk_entity_relation_graph.upsert_node(target_entity, merged_entity_data)\n        logger.info(f\"Entity Merge: Updated target '{target_entity}'\")\n\n    # 6. Recreate all relations pointing to the target entity in KG\n    # Also collect chunk tracking information in the same loop\n    relation_updates = {}  # Track relationships that need to be merged\n    relations_to_delete = []\n\n    # Initialize chunk tracking variables\n    relation_chunk_tracking = {}  # key: storage_key, value: list of chunk_ids\n    old_relation_keys_to_delete = []\n\n    for src, tgt, edge_data in all_relations:\n        relations_to_delete.append(compute_mdhash_id(src + tgt, prefix=\"rel-\"))\n        relations_to_delete.append(compute_mdhash_id(tgt + src, prefix=\"rel-\"))\n\n        # Collect old chunk tracking key for deletion\n        if relation_chunks_storage is not None:\n            from .utils import make_relation_chunk_key\n\n            old_storage_key = make_relation_chunk_key(src, tgt)\n            old_relation_keys_to_delete.append(old_storage_key)\n\n        new_src = target_entity if src in source_entities else src\n        new_tgt = target_entity if tgt in source_entities else tgt\n\n        # Skip relationships between source entities to avoid self-loops\n        if new_src == new_tgt:\n            logger.info(f\"Entity Merge: skipping `{src}`~`{tgt}` to avoid self-loop\")\n            continue\n\n        # Normalize entity order for consistent duplicate detection (undirected relationships)\n        normalized_src, normalized_tgt = sorted([new_src, new_tgt])\n        relation_key = f\"{normalized_src}|{normalized_tgt}\"\n\n        # Process chunk tracking for this relation\n        if relation_chunks_storage is not None:\n            storage_key = make_relation_chunk_key(normalized_src, normalized_tgt)\n\n            # Get chunk_ids from storage for this original relation\n            stored = await relation_chunks_storage.get_by_id(old_storage_key)\n\n            if stored is not None and isinstance(stored, dict):\n                chunk_ids = [cid for cid in stored.get(\"chunk_ids\", []) if cid]\n            else:\n                # Fallback to source_id from graph\n                source_id = edge_data.get(\"source_id\", \"\")\n                chunk_ids = [cid for cid in source_id.split(GRAPH_FIELD_SEP) if cid]\n\n            # Accumulate chunk_ids with ordered deduplication\n            if storage_key not in relation_chunk_tracking:\n                relation_chunk_tracking[storage_key] = []\n\n            existing_chunks = set(relation_chunk_tracking[storage_key])\n            for chunk_id in chunk_ids:\n                if chunk_id not in existing_chunks:\n                    existing_chunks.add(chunk_id)\n                    relation_chunk_tracking[storage_key].append(chunk_id)\n\n        if relation_key in relation_updates:\n            # Merge relationship data\n            existing_data = relation_updates[relation_key][\"data\"]\n            merged_relation = _merge_attributes(\n                [existing_data, edge_data],\n                {\n                    \"description\": \"concatenate\",\n                    \"keywords\": \"join_unique_comma\",\n                    \"source_id\": \"join_unique\",\n                    \"file_path\": \"join_unique\",\n                    \"weight\": \"max\",\n                },\n                filter_none_only=True,  # Use relation behavior: only filter None\n            )\n            relation_updates[relation_key][\"data\"] = merged_relation\n            logger.debug(\n                f\"Entity Merge: deduplicating relation `{normalized_src}`~`{normalized_tgt}`\"\n            )\n        else:\n            relation_updates[relation_key] = {\n                \"graph_src\": new_src,\n                \"graph_tgt\": new_tgt,\n                \"norm_src\": normalized_src,\n                \"norm_tgt\": normalized_tgt,\n                \"data\": edge_data.copy(),\n            }\n\n    # Apply relationship updates\n    logger.info(f\"Entity Merge: updatign {len(relation_updates)} relations\")\n    for rel_data in relation_updates.values():\n        await chunk_entity_relation_graph.upsert_edge(\n            rel_data[\"graph_src\"], rel_data[\"graph_tgt\"], rel_data[\"data\"]\n        )\n        logger.info(\n            f\"Entity Merge: updating relation `{rel_data['graph_src']}`~`{rel_data['graph_tgt']}`\"\n        )\n\n    # Update relation chunk tracking storage\n    if relation_chunks_storage is not None and all_relations:\n        if old_relation_keys_to_delete:\n            await relation_chunks_storage.delete(old_relation_keys_to_delete)\n\n        if relation_chunk_tracking:\n            updates = {}\n            for storage_key, chunk_ids in relation_chunk_tracking.items():\n                updates[storage_key] = {\n                    \"chunk_ids\": chunk_ids,\n                    \"count\": len(chunk_ids),\n                }\n\n            await relation_chunks_storage.upsert(updates)\n            logger.info(\n                f\"Entity Merge: {len(updates)} relation chunk tracking records updated\"\n            )\n\n    # 7. Update relationship vector representations\n    logger.debug(\n        f\"Entity Merge: deleting {len(relations_to_delete)} relations from vdb\"\n    )\n    await relationships_vdb.delete(relations_to_delete)\n\n    for rel_data in relation_updates.values():\n        edge_data = rel_data[\"data\"]\n        normalized_src = rel_data[\"norm_src\"]\n        normalized_tgt = rel_data[\"norm_tgt\"]\n\n        description = edge_data.get(\"description\", \"\")\n        keywords = edge_data.get(\"keywords\", \"\")\n        source_id = edge_data.get(\"source_id\", \"\")\n        weight = float(edge_data.get(\"weight\", 1.0))\n\n        # Use normalized order for content and relation ID\n        content = f\"{keywords}\\t{normalized_src}\\n{normalized_tgt}\\n{description}\"\n        relation_id = compute_mdhash_id(normalized_src + normalized_tgt, prefix=\"rel-\")\n\n        relation_data_for_vdb = {\n            relation_id: {\n                \"content\": content,\n                \"src_id\": normalized_src,\n                \"tgt_id\": normalized_tgt,\n                \"source_id\": source_id,\n                \"description\": description,\n                \"keywords\": keywords,\n                \"weight\": weight,\n            }\n        }\n        await relationships_vdb.upsert(relation_data_for_vdb)\n        logger.debug(\n            f\"Entity Merge: updating vdb `{normalized_src}`~`{normalized_tgt}`\"\n        )\n\n    logger.info(f\"Entity Merge: {len(relation_updates)} relations in vdb updated\")\n\n    # 8. Update entity vector representation\n    description = merged_entity_data.get(\"description\", \"\")\n    source_id = merged_entity_data.get(\"source_id\", \"\")\n    entity_type = merged_entity_data.get(\"entity_type\", \"\")\n    content = target_entity + \"\\n\" + description\n\n    entity_id = compute_mdhash_id(target_entity, prefix=\"ent-\")\n    entity_data_for_vdb = {\n        entity_id: {\n            \"content\": content,\n            \"entity_name\": target_entity,\n            \"source_id\": source_id,\n            \"description\": description,\n            \"entity_type\": entity_type,\n        }\n    }\n    await entities_vdb.upsert(entity_data_for_vdb)\n    logger.info(f\"Entity Merge: updating vdb `{target_entity}`\")\n\n    # 9. Merge entity chunk tracking (source entities first, then target entity)\n    if entity_chunks_storage is not None:\n        all_chunk_id_lists = []\n\n        # Build list of entities to process (source entities first, then target entity)\n        entities_to_process = []\n\n        # Add source entities first (excluding target if it's already in source list)\n        for entity_name in source_entities:\n            if entity_name != target_entity:\n                entities_to_process.append(entity_name)\n\n        # Add target entity last (if it exists)\n        if target_exists:\n            entities_to_process.append(target_entity)\n\n        # Process all entities in order with unified logic\n        for entity_name in entities_to_process:\n            stored = await entity_chunks_storage.get_by_id(entity_name)\n            if stored and isinstance(stored, dict):\n                chunk_ids = [cid for cid in stored.get(\"chunk_ids\", []) if cid]\n                if chunk_ids:\n                    all_chunk_id_lists.append(chunk_ids)\n\n        # Merge chunk_ids with ordered deduplication (preserves order, source entities first)\n        merged_chunk_ids = []\n        seen = set()\n        for chunk_id_list in all_chunk_id_lists:\n            for chunk_id in chunk_id_list:\n                if chunk_id not in seen:\n                    seen.add(chunk_id)\n                    merged_chunk_ids.append(chunk_id)\n\n        # Delete source entities' chunk tracking records\n        entity_keys_to_delete = [e for e in source_entities if e != target_entity]\n        if entity_keys_to_delete:\n            await entity_chunks_storage.delete(entity_keys_to_delete)\n\n        # Update target entity's chunk tracking\n        if merged_chunk_ids:\n            await entity_chunks_storage.upsert(\n                {\n                    target_entity: {\n                        \"chunk_ids\": merged_chunk_ids,\n                        \"count\": len(merged_chunk_ids),\n                    }\n                }\n            )\n            logger.info(\n                f\"Entity Merge: find {len(merged_chunk_ids)} chunks related to '{target_entity}'\"\n            )\n\n    # 10. Delete source entities\n    for entity_name in source_entities:\n        if entity_name == target_entity:\n            logger.warning(\n                f\"Entity Merge: source entity'{entity_name}' is same as target entity\"\n            )\n            continue\n\n        logger.info(f\"Entity Merge: deleting '{entity_name}' from KG and vdb\")\n\n        # Delete entity node and related edges from knowledge graph\n        await chunk_entity_relation_graph.delete_node(entity_name)\n\n        # Delete entity record from vector database\n        entity_id = compute_mdhash_id(entity_name, prefix=\"ent-\")\n        await entities_vdb.delete([entity_id])\n\n    # 11. Save changes\n    await _persist_graph_updates(\n        entities_vdb=entities_vdb,\n        relationships_vdb=relationships_vdb,\n        chunk_entity_relation_graph=chunk_entity_relation_graph,\n        entity_chunks_storage=entity_chunks_storage,\n        relation_chunks_storage=relation_chunks_storage,\n    )\n\n    logger.info(\n        f\"Entity Merge: successfully merged {len(source_entities)} entities into '{target_entity}'\"\n    )\n    return await get_entity_info(\n        chunk_entity_relation_graph,\n        entities_vdb,\n        target_entity,\n        include_vector_data=True,\n    )\n\n\nasync def amerge_entities(\n    chunk_entity_relation_graph,\n    entities_vdb,\n    relationships_vdb,\n    source_entities: list[str],\n    target_entity: str,\n    merge_strategy: dict[str, str] = None,\n    target_entity_data: dict[str, Any] = None,\n    entity_chunks_storage=None,\n    relation_chunks_storage=None,\n) -> dict[str, Any]:\n    \"\"\"Asynchronously merge multiple entities into one entity.\n\n    Merges multiple source entities into a target entity, handling all relationships,\n    and updating both the knowledge graph and vector database.\n    Also merges chunk tracking information from entity_chunks_storage and relation_chunks_storage.\n\n    Args:\n        chunk_entity_relation_graph: Graph storage instance\n        entities_vdb: Vector database storage for entities\n        relationships_vdb: Vector database storage for relationships\n        source_entities: List of source entity names to merge\n        target_entity: Name of the target entity after merging\n        merge_strategy: Deprecated (Each field uses its own default strategy). If provided,\n            customizations are applied but a warning is logged.\n        target_entity_data: Dictionary of specific values to set for the target entity,\n            overriding any merged values, e.g. {\"description\": \"custom description\", \"entity_type\": \"PERSON\"}\n        entity_chunks_storage: Optional KV storage for tracking chunks that reference entities\n        relation_chunks_storage: Optional KV storage for tracking chunks that reference relations\n\n    Returns:\n        Dictionary containing the merged entity information\n    \"\"\"\n    # Collect all entities involved (source + target) and lock them all in sorted order\n    all_entities = set(source_entities)\n    all_entities.add(target_entity)\n    lock_keys = sorted(all_entities)\n\n    workspace = entities_vdb.global_config.get(\"workspace\", \"\")\n    namespace = f\"{workspace}:GraphDB\" if workspace else \"GraphDB\"\n    async with get_storage_keyed_lock(\n        lock_keys, namespace=namespace, enable_logging=False\n    ):\n        try:\n            return await _merge_entities_impl(\n                chunk_entity_relation_graph,\n                entities_vdb,\n                relationships_vdb,\n                source_entities,\n                target_entity,\n                merge_strategy=merge_strategy,\n                target_entity_data=target_entity_data,\n                entity_chunks_storage=entity_chunks_storage,\n                relation_chunks_storage=relation_chunks_storage,\n            )\n        except Exception as e:\n            logger.error(f\"Error merging entities: {e}\")\n            raise\n\n\ndef _merge_attributes(\n    data_list: list[dict[str, Any]],\n    merge_strategy: dict[str, str],\n    filter_none_only: bool = False,\n) -> dict[str, Any]:\n    \"\"\"Merge attributes from multiple entities or relationships.\n\n    This unified function handles merging of both entity and relationship attributes,\n    applying different merge strategies per field.\n\n    Args:\n        data_list: List of dictionaries containing entity or relationship data\n        merge_strategy: Merge strategy for each field. Supported strategies:\n            - \"concatenate\": Join all values with GRAPH_FIELD_SEP\n            - \"keep_first\": Keep the first non-empty value\n            - \"keep_last\": Keep the last non-empty value\n            - \"join_unique\": Join unique items separated by GRAPH_FIELD_SEP\n            - \"join_unique_comma\": Join unique items separated by comma and space\n            - \"max\": Keep the maximum numeric value (for numeric fields)\n        filter_none_only: If True, only filter None values (keep empty strings, 0, etc.).\n            If False, filter all falsy values. Default is False for backward compatibility.\n\n    Returns:\n        Dictionary containing merged data\n    \"\"\"\n    merged_data = {}\n\n    # Collect all possible keys\n    all_keys = set()\n    for data in data_list:\n        all_keys.update(data.keys())\n\n    # Merge values for each key\n    for key in all_keys:\n        # Get all values for this key based on filtering mode\n        if filter_none_only:\n            values = [data.get(key) for data in data_list if data.get(key) is not None]\n        else:\n            values = [data.get(key) for data in data_list if data.get(key)]\n\n        if not values:\n            continue\n\n        # Merge values according to strategy\n        strategy = merge_strategy.get(key, \"keep_first\")\n\n        if strategy == \"concatenate\":\n            # Convert all values to strings and join with GRAPH_FIELD_SEP\n            merged_data[key] = GRAPH_FIELD_SEP.join(str(v) for v in values)\n        elif strategy == \"keep_first\":\n            merged_data[key] = values[0]\n        elif strategy == \"keep_last\":\n            merged_data[key] = values[-1]\n        elif strategy == \"join_unique\":\n            # Handle fields separated by GRAPH_FIELD_SEP\n            unique_items = set()\n            for value in values:\n                items = str(value).split(GRAPH_FIELD_SEP)\n                unique_items.update(items)\n            merged_data[key] = GRAPH_FIELD_SEP.join(unique_items)\n        elif strategy == \"join_unique_comma\":\n            # Handle fields separated by comma, join unique items with comma\n            unique_items = set()\n            for value in values:\n                items = str(value).split(\",\")\n                unique_items.update(item.strip() for item in items if item.strip())\n            merged_data[key] = \",\".join(sorted(unique_items))\n        elif strategy == \"max\":\n            # For numeric fields like weight\n            try:\n                merged_data[key] = max(float(v) for v in values)\n            except (ValueError, TypeError):\n                # Fallback to first value if conversion fails\n                merged_data[key] = values[0]\n        else:\n            # Default strategy: keep first value\n            merged_data[key] = values[0]\n\n    return merged_data\n\n\nasync def get_entity_info(\n    chunk_entity_relation_graph,\n    entities_vdb,\n    entity_name: str,\n    include_vector_data: bool = False,\n) -> dict[str, str | None | dict[str, str]]:\n    \"\"\"Get detailed information of an entity\"\"\"\n\n    # Get information from the graph\n    node_data = await chunk_entity_relation_graph.get_node(entity_name)\n    source_id = node_data.get(\"source_id\") if node_data else None\n\n    result: dict[str, str | None | dict[str, str]] = {\n        \"entity_name\": entity_name,\n        \"source_id\": source_id,\n        \"graph_data\": node_data,\n    }\n\n    # Optional: Get vector database information\n    if include_vector_data:\n        entity_id = compute_mdhash_id(entity_name, prefix=\"ent-\")\n        vector_data = await entities_vdb.get_by_id(entity_id)\n        result[\"vector_data\"] = vector_data\n\n    return result\n\n\nasync def get_relation_info(\n    chunk_entity_relation_graph,\n    relationships_vdb,\n    src_entity: str,\n    tgt_entity: str,\n    include_vector_data: bool = False,\n) -> dict[str, str | None | dict[str, str]]:\n    \"\"\"\n    Get detailed information of a relationship between two entities.\n    Relationship is unidirectional, swap src_entity and tgt_entity does not change the relationship.\n\n    Args:\n        src_entity: Source entity name\n        tgt_entity: Target entity name\n        include_vector_data: Whether to include vector database information\n\n    Returns:\n        Dictionary containing relationship information\n    \"\"\"\n\n    # Get information from the graph\n    edge_data = await chunk_entity_relation_graph.get_edge(src_entity, tgt_entity)\n    source_id = edge_data.get(\"source_id\") if edge_data else None\n\n    result: dict[str, str | None | dict[str, str]] = {\n        \"src_entity\": src_entity,\n        \"tgt_entity\": tgt_entity,\n        \"source_id\": source_id,\n        \"graph_data\": edge_data,\n    }\n\n    # Optional: Get vector database information\n    if include_vector_data:\n        rel_id = compute_mdhash_id(src_entity + tgt_entity, prefix=\"rel-\")\n        vector_data = await relationships_vdb.get_by_id(rel_id)\n        result[\"vector_data\"] = vector_data\n\n    return result\n"
  },
  {
    "path": "lightrag.service.example",
    "content": "[Unit]\nDescription=LightRAG XYJ Service\nAfter=network.target\n\n[Service]\nType=simple\nUser=netman\n# Memory settings\nMemoryHigh=8G\nMemoryMax=12G\n\n# Set the LightRAG installation directory (change this to match your installation path)\nEnvironment=\"LIGHTRAG_HOME=/home/netman/lightrag-xyj\"\n\n# Set Environment to your Python virtual environment\nEnvironment=\"PATH=${LIGHTRAG_HOME}/.venv/bin\"\nWorkingDirectory=${LIGHTRAG_HOME}\nExecStart=${LIGHTRAG_HOME}/.venv/bin/lightrag-server\n# ExecStart=${LIGHTRAG_HOME}/.venv/bin/lightrag-gunicorn\n\n# Kill mode require ExecStart must be gunicorn or unvicorn main process\nKillMode=process\nExecStop=/bin/kill -s TERM $MAINPID\nTimeoutStopSec=60\n\nRestart=always\nRestartSec=30\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "lightrag_webui/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "lightrag_webui/.prettierrc.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/prettierrc\",\n  \"semi\": false,\n  \"tabWidth\": 2,\n  \"singleQuote\": true,\n  \"printWidth\": 100,\n  \"trailingComma\": \"none\",\n  \"endOfLine\": \"crlf\",\n  \"plugins\": [\"prettier-plugin-tailwindcss\"]\n}\n"
  },
  {
    "path": "lightrag_webui/README.md",
    "content": "# LightRAG WebUI\n\nLightRAG WebUI is a React-based web interface for interacting with the LightRAG system. It provides a user-friendly interface for querying, managing, and exploring LightRAG's functionalities.\n\n## Installation\n\n### Using Bun (recommended)\n\n1. **Install Bun:**\n\n    If you haven't already installed Bun, follow the official documentation: [https://bun.sh/docs/installation](https://bun.sh/docs/installation)\n\n2. **Install Dependencies:**\n\n    In the `lightrag_webui` directory, run the following command to install project dependencies:\n\n    ```bash\n    bun install --frozen-lockfile\n    ```\n\n3. **Build the Project:**\n\n    Run the following command to build the project:\n\n    ```bash\n    bun run build\n    ```\n\n    This command will bundle the project and output the built files to the `lightrag/api/webui` directory.\n\n### Using Node.js / npm (alternative)\n\nIf Bun is unavailable or the Bun build fails in your environment (e.g., older Linux distributions, restricted environments, or Bun version incompatibilities), you can use Node.js instead:\n\n```bash\nnpm install\nnpm run build\n```\n\n> **Note:** Tests (`bun test`) still require Bun. All other scripts (`dev`, `build`, `preview`, `lint`) work with both Bun and Node.js/npm.\n\n## Development\n\n- **Start the Development Server:**\n\n  ```bash\n  # With Bun\n  bun run dev\n\n  # With Node.js/npm\n  npm run dev\n  ```\n\n## Script Commands\n\nThe following are some commonly used script commands defined in `package.json`:\n\n| Command | Description |\n|---------|-------------|\n| `bun run dev` / `npm run dev` | Starts the development server |\n| `bun run build` / `npm run build` | Builds the project for production |\n| `bun run lint` / `npm run lint` | Runs the linter |\n| `bun run preview` / `npm run preview` | Previews the production build |\n| `bun run build:bun` | Builds using Bun runtime explicitly |\n| `bun test` | Runs tests (Bun only) |\n\n## Troubleshooting\n\n### `bun run build` fails silently or with exit code 1\n\nThis can happen due to Bun version incompatibilities or restricted environments. Try:\n\n```bash\nnpm install\nnpm run build\n```\n\n### `Cannot find package '@/lib'`\n\nThis error occurred in older versions when the Vite config used a TypeScript path alias (`@/`) that only Bun could resolve at config load time. This has been fixed by using a relative import in `vite.config.ts`.\n"
  },
  {
    "path": "lightrag_webui/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/index.css\",\n    \"baseColor\": \"zinc\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"iconLibrary\": \"lucide\"\n}\n"
  },
  {
    "path": "lightrag_webui/env.development.smaple",
    "content": "# Development environment configuration\nVITE_BACKEND_URL=http://localhost:9621\nVITE_API_PROXY=true\nVITE_API_ENDPOINTS=/api,/documents,/graphs,/graph,/health,/query,/docs,/redoc,/openapi.json,/login,/auth-status,/static\n"
  },
  {
    "path": "lightrag_webui/env.local.sample",
    "content": "VITE_BACKEND_URL=http://localhost:9621\nVITE_API_PROXY=true\nVITE_API_ENDPOINTS=/api,/documents,/graphs,/graph,/health,/query,/docs,/redoc,/openapi.json,/login,/auth-status\n"
  },
  {
    "path": "lightrag_webui/eslint.config.js",
    "content": "import js from '@eslint/js'\nimport globals from 'globals'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport reactRefresh from 'eslint-plugin-react-refresh'\nimport stylisticJs from '@stylistic/eslint-plugin-js'\nimport tseslint from 'typescript-eslint'\nimport prettier from 'eslint-config-prettier'\nimport react from 'eslint-plugin-react'\n\nexport default tseslint.config(\n  { ignores: ['dist'] },\n  {\n    extends: [js.configs.recommended, ...tseslint.configs.recommended, prettier],\n    files: ['**/*.{ts,tsx,js,jsx}'],\n    languageOptions: {\n      ecmaVersion: 2020,\n      globals: globals.browser\n    },\n    settings: { react: { version: '19.0' } },\n    plugins: {\n      'react-hooks': reactHooks,\n      'react-refresh': reactRefresh,\n      '@stylistic/js': stylisticJs,\n      react\n    },\n    rules: {\n      ...reactHooks.configs.recommended.rules,\n      'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],\n      ...react.configs.recommended.rules,\n      ...react.configs['jsx-runtime'].rules,\n      '@stylistic/js/indent': ['error', 2],\n      '@stylistic/js/quotes': ['error', 'single'],\n      '@typescript-eslint/no-explicit-any': ['off']\n    }\n  }\n)\n"
  },
  {
    "path": "lightrag_webui/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta http-equiv=\"Cache-Control\" content=\"no-cache, no-store, must-revalidate\" />\n    <meta http-equiv=\"Pragma\" content=\"no-cache\" />\n    <meta http-equiv=\"Expires\" content=\"0\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"favicon.png\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Lightrag</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "lightrag_webui/package.json",
    "content": "{\n  \"name\": \"lightrag-webui\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"lint\": \"eslint .\",\n    \"preview\": \"vite preview\",\n    \"test\": \"bun test\",\n    \"test:watch\": \"bun test --watch\",\n    \"test:coverage\": \"bun test --coverage\",\n    \"dev:bun\": \"bunx --bun vite\",\n    \"build:bun\": \"bunx --bun vite build\",\n    \"preview:bun\": \"bunx --bun vite preview\"\n  },\n  \"dependencies\": {\n    \"@faker-js/faker\": \"^10.3.0\",\n    \"@radix-ui/react-alert-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-checkbox\": \"^1.3.3\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-progress\": \"^1.1.8\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.10\",\n    \"@radix-ui/react-select\": \"^2.2.6\",\n    \"@radix-ui/react-separator\": \"^1.1.8\",\n    \"@radix-ui/react-slot\": \"^1.2.4\",\n    \"@radix-ui/react-tabs\": \"^1.1.13\",\n    \"@radix-ui/react-tooltip\": \"^1.2.8\",\n    \"@radix-ui/react-use-controllable-state\": \"^1.2.2\",\n    \"@react-sigma/core\": \"^5.0.6\",\n    \"@react-sigma/graph-search\": \"^5.0.6\",\n    \"@react-sigma/layout-circlepack\": \"^5.0.6\",\n    \"@react-sigma/layout-circular\": \"^5.0.6\",\n    \"@react-sigma/layout-force\": \"^5.0.6\",\n    \"@react-sigma/layout-forceatlas2\": \"^5.0.6\",\n    \"@react-sigma/layout-noverlap\": \"^5.0.6\",\n    \"@react-sigma/layout-random\": \"^5.0.6\",\n    \"@react-sigma/minimap\": \"^5.0.6\",\n    \"@sigma/edge-curve\": \"^3.1.0\",\n    \"@sigma/node-border\": \"^3.0.0\",\n    \"@tanstack/react-table\": \"^8.21.3\",\n    \"axios\": \"^1.13.6\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.1.1\",\n    \"graphology\": \"^0.26.0\",\n    \"graphology-generators\": \"^0.11.2\",\n    \"graphology-layout\": \"^0.6.1\",\n    \"graphology-layout-force\": \"^0.2.4\",\n    \"graphology-layout-forceatlas2\": \"^0.10.1\",\n    \"graphology-layout-noverlap\": \"^0.4.2\",\n    \"i18next\": \"^25.8.18\",\n    \"katex\": \"^0.16.38\",\n    \"mermaid\": \"^11.13.0\",\n    \"lucide-react\": \"^0.577.0\",\n    \"minisearch\": \"^7.2.0\",\n    \"react\": \"^19.2.4\",\n    \"react-dom\": \"^19.2.4\",\n    \"react-dropzone\": \"^15.0.0\",\n    \"react-error-boundary\": \"^6.1.1\",\n    \"react-i18next\": \"^16.5.8\",\n    \"react-markdown\": \"^10.1.0\",\n    \"react-number-format\": \"^5.4.4\",\n    \"react-router-dom\": \"^7.13.1\",\n    \"react-select\": \"^5.10.2\",\n    \"react-syntax-highlighter\": \"^16.1.1\",\n    \"rehype-katex\": \"^7.0.1\",\n    \"rehype-raw\": \"^7.0.0\",\n    \"rehype-react\": \"^8.0.0\",\n    \"remark-gfm\": \"^4.0.1\",\n    \"remark-math\": \"^6.0.0\",\n    \"seedrandom\": \"^3.0.5\",\n    \"sigma\": \"^3.0.2\",\n    \"sonner\": \"^2.0.7\",\n    \"tailwind-merge\": \"^3.5.0\",\n    \"tailwind-scrollbar\": \"^4.0.2\",\n    \"typography\": \"^0.16.24\",\n    \"unist-util-visit\": \"^5.1.0\",\n    \"zustand\": \"^5.0.12\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^10.0.1\",\n    \"@stylistic/eslint-plugin-js\": \"^4.4.1\",\n    \"@types/bun\": \"^1.3.10\",\n    \"@tailwindcss/vite\": \"^4.2.1\",\n    \"@types/katex\": \"^0.16.8\",\n    \"@types/node\": \"^25.5.0\",\n    \"@tailwindcss/typography\": \"^0.5.15\",\n    \"@types/react\": \"^19.2.14\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"@types/react-i18next\": \"^8.1.0\",\n    \"@types/react-syntax-highlighter\": \"^15.5.13\",\n    \"@types/seedrandom\": \"^3.0.8\",\n    \"@vitejs/plugin-react-swc\": \"^4.3.0\",\n    \"eslint\": \"^10.0.0\",\n    \"eslint-config-prettier\": \"^10.1.8\",\n    \"eslint-plugin-react\": \"^7.37.5\",\n    \"eslint-plugin-react-hooks\": \"^7.0.1\",\n    \"eslint-plugin-react-refresh\": \"^0.5.2\",\n    \"globals\": \"^17.4.0\",\n    \"graphology-types\": \"^0.24.8\",\n    \"prettier\": \"^3.8.1\",\n    \"prettier-plugin-tailwindcss\": \"^0.7.2\",\n    \"typescript-eslint\": \"^8.57.0\",\n    \"tailwindcss\": \"^4.2.1\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"typescript\": \"~5.9.3\",\n    \"vite\": \"^7.3.1\"\n  }\n}\n"
  },
  {
    "path": "lightrag_webui/src/App.tsx",
    "content": "import { useState, useCallback, useEffect, useRef } from 'react'\nimport ThemeProvider from '@/components/ThemeProvider'\nimport TabVisibilityProvider from '@/contexts/TabVisibilityProvider'\nimport ApiKeyAlert from '@/components/ApiKeyAlert'\nimport StatusIndicator from '@/components/status/StatusIndicator'\nimport { SiteInfo, webuiPrefix } from '@/lib/constants'\nimport { useBackendState, useAuthStore } from '@/stores/state'\nimport { useSettingsStore } from '@/stores/settings'\nimport { getAuthStatus } from '@/api/lightrag'\nimport SiteHeader from '@/features/SiteHeader'\nimport { InvalidApiKeyError, RequireApiKeError } from '@/api/lightrag'\nimport { ZapIcon } from 'lucide-react'\n\nimport GraphViewer from '@/features/GraphViewer'\nimport DocumentManager from '@/features/DocumentManager'\nimport RetrievalTesting from '@/features/RetrievalTesting'\nimport ApiSite from '@/features/ApiSite'\n\nimport { Tabs, TabsContent } from '@/components/ui/Tabs'\n\nfunction App() {\n  const message = useBackendState.use.message()\n  const enableHealthCheck = useSettingsStore.use.enableHealthCheck()\n  const currentTab = useSettingsStore.use.currentTab()\n  const [apiKeyAlertOpen, setApiKeyAlertOpen] = useState(false)\n  const [initializing, setInitializing] = useState(true) // Add initializing state\n  const versionCheckRef = useRef(false); // Prevent duplicate calls in Vite dev mode\n  const healthCheckInitializedRef = useRef(false); // Prevent duplicate health checks in Vite dev mode\n\n  const handleApiKeyAlertOpenChange = useCallback((open: boolean) => {\n    setApiKeyAlertOpen(open)\n    if (!open) {\n      useBackendState.getState().clear()\n    }\n  }, [])\n\n  // Track component mount status with useRef\n  const isMountedRef = useRef(true);\n\n  // Set up mount/unmount status tracking\n  useEffect(() => {\n    isMountedRef.current = true;\n\n    // Handle page reload/unload\n    const handleBeforeUnload = () => {\n      isMountedRef.current = false;\n    };\n\n    window.addEventListener('beforeunload', handleBeforeUnload);\n\n    return () => {\n      isMountedRef.current = false;\n      window.removeEventListener('beforeunload', handleBeforeUnload);\n    };\n  }, []);\n\n  // Health check - can be disabled\n  useEffect(() => {\n    // Health check function\n    const performHealthCheck = async () => {\n      try {\n        // Only perform health check if component is still mounted\n        if (isMountedRef.current) {\n          await useBackendState.getState().check();\n        }\n      } catch (error) {\n        console.error('Health check error:', error);\n      }\n    };\n\n    // Set health check function in the store\n    useBackendState.getState().setHealthCheckFunction(performHealthCheck);\n\n    if (!enableHealthCheck || apiKeyAlertOpen) {\n      useBackendState.getState().clearHealthCheckTimer();\n      return;\n    }\n\n    // On first mount or when enableHealthCheck becomes true and apiKeyAlertOpen is false,\n    // perform an immediate health check and start the timer\n    if (!healthCheckInitializedRef.current) {\n      healthCheckInitializedRef.current = true;\n    }\n\n    // Start/reset the health check timer using the store\n    useBackendState.getState().resetHealthCheckTimer();\n\n    // Component unmount cleanup\n    return () => {\n      useBackendState.getState().clearHealthCheckTimer();\n    };\n  }, [enableHealthCheck, apiKeyAlertOpen]);\n\n  // Version check - independent and executed only once\n  useEffect(() => {\n    const checkVersion = async () => {\n      // Prevent duplicate calls in Vite dev mode\n      if (versionCheckRef.current) return;\n      versionCheckRef.current = true;\n\n      // Check if version info was already obtained in login page\n      const versionCheckedFromLogin = sessionStorage.getItem('VERSION_CHECKED_FROM_LOGIN') === 'true';\n      if (versionCheckedFromLogin) {\n        setInitializing(false); // Skip initialization if already checked\n        return;\n      }\n\n      try {\n        setInitializing(true); // Start initialization\n\n        // Get version info\n        const token = localStorage.getItem('LIGHTRAG-API-TOKEN');\n        const status = await getAuthStatus();\n\n        // If auth is not configured and a new token is returned, use the new token\n        if (!status.auth_configured && status.access_token) {\n          useAuthStore.getState().login(\n            status.access_token, // Use the new token\n            true, // Guest mode\n            status.core_version,\n            status.api_version,\n            status.webui_title || null,\n            status.webui_description || null\n          );\n        } else if (token && (status.core_version || status.api_version || status.webui_title || status.webui_description)) {\n          // Otherwise use the old token (if it exists)\n          const isGuestMode = status.auth_mode === 'disabled' || useAuthStore.getState().isGuestMode;\n          useAuthStore.getState().login(\n            token,\n            isGuestMode,\n            status.core_version,\n            status.api_version,\n            status.webui_title || null,\n            status.webui_description || null\n          );\n        }\n\n        // Set flag to indicate version info has been checked\n        sessionStorage.setItem('VERSION_CHECKED_FROM_LOGIN', 'true');\n      } catch (error) {\n        console.error('Failed to get version info:', error);\n      } finally {\n        // Ensure initializing is set to false even if there's an error\n        setInitializing(false);\n      }\n    };\n\n    // Execute version check\n    checkVersion();\n  }, []); // Empty dependency array ensures it only runs once on mount\n\n  const handleTabChange = useCallback(\n    (tab: string) => useSettingsStore.getState().setCurrentTab(tab as any),\n    []\n  )\n\n  useEffect(() => {\n    if (message) {\n      if (message.includes(InvalidApiKeyError) || message.includes(RequireApiKeError)) {\n        setApiKeyAlertOpen(true)\n      }\n    }\n  }, [message])\n\n  return (\n    <ThemeProvider>\n      <TabVisibilityProvider>\n        {initializing ? (\n          // Loading state while initializing with simplified header\n          <div className=\"flex h-screen w-screen flex-col\">\n            {/* Simplified header during initialization - matches SiteHeader structure */}\n            <header className=\"border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur\">\n              <div className=\"min-w-[200px] w-auto flex items-center\">\n                <a href={webuiPrefix} className=\"flex items-center gap-2\">\n                  <ZapIcon className=\"size-4 text-emerald-400\" aria-hidden=\"true\" />\n                  <span className=\"font-bold md:inline-block\">{SiteInfo.name}</span>\n                </a>\n              </div>\n\n              {/* Empty middle section to maintain layout */}\n              <div className=\"flex h-10 flex-1 items-center justify-center\">\n              </div>\n\n              {/* Empty right section to maintain layout */}\n              <nav className=\"w-[200px] flex items-center justify-end\">\n              </nav>\n            </header>\n\n            {/* Loading indicator in content area */}\n            <div className=\"flex flex-1 items-center justify-center\">\n              <div className=\"text-center\">\n                <div className=\"mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent mx-auto\"></div>\n                <p>Initializing...</p>\n              </div>\n            </div>\n          </div>\n        ) : (\n          // Main content after initialization\n          <main className=\"flex h-screen w-screen overflow-hidden\">\n            <Tabs\n              defaultValue={currentTab}\n              className=\"!m-0 flex grow flex-col !p-0 overflow-hidden\"\n              onValueChange={handleTabChange}\n            >\n              <SiteHeader />\n              <div className=\"relative grow\">\n                <TabsContent value=\"documents\" className=\"absolute top-0 right-0 bottom-0 left-0 overflow-auto\">\n                  <DocumentManager />\n                </TabsContent>\n                <TabsContent value=\"knowledge-graph\" className=\"absolute top-0 right-0 bottom-0 left-0 overflow-hidden\">\n                  <GraphViewer />\n                </TabsContent>\n                <TabsContent value=\"retrieval\" className=\"absolute top-0 right-0 bottom-0 left-0 overflow-hidden\">\n                  <RetrievalTesting />\n                </TabsContent>\n                <TabsContent value=\"api\" className=\"absolute top-0 right-0 bottom-0 left-0 overflow-hidden\">\n                  <ApiSite />\n                </TabsContent>\n              </div>\n            </Tabs>\n            {enableHealthCheck && <StatusIndicator />}\n            <ApiKeyAlert open={apiKeyAlertOpen} onOpenChange={handleApiKeyAlertOpenChange} />\n          </main>\n        )}\n      </TabVisibilityProvider>\n    </ThemeProvider>\n  )\n}\n\nexport default App\n"
  },
  {
    "path": "lightrag_webui/src/AppRouter.tsx",
    "content": "import '@/lib/extensions'; // Import all global extensions\nimport { HashRouter as Router, Routes, Route, useNavigate } from 'react-router-dom'\nimport { useEffect, useState } from 'react'\nimport { useAuthStore } from '@/stores/state'\nimport { navigationService } from '@/services/navigation'\nimport { Toaster } from 'sonner'\nimport App from './App'\nimport LoginPage from '@/features/LoginPage'\nimport ThemeProvider from '@/components/ThemeProvider'\n\nconst AppContent = () => {\n  const [initializing, setInitializing] = useState(true)\n  const { isAuthenticated } = useAuthStore()\n  const navigate = useNavigate()\n\n  // Set navigate function for navigation service\n  useEffect(() => {\n    navigationService.setNavigate(navigate)\n  }, [navigate])\n\n  // Token validity check\n  useEffect(() => {\n\n    const checkAuth = async () => {\n      try {\n        const token = localStorage.getItem('LIGHTRAG-API-TOKEN')\n\n        if (token && isAuthenticated) {\n          setInitializing(false);\n          return;\n        }\n\n        if (!token) {\n          useAuthStore.getState().logout()\n        }\n      } catch (error) {\n        console.error('Auth initialization error:', error)\n        if (!isAuthenticated) {\n          useAuthStore.getState().logout()\n        }\n      } finally {\n        setInitializing(false)\n      }\n    }\n\n    checkAuth()\n\n    return () => {\n    }\n  }, [isAuthenticated])\n\n  // Redirect effect for protected routes\n  useEffect(() => {\n    if (!initializing && !isAuthenticated) {\n      const currentPath = window.location.hash.slice(1);\n      if (currentPath !== '/login') {\n        console.log('Not authenticated, redirecting to login');\n        navigate('/login');\n      }\n    }\n  }, [initializing, isAuthenticated, navigate]);\n\n  // Show nothing while initializing\n  if (initializing) {\n    return null\n  }\n\n  return (\n    <Routes>\n      <Route path=\"/login\" element={<LoginPage />} />\n      <Route\n        path=\"/*\"\n        element={isAuthenticated ? <App /> : null}\n      />\n    </Routes>\n  )\n}\n\nconst AppRouter = () => {\n  return (\n    <ThemeProvider>\n      <Router>\n        <AppContent />\n        <Toaster\n          position=\"bottom-center\"\n          theme=\"system\"\n          closeButton\n          richColors\n        />\n      </Router>\n    </ThemeProvider>\n  )\n}\n\nexport default AppRouter\n"
  },
  {
    "path": "lightrag_webui/src/api/lightrag.ts",
    "content": "import axios, { AxiosError } from 'axios'\nimport { backendBaseUrl, popularLabelsDefaultLimit, searchLabelsDefaultLimit } from '@/lib/constants'\nimport { errorMessage } from '@/lib/utils'\nimport { useSettingsStore } from '@/stores/settings'\nimport { useAuthStore } from '@/stores/state'\nimport { navigationService } from '@/services/navigation'\n\n// Types\nexport type LightragNodeType = {\n  id: string\n  labels: string[]\n  properties: Record<string, any>\n}\n\nexport type LightragEdgeType = {\n  id: string\n  source: string\n  target: string\n  type: string\n  properties: Record<string, any>\n}\n\nexport type LightragGraphType = {\n  nodes: LightragNodeType[]\n  edges: LightragEdgeType[]\n}\n\nexport type LightragStatus = {\n  status: 'healthy'\n  working_directory: string\n  input_directory: string\n  configuration: {\n    llm_binding: string\n    llm_binding_host: string\n    llm_model: string\n    embedding_binding: string\n    embedding_binding_host: string\n    embedding_model: string\n    kv_storage: string\n    doc_status_storage: string\n    graph_storage: string\n    vector_storage: string\n    workspace?: string\n    max_graph_nodes?: string\n    enable_rerank?: boolean\n    rerank_binding?: string | null\n    rerank_model?: string | null\n    rerank_binding_host?: string | null\n    summary_language: string\n    force_llm_summary_on_merge: boolean\n    max_parallel_insert: number\n    max_async: number\n    embedding_func_max_async: number\n    embedding_batch_num: number\n    cosine_threshold: number\n    min_rerank_score: number\n    related_chunk_number: number\n  }\n  update_status?: Record<string, any>\n  core_version?: string\n  api_version?: string\n  auth_mode?: 'enabled' | 'disabled'\n  pipeline_busy: boolean\n  keyed_locks?: {\n    process_id: number\n    cleanup_performed: {\n      mp_cleaned: number\n      async_cleaned: number\n    }\n    current_status: {\n      total_mp_locks: number\n      pending_mp_cleanup: number\n      total_async_locks: number\n      pending_async_cleanup: number\n    }\n  }\n  webui_title?: string\n  webui_description?: string\n}\n\nexport type LightragDocumentsScanProgress = {\n  is_scanning: boolean\n  current_file: string\n  indexed_count: number\n  total_files: number\n  progress: number\n}\n\n/**\n * Specifies the retrieval mode:\n * - \"naive\": Performs a basic search without advanced techniques.\n * - \"local\": Focuses on context-dependent information.\n * - \"global\": Utilizes global knowledge.\n * - \"hybrid\": Combines local and global retrieval methods.\n * - \"mix\": Integrates knowledge graph and vector retrieval.\n * - \"bypass\": Bypasses knowledge retrieval and directly uses the LLM.\n */\nexport type QueryMode = 'naive' | 'local' | 'global' | 'hybrid' | 'mix' | 'bypass'\n\nexport type Message = {\n  role: 'user' | 'assistant' | 'system'\n  content: string\n  thinkingContent?: string\n  displayContent?: string\n  thinkingTime?: number | null\n}\n\nexport type QueryRequest = {\n  query: string\n  /** Specifies the retrieval mode. */\n  mode: QueryMode\n  /** If True, only returns the retrieved context without generating a response. */\n  only_need_context?: boolean\n  /** If True, only returns the generated prompt without producing a response. */\n  only_need_prompt?: boolean\n  /** Defines the response format. Examples: 'Multiple Paragraphs', 'Single Paragraph', 'Bullet Points'. */\n  response_type?: string\n  /** If True, enables streaming output for real-time responses. */\n  stream?: boolean\n  /** Number of top items to retrieve. Represents entities in 'local' mode and relationships in 'global' mode. */\n  top_k?: number\n  /** Maximum number of text chunks to retrieve and keep after reranking. */\n  chunk_top_k?: number\n  /** Maximum number of tokens allocated for entity context in unified token control system. */\n  max_entity_tokens?: number\n  /** Maximum number of tokens allocated for relationship context in unified token control system. */\n  max_relation_tokens?: number\n  /** Maximum total tokens budget for the entire query context (entities + relations + chunks + system prompt). */\n  max_total_tokens?: number\n  /**\n   * Stores past conversation history to maintain context.\n   * Format: [{\"role\": \"user/assistant\", \"content\": \"message\"}].\n   */\n  conversation_history?: Message[]\n  /** Number of complete conversation turns (user-assistant pairs) to consider in the response context. */\n  history_turns?: number\n  /** User-provided prompt for the query. If provided, this will be used instead of the default value from prompt template. */\n  user_prompt?: string\n  /** Enable reranking for retrieved text chunks. If True but no rerank model is configured, a warning will be issued. Default is True. */\n  enable_rerank?: boolean\n}\n\nexport type QueryResponse = {\n  response: string\n}\n\nexport type EntityUpdateResponse = {\n  status: string\n  message: string\n  data: Record<string, any>\n  operation_summary?: {\n    merged: boolean\n    merge_status: 'success' | 'failed' | 'not_attempted'\n    merge_error: string | null\n    operation_status: 'success' | 'partial_success' | 'failure'\n    target_entity: string | null\n    final_entity?: string | null\n    renamed?: boolean\n  }\n}\n\nexport type DocActionResponse = {\n  status: 'success' | 'partial_success' | 'failure' | 'duplicated'\n  message: string\n  track_id?: string\n}\n\nexport type ScanResponse = {\n  status: 'scanning_started'\n  message: string\n  track_id: string\n}\n\nexport type ReprocessFailedResponse = {\n  status: 'reprocessing_started'\n  message: string\n  track_id: string\n}\n\nexport type DeleteDocResponse = {\n  status: 'deletion_started' | 'busy' | 'not_allowed'\n  message: string\n  doc_id: string\n}\n\nexport type DocStatus = 'pending' | 'processing' | 'preprocessed' | 'processed' | 'failed'\n\nexport type DocStatusResponse = {\n  id: string\n  content_summary: string\n  content_length: number\n  status: DocStatus\n  created_at: string\n  updated_at: string\n  track_id?: string\n  chunks_count?: number\n  error_msg?: string\n  metadata?: Record<string, any>\n  file_path: string\n}\n\nexport type DocsStatusesResponse = {\n  statuses: Record<DocStatus, DocStatusResponse[]>\n}\n\nexport type TrackStatusResponse = {\n  track_id: string\n  documents: DocStatusResponse[]\n  total_count: number\n  status_summary: Record<string, number>\n}\n\nexport type DocumentsRequest = {\n  status_filter?: DocStatus | null\n  page: number\n  page_size: number\n  sort_field: 'created_at' | 'updated_at' | 'id' | 'file_path'\n  sort_direction: 'asc' | 'desc'\n}\n\nexport type PaginationInfo = {\n  page: number\n  page_size: number\n  total_count: number\n  total_pages: number\n  has_next: boolean\n  has_prev: boolean\n}\n\nexport type PaginatedDocsResponse = {\n  documents: DocStatusResponse[]\n  pagination: PaginationInfo\n  status_counts: Record<string, number>\n}\n\nexport type StatusCountsResponse = {\n  status_counts: Record<string, number>\n}\n\nexport type AuthStatusResponse = {\n  auth_configured: boolean\n  access_token?: string\n  token_type?: string\n  auth_mode?: 'enabled' | 'disabled'\n  message?: string\n  core_version?: string\n  api_version?: string\n  webui_title?: string\n  webui_description?: string\n}\n\nexport type PipelineStatusResponse = {\n  autoscanned: boolean\n  busy: boolean\n  job_name: string\n  job_start?: string\n  docs: number\n  batchs: number\n  cur_batch: number\n  request_pending: boolean\n  cancellation_requested?: boolean\n  latest_message: string\n  history_messages?: string[]\n  update_status?: Record<string, any>\n}\n\nexport type LoginResponse = {\n  access_token: string\n  token_type: string\n  auth_mode?: 'enabled' | 'disabled'  // Authentication mode identifier\n  message?: string                    // Optional message\n  core_version?: string\n  api_version?: string\n  webui_title?: string\n  webui_description?: string\n}\n\nexport const InvalidApiKeyError = 'Invalid API Key'\nexport const RequireApiKeError = 'API Key required'\n\n// Axios instance\nconst axiosInstance = axios.create({\n  baseURL: backendBaseUrl,\n  headers: {\n    'Content-Type': 'application/json'\n  }\n})\n\n// ========== Token Management ==========\n// Prevent multiple requests from triggering token refresh simultaneously\nlet isRefreshingGuestToken = false;\nlet refreshTokenPromise: Promise<string> | null = null;\n\n// Silent refresh for guest token\nconst silentRefreshGuestToken = async (): Promise<string> => {\n  // If already refreshing, return the same Promise\n  if (isRefreshingGuestToken && refreshTokenPromise) {\n    return refreshTokenPromise;\n  }\n\n  isRefreshingGuestToken = true;\n  refreshTokenPromise = (async () => {\n    try {\n      // Call /auth-status to get new guest token\n      const response = await axios.get('/auth-status', {\n        baseURL: backendBaseUrl,\n        // This request must skip the interceptor to avoid adding expired token\n        headers: { 'X-Skip-Interceptor': 'true' }\n      });\n\n      if (response.data.access_token && !response.data.auth_configured) {\n        const newToken = response.data.access_token;\n        // Update localStorage\n        localStorage.setItem('LIGHTRAG-API-TOKEN', newToken);\n        // Update auth state\n        useAuthStore.getState().login(\n          newToken,\n          true,\n          response.data.core_version,\n          response.data.api_version,\n          response.data.webui_title || null,\n          response.data.webui_description || null\n        );\n        return newToken;\n      } else {\n        throw new Error('Failed to get guest token');\n      }\n    } finally {\n      isRefreshingGuestToken = false;\n      refreshTokenPromise = null;\n    }\n  })();\n\n  return refreshTokenPromise;\n};\n\n// Interceptor: add api key and check authentication\naxiosInstance.interceptors.request.use((config) => {\n  // Skip interceptor for token refresh requests\n  if (config.headers['X-Skip-Interceptor']) {\n    delete config.headers['X-Skip-Interceptor'];\n    return config;\n  }\n\n  const apiKey = useSettingsStore.getState().apiKey\n  const token = localStorage.getItem('LIGHTRAG-API-TOKEN');\n\n  // Always include token if it exists, regardless of path\n  if (token) {\n    config.headers['Authorization'] = `Bearer ${token}`\n  }\n  if (apiKey) {\n    config.headers['X-API-Key'] = apiKey\n  }\n  return config\n})\n\n// Interceptor：handle token renewal and authentication errors\naxiosInstance.interceptors.response.use(\n  (response) => {\n    // ========== Check for new token from backend ==========\n    const newToken = response.headers['x-new-token'];\n    if (newToken) {\n      localStorage.setItem('LIGHTRAG-API-TOKEN', newToken);\n\n      // Optional: log in development mode\n      if (import.meta.env.DEV) {\n        console.log('[Auth] Token auto-renewed by backend');\n      }\n\n      // Update auth state with renewal tracking\n      try {\n        const payload = JSON.parse(atob(newToken.split('.')[1]));\n        const authStore = useAuthStore.getState();\n        if (authStore.isAuthenticated) {\n          // Track token renewal time and expiration\n          const renewalTime = Date.now();\n          const expiresAt = payload.exp ? payload.exp * 1000 : 0;\n          authStore.setTokenRenewal(renewalTime, expiresAt);\n\n          // Update username (usually unchanged, but just in case)\n          const newUsername = payload.sub;\n          if (newUsername && newUsername !== authStore.username) {\n            // Need to add setUsername method or just update via login\n            // For now, we'll skip username update as it's rare\n          }\n        }\n      } catch (error) {\n        console.warn('[Auth] Failed to parse renewed token:', error);\n      }\n    }\n    // ========== End of token renewal check ==========\n\n    return response;\n  },\n  async (error: AxiosError) => {\n    if (error.response) {\n      if (error.response?.status === 401) {\n        const originalRequest = error.config;\n\n        // 1. For login API, throw error directly\n        if (originalRequest?.url?.includes('/login')) {\n          throw error;\n        }\n\n        // 2. Prevent infinite retry\n        if (originalRequest && (originalRequest as any)._retry) {\n          navigationService.navigateToLogin();\n          return Promise.reject(new Error('Authentication required'));\n        }\n\n        // 3. Check if in guest mode\n        const authStore = useAuthStore.getState();\n        const currentToken = localStorage.getItem('LIGHTRAG-API-TOKEN');\n        const isGuest = currentToken && authStore.isGuestMode;\n\n        // 4. Guest mode: silent refresh and retry\n        if (isGuest && originalRequest) {\n          try {\n            const newToken = await silentRefreshGuestToken();\n\n            // Mark as retried to prevent infinite loop\n            (originalRequest as any)._retry = true;\n\n            // Update token in request headers\n            originalRequest.headers['Authorization'] = `Bearer ${newToken}`;\n\n            // Retry original request\n            return axiosInstance(originalRequest);\n          } catch (refreshError) {\n            console.error('Failed to refresh guest token:', refreshError);\n            // Refresh failed, navigate to login\n            navigationService.navigateToLogin();\n            return Promise.reject(new Error('Failed to refresh authentication'));\n          }\n        }\n\n        // 5. Non-guest mode: navigate to login page\n        navigationService.navigateToLogin();\n        return Promise.reject(new Error('Authentication required'));\n      }\n      throw new Error(\n        `${error.response.status} ${error.response.statusText}\\n${JSON.stringify(\n          error.response.data\n        )}\\n${error.config?.url}`\n      )\n    }\n    throw error\n  }\n)\n\n// API methods\nexport const queryGraphs = async (\n  label: string,\n  maxDepth: number,\n  maxNodes: number\n): Promise<LightragGraphType> => {\n  const response = await axiosInstance.get(`/graphs?label=${encodeURIComponent(label)}&max_depth=${maxDepth}&max_nodes=${maxNodes}`)\n  return response.data\n}\n\nexport const getGraphLabels = async (): Promise<string[]> => {\n  const response = await axiosInstance.get('/graph/label/list')\n  return response.data\n}\n\nexport const getPopularLabels = async (limit: number = popularLabelsDefaultLimit): Promise<string[]> => {\n  const response = await axiosInstance.get(`/graph/label/popular?limit=${limit}`)\n  return response.data\n}\n\nexport const searchLabels = async (query: string, limit: number = searchLabelsDefaultLimit): Promise<string[]> => {\n  const response = await axiosInstance.get(`/graph/label/search?q=${encodeURIComponent(query)}&limit=${limit}`)\n  return response.data\n}\n\nexport const checkHealth = async (): Promise<\n  LightragStatus | { status: 'error'; message: string }\n> => {\n  try {\n    const response = await axiosInstance.get('/health')\n    return response.data\n  } catch (error) {\n    return {\n      status: 'error',\n      message: errorMessage(error)\n    }\n  }\n}\n\nexport const getDocuments = async (): Promise<DocsStatusesResponse> => {\n  const response = await axiosInstance.get('/documents')\n  return response.data\n}\n\nexport const scanNewDocuments = async (): Promise<ScanResponse> => {\n  const response = await axiosInstance.post('/documents/scan')\n  return response.data\n}\n\nexport const reprocessFailedDocuments = async (): Promise<ReprocessFailedResponse> => {\n  const response = await axiosInstance.post('/documents/reprocess_failed')\n  return response.data\n}\n\nexport const getDocumentsScanProgress = async (): Promise<LightragDocumentsScanProgress> => {\n  const response = await axiosInstance.get('/documents/scan-progress')\n  return response.data\n}\n\nexport const queryText = async (request: QueryRequest): Promise<QueryResponse> => {\n  const response = await axiosInstance.post('/query', request)\n  return response.data\n}\n\nexport const queryTextStream = async (\n  request: QueryRequest,\n  onChunk: (chunk: string) => void,\n  onError?: (error: string) => void\n) => {\n  const apiKey = useSettingsStore.getState().apiKey;\n  const token = localStorage.getItem('LIGHTRAG-API-TOKEN');\n  const headers: HeadersInit = {\n    'Content-Type': 'application/json',\n    'Accept': 'application/x-ndjson',\n  };\n  if (token) {\n    headers['Authorization'] = `Bearer ${token}`;\n  }\n  if (apiKey) {\n    headers['X-API-Key'] = apiKey;\n  }\n\n  try {\n    const response = await fetch(`${backendBaseUrl}/query/stream`, {\n      method: 'POST',\n      headers: headers,\n      body: JSON.stringify(request),\n    });\n\n    if (!response.ok) {\n      // Handle 401 Unauthorized error specifically\n      if (response.status === 401) {\n        // Check if in guest mode\n        const authStore = useAuthStore.getState();\n        const currentToken = localStorage.getItem('LIGHTRAG-API-TOKEN');\n        const isGuest = currentToken && authStore.isGuestMode;\n\n        if (isGuest) {\n          try {\n            // Silent refresh token for guest mode\n            const newToken = await silentRefreshGuestToken();\n\n            // Retry stream request with new token\n            const retryHeaders = { ...headers };\n            retryHeaders['Authorization'] = `Bearer ${newToken}`;\n\n            const retryResponse = await fetch(`${backendBaseUrl}/query/stream`, {\n              method: 'POST',\n              headers: retryHeaders,\n              body: JSON.stringify(request),\n            });\n\n            if (!retryResponse.ok) {\n              throw new Error(`HTTP error! status: ${retryResponse.status}`);\n            }\n\n            // Retry successful, process stream response\n            // Re-execute the stream processing logic with retryResponse\n            if (!retryResponse.body) {\n              throw new Error('Response body is null');\n            }\n\n            const reader = retryResponse.body.getReader();\n            const decoder = new TextDecoder();\n            let buffer = '';\n\n            while (true) {\n              const { done, value } = await reader.read();\n              if (done) break;\n\n              buffer += decoder.decode(value, { stream: true });\n              const lines = buffer.split('\\n');\n              buffer = lines.pop() || '';\n\n              for (const line of lines) {\n                if (line.trim()) {\n                  try {\n                    const parsed = JSON.parse(line);\n                    if (parsed.response) {\n                      onChunk(parsed.response);\n                    } else if (parsed.error) {\n                      onError?.(parsed.error);\n                    }\n                  } catch (parseError) {\n                    console.error('Failed to parse JSON:', parseError, 'Line:', line);\n                    onError?.(`JSON parse error: ${parseError}`);\n                  }\n                }\n              }\n            }\n\n            // Process any remaining data in buffer\n            if (buffer.trim()) {\n              try {\n                const parsed = JSON.parse(buffer);\n                if (parsed.response) {\n                  onChunk(parsed.response);\n                } else if (parsed.error) {\n                  onError?.(parsed.error);\n                }\n              } catch (parseError) {\n                console.error('Failed to parse final buffer:', parseError);\n              }\n            }\n\n            return; // Successfully completed retry\n          } catch (refreshError) {\n            console.error('Failed to refresh guest token for streaming:', refreshError);\n            navigationService.navigateToLogin();\n            throw new Error('Failed to refresh authentication');\n          }\n        }\n\n        // Non-guest mode: navigate to login page\n        navigationService.navigateToLogin();\n\n        // Create a specific authentication error\n        const authError = new Error('Authentication required');\n        throw authError;\n      }\n\n      // Handle other common HTTP errors with specific messages\n      let errorBody = 'Unknown error';\n      try {\n        errorBody = await response.text(); // Try to get error details from body\n      } catch { /* ignore */ }\n\n      // Format error message similar to axios interceptor for consistency\n      const url = `${backendBaseUrl}/query/stream`;\n      throw new Error(\n        `${response.status} ${response.statusText}\\n${JSON.stringify(\n          { error: errorBody }\n        )}\\n${url}`\n      );\n    }\n\n    if (!response.body) {\n      throw new Error('Response body is null');\n    }\n\n    const reader = response.body.getReader();\n    const decoder = new TextDecoder();\n    let buffer = '';\n\n    while (true) {\n      const { done, value } = await reader.read();\n      if (done) {\n        break; // Stream finished\n      }\n\n      // Decode the chunk and add to buffer\n      buffer += decoder.decode(value, { stream: true }); // stream: true handles multi-byte chars split across chunks\n\n      // Process complete lines (NDJSON)\n      const lines = buffer.split('\\n');\n      buffer = lines.pop() || ''; // Keep potentially incomplete line in buffer\n\n      for (const line of lines) {\n        if (line.trim()) {\n          try {\n            const parsed = JSON.parse(line);\n            if (parsed.response) {\n              onChunk(parsed.response);\n            } else if (parsed.error && onError) {\n              onError(parsed.error);\n            }\n          } catch (error) {\n            console.error('Error parsing stream chunk:', line, error);\n            if (onError) onError(`Error parsing server response: ${line}`);\n          }\n        }\n      }\n    }\n\n    // Process any remaining data in the buffer after the stream ends\n    if (buffer.trim()) {\n      try {\n        const parsed = JSON.parse(buffer);\n        if (parsed.response) {\n          onChunk(parsed.response);\n        } else if (parsed.error && onError) {\n          onError(parsed.error);\n        }\n      } catch (error) {\n        console.error('Error parsing final chunk:', buffer, error);\n        if (onError) onError(`Error parsing final server response: ${buffer}`);\n      }\n    }\n\n  } catch (error) {\n    const message = errorMessage(error);\n\n    // Check if this is an authentication error\n    if (message === 'Authentication required') {\n      // Already navigated to login page in the response.status === 401 block\n      console.error('Authentication required for stream request');\n      if (onError) {\n        onError('Authentication required');\n      }\n      return; // Exit early, no need for further error handling\n    }\n\n    // Check for specific HTTP error status codes in the error message\n    const statusCodeMatch = message.match(/^(\\d{3})\\s/);\n    if (statusCodeMatch) {\n      const statusCode = parseInt(statusCodeMatch[1], 10);\n\n      // Handle specific status codes with user-friendly messages\n      let userMessage = message;\n\n      switch (statusCode) {\n      case 403:\n        userMessage = 'You do not have permission to access this resource (403 Forbidden)';\n        console.error('Permission denied for stream request:', message);\n        break;\n      case 404:\n        userMessage = 'The requested resource does not exist (404 Not Found)';\n        console.error('Resource not found for stream request:', message);\n        break;\n      case 429:\n        userMessage = 'Too many requests, please try again later (429 Too Many Requests)';\n        console.error('Rate limited for stream request:', message);\n        break;\n      case 500:\n      case 502:\n      case 503:\n      case 504:\n        userMessage = `Server error, please try again later (${statusCode})`;\n        console.error('Server error for stream request:', message);\n        break;\n      default:\n        console.error('Stream request failed with status code:', statusCode, message);\n      }\n\n      if (onError) {\n        onError(userMessage);\n      }\n      return;\n    }\n\n    // Handle network errors (like connection refused, timeout, etc.)\n    if (message.includes('NetworkError') ||\n        message.includes('Failed to fetch') ||\n        message.includes('Network request failed')) {\n      console.error('Network error for stream request:', message);\n      if (onError) {\n        onError('Network connection error, please check your internet connection');\n      }\n      return;\n    }\n\n    // Handle JSON parsing errors during stream processing\n    if (message.includes('Error parsing') || message.includes('SyntaxError')) {\n      console.error('JSON parsing error in stream:', message);\n      if (onError) {\n        onError('Error processing response data');\n      }\n      return;\n    }\n\n    // Handle other errors\n    console.error('Unhandled stream error:', message);\n    if (onError) {\n      onError(message);\n    } else {\n      console.error('No error handler provided for stream error:', message);\n    }\n  }\n};\n\nexport const insertText = async (text: string): Promise<DocActionResponse> => {\n  const response = await axiosInstance.post('/documents/text', { text })\n  return response.data\n}\n\nexport const insertTexts = async (texts: string[]): Promise<DocActionResponse> => {\n  const response = await axiosInstance.post('/documents/texts', { texts })\n  return response.data\n}\n\nexport const uploadDocument = async (\n  file: File,\n  onUploadProgress?: (percentCompleted: number) => void\n): Promise<DocActionResponse> => {\n  const formData = new FormData()\n  formData.append('file', file)\n\n  const response = await axiosInstance.post('/documents/upload', formData, {\n    headers: {\n      'Content-Type': 'multipart/form-data'\n    },\n    // prettier-ignore\n    onUploadProgress:\n      onUploadProgress !== undefined\n        ? (progressEvent) => {\n          const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total!)\n          onUploadProgress(percentCompleted)\n        }\n        : undefined\n  })\n  return response.data\n}\n\nexport const batchUploadDocuments = async (\n  files: File[],\n  onUploadProgress?: (fileName: string, percentCompleted: number) => void\n): Promise<DocActionResponse[]> => {\n  return await Promise.all(\n    files.map(async (file) => {\n      return await uploadDocument(file, (percentCompleted) => {\n        onUploadProgress?.(file.name, percentCompleted)\n      })\n    })\n  )\n}\n\nexport const clearDocuments = async (): Promise<DocActionResponse> => {\n  const response = await axiosInstance.delete('/documents')\n  return response.data\n}\n\nexport const clearCache = async (): Promise<{\n  status: 'success' | 'fail'\n  message: string\n}> => {\n  const response = await axiosInstance.post('/documents/clear_cache', {})\n  return response.data\n}\n\nexport const deleteDocuments = async (\n  docIds: string[],\n  deleteFile: boolean = false,\n  deleteLLMCache: boolean = false\n): Promise<DeleteDocResponse> => {\n  const response = await axiosInstance.delete('/documents/delete_document', {\n    data: { doc_ids: docIds, delete_file: deleteFile, delete_llm_cache: deleteLLMCache }\n  })\n  return response.data\n}\n\nexport const getAuthStatus = async (): Promise<AuthStatusResponse> => {\n  try {\n    // Add a timeout to the request to prevent hanging\n    const response = await axiosInstance.get('/auth-status', {\n      timeout: 5000, // 5 second timeout\n      headers: {\n        'Accept': 'application/json' // Explicitly request JSON\n      }\n    });\n\n    // Check if response is HTML (which indicates a redirect or wrong endpoint)\n    const contentType = response.headers['content-type'] || '';\n    if (contentType.includes('text/html')) {\n      console.warn('Received HTML response instead of JSON for auth-status endpoint');\n      return {\n        auth_configured: true,\n        auth_mode: 'enabled'\n      };\n    }\n\n    // Strict validation of the response data\n    if (response.data &&\n        typeof response.data === 'object' &&\n        'auth_configured' in response.data &&\n        typeof response.data.auth_configured === 'boolean') {\n\n      // For unconfigured auth, ensure we have an access token\n      if (!response.data.auth_configured) {\n        if (response.data.access_token && typeof response.data.access_token === 'string') {\n          return response.data;\n        } else {\n          console.warn('Auth not configured but no valid access token provided');\n        }\n      } else {\n        // For configured auth, just return the data\n        return response.data;\n      }\n    }\n\n    // If response data is invalid but we got a response, log it\n    console.warn('Received invalid auth status response:', response.data);\n\n    // Default to auth configured if response is invalid\n    return {\n      auth_configured: true,\n      auth_mode: 'enabled'\n    };\n  } catch (error) {\n    // If the request fails, assume authentication is configured\n    console.error('Failed to get auth status:', errorMessage(error));\n    return {\n      auth_configured: true,\n      auth_mode: 'enabled'\n    };\n  }\n}\n\nexport const getPipelineStatus = async (): Promise<PipelineStatusResponse> => {\n  const response = await axiosInstance.get('/documents/pipeline_status')\n  return response.data\n}\n\nexport const cancelPipeline = async (): Promise<{\n  status: 'cancellation_requested' | 'not_busy'\n  message: string\n}> => {\n  const response = await axiosInstance.post('/documents/cancel_pipeline')\n  return response.data\n}\n\nexport const loginToServer = async (username: string, password: string): Promise<LoginResponse> => {\n  const formData = new FormData();\n  formData.append('username', username);\n  formData.append('password', password);\n\n  const response = await axiosInstance.post('/login', formData, {\n    headers: {\n      'Content-Type': 'multipart/form-data'\n    }\n  });\n\n  return response.data;\n}\n\n/**\n * Updates an entity's properties in the knowledge graph\n * @param entityName The name of the entity to update\n * @param updatedData Dictionary containing updated attributes\n * @param allowRename Whether to allow renaming the entity (default: false)\n * @param allowMerge Whether to merge into an existing entity when renaming to a duplicate name\n * @returns Promise with the updated entity information\n */\nexport const updateEntity = async (\n  entityName: string,\n  updatedData: Record<string, any>,\n  allowRename: boolean = false,\n  allowMerge: boolean = false\n): Promise<EntityUpdateResponse> => {\n  const response = await axiosInstance.post('/graph/entity/edit', {\n    entity_name: entityName,\n    updated_data: updatedData,\n    allow_rename: allowRename,\n    allow_merge: allowMerge\n  })\n  return response.data\n}\n\n/**\n * Updates a relation's properties in the knowledge graph\n * @param sourceEntity The source entity name\n * @param targetEntity The target entity name\n * @param updatedData Dictionary containing updated attributes\n * @returns Promise with the updated relation information\n */\nexport const updateRelation = async (\n  sourceEntity: string,\n  targetEntity: string,\n  updatedData: Record<string, any>\n): Promise<DocActionResponse> => {\n  const response = await axiosInstance.post('/graph/relation/edit', {\n    source_id: sourceEntity,\n    target_id: targetEntity,\n    updated_data: updatedData\n  })\n  return response.data\n}\n\n/**\n * Checks if an entity name already exists in the knowledge graph\n * @param entityName The entity name to check\n * @returns Promise with boolean indicating if the entity exists\n */\nexport const checkEntityNameExists = async (entityName: string): Promise<boolean> => {\n  try {\n    const response = await axiosInstance.get(`/graph/entity/exists?name=${encodeURIComponent(entityName)}`)\n    return response.data.exists\n  } catch (error) {\n    console.error('Error checking entity name:', error)\n    return false\n  }\n}\n\n/**\n * Get the processing status of documents by tracking ID\n * @param trackId The tracking ID returned from upload, text, or texts endpoints\n * @returns Promise with the track status response containing documents and summary\n */\nexport const getTrackStatus = async (trackId: string): Promise<TrackStatusResponse> => {\n  const response = await axiosInstance.get(`/documents/track_status/${encodeURIComponent(trackId)}`)\n  return response.data\n}\n\n/**\n * Get documents with pagination support\n * @param request The pagination request parameters\n * @returns Promise with paginated documents response\n */\nexport const getDocumentsPaginated = async (request: DocumentsRequest): Promise<PaginatedDocsResponse> => {\n  const response = await axiosInstance.post('/documents/paginated', request)\n  return response.data\n}\n\n/**\n * Get counts of documents by status\n * @returns Promise with status counts response\n */\nexport const getDocumentStatusCounts = async (): Promise<StatusCountsResponse> => {\n  const response = await axiosInstance.get('/documents/status_counts')\n  return response.data\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/ApiKeyAlert.tsx",
    "content": "import { useState, useCallback, useEffect } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport {\n  AlertDialog,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogHeader,\n  AlertDialogTitle\n} from '@/components/ui/AlertDialog'\nimport Button from '@/components/ui/Button'\nimport Input from '@/components/ui/Input'\nimport { useSettingsStore } from '@/stores/settings'\nimport { useBackendState } from '@/stores/state'\nimport { InvalidApiKeyError, RequireApiKeError } from '@/api/lightrag'\n\ninterface ApiKeyAlertProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nconst ApiKeyAlert = ({ open: opened, onOpenChange: setOpened }: ApiKeyAlertProps) => {\n  const { t } = useTranslation()\n  const apiKey = useSettingsStore.use.apiKey()\n  const [tempApiKey, setTempApiKey] = useState<string>('')\n  const message = useBackendState.use.message()\n\n  useEffect(() => {\n    setTempApiKey(apiKey || '')\n  }, [apiKey, opened])\n\n  useEffect(() => {\n    if (message) {\n      if (message.includes(InvalidApiKeyError) || message.includes(RequireApiKeError)) {\n        setOpened(true)\n      }\n    }\n  }, [message, setOpened])\n\n  const setApiKey = useCallback(() => {\n    useSettingsStore.setState({ apiKey: tempApiKey || null })\n    setOpened(false)\n  }, [tempApiKey, setOpened])\n\n  const handleTempApiKeyChange = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      setTempApiKey(e.target.value)\n    },\n    [setTempApiKey]\n  )\n\n  return (\n    <AlertDialog open={opened} onOpenChange={setOpened}>\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle>{t('apiKeyAlert.title')}</AlertDialogTitle>\n          <AlertDialogDescription>\n            {t('apiKeyAlert.description')}\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <div className=\"flex flex-col gap-4\">\n          <form className=\"flex gap-2\" onSubmit={(e) => e.preventDefault()}>\n            <Input\n              type=\"password\"\n              value={tempApiKey}\n              onChange={handleTempApiKeyChange}\n              placeholder={t('apiKeyAlert.placeholder')}\n              className=\"max-h-full w-full min-w-0\"\n              autoComplete=\"off\"\n            />\n\n            <Button onClick={setApiKey} variant=\"outline\" size=\"sm\">\n              {t('apiKeyAlert.save')}\n            </Button>\n          </form>\n          {message && (\n            <div className=\"text-sm text-red-500\">\n              {message}\n            </div>\n          )}\n        </div>\n      </AlertDialogContent>\n    </AlertDialog>\n  )\n}\n\nexport default ApiKeyAlert\n"
  },
  {
    "path": "lightrag_webui/src/components/AppSettings.tsx",
    "content": "import { useState, useCallback } from 'react'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'\nimport Button from '@/components/ui/Button'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select'\nimport { useSettingsStore } from '@/stores/settings'\nimport { PaletteIcon } from 'lucide-react'\nimport { useTranslation } from 'react-i18next'\nimport { cn } from '@/lib/utils'\n\ninterface AppSettingsProps {\n  className?: string\n}\n\nexport default function AppSettings({ className }: AppSettingsProps) {\n  const [opened, setOpened] = useState<boolean>(false)\n  const { t } = useTranslation()\n\n  const language = useSettingsStore.use.language()\n  const setLanguage = useSettingsStore.use.setLanguage()\n\n  const theme = useSettingsStore.use.theme()\n  const setTheme = useSettingsStore.use.setTheme()\n\n  const handleLanguageChange = useCallback((value: string) => {\n    setLanguage(value as 'en' | 'zh' | 'fr' | 'ar' | 'zh_TW' | 'ru' | 'ja' | 'de' | 'uk' | 'ko' | 'vi')\n  }, [setLanguage])\n\n  const handleThemeChange = useCallback((value: string) => {\n    setTheme(value as 'light' | 'dark' | 'system')\n  }, [setTheme])\n\n  return (\n    <Popover open={opened} onOpenChange={setOpened}>\n      <PopoverTrigger asChild>\n        <Button variant=\"ghost\" size=\"icon\" className={cn('h-9 w-9', className)}>\n          <PaletteIcon className=\"h-5 w-5\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent side=\"bottom\" align=\"end\" className=\"w-56\">\n        <div className=\"flex flex-col gap-4\">\n          <div className=\"flex flex-col gap-2\">\n            <label className=\"text-sm font-medium\">{t('settings.language')}</label>\n            <Select value={language} onValueChange={handleLanguageChange}>\n              <SelectTrigger>\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"en\">English</SelectItem>\n                <SelectItem value=\"zh\">中文</SelectItem>\n                <SelectItem value=\"fr\">Français</SelectItem>\n                <SelectItem value=\"ar\">العربية</SelectItem>\n                <SelectItem value=\"zh_TW\">繁體中文</SelectItem>\n                <SelectItem value=\"ru\">Русский</SelectItem>\n                <SelectItem value=\"ja\">日本語</SelectItem>\n                <SelectItem value=\"de\">Deutsch</SelectItem>\n                <SelectItem value=\"uk\">Українська</SelectItem>\n                <SelectItem value=\"ko\">한국어</SelectItem>\n                <SelectItem value=\"vi\">Tiếng Việt</SelectItem>\n              </SelectContent>\n            </Select>\n          </div>\n\n          <div className=\"flex flex-col gap-2\">\n            <label className=\"text-sm font-medium\">{t('settings.theme')}</label>\n            <Select value={theme} onValueChange={handleThemeChange}>\n              <SelectTrigger>\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"light\">{t('settings.light')}</SelectItem>\n                <SelectItem value=\"dark\">{t('settings.dark')}</SelectItem>\n                <SelectItem value=\"system\">{t('settings.system')}</SelectItem>\n              </SelectContent>\n            </Select>\n          </div>\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/LanguageToggle.tsx",
    "content": "import Button from '@/components/ui/Button'\nimport { useCallback } from 'react'\nimport { controlButtonVariant } from '@/lib/constants'\nimport { useTranslation } from 'react-i18next'\nimport { useSettingsStore } from '@/stores/settings'\n\n/**\n * Component that toggles the language between English and Chinese.\n */\nexport default function LanguageToggle() {\n  const { i18n } = useTranslation()\n  const currentLanguage = i18n.language\n  const setLanguage = useSettingsStore.use.setLanguage()\n\n  const setEnglish = useCallback(() => {\n    i18n.changeLanguage('en')\n    setLanguage('en')\n  }, [i18n, setLanguage])\n\n  const setChinese = useCallback(() => {\n    i18n.changeLanguage('zh')\n    setLanguage('zh')\n  }, [i18n, setLanguage])\n\n  if (currentLanguage === 'zh') {\n    return (\n      <Button\n        onClick={setEnglish}\n        variant={controlButtonVariant}\n        tooltip=\"Switch to English\"\n        size=\"icon\"\n        side=\"bottom\"\n      >\n        中\n      </Button>\n    )\n  }\n  return (\n    <Button\n      onClick={setChinese}\n      variant={controlButtonVariant}\n      tooltip=\"切换到中文\"\n      size=\"icon\"\n      side=\"bottom\"\n    >\n      EN\n    </Button>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/Root.tsx",
    "content": "import { StrictMode } from 'react'\nimport App from '@/App'\nimport '@/i18n'\n\nexport const Root = () => (\n  <StrictMode>\n    <App />\n  </StrictMode>\n)\n"
  },
  {
    "path": "lightrag_webui/src/components/ThemeProvider.tsx",
    "content": "import { createContext, useEffect } from 'react'\nimport { Theme, useSettingsStore } from '@/stores/settings'\n\ntype ThemeProviderProps = {\n  children: React.ReactNode\n}\n\ntype ThemeProviderState = {\n  theme: Theme\n  setTheme: (theme: Theme) => void\n}\n\nconst initialState: ThemeProviderState = {\n  theme: 'system',\n  setTheme: () => null\n}\n\nconst ThemeProviderContext = createContext<ThemeProviderState>(initialState)\n\n/**\n * Component that provides the theme state and setter function to its children.\n */\nexport default function ThemeProvider({ children, ...props }: ThemeProviderProps) {\n  const theme = useSettingsStore.use.theme()\n  const setTheme = useSettingsStore.use.setTheme()\n\n  useEffect(() => {\n    const root = window.document.documentElement\n    root.classList.remove('light', 'dark')\n\n    if (theme === 'system') {\n      const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')\n      const handleChange = (e: MediaQueryListEvent) => {\n        root.classList.remove('light', 'dark')\n        root.classList.add(e.matches ? 'dark' : 'light')\n      }\n\n      root.classList.add(mediaQuery.matches ? 'dark' : 'light')\n      mediaQuery.addEventListener('change', handleChange)\n\n      return () => mediaQuery.removeEventListener('change', handleChange)\n    } else {\n      root.classList.add(theme)\n    }\n  }, [theme])\n\n  const value = {\n    theme,\n    setTheme\n  }\n\n  return (\n    <ThemeProviderContext.Provider {...props} value={value}>\n      {children}\n    </ThemeProviderContext.Provider>\n  )\n}\n\nexport { ThemeProviderContext }\n"
  },
  {
    "path": "lightrag_webui/src/components/ThemeToggle.tsx",
    "content": "import Button from '@/components/ui/Button'\nimport useTheme from '@/hooks/useTheme'\nimport { MoonIcon, SunIcon } from 'lucide-react'\nimport { useCallback } from 'react'\nimport { controlButtonVariant } from '@/lib/constants'\nimport { useTranslation } from 'react-i18next'\n\n/**\n * Component that toggles the theme between light and dark.\n */\nexport default function ThemeToggle() {\n  const { theme, setTheme } = useTheme()\n  const setLight = useCallback(() => setTheme('light'), [setTheme])\n  const setDark = useCallback(() => setTheme('dark'), [setTheme])\n  const { t } = useTranslation()\n\n  if (theme === 'dark') {\n    return (\n      <Button\n        onClick={setLight}\n        variant={controlButtonVariant}\n        tooltip={t('header.themeToggle.switchToLight')}\n        size=\"icon\"\n        side=\"bottom\"\n      >\n        <MoonIcon />\n      </Button>\n    )\n  }\n  return (\n    <Button\n      onClick={setDark}\n      variant={controlButtonVariant}\n      tooltip={t('header.themeToggle.switchToDark')}\n      size=\"icon\"\n      side=\"bottom\"\n    >\n      <SunIcon />\n    </Button>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/documents/ClearDocumentsDialog.tsx",
    "content": "import { useState, useCallback, useEffect, useRef } from 'react'\nimport Button from '@/components/ui/Button'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  DialogFooter\n} from '@/components/ui/Dialog'\nimport Input from '@/components/ui/Input'\nimport Checkbox from '@/components/ui/Checkbox'\nimport { toast } from 'sonner'\nimport { errorMessage } from '@/lib/utils'\nimport { clearDocuments, clearCache } from '@/api/lightrag'\n\nimport { EraserIcon, AlertTriangleIcon, Loader2Icon } from 'lucide-react'\nimport { useTranslation } from 'react-i18next'\n\n// Simple Label component\nconst Label = ({\n  htmlFor,\n  className,\n  children,\n  ...props\n}: React.LabelHTMLAttributes<HTMLLabelElement>) => (\n  <label\n    htmlFor={htmlFor}\n    className={className}\n    {...props}\n  >\n    {children}\n  </label>\n)\n\ninterface ClearDocumentsDialogProps {\n  onDocumentsCleared?: () => Promise<void>\n}\n\nexport default function ClearDocumentsDialog({ onDocumentsCleared }: ClearDocumentsDialogProps) {\n  const { t } = useTranslation()\n  const [open, setOpen] = useState(false)\n  const [confirmText, setConfirmText] = useState('')\n  const [clearCacheOption, setClearCacheOption] = useState(false)\n  const [isClearing, setIsClearing] = useState(false)\n  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const isConfirmEnabled = confirmText.toLowerCase() === 'yes'\n\n  // Timeout constant (30 seconds)\n  const CLEAR_TIMEOUT = 30000\n\n  // Reset state when dialog closes\n  useEffect(() => {\n    if (!open) {\n      setConfirmText('')\n      setClearCacheOption(false)\n      setIsClearing(false)\n\n      // Clear timeout timer\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current)\n        timeoutRef.current = null\n      }\n    }\n  }, [open])\n\n  // Cleanup when component unmounts\n  useEffect(() => {\n    return () => {\n      // Clear timeout timer when component unmounts\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current)\n      }\n    }\n  }, [])\n\n  const handleClear = useCallback(async () => {\n    if (!isConfirmEnabled || isClearing) return\n\n    setIsClearing(true)\n\n    // Set timeout protection\n    timeoutRef.current = setTimeout(() => {\n      if (isClearing) {\n        toast.error(t('documentPanel.clearDocuments.timeout'))\n        setIsClearing(false)\n        setConfirmText('') // Reset confirmation text after timeout\n      }\n    }, CLEAR_TIMEOUT)\n\n    try {\n      const result = await clearDocuments()\n\n      if (result.status !== 'success') {\n        toast.error(t('documentPanel.clearDocuments.failed', { message: result.message }))\n        setConfirmText('')\n        return\n      }\n\n      toast.success(t('documentPanel.clearDocuments.success'))\n\n      if (clearCacheOption) {\n        try {\n          await clearCache()\n          toast.success(t('documentPanel.clearDocuments.cacheCleared'))\n        } catch (cacheErr) {\n          toast.error(t('documentPanel.clearDocuments.cacheClearFailed', { error: errorMessage(cacheErr) }))\n        }\n      }\n\n      // Refresh document list if provided\n      if (onDocumentsCleared) {\n        onDocumentsCleared().catch(console.error)\n      }\n\n      // Close dialog after all operations succeed\n      setOpen(false)\n    } catch (err) {\n      toast.error(t('documentPanel.clearDocuments.error', { error: errorMessage(err) }))\n      setConfirmText('')\n    } finally {\n      // Clear timeout timer\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current)\n        timeoutRef.current = null\n      }\n      setIsClearing(false)\n    }\n  }, [isConfirmEnabled, isClearing, clearCacheOption, setOpen, t, onDocumentsCleared, CLEAR_TIMEOUT])\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>\n        <Button variant=\"outline\" side=\"bottom\" tooltip={t('documentPanel.clearDocuments.tooltip')} size=\"sm\">\n          <EraserIcon/> {t('documentPanel.clearDocuments.button')}\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-xl\" onCloseAutoFocus={(e) => e.preventDefault()}>\n        <DialogHeader>\n          <DialogTitle className=\"flex items-center gap-2 text-red-500 dark:text-red-400 font-bold\">\n            <AlertTriangleIcon className=\"h-5 w-5\" />\n            {t('documentPanel.clearDocuments.title')}\n          </DialogTitle>\n          <DialogDescription className=\"pt-2\">\n            {t('documentPanel.clearDocuments.description')}\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"text-red-500 dark:text-red-400 font-semibold mb-4\">\n          {t('documentPanel.clearDocuments.warning')}\n        </div>\n        <div className=\"mb-4\">\n          {t('documentPanel.clearDocuments.confirm')}\n        </div>\n\n        <div className=\"space-y-4\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"confirm-text\" className=\"text-sm font-medium\">\n              {t('documentPanel.clearDocuments.confirmPrompt')}\n            </Label>\n            <Input\n              id=\"confirm-text\"\n              value={confirmText}\n              onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmText(e.target.value)}\n              placeholder={t('documentPanel.clearDocuments.confirmPlaceholder')}\n              className=\"w-full\"\n              disabled={isClearing}\n            />\n          </div>\n\n          <div className=\"flex items-center space-x-2\">\n            <Checkbox\n              id=\"clear-cache\"\n              checked={clearCacheOption}\n              onCheckedChange={(checked: boolean | 'indeterminate') => setClearCacheOption(checked === true)}\n              disabled={isClearing}\n            />\n            <Label htmlFor=\"clear-cache\" className=\"text-sm font-medium cursor-pointer\">\n              {t('documentPanel.clearDocuments.clearCache')}\n            </Label>\n          </div>\n        </div>\n\n        <DialogFooter>\n          <Button\n            variant=\"outline\"\n            onClick={() => setOpen(false)}\n            disabled={isClearing}\n          >\n            {t('common.cancel')}\n          </Button>\n          <Button\n            variant=\"destructive\"\n            onClick={handleClear}\n            disabled={!isConfirmEnabled || isClearing}\n          >\n            {isClearing ? (\n              <>\n                <Loader2Icon className=\"mr-2 h-4 w-4 animate-spin\" />\n                {t('documentPanel.clearDocuments.clearing')}\n              </>\n            ) : (\n              t('documentPanel.clearDocuments.confirmButton')\n            )}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/documents/DeleteDocumentsDialog.tsx",
    "content": "import { useState, useCallback, useEffect } from 'react'\nimport Button from '@/components/ui/Button'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n  DialogFooter\n} from '@/components/ui/Dialog'\nimport Input from '@/components/ui/Input'\nimport { toast } from 'sonner'\nimport { errorMessage } from '@/lib/utils'\nimport { deleteDocuments } from '@/api/lightrag'\n\nimport { TrashIcon, AlertTriangleIcon } from 'lucide-react'\nimport { useTranslation } from 'react-i18next'\n\n// Simple Label component\nconst Label = ({\n  htmlFor,\n  className,\n  children,\n  ...props\n}: React.LabelHTMLAttributes<HTMLLabelElement>) => (\n  <label\n    htmlFor={htmlFor}\n    className={className}\n    {...props}\n  >\n    {children}\n  </label>\n)\n\ninterface DeleteDocumentsDialogProps {\n  selectedDocIds: string[]\n  onDocumentsDeleted?: () => Promise<void>\n}\n\nexport default function DeleteDocumentsDialog({ selectedDocIds, onDocumentsDeleted }: DeleteDocumentsDialogProps) {\n  const { t } = useTranslation()\n  const [open, setOpen] = useState(false)\n  const [confirmText, setConfirmText] = useState('')\n  const [deleteFile, setDeleteFile] = useState(false)\n  const [isDeleting, setIsDeleting] = useState(false)\n  const [deleteLLMCache, setDeleteLLMCache] = useState(false)\n  const isConfirmEnabled = confirmText.toLowerCase() === 'yes' && !isDeleting\n\n  // Reset state when dialog closes\n  useEffect(() => {\n    if (!open) {\n      setConfirmText('')\n      setDeleteFile(false)\n      setDeleteLLMCache(false)\n      setIsDeleting(false)\n    }\n  }, [open])\n\n  const handleDelete = useCallback(async () => {\n    if (!isConfirmEnabled || selectedDocIds.length === 0) return\n\n    setIsDeleting(true)\n    try {\n      const result = await deleteDocuments(selectedDocIds, deleteFile, deleteLLMCache)\n\n      if (result.status === 'deletion_started') {\n        toast.success(t('documentPanel.deleteDocuments.success', { count: selectedDocIds.length }))\n      } else if (result.status === 'busy') {\n        toast.error(t('documentPanel.deleteDocuments.busy'))\n        setConfirmText('')\n        setIsDeleting(false)\n        return\n      } else if (result.status === 'not_allowed') {\n        toast.error(t('documentPanel.deleteDocuments.notAllowed'))\n        setConfirmText('')\n        setIsDeleting(false)\n        return\n      } else {\n        toast.error(t('documentPanel.deleteDocuments.failed', { message: result.message }))\n        setConfirmText('')\n        setIsDeleting(false)\n        return\n      }\n\n      // Refresh document list if provided\n      if (onDocumentsDeleted) {\n        onDocumentsDeleted().catch(console.error)\n      }\n\n      // Close dialog after successful operation\n      setOpen(false)\n    } catch (err) {\n      toast.error(t('documentPanel.deleteDocuments.error', { error: errorMessage(err) }))\n      setConfirmText('')\n    } finally {\n      setIsDeleting(false)\n    }\n  }, [isConfirmEnabled, selectedDocIds, deleteFile, deleteLLMCache, setOpen, t, onDocumentsDeleted])\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>\n        <Button\n          variant=\"destructive\"\n          side=\"bottom\"\n          tooltip={t('documentPanel.deleteDocuments.tooltip', { count: selectedDocIds.length })}\n          size=\"sm\"\n        >\n          <TrashIcon/> {t('documentPanel.deleteDocuments.button')}\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-xl\" onCloseAutoFocus={(e) => e.preventDefault()}>\n        <DialogHeader>\n          <DialogTitle className=\"flex items-center gap-2 text-red-500 dark:text-red-400 font-bold\">\n            <AlertTriangleIcon className=\"h-5 w-5\" />\n            {t('documentPanel.deleteDocuments.title')}\n          </DialogTitle>\n          <DialogDescription className=\"pt-2\">\n            {t('documentPanel.deleteDocuments.description', { count: selectedDocIds.length })}\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"text-red-500 dark:text-red-400 font-semibold mb-4\">\n          {t('documentPanel.deleteDocuments.warning')}\n        </div>\n\n        <div className=\"mb-4\">\n          {t('documentPanel.deleteDocuments.confirm', { count: selectedDocIds.length })}\n        </div>\n\n        <div className=\"space-y-4\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"confirm-text\" className=\"text-sm font-medium\">\n              {t('documentPanel.deleteDocuments.confirmPrompt')}\n            </Label>\n            <Input\n              id=\"confirm-text\"\n              value={confirmText}\n              onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmText(e.target.value)}\n              placeholder={t('documentPanel.deleteDocuments.confirmPlaceholder')}\n              className=\"w-full\"\n              disabled={isDeleting}\n            />\n          </div>\n\n          <div className=\"flex items-center space-x-2\">\n            <input\n              type=\"checkbox\"\n              id=\"delete-file\"\n              checked={deleteFile}\n              onChange={(e) => setDeleteFile(e.target.checked)}\n              disabled={isDeleting}\n              className=\"h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded\"\n            />\n            <Label htmlFor=\"delete-file\" className=\"text-sm font-medium cursor-pointer\">\n              {t('documentPanel.deleteDocuments.deleteFileOption')}\n            </Label>\n          </div>\n\n          <div className=\"flex items-center space-x-2\">\n            <input\n              type=\"checkbox\"\n              id=\"delete-llm-cache\"\n              checked={deleteLLMCache}\n              onChange={(e) => setDeleteLLMCache(e.target.checked)}\n              disabled={isDeleting}\n              className=\"h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded\"\n            />\n            <Label htmlFor=\"delete-llm-cache\" className=\"text-sm font-medium cursor-pointer\">\n              {t('documentPanel.deleteDocuments.deleteLLMCacheOption')}\n            </Label>\n          </div>\n        </div>\n\n        <DialogFooter>\n          <Button variant=\"outline\" onClick={() => setOpen(false)} disabled={isDeleting}>\n            {t('common.cancel')}\n          </Button>\n          <Button\n            variant=\"destructive\"\n            onClick={handleDelete}\n            disabled={!isConfirmEnabled}\n          >\n            {isDeleting ? t('documentPanel.deleteDocuments.deleting') : t('documentPanel.deleteDocuments.confirmButton')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/documents/PipelineStatusDialog.tsx",
    "content": "import { useState, useEffect, useRef } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { toast } from 'sonner'\nimport { AlignLeft, AlignCenter, AlignRight } from 'lucide-react'\n\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogDescription\n} from '@/components/ui/Dialog'\nimport Button from '@/components/ui/Button'\nimport { getPipelineStatus, cancelPipeline, PipelineStatusResponse } from '@/api/lightrag'\nimport { errorMessage } from '@/lib/utils'\nimport { cn } from '@/lib/utils'\n\ntype DialogPosition = 'left' | 'center' | 'right'\n\ninterface PipelineStatusDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n}\n\nexport default function PipelineStatusDialog({\n  open,\n  onOpenChange\n}: PipelineStatusDialogProps) {\n  const { t } = useTranslation()\n  const [status, setStatus] = useState<PipelineStatusResponse | null>(null)\n  const [position, setPosition] = useState<DialogPosition>('center')\n  const [isUserScrolled, setIsUserScrolled] = useState(false)\n  const [showCancelConfirm, setShowCancelConfirm] = useState(false)\n  const historyRef = useRef<HTMLDivElement>(null)\n\n  // Reset position when dialog opens\n  useEffect(() => {\n    if (open) {\n      setPosition('center')\n      setIsUserScrolled(false)\n    } else {\n      // Reset confirmation dialog state when main dialog closes\n      setShowCancelConfirm(false)\n    }\n  }, [open])\n\n  // Handle scroll position\n  useEffect(() => {\n    const container = historyRef.current\n    if (!container || isUserScrolled) return\n\n    container.scrollTop = container.scrollHeight\n  }, [status?.history_messages, isUserScrolled])\n\n  const handleScroll = () => {\n    const container = historyRef.current\n    if (!container) return\n\n    const isAtBottom = Math.abs(\n      (container.scrollHeight - container.scrollTop) - container.clientHeight\n    ) < 1\n\n    if (isAtBottom) {\n      setIsUserScrolled(false)\n    } else {\n      setIsUserScrolled(true)\n    }\n  }\n\n  // Refresh status every 2 seconds\n  useEffect(() => {\n    if (!open) return\n\n    const fetchStatus = async () => {\n      try {\n        const data = await getPipelineStatus()\n        setStatus(data)\n      } catch (err) {\n        toast.error(t('documentPanel.pipelineStatus.errors.fetchFailed', { error: errorMessage(err) }))\n      }\n    }\n\n    fetchStatus()\n    const interval = setInterval(fetchStatus, 2000)\n    return () => clearInterval(interval)\n  }, [open, t])\n\n  // Handle cancel pipeline confirmation\n  const handleConfirmCancel = async () => {\n    setShowCancelConfirm(false)\n    try {\n      const result = await cancelPipeline()\n      if (result.status === 'cancellation_requested') {\n        toast.success(t('documentPanel.pipelineStatus.cancelSuccess'))\n      } else if (result.status === 'not_busy') {\n        toast.info(t('documentPanel.pipelineStatus.cancelNotBusy'))\n      }\n    } catch (err) {\n      toast.error(t('documentPanel.pipelineStatus.cancelFailed', { error: errorMessage(err) }))\n    }\n  }\n\n  // Determine if cancel button should be enabled\n  const canCancel = status?.busy === true && !status?.cancellation_requested\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent\n        className={cn(\n          'sm:max-w-[800px] transition-all duration-200 fixed',\n          position === 'left' && '!left-[25%] !translate-x-[-50%] !mx-4',\n          position === 'center' && '!left-1/2 !-translate-x-1/2',\n          position === 'right' && '!left-[75%] !translate-x-[-50%] !mx-4'\n        )}\n      >\n        <DialogDescription className=\"sr-only\">\n          {status?.job_name\n            ? `${t('documentPanel.pipelineStatus.jobName')}: ${status.job_name}, ${t('documentPanel.pipelineStatus.progress')}: ${status.cur_batch}/${status.batchs}`\n            : t('documentPanel.pipelineStatus.noActiveJob')\n          }\n        </DialogDescription>\n        <DialogHeader className=\"flex flex-row items-center\">\n          <DialogTitle className=\"flex-1\">\n            {t('documentPanel.pipelineStatus.title')}\n          </DialogTitle>\n\n          {/* Position control buttons */}\n          <div className=\"flex items-center gap-2 mr-8\">\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className={cn(\n                'h-6 w-6',\n                position === 'left' && 'bg-zinc-200 text-zinc-800 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600'\n              )}\n              onClick={() => setPosition('left')}\n            >\n              <AlignLeft className=\"h-4 w-4\" />\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className={cn(\n                'h-6 w-6',\n                position === 'center' && 'bg-zinc-200 text-zinc-800 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600'\n              )}\n              onClick={() => setPosition('center')}\n            >\n              <AlignCenter className=\"h-4 w-4\" />\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className={cn(\n                'h-6 w-6',\n                position === 'right' && 'bg-zinc-200 text-zinc-800 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600'\n              )}\n              onClick={() => setPosition('right')}\n            >\n              <AlignRight className=\"h-4 w-4\" />\n            </Button>\n          </div>\n        </DialogHeader>\n\n        {/* Status Content */}\n        <div className=\"space-y-4 pt-4\">\n          {/* Pipeline Status - with cancel button */}\n          <div className=\"flex flex-wrap items-center justify-between gap-4\">\n            {/* Left side: Status indicators */}\n            <div className=\"flex items-center gap-4\">\n              <div className=\"flex items-center gap-2\">\n                <div className=\"text-sm font-medium\">{t('documentPanel.pipelineStatus.busy')}:</div>\n                <div className={`h-2 w-2 rounded-full ${status?.busy ? 'bg-green-500' : 'bg-gray-300'}`} />\n              </div>\n              <div className=\"flex items-center gap-2\">\n                <div className=\"text-sm font-medium\">{t('documentPanel.pipelineStatus.requestPending')}:</div>\n                <div className={`h-2 w-2 rounded-full ${status?.request_pending ? 'bg-green-500' : 'bg-gray-300'}`} />\n              </div>\n              {/* Only show cancellation status when it's requested */}\n              {status?.cancellation_requested && (\n                <div className=\"flex items-center gap-2\">\n                  <div className=\"text-sm font-medium\">{t('documentPanel.pipelineStatus.cancellationRequested')}:</div>\n                  <div className=\"h-2 w-2 rounded-full bg-red-500\" />\n                </div>\n              )}\n            </div>\n\n            {/* Right side: Cancel button - only show when pipeline is busy */}\n            {status?.busy && (\n              <Button\n                variant=\"destructive\"\n                size=\"sm\"\n                disabled={!canCancel}\n                onClick={() => setShowCancelConfirm(true)}\n                title={\n                  status?.cancellation_requested\n                    ? t('documentPanel.pipelineStatus.cancelInProgress')\n                    : t('documentPanel.pipelineStatus.cancelTooltip')\n                }\n              >\n                {t('documentPanel.pipelineStatus.cancelButton')}\n              </Button>\n            )}\n          </div>\n\n          {/* Job Information */}\n          <div className=\"rounded-md border p-3 space-y-2\">\n            <div>{t('documentPanel.pipelineStatus.jobName')}: {status?.job_name || '-'}</div>\n            <div className=\"flex justify-between\">\n              <span>{t('documentPanel.pipelineStatus.startTime')}: {status?.job_start\n                ? new Date(status.job_start).toLocaleString(undefined, {\n                  year: 'numeric',\n                  month: 'numeric',\n                  day: 'numeric',\n                  hour: 'numeric',\n                  minute: 'numeric',\n                  second: 'numeric'\n                })\n                : '-'}</span>\n              <span>{t('documentPanel.pipelineStatus.progress')}: {status ? `${status.cur_batch}/${status.batchs} ${t('documentPanel.pipelineStatus.unit')}` : '-'}</span>\n            </div>\n          </div>\n\n          {/* History Messages */}\n          <div className=\"space-y-2\">\n            <div className=\"text-sm font-medium\">{t('documentPanel.pipelineStatus.pipelineMessages')}:</div>\n            <div\n              ref={historyRef}\n              onScroll={handleScroll}\n              className=\"font-mono text-xs rounded-md bg-zinc-800 text-zinc-100 p-3 overflow-y-auto overflow-x-hidden min-h-[7.5em] max-h-[40vh]\"\n            >\n              {status?.history_messages?.length ? (\n                status.history_messages.map((msg, idx) => (\n                  <div key={idx} className=\"whitespace-pre-wrap break-all\">{msg}</div>\n                ))\n              ) : '-'}\n            </div>\n          </div>\n        </div>\n      </DialogContent>\n\n      {/* Cancel Confirmation Dialog */}\n      <Dialog open={showCancelConfirm} onOpenChange={setShowCancelConfirm}>\n        <DialogContent className=\"sm:max-w-[425px]\">\n          <DialogHeader>\n            <DialogTitle>{t('documentPanel.pipelineStatus.cancelConfirmTitle')}</DialogTitle>\n            <DialogDescription>\n              {t('documentPanel.pipelineStatus.cancelConfirmDescription')}\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"flex justify-end gap-3 mt-4\">\n            <Button\n              variant=\"outline\"\n              onClick={() => setShowCancelConfirm(false)}\n            >\n              {t('common.cancel')}\n            </Button>\n            <Button\n              variant=\"destructive\"\n              onClick={handleConfirmCancel}\n            >\n              {t('documentPanel.pipelineStatus.cancelConfirmButton')}\n            </Button>\n          </div>\n        </DialogContent>\n      </Dialog>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/documents/UploadDocumentsDialog.tsx",
    "content": "import { useState, useCallback } from 'react'\nimport { FileRejection } from 'react-dropzone'\nimport Button from '@/components/ui/Button'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger\n} from '@/components/ui/Dialog'\nimport FileUploader from '@/components/ui/FileUploader'\nimport { toast } from 'sonner'\nimport { errorMessage } from '@/lib/utils'\nimport { uploadDocument } from '@/api/lightrag'\n\nimport { UploadIcon } from 'lucide-react'\nimport { useTranslation } from 'react-i18next'\n\ninterface UploadDocumentsDialogProps {\n  onDocumentsUploaded?: () => Promise<void>\n}\n\nexport default function UploadDocumentsDialog({ onDocumentsUploaded }: UploadDocumentsDialogProps) {\n  const { t } = useTranslation()\n  const [open, setOpen] = useState(false)\n  const [isUploading, setIsUploading] = useState(false)\n  const [progresses, setProgresses] = useState<Record<string, number>>({})\n  const [fileErrors, setFileErrors] = useState<Record<string, string>>({})\n\n  const handleRejectedFiles = useCallback(\n    (rejectedFiles: FileRejection[]) => {\n      // Process rejected files and add them to fileErrors\n      rejectedFiles.forEach(({ file, errors }) => {\n        // Get the first error message\n        let errorMsg = errors[0]?.message || t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name })\n\n        // Simplify error message for unsupported file types\n        if (errorMsg.includes('file-invalid-type')) {\n          errorMsg = t('documentPanel.uploadDocuments.fileUploader.unsupportedType')\n        }\n\n        // Set progress to 100% to display error message\n        setProgresses((pre) => ({\n          ...pre,\n          [file.name]: 100\n        }))\n\n        // Add error message to fileErrors\n        setFileErrors(prev => ({\n          ...prev,\n          [file.name]: errorMsg\n        }))\n      })\n    },\n    [setProgresses, setFileErrors, t]\n  )\n\n  const handleDocumentsUpload = useCallback(\n    async (filesToUpload: File[]) => {\n      setIsUploading(true)\n      let hasSuccessfulUpload = false\n\n      // Only clear errors for files that are being uploaded, keep errors for rejected files\n      setFileErrors(prev => {\n        const newErrors = { ...prev };\n        filesToUpload.forEach(file => {\n          delete newErrors[file.name];\n        });\n        return newErrors;\n      });\n\n      // Show uploading toast\n      const toastId = toast.loading(t('documentPanel.uploadDocuments.batch.uploading'))\n\n      try {\n        // Track errors locally to ensure we have the final state\n        const uploadErrors: Record<string, string> = {}\n\n        // Create a collator that supports Chinese sorting\n        const collator = new Intl.Collator(['zh-CN', 'en'], {\n          sensitivity: 'accent',  // consider basic characters, accents, and case\n          numeric: true           // enable numeric sorting, e.g., \"File 10\" will be after \"File 2\"\n        });\n        const sortedFiles = [...filesToUpload].sort((a, b) =>\n          collator.compare(a.name, b.name)\n        );\n\n        // Upload files in sequence, not parallel\n        for (const file of sortedFiles) {\n          try {\n            // Initialize upload progress\n            setProgresses((pre) => ({\n              ...pre,\n              [file.name]: 0\n            }))\n\n            const result = await uploadDocument(file, (percentCompleted: number) => {\n              console.debug(t('documentPanel.uploadDocuments.single.uploading', { name: file.name, percent: percentCompleted }))\n              setProgresses((pre) => ({\n                ...pre,\n                [file.name]: percentCompleted\n              }))\n            })\n\n            if (result.status === 'duplicated') {\n              uploadErrors[file.name] = t('documentPanel.uploadDocuments.fileUploader.duplicateFile')\n              setFileErrors(prev => ({\n                ...prev,\n                [file.name]: t('documentPanel.uploadDocuments.fileUploader.duplicateFile')\n              }))\n            } else if (result.status !== 'success') {\n              uploadErrors[file.name] = result.message\n              setFileErrors(prev => ({\n                ...prev,\n                [file.name]: result.message\n              }))\n            } else {\n              // Mark that we had at least one successful upload\n              hasSuccessfulUpload = true\n            }\n          } catch (err) {\n            console.error(`Upload failed for ${file.name}:`, err)\n\n            // Handle HTTP errors, including 400 errors\n            let errorMsg = errorMessage(err)\n\n            // If it's an axios error with response data, try to extract more detailed error info\n            if (err && typeof err === 'object' && 'response' in err) {\n              const axiosError = err as { response?: { status: number, data?: { detail?: string } } }\n              if (axiosError.response?.status === 400) {\n                // Extract specific error message from backend response\n                errorMsg = axiosError.response.data?.detail || errorMsg\n              }\n\n              // Set progress to 100% to display error message\n              setProgresses((pre) => ({\n                ...pre,\n                [file.name]: 100\n              }))\n            }\n\n            // Record error message in both local tracking and state\n            uploadErrors[file.name] = errorMsg\n            setFileErrors(prev => ({\n              ...prev,\n              [file.name]: errorMsg\n            }))\n          }\n        }\n\n        // Check if any files failed to upload using our local tracking\n        const hasErrors = Object.keys(uploadErrors).length > 0\n\n        // Update toast status\n        if (hasErrors) {\n          toast.error(t('documentPanel.uploadDocuments.batch.error'), { id: toastId })\n        } else {\n          toast.success(t('documentPanel.uploadDocuments.batch.success'), { id: toastId })\n        }\n\n        // Only update if at least one file was uploaded successfully\n        if (hasSuccessfulUpload) {\n          // Refresh document list\n          if (onDocumentsUploaded) {\n            onDocumentsUploaded().catch(err => {\n              console.error('Error refreshing documents:', err)\n            })\n          }\n        }\n      } catch (err) {\n        console.error('Unexpected error during upload:', err)\n        toast.error(t('documentPanel.uploadDocuments.generalError', { error: errorMessage(err) }), { id: toastId })\n      } finally {\n        setIsUploading(false)\n      }\n    },\n    [setIsUploading, setProgresses, setFileErrors, t, onDocumentsUploaded]\n  )\n\n  return (\n    <Dialog\n      open={open}\n      onOpenChange={(open) => {\n        if (isUploading) {\n          return\n        }\n        if (!open) {\n          setProgresses({})\n          setFileErrors({})\n        }\n        setOpen(open)\n      }}\n    >\n      <DialogTrigger asChild>\n        <Button variant=\"default\" side=\"bottom\" tooltip={t('documentPanel.uploadDocuments.tooltip')} size=\"sm\">\n          <UploadIcon /> {t('documentPanel.uploadDocuments.button')}\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-xl\" onCloseAutoFocus={(e) => e.preventDefault()}>\n        <DialogHeader>\n          <DialogTitle>{t('documentPanel.uploadDocuments.title')}</DialogTitle>\n          <DialogDescription>\n            {t('documentPanel.uploadDocuments.description')}\n          </DialogDescription>\n        </DialogHeader>\n        <FileUploader\n          maxFileCount={Infinity}\n          maxSize={200 * 1024 * 1024}\n          description={t('documentPanel.uploadDocuments.fileTypes')}\n          onUpload={handleDocumentsUpload}\n          onReject={handleRejectedFiles}\n          progresses={progresses}\n          fileErrors={fileErrors}\n          disabled={isUploading}\n        />\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/EditablePropertyRow.tsx",
    "content": "import { useState, useEffect } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { toast } from 'sonner'\nimport { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag'\nimport { useGraphStore } from '@/stores/graph'\nimport { useSettingsStore } from '@/stores/settings'\nimport { SearchHistoryManager } from '@/utils/SearchHistoryManager'\nimport { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents'\nimport PropertyEditDialog from './PropertyEditDialog'\nimport MergeDialog from './MergeDialog'\n\n/**\n * Interface for the EditablePropertyRow component props\n */\ninterface EditablePropertyRowProps {\n  name: string                  // Property name to display and edit\n  value: any                    // Initial value of the property\n  onClick?: () => void          // Optional click handler for the property value\n  nodeId?: string               // ID of the node (for node type)\n  entityId?: string             // ID of the entity (for node type)\n  edgeId?: string               // ID of the edge (for edge type)\n  dynamicId?: string\n  entityType?: 'node' | 'edge'  // Type of graph entity\n  sourceId?: string            // Source node ID (for edge type)\n  targetId?: string            // Target node ID (for edge type)\n  onValueChange?: (newValue: any) => void  // Optional callback when value changes\n  isEditable?: boolean         // Whether this property can be edited\n  tooltip?: string             // Optional tooltip to display on hover\n}\n\n/**\n * EditablePropertyRow component that supports editing property values\n * This component is used in the graph properties panel to display and edit entity properties\n */\nconst EditablePropertyRow = ({\n  name,\n  value: initialValue,\n  onClick,\n  nodeId,\n  edgeId,\n  entityId,\n  dynamicId,\n  entityType,\n  sourceId,\n  targetId,\n  onValueChange,\n  isEditable = false,\n  tooltip\n}: EditablePropertyRowProps) => {\n  const { t } = useTranslation()\n  const [isEditing, setIsEditing] = useState(false)\n  const [isSubmitting, setIsSubmitting] = useState(false)\n  const [currentValue, setCurrentValue] = useState(initialValue)\n  const [errorMessage, setErrorMessage] = useState<string | null>(null)\n  const [mergeDialogOpen, setMergeDialogOpen] = useState(false)\n  const [mergeDialogInfo, setMergeDialogInfo] = useState<{\n    targetEntity: string\n    sourceEntity: string\n  } | null>(null)\n\n  useEffect(() => {\n    setCurrentValue(initialValue)\n  }, [initialValue])\n\n  const handleEditClick = () => {\n    if (isEditable && !isEditing) {\n      setIsEditing(true)\n      setErrorMessage(null)\n    }\n  }\n\n  const handleCancel = () => {\n    setIsEditing(false)\n    setErrorMessage(null)\n  }\n\n  const handleSave = async (value: string, options?: { allowMerge?: boolean }) => {\n    if (isSubmitting || value === String(currentValue)) {\n      setIsEditing(false)\n      setErrorMessage(null)\n      return\n    }\n\n    setIsSubmitting(true)\n    setErrorMessage(null)\n\n    try {\n      if (entityType === 'node' && entityId && nodeId) {\n        let updatedData = { [name]: value }\n        const allowMerge = options?.allowMerge ?? false\n\n        if (name === 'entity_id') {\n          if (!allowMerge) {\n            const exists = await checkEntityNameExists(value)\n            if (exists) {\n              const errorMsg = t('graphPanel.propertiesView.errors.duplicateName')\n              setErrorMessage(errorMsg)\n              toast.error(errorMsg)\n              return\n            }\n          }\n          updatedData = { 'entity_name': value }\n        }\n\n        const response = await updateEntity(entityId, updatedData, true, allowMerge)\n        const operationSummary = response.operation_summary\n        const operationStatus = operationSummary?.operation_status || 'complete_success'\n        const finalValue = operationSummary?.final_entity ?? value\n\n        // Handle different operation statuses\n        if (operationStatus === 'success') {\n          if (operationSummary?.merged) {\n            // Node was successfully merged into an existing entity\n            setMergeDialogInfo({\n              targetEntity: finalValue,\n              sourceEntity: entityId,\n            })\n            setMergeDialogOpen(true)\n\n            // Remove old entity name from search history\n            SearchHistoryManager.removeLabel(entityId)\n\n            // Note: Search Label update is deferred until user clicks refresh button in merge dialog\n\n            toast.success(t('graphPanel.propertiesView.success.entityMerged'))\n          } else {\n            // Node was updated/renamed normally\n            try {\n              const graphValue = name === 'entity_id' ? finalValue : value\n              await useGraphStore\n                .getState()\n                .updateNodeAndSelect(nodeId, entityId, name, graphValue)\n            } catch (error) {\n              console.error('Error updating node in graph:', error)\n              throw new Error('Failed to update node in graph')\n            }\n\n            // Update search history: remove old name, add new name\n            if (name === 'entity_id') {\n              const currentLabel = useSettingsStore.getState().queryLabel\n\n              SearchHistoryManager.removeLabel(entityId)\n              SearchHistoryManager.addToHistory(finalValue)\n\n              // Trigger dropdown refresh to show updated search history\n              useSettingsStore.getState().triggerSearchLabelDropdownRefresh()\n\n              // If current queryLabel is the old entity name, update to new name\n              if (currentLabel === entityId) {\n                useSettingsStore.getState().setQueryLabel(finalValue)\n              }\n            }\n\n            toast.success(t('graphPanel.propertiesView.success.entityUpdated'))\n          }\n\n          // Update local state and notify parent component\n          // For entity_id updates, use finalValue (which may be different due to merging)\n          // For other properties, use the original value the user entered\n          const valueToSet = name === 'entity_id' ? finalValue : value\n          setCurrentValue(valueToSet)\n          onValueChange?.(valueToSet)\n\n        } else if (operationStatus === 'partial_success') {\n          // Partial success: update succeeded but merge failed\n          // Do NOT update graph data to keep frontend in sync with backend\n          const mergeError = operationSummary?.merge_error || 'Unknown error'\n\n          const errorMsg = t('graphPanel.propertiesView.errors.updateSuccessButMergeFailed', {\n            error: mergeError\n          })\n          setErrorMessage(errorMsg)\n          toast.error(errorMsg)\n          // Do not update currentValue or call onValueChange\n          return\n\n        } else {\n          // Complete failure or unknown status\n          // Check if this was a merge attempt or just a regular update\n          if (operationSummary?.merge_status === 'failed') {\n            // Merge operation was attempted but failed\n            const mergeError = operationSummary?.merge_error || 'Unknown error'\n            const errorMsg = t('graphPanel.propertiesView.errors.mergeFailed', {\n              error: mergeError\n            })\n            setErrorMessage(errorMsg)\n            toast.error(errorMsg)\n          } else {\n            // Regular update failed (no merge involved)\n            const errorMsg = t('graphPanel.propertiesView.errors.updateFailed')\n            setErrorMessage(errorMsg)\n            toast.error(errorMsg)\n          }\n          // Do not update currentValue or call onValueChange\n          return\n        }\n      } else if (entityType === 'edge' && sourceId && targetId && edgeId && dynamicId) {\n        const updatedData = { [name]: value }\n        await updateRelation(sourceId, targetId, updatedData)\n        try {\n          await useGraphStore.getState().updateEdgeAndSelect(edgeId, dynamicId, sourceId, targetId, name, value)\n        } catch (error) {\n          console.error(`Error updating edge ${sourceId}->${targetId} in graph:`, error)\n          throw new Error('Failed to update edge in graph')\n        }\n        toast.success(t('graphPanel.propertiesView.success.relationUpdated'))\n        setCurrentValue(value)\n        onValueChange?.(value)\n      }\n\n      setIsEditing(false)\n    } catch (error) {\n      console.error('Error updating property:', error)\n      const errorMsg = error instanceof Error ? error.message : t('graphPanel.propertiesView.errors.updateFailed')\n      setErrorMessage(errorMsg)\n      toast.error(errorMsg)\n      return\n    } finally {\n      setIsSubmitting(false)\n    }\n  }\n\n  const handleMergeRefresh = (useMergedStart: boolean) => {\n    const info = mergeDialogInfo\n    const graphState = useGraphStore.getState()\n    const settingsState = useSettingsStore.getState()\n    const currentLabel = settingsState.queryLabel\n\n    // Clear graph state\n    graphState.clearSelection()\n    graphState.setGraphDataFetchAttempted(false)\n    graphState.setLastSuccessfulQueryLabel('')\n\n    if (useMergedStart && info?.targetEntity) {\n      // Use merged entity as new start point (might already be set in handleSave)\n      settingsState.setQueryLabel(info.targetEntity)\n    } else {\n      // Keep current start point - refresh by resetting and restoring label\n      // This handles the case where user wants to stay with current label\n      settingsState.setQueryLabel('')\n      setTimeout(() => {\n        settingsState.setQueryLabel(currentLabel)\n      }, 50)\n    }\n\n    // Force graph re-render and reset zoom/scale (same as refresh button behavior)\n    graphState.incrementGraphDataVersion()\n\n    setMergeDialogOpen(false)\n    setMergeDialogInfo(null)\n    toast.info(t('graphPanel.propertiesView.mergeDialog.refreshing'))\n  }\n\n  return (\n    <div className=\"flex items-center gap-1 overflow-hidden\">\n      <PropertyName name={name} />\n      <EditIcon onClick={handleEditClick} />:\n      <PropertyValue\n        value={currentValue}\n        onClick={onClick}\n        tooltip={tooltip || (typeof currentValue === 'string' ? currentValue : JSON.stringify(currentValue, null, 2))}\n      />\n      <PropertyEditDialog\n        isOpen={isEditing}\n        onClose={handleCancel}\n        onSave={handleSave}\n        propertyName={name}\n        initialValue={String(currentValue)}\n        isSubmitting={isSubmitting}\n        errorMessage={errorMessage}\n      />\n\n      <MergeDialog\n        mergeDialogOpen={mergeDialogOpen}\n        mergeDialogInfo={mergeDialogInfo}\n        onOpenChange={(open) => {\n          setMergeDialogOpen(open)\n          if (!open) {\n            setMergeDialogInfo(null)\n          }\n        }}\n        onRefresh={handleMergeRefresh}\n      />\n    </div>\n  )\n}\n\nexport default EditablePropertyRow\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/FocusOnNode.tsx",
    "content": "import { useCamera, useSigma } from '@react-sigma/core'\nimport { useEffect } from 'react'\nimport { useGraphStore } from '@/stores/graph'\n\n/**\n * Component that highlights a node and centers the camera on it.\n */\nconst FocusOnNode = ({ node, move }: { node: string | null; move?: boolean }) => {\n  const sigma = useSigma()\n  const { gotoNode } = useCamera()\n\n  /**\n   * When the selected item changes, highlighted the node and center the camera on it.\n   */\n  useEffect(() => {\n    const graph = sigma.getGraph();\n\n    if (move) {\n      if (node && graph.hasNode(node)) {\n        try {\n          graph.setNodeAttribute(node, 'highlighted', true);\n          gotoNode(node);\n        } catch (error) {\n          console.error('Error focusing on node:', error);\n        }\n      } else {\n        // If no node is selected but move is true, reset to default view\n        sigma.setCustomBBox(null);\n        sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 });\n      }\n      useGraphStore.getState().setMoveToSelectedNode(false);\n    } else if (node && graph.hasNode(node)) {\n      try {\n        graph.setNodeAttribute(node, 'highlighted', true);\n      } catch (error) {\n        console.error('Error highlighting node:', error);\n      }\n    }\n\n    return () => {\n      if (node && graph.hasNode(node)) {\n        try {\n          graph.setNodeAttribute(node, 'highlighted', false);\n        } catch (error) {\n          console.error('Error cleaning up node highlight:', error);\n        }\n      }\n    }\n  }, [node, move, sigma, gotoNode])\n\n  return null\n}\n\nexport default FocusOnNode\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/FullScreenControl.tsx",
    "content": "import { useFullScreen } from '@react-sigma/core'\nimport { MaximizeIcon, MinimizeIcon } from 'lucide-react'\nimport { controlButtonVariant } from '@/lib/constants'\nimport Button from '@/components/ui/Button'\nimport { useTranslation } from 'react-i18next'\n\n/**\n * Component that toggles full screen mode.\n */\nconst FullScreenControl = () => {\n  const { isFullScreen, toggle } = useFullScreen()\n  const { t } = useTranslation()\n\n  return (\n    <>\n      {isFullScreen ? (\n        <Button variant={controlButtonVariant} onClick={toggle} tooltip={t('graphPanel.sideBar.fullScreenControl.windowed')} size=\"icon\">\n          <MinimizeIcon />\n        </Button>\n      ) : (\n        <Button variant={controlButtonVariant} onClick={toggle} tooltip={t('graphPanel.sideBar.fullScreenControl.fullScreen')} size=\"icon\">\n          <MaximizeIcon />\n        </Button>\n      )}\n    </>\n  )\n}\n\nexport default FullScreenControl\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/GraphControl.tsx",
    "content": "import { useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'\nimport { AbstractGraph } from 'graphology-types'\n// import { useLayoutCircular } from '@react-sigma/layout-circular'\nimport { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'\nimport { useEffect, useState } from 'react'\n\n// import useRandomGraph, { EdgeType, NodeType } from '@/hooks/useRandomGraph'\nimport { EdgeType, NodeType } from '@/hooks/useLightragGraph'\nimport useTheme from '@/hooks/useTheme'\nimport * as Constants from '@/lib/constants'\n\nimport { useSettingsStore } from '@/stores/settings'\nimport { useGraphStore } from '@/stores/graph'\n\nconst isButtonPressed = (ev: MouseEvent | TouchEvent) => {\n  if (ev.type.startsWith('mouse')) {\n    if ((ev as MouseEvent).buttons !== 0) {\n      return true\n    }\n  }\n  return false\n}\n\nconst GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean }) => {\n  const sigma = useSigma<NodeType, EdgeType>()\n  const registerEvents = useRegisterEvents<NodeType, EdgeType>()\n  const setSettings = useSetSettings<NodeType, EdgeType>()\n\n  const maxIterations = useSettingsStore.use.graphLayoutMaxIterations()\n  const { assign: assignLayout } = useLayoutForceAtlas2({\n    iterations: maxIterations\n  })\n\n  const { theme } = useTheme()\n  const hideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()\n  const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()\n  const renderEdgeLabels = useSettingsStore.use.showEdgeLabel()\n  const renderLabels = useSettingsStore.use.showNodeLabel()\n  const minEdgeSize = useSettingsStore.use.minEdgeSize()\n  const maxEdgeSize = useSettingsStore.use.maxEdgeSize()\n  const selectedNode = useGraphStore.use.selectedNode()\n  const focusedNode = useGraphStore.use.focusedNode()\n  const selectedEdge = useGraphStore.use.selectedEdge()\n  const focusedEdge = useGraphStore.use.focusedEdge()\n  const sigmaGraph = useGraphStore.use.sigmaGraph()\n\n  // Track system theme changes when theme is set to 'system'\n  const [systemThemeIsDark, setSystemThemeIsDark] = useState(() =>\n    window.matchMedia('(prefers-color-scheme: dark)').matches\n  )\n\n  useEffect(() => {\n    if (theme === 'system') {\n      const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')\n      const handler = (e: MediaQueryListEvent) => setSystemThemeIsDark(e.matches)\n      mediaQuery.addEventListener('change', handler)\n      return () => mediaQuery.removeEventListener('change', handler)\n    }\n  }, [theme])\n\n  /**\n   * When component mount or maxIterations changes\n   * => ensure graph reference and apply layout\n   */\n  useEffect(() => {\n    if (sigmaGraph && sigma) {\n      // Ensure sigma binding to sigmaGraph\n      try {\n        if (typeof sigma.setGraph === 'function') {\n          sigma.setGraph(sigmaGraph as unknown as AbstractGraph<NodeType, EdgeType>);\n          console.log('Binding graph to sigma instance');\n        } else {\n          (sigma as any).graph = sigmaGraph;\n          console.warn('Simgma missing setGraph function, set graph property directly');\n        }\n      } catch (error) {\n        console.error('Error setting graph on sigma instance:', error);\n      }\n\n      assignLayout();\n      console.log('Initial layout applied to graph');\n    }\n  }, [sigma, sigmaGraph, assignLayout, maxIterations])\n\n  /**\n   * Ensure the sigma instance is set in the store\n   * This provides a backup in case the instance wasn't set in GraphViewer\n   */\n  useEffect(() => {\n    if (sigma) {\n      // Double-check that the store has the sigma instance\n      const currentInstance = useGraphStore.getState().sigmaInstance;\n      if (!currentInstance) {\n        console.log('Setting sigma instance from GraphControl');\n        useGraphStore.getState().setSigmaInstance(sigma);\n      }\n    }\n  }, [sigma]);\n\n  /**\n   * When component mount\n   * => register events\n   */\n  useEffect(() => {\n    const { setFocusedNode, setSelectedNode, setFocusedEdge, setSelectedEdge, clearSelection } =\n      useGraphStore.getState()\n\n    // Define event types\n    type NodeEvent = { node: string; event: { original: MouseEvent | TouchEvent } }\n    type EdgeEvent = { edge: string; event: { original: MouseEvent | TouchEvent } }\n\n    // Register all events, but edge events will only be processed if enableEdgeEvents is true\n    const events: Record<string, any> = {\n      enterNode: (event: NodeEvent) => {\n        if (!isButtonPressed(event.event.original)) {\n          const graph = sigma.getGraph()\n          if (graph.hasNode(event.node)) {\n            setFocusedNode(event.node)\n          }\n        }\n      },\n      leaveNode: (event: NodeEvent) => {\n        if (!isButtonPressed(event.event.original)) {\n          setFocusedNode(null)\n        }\n      },\n      clickNode: (event: NodeEvent) => {\n        const graph = sigma.getGraph()\n        if (graph.hasNode(event.node)) {\n          setSelectedNode(event.node)\n          setSelectedEdge(null)\n        }\n      },\n      clickStage: () => clearSelection()\n    }\n\n    // Only add edge event handlers if enableEdgeEvents is true\n    if (enableEdgeEvents) {\n      events.clickEdge = (event: EdgeEvent) => {\n        setSelectedEdge(event.edge)\n        setSelectedNode(null)\n      }\n\n      events.enterEdge = (event: EdgeEvent) => {\n        if (!isButtonPressed(event.event.original)) {\n          setFocusedEdge(event.edge)\n        }\n      }\n\n      events.leaveEdge = (event: EdgeEvent) => {\n        if (!isButtonPressed(event.event.original)) {\n          setFocusedEdge(null)\n        }\n      }\n    }\n\n    // Register the events\n    registerEvents(events)\n\n    // Cleanup function - basic cleanup without relying on specific APIs\n    return () => {\n      try {\n        console.log('Cleaning up graph event listeners')\n      } catch (error) {\n        console.warn('Error cleaning up graph event listeners:', error)\n      }\n    }\n  }, [registerEvents, enableEdgeEvents, sigma])\n\n  /**\n   * When edge size settings change, recalculate edge sizes and refresh the sigma instance\n   * to ensure changes take effect immediately\n   */\n  useEffect(() => {\n    if (sigma && sigmaGraph) {\n      // Get the graph from sigma\n      const graph = sigma.getGraph()\n\n      // Find min and max weight values\n      let minWeight = Number.MAX_SAFE_INTEGER\n      let maxWeight = 0\n\n      graph.forEachEdge(edge => {\n        // Get original weight (before scaling)\n        const weight = graph.getEdgeAttribute(edge, 'originalWeight') || 1\n        if (typeof weight === 'number') {\n          minWeight = Math.min(minWeight, weight)\n          maxWeight = Math.max(maxWeight, weight)\n        }\n      })\n\n      // Scale edge sizes based on weight range and current min/max edge size settings\n      const weightRange = maxWeight - minWeight\n      if (weightRange > 0) {\n        const sizeScale = maxEdgeSize - minEdgeSize\n        graph.forEachEdge(edge => {\n          const weight = graph.getEdgeAttribute(edge, 'originalWeight') || 1\n          if (typeof weight === 'number') {\n            const scaledSize = minEdgeSize + sizeScale * Math.pow((weight - minWeight) / weightRange, 0.5)\n            graph.setEdgeAttribute(edge, 'size', scaledSize)\n          }\n        })\n      } else {\n        // If all weights are the same, use default size\n        graph.forEachEdge(edge => {\n          graph.setEdgeAttribute(edge, 'size', minEdgeSize)\n        })\n      }\n\n      // Refresh the sigma instance to apply changes\n      sigma.refresh()\n    }\n  }, [sigma, sigmaGraph, minEdgeSize, maxEdgeSize])\n\n\n  /**\n   * When component mount or hovered node change\n   * => Setting the sigma reducers\n   */\n  useEffect(() => {\n    // Check if dark mode is actually applied (handles both 'dark' theme and 'system' theme when OS is dark)\n    const isDarkTheme = theme === 'dark' ||\n      (theme === 'system' && window.document.documentElement.classList.contains('dark'))\n    const labelColor = isDarkTheme ? Constants.labelColorDarkTheme : undefined\n    const edgeColor = isDarkTheme ? Constants.edgeColorDarkTheme : undefined\n\n    // Update all dynamic settings directly without recreating the sigma container\n    setSettings({\n      // Update display settings\n      enableEdgeEvents,\n      renderEdgeLabels,\n      renderLabels,\n\n      // Node reducer for node appearance\n      nodeReducer: (node, data) => {\n        const graph = sigma.getGraph()\n\n        // Add defensive check for node existence during theme switching\n        if (!graph.hasNode(node)) {\n          console.warn(`Node ${node} not found in graph during theme switch, returning default data`)\n          return { ...data, highlighted: false, labelColor }\n        }\n\n        const newData: NodeType & {\n          labelColor?: string\n          borderColor?: string\n        } = { ...data, highlighted: data.highlighted || false, labelColor }\n\n        if (!disableHoverEffect) {\n          newData.highlighted = false\n          const _focusedNode = focusedNode || selectedNode\n          const _focusedEdge = focusedEdge || selectedEdge\n\n          if (_focusedNode && graph.hasNode(_focusedNode)) {\n            try {\n              if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {\n                newData.highlighted = true\n                if (node === selectedNode) {\n                  newData.borderColor = Constants.nodeBorderColorSelected\n                }\n              }\n            } catch (error) {\n              console.error('Error in nodeReducer:', error);\n              return { ...data, highlighted: false, labelColor }\n            }\n          } else if (_focusedEdge && graph.hasEdge(_focusedEdge)) {\n            try {\n              if (graph.extremities(_focusedEdge).includes(node)) {\n                newData.highlighted = true\n                newData.size = 3\n              }\n            } catch (error) {\n              console.error('Error accessing edge extremities in nodeReducer:', error);\n              return { ...data, highlighted: false, labelColor }\n            }\n          } else {\n            return newData\n          }\n\n          if (newData.highlighted) {\n            if (isDarkTheme) {\n              newData.labelColor = Constants.LabelColorHighlightedDarkTheme\n            }\n          } else {\n            newData.color = Constants.nodeColorDisabled\n          }\n        }\n        return newData\n      },\n\n      // Edge reducer for edge appearance\n      edgeReducer: (edge, data) => {\n        const graph = sigma.getGraph()\n\n        // Add defensive check for edge existence during theme switching\n        if (!graph.hasEdge(edge)) {\n          console.warn(`Edge ${edge} not found in graph during theme switch, returning default data`)\n          return { ...data, hidden: false, labelColor, color: edgeColor }\n        }\n\n        const newData = { ...data, hidden: false, labelColor, color: edgeColor }\n\n        if (!disableHoverEffect) {\n          const _focusedNode = focusedNode || selectedNode\n          // Choose edge highlight color based on theme\n          const edgeHighlightColor = isDarkTheme\n            ? Constants.edgeColorHighlightedDarkTheme\n            : Constants.edgeColorHighlightedLightTheme\n\n          if (_focusedNode && graph.hasNode(_focusedNode)) {\n            try {\n              if (hideUnselectedEdges) {\n                if (!graph.extremities(edge).includes(_focusedNode)) {\n                  newData.hidden = true\n                }\n              } else {\n                if (graph.extremities(edge).includes(_focusedNode)) {\n                  newData.color = edgeHighlightColor\n                }\n              }\n            } catch (error) {\n              console.error('Error in edgeReducer:', error);\n              return { ...data, hidden: false, labelColor, color: edgeColor }\n            }\n          } else {\n            const _selectedEdge = selectedEdge && graph.hasEdge(selectedEdge) ? selectedEdge : null;\n            const _focusedEdge = focusedEdge && graph.hasEdge(focusedEdge) ? focusedEdge : null;\n\n            if (_selectedEdge || _focusedEdge) {\n              if (edge === _selectedEdge) {\n                newData.color = Constants.edgeColorSelected\n              } else if (edge === _focusedEdge) {\n                newData.color = edgeHighlightColor\n              } else if (hideUnselectedEdges) {\n                newData.hidden = true\n              }\n            }\n          }\n        }\n        return newData\n      }\n    })\n  }, [\n    selectedNode,\n    focusedNode,\n    selectedEdge,\n    focusedEdge,\n    setSettings,\n    sigma,\n    disableHoverEffect,\n    theme,\n    systemThemeIsDark,\n    hideUnselectedEdges,\n    enableEdgeEvents,\n    renderEdgeLabels,\n    renderLabels\n  ])\n\n  return null\n}\n\nexport default GraphControl\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/GraphLabels.tsx",
    "content": "import { useCallback, useEffect, useState, useRef } from 'react'\nimport { AsyncSelect } from '@/components/ui/AsyncSelect'\nimport { useSettingsStore } from '@/stores/settings'\nimport { useGraphStore } from '@/stores/graph'\nimport { useBackendState } from '@/stores/state'\nimport {\n  dropdownDisplayLimit,\n  controlButtonVariant,\n  popularLabelsDefaultLimit,\n  searchLabelsDefaultLimit\n} from '@/lib/constants'\nimport { useTranslation } from 'react-i18next'\nimport { RefreshCw } from 'lucide-react'\nimport Button from '@/components/ui/Button'\nimport { SearchHistoryManager } from '@/utils/SearchHistoryManager'\nimport { getPopularLabels, searchLabels } from '@/api/lightrag'\n\nconst GraphLabels = () => {\n  const { t } = useTranslation()\n  const label = useSettingsStore.use.queryLabel()\n  const dropdownRefreshTrigger = useSettingsStore.use.searchLabelDropdownRefreshTrigger()\n  const [isRefreshing, setIsRefreshing] = useState(false)\n  const [refreshTrigger, setRefreshTrigger] = useState(0)\n  const [selectKey, setSelectKey] = useState(0)\n\n  // Pipeline state monitoring\n  const pipelineBusy = useBackendState.use.pipelineBusy()\n  const prevPipelineBusy = useRef<boolean | undefined>(undefined)\n  const shouldRefreshPopularLabelsRef = useRef(false)\n\n  // Dynamic tooltip based on current label state\n  const getRefreshTooltip = useCallback(() => {\n    if (isRefreshing) {\n      return t('graphPanel.graphLabels.refreshingTooltip')\n    }\n\n    if (!label || label === '*') {\n      return t('graphPanel.graphLabels.refreshGlobalTooltip')\n    } else {\n      return t('graphPanel.graphLabels.refreshCurrentLabelTooltip', { label })\n    }\n  }, [label, t, isRefreshing])\n\n  // Initialize search history on component mount\n  useEffect(() => {\n    const initializeHistory = async () => {\n      const history = SearchHistoryManager.getHistory()\n\n      if (history.length === 0) {\n        // If no history exists, fetch popular labels and initialize\n        try {\n          const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)\n          await SearchHistoryManager.initializeWithDefaults(popularLabels)\n        } catch (error) {\n          console.error('Failed to initialize search history:', error)\n          // No fallback needed, API is the source of truth\n        }\n      }\n    }\n\n    initializeHistory()\n  }, [])\n\n  // Force AsyncSelect to re-render when label changes externally (e.g., from entity rename/merge)\n  useEffect(() => {\n    setSelectKey(prev => prev + 1)\n  }, [label])\n\n  // Force AsyncSelect to re-render when dropdown refresh is triggered (e.g., after entity rename)\n  useEffect(() => {\n    if (dropdownRefreshTrigger > 0) {\n      setSelectKey(prev => prev + 1)\n    }\n  }, [dropdownRefreshTrigger])\n\n  // Monitor pipeline state changes: busy -> idle\n  useEffect(() => {\n    if (prevPipelineBusy.current === true && pipelineBusy === false) {\n      console.log('Pipeline changed from busy to idle, marking for popular labels refresh')\n      shouldRefreshPopularLabelsRef.current = true\n    }\n    prevPipelineBusy.current = pipelineBusy\n  }, [pipelineBusy])\n\n  // Helper: Reload popular labels from backend\n  const reloadPopularLabels = useCallback(async () => {\n    if (!shouldRefreshPopularLabelsRef.current) return\n\n    console.log('Reloading popular labels (triggered by pipeline idle)')\n    try {\n      const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)\n      SearchHistoryManager.clearHistory()\n\n      if (popularLabels.length === 0) {\n        const fallbackLabels = ['entity', 'relationship', 'document', 'concept']\n        await SearchHistoryManager.initializeWithDefaults(fallbackLabels)\n      } else {\n        await SearchHistoryManager.initializeWithDefaults(popularLabels)\n      }\n    } catch (error) {\n      console.error('Failed to reload popular labels:', error)\n      const fallbackLabels = ['entity', 'relationship', 'document']\n      SearchHistoryManager.clearHistory()\n      await SearchHistoryManager.initializeWithDefaults(fallbackLabels)\n    } finally {\n      // Always clear the flag\n      shouldRefreshPopularLabelsRef.current = false\n    }\n  }, [])\n\n  // Helper: Bump dropdown data to trigger refresh\n  const bumpDropdownData = useCallback(({ forceSelectKey = false } = {}) => {\n    setRefreshTrigger(prev => prev + 1)\n    if (forceSelectKey) {\n      setSelectKey(prev => prev + 1)\n    }\n  }, [])\n\n  const fetchData = useCallback(\n    async (query?: string): Promise<string[]> => {\n      let results: string[] = [];\n      if (!query || query.trim() === '' || query.trim() === '*') {\n        // Empty query: return search history\n        results = SearchHistoryManager.getHistoryLabels(dropdownDisplayLimit)\n      } else {\n        // Non-empty query: call backend search API\n        try {\n          const apiResults = await searchLabels(query.trim(), searchLabelsDefaultLimit)\n          results = apiResults.length <= dropdownDisplayLimit\n            ? apiResults\n            : [...apiResults.slice(0, dropdownDisplayLimit), '...']\n        } catch (error) {\n          console.error('Search API failed, falling back to local history search:', error)\n\n          // Fallback to local history search\n          const history = SearchHistoryManager.getHistory()\n          const queryLower = query.toLowerCase().trim()\n          results = history\n            .filter(item => item.label.toLowerCase().includes(queryLower))\n            .map(item => item.label)\n            .slice(0, dropdownDisplayLimit)\n        }\n      }\n      // Always show '*' at the top, and remove duplicates\n      const finalResults = ['*', ...results.filter(label => label !== '*')];\n      return finalResults;\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [refreshTrigger] // Intentionally added to trigger re-creation when data changes\n  )\n\n  const handleRefresh = useCallback(async () => {\n    setIsRefreshing(true)\n\n    // Clear legend cache to ensure legend is re-generated on refresh\n    useGraphStore.getState().setTypeColorMap(new Map<string, string>())\n\n    try {\n      let currentLabel = label\n\n      // If queryLabel is empty, set it to '*'\n      if (!currentLabel || currentLabel.trim() === '') {\n        useSettingsStore.getState().setQueryLabel('*')\n        currentLabel = '*'\n      }\n\n      // Scenario 1: Manual refresh - reload popular labels if flag is set (regardless of current label)\n      if (shouldRefreshPopularLabelsRef.current) {\n        await reloadPopularLabels()\n        bumpDropdownData({ forceSelectKey: true })\n      }\n\n      if (currentLabel && currentLabel !== '*') {\n        // Scenario 1: Has specific label, try to refresh current label\n        console.log(`Refreshing current label: ${currentLabel}`)\n\n        // Reset graph data fetch status to trigger refresh\n        useGraphStore.getState().setGraphDataFetchAttempted(false)\n        useGraphStore.getState().setLastSuccessfulQueryLabel('')\n\n        // Force data refresh for current label\n        useGraphStore.getState().incrementGraphDataVersion()\n\n        // Note: If the current label has no data after refresh,\n        // the fallback logic would be handled by the graph component itself\n        // For now, we keep the current label and let the user see the result\n\n      } else {\n        // Scenario 3: queryLabel is \"*\", refresh global data and popular labels\n        console.log('Refreshing global data and popular labels')\n\n        try {\n          // Re-fetch popular labels and update search history (if not already done)\n          const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)\n          SearchHistoryManager.clearHistory()\n\n          if (popularLabels.length === 0) {\n            // If no popular labels, provide fallback defaults\n            const fallbackLabels = ['entity', 'relationship', 'document', 'concept']\n            await SearchHistoryManager.initializeWithDefaults(fallbackLabels)\n          } else {\n            await SearchHistoryManager.initializeWithDefaults(popularLabels)\n          }\n        } catch (error) {\n          console.error('Failed to reload popular labels:', error)\n          // Provide fallback even if API fails\n          const fallbackLabels = ['entity', 'relationship', 'document']\n          SearchHistoryManager.clearHistory()\n          await SearchHistoryManager.initializeWithDefaults(fallbackLabels)\n        }\n\n        // Reset graph data fetch status\n        useGraphStore.getState().setGraphDataFetchAttempted(false)\n        useGraphStore.getState().setLastSuccessfulQueryLabel('')\n\n        // Force global data refresh\n        useGraphStore.getState().incrementGraphDataVersion()\n\n        // Ensure data update completes before triggering UI refresh\n        await new Promise(resolve => setTimeout(resolve, 0))\n\n        // Trigger both refresh mechanisms to ensure dropdown updates\n        setRefreshTrigger(prev => prev + 1)\n        setSelectKey(prev => prev + 1)\n      }\n    } catch (error) {\n      console.error('Error during refresh:', error)\n    } finally {\n      setIsRefreshing(false)\n    }\n  }, [label, reloadPopularLabels, bumpDropdownData])\n\n  // Handle dropdown before open - reload popular labels if needed\n  const handleDropdownBeforeOpen = useCallback(async () => {\n    const currentLabel = useSettingsStore.getState().queryLabel\n    if (shouldRefreshPopularLabelsRef.current && (!currentLabel || currentLabel === '*')) {\n      await reloadPopularLabels()\n      bumpDropdownData()\n    }\n  }, [reloadPopularLabels, bumpDropdownData])\n\n  return (\n    <div className=\"flex items-center\">\n      {/* Always show refresh button */}\n      <Button\n        size=\"icon\"\n        variant={controlButtonVariant}\n        onClick={handleRefresh}\n        tooltip={getRefreshTooltip()}\n        className=\"mr-2\"\n        disabled={isRefreshing}\n      >\n        <RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />\n      </Button>\n      <div className=\"w-full min-w-[280px] max-w-[500px]\">\n        <AsyncSelect<string>\n          key={selectKey} // Force re-render when data changes\n          className=\"min-w-[300px]\"\n          triggerClassName=\"max-h-8 w-full overflow-hidden\"\n          searchInputClassName=\"max-h-8\"\n          triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}\n          fetcher={fetchData}\n          onBeforeOpen={handleDropdownBeforeOpen}\n          renderOption={(item) => (\n            <div className=\"truncate\" title={item}>\n              {item}\n            </div>\n          )}\n          getOptionValue={(item) => item}\n          getDisplayValue={(item) => (\n            <div className=\"min-w-0 flex-1 truncate text-left\" title={item}>\n              {item}\n            </div>\n          )}\n          notFound={<div className=\"py-6 text-center text-sm\">{t('graphPanel.graphLabels.noLabels')}</div>}\n          ariaLabel={t('graphPanel.graphLabels.label')}\n          placeholder={t('graphPanel.graphLabels.placeholder')}\n          searchPlaceholder={t('graphPanel.graphLabels.placeholder')}\n          noResultsMessage={t('graphPanel.graphLabels.noLabels')}\n          value={label !== null ? label : '*'}\n          onChange={(newLabel) => {\n            const currentLabel = useSettingsStore.getState().queryLabel;\n\n            // select the last item means query all\n            if (newLabel === '...') {\n              newLabel = '*';\n            }\n\n            // Handle reselecting the same label\n            if (newLabel === currentLabel && newLabel !== '*') {\n              newLabel = '*';\n            }\n\n            // Add selected label to search history (except for special cases)\n            if (newLabel && newLabel !== '*' && newLabel !== '...' && newLabel.trim() !== '') {\n              SearchHistoryManager.addToHistory(newLabel);\n            }\n\n            // Reset graphDataFetchAttempted flag to ensure data fetch is triggered\n            useGraphStore.getState().setGraphDataFetchAttempted(false);\n\n            // Update the label to trigger data loading\n            useSettingsStore.getState().setQueryLabel(newLabel);\n\n            // Force graph re-render and reset zoom/scale (must be AFTER setQueryLabel)\n            useGraphStore.getState().incrementGraphDataVersion();\n          }}\n          clearable={false}  // Prevent clearing value on reselect\n          debounceTime={500}\n        />\n      </div>\n    </div>\n  )\n}\n\nexport default GraphLabels\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/GraphSearch.tsx",
    "content": "import { FC, useCallback, useEffect } from 'react'\nimport {\n  EdgeById,\n  GraphSearchInputProps,\n  GraphSearchContextProviderProps\n} from '@react-sigma/graph-search'\nimport { AsyncSearch } from '@/components/ui/AsyncSearch'\nimport { searchResultLimit } from '@/lib/constants'\nimport { useGraphStore } from '@/stores/graph'\nimport MiniSearch from 'minisearch'\nimport { useTranslation } from 'react-i18next'\n\n// Message item identifier for search results\nexport const messageId = '__message_item'\n\n// Search result option item interface\nexport interface OptionItem {\n  id: string\n  type: 'nodes' | 'edges' | 'message'\n  message?: string\n}\n\nconst NodeOption = ({ id }: { id: string }) => {\n  const graph = useGraphStore.use.sigmaGraph()\n\n  // Early return if no graph or node doesn't exist\n  if (!graph?.hasNode(id)) {\n    return null\n  }\n\n  // Safely get node attributes with fallbacks\n  const label = graph.getNodeAttribute(id, 'label') || id\n  const color = graph.getNodeAttribute(id, 'color') || '#666'\n  const size = graph.getNodeAttribute(id, 'size') || 4\n\n  // Custom node display component that doesn't rely on @react-sigma/graph-search\n  return (\n    <div className=\"flex items-center gap-2 p-2 text-sm\">\n      <div\n        className=\"rounded-full flex-shrink-0\"\n        style={{\n          width: Math.max(8, Math.min(size * 2, 16)),\n          height: Math.max(8, Math.min(size * 2, 16)),\n          backgroundColor: color\n        }}\n      />\n      <span className=\"truncate\">{label}</span>\n    </div>\n  )\n}\n\nfunction OptionComponent(item: OptionItem) {\n  return (\n    <div>\n      {item.type === 'nodes' && <NodeOption id={item.id} />}\n      {item.type === 'edges' && <EdgeById id={item.id} />}\n      {item.type === 'message' && <div>{item.message}</div>}\n    </div>\n  )\n}\n\n\n/**\n * Component thats display the search input.\n */\nexport const GraphSearchInput = ({\n  onChange,\n  onFocus,\n  value\n}: {\n  onChange: GraphSearchInputProps['onChange']\n  onFocus?: GraphSearchInputProps['onFocus']\n  value?: GraphSearchInputProps['value']\n}) => {\n  const { t } = useTranslation()\n  const graph = useGraphStore.use.sigmaGraph()\n  const searchEngine = useGraphStore.use.searchEngine()\n\n  // Reset search engine when graph changes\n  useEffect(() => {\n    if (graph) {\n      useGraphStore.getState().resetSearchEngine()\n    }\n  }, [graph]);\n\n  // Create search engine when needed\n  useEffect(() => {\n    // Skip if no graph, empty graph, or search engine already exists\n    if (!graph || graph.nodes().length === 0 || searchEngine) {\n      return\n    }\n\n    // Create new search engine\n    const newSearchEngine = new MiniSearch({\n      idField: 'id',\n      fields: ['label'],\n      searchOptions: {\n        prefix: true,\n        fuzzy: 0.2,\n        boost: {\n          label: 2\n        }\n      }\n    })\n\n    // Add nodes to search engine with safety checks\n    const documents = graph.nodes()\n      .filter(id => graph.hasNode(id)) // Ensure node exists before accessing attributes\n      .map((id: string) => ({\n        id: id,\n        label: graph.getNodeAttribute(id, 'label')\n      }))\n\n    if (documents.length > 0) {\n      newSearchEngine.addAll(documents)\n    }\n\n    // Update search engine in store\n    useGraphStore.getState().setSearchEngine(newSearchEngine)\n  }, [graph, searchEngine])\n\n  /**\n   * Loading the options while the user is typing.\n   */\n  const loadOptions = useCallback(\n    async (query?: string): Promise<OptionItem[]> => {\n      if (onFocus) onFocus(null)\n\n      // Safety checks to prevent crashes\n      if (!graph || !searchEngine) {\n        return []\n      }\n\n      // Verify graph has nodes before proceeding\n      if (graph.nodes().length === 0) {\n        return []\n      }\n\n      // If no query, return some nodes for user to select\n      if (!query) {\n        const nodeIds = graph.nodes()\n          .filter(id => graph.hasNode(id))\n          .slice(0, searchResultLimit)\n        return nodeIds.map(id => ({\n          id,\n          type: 'nodes'\n        }))\n      }\n\n      // If has query, search nodes and verify they still exist\n      let result: OptionItem[] = searchEngine.search(query)\n        .filter((r: { id: string }) => graph.hasNode(r.id))\n        .map((r: { id: string }) => ({\n          id: r.id,\n          type: 'nodes'\n        }))\n\n      // Add middle-content matching if results are few\n      // This enables matching content in the middle of text, not just from the beginning\n      if (result.length < 5) {\n        // Get already matched IDs to avoid duplicates\n        const matchedIds = new Set(result.map(item => item.id))\n\n        // Perform middle-content matching on all nodes with safety checks\n        const middleMatchResults = graph.nodes()\n          .filter(id => {\n            // Skip already matched nodes\n            if (matchedIds.has(id)) return false\n\n            // Ensure node exists before accessing attributes\n            if (!graph.hasNode(id)) return false\n\n            // Get node label safely\n            const label = graph.getNodeAttribute(id, 'label')\n            // Match if label contains query string but doesn't start with it\n            return label &&\n                   typeof label === 'string' &&\n                   !label.toLowerCase().startsWith(query.toLowerCase()) &&\n                   label.toLowerCase().includes(query.toLowerCase())\n          })\n          .map(id => ({\n            id,\n            type: 'nodes' as const\n          }))\n\n        // Merge results\n        result = [...result, ...middleMatchResults]\n      }\n\n      // prettier-ignore\n      return result.length <= searchResultLimit\n        ? result\n        : [\n          ...result.slice(0, searchResultLimit),\n          {\n            type: 'message',\n            id: messageId,\n            message: t('graphPanel.search.message', { count: result.length - searchResultLimit })\n          }\n        ]\n    },\n    [graph, searchEngine, onFocus, t]\n  )\n\n  return (\n    <AsyncSearch\n      className=\"bg-background/60 w-24 rounded-xl border-1 opacity-60 backdrop-blur-lg transition-all hover:w-fit hover:opacity-100 w-full\"\n      fetcher={loadOptions}\n      renderOption={OptionComponent}\n      getOptionValue={(item) => item.id}\n      value={value && value.type !== 'message' ? value.id : null}\n      onChange={(id) => {\n        if (id !== messageId) onChange(id ? { id, type: 'nodes' } : null)\n      }}\n      onFocus={(id) => {\n        if (id !== messageId && onFocus) onFocus(id ? { id, type: 'nodes' } : null)\n      }}\n      ariaLabel={t('graphPanel.search.placeholder')}\n      placeholder={t('graphPanel.search.placeholder')}\n      noResultsMessage={t('graphPanel.search.placeholder')}\n    />\n  )\n}\n\n/**\n * Component that display the search.\n */\nconst GraphSearch: FC<GraphSearchInputProps & GraphSearchContextProviderProps> = ({ ...props }) => {\n  return <GraphSearchInput {...props} />\n}\n\nexport default GraphSearch\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/LayoutsControl.tsx",
    "content": "import { useSigma } from '@react-sigma/core'\nimport { animateNodes } from 'sigma/utils'\nimport { useLayoutCirclepack } from '@react-sigma/layout-circlepack'\nimport { useLayoutCircular } from '@react-sigma/layout-circular'\nimport { LayoutHook, LayoutWorkerHook, WorkerLayoutControlProps } from '@react-sigma/layout-core'\nimport { useLayoutForce, useWorkerLayoutForce } from '@react-sigma/layout-force'\nimport { useLayoutForceAtlas2, useWorkerLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'\nimport { useLayoutNoverlap, useWorkerLayoutNoverlap } from '@react-sigma/layout-noverlap'\nimport { useLayoutRandom } from '@react-sigma/layout-random'\nimport { useCallback, useMemo, useState, useEffect, useRef } from 'react'\n\nimport Button from '@/components/ui/Button'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'\nimport { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/Command'\nimport { controlButtonVariant } from '@/lib/constants'\nimport { useSettingsStore } from '@/stores/settings'\n\nimport { GripIcon, PlayIcon, PauseIcon } from 'lucide-react'\nimport { useTranslation } from 'react-i18next'\n\ntype LayoutName =\n  | 'Circular'\n  | 'Circlepack'\n  | 'Random'\n  | 'Noverlaps'\n  | 'Force Directed'\n  | 'Force Atlas'\n\n// Extend WorkerLayoutControlProps to include mainLayout\ninterface ExtendedWorkerLayoutControlProps extends WorkerLayoutControlProps {\n  mainLayout: LayoutHook;\n}\n\nconst WorkerLayoutControl = ({ layout, autoRunFor, mainLayout }: ExtendedWorkerLayoutControlProps) => {\n  const sigma = useSigma()\n  // Use local state to track animation running status\n  const [isRunning, setIsRunning] = useState(false)\n  // Timer reference for animation\n  const animationTimerRef = useRef<number | null>(null)\n  const { t } = useTranslation()\n\n  // Function to update node positions using the layout algorithm\n  const updatePositions = useCallback(() => {\n    if (!sigma) return\n\n    try {\n      const graph = sigma.getGraph()\n      if (!graph || graph.order === 0) return\n\n      // Use mainLayout to get positions, similar to refreshLayout function\n      // console.log('Getting positions from mainLayout')\n      const positions = mainLayout.positions()\n\n      // Animate nodes to new positions\n      // console.log('Updating node positions with layout algorithm')\n      animateNodes(graph, positions, { duration: 300 }) // Reduced duration for more frequent updates\n    } catch (error) {\n      console.error('Error updating positions:', error)\n      // Stop animation if there's an error\n      if (animationTimerRef.current) {\n        window.clearInterval(animationTimerRef.current)\n        animationTimerRef.current = null\n        setIsRunning(false)\n      }\n    }\n  }, [sigma, mainLayout])\n\n  // Improved click handler that uses our own animation timer\n  const handleClick = useCallback(() => {\n    if (isRunning) {\n      // Stop the animation\n      console.log('Stopping layout animation')\n      if (animationTimerRef.current) {\n        window.clearInterval(animationTimerRef.current)\n        animationTimerRef.current = null\n      }\n\n      // Try to kill the layout algorithm if it's running\n      try {\n        if (typeof layout.kill === 'function') {\n          layout.kill()\n          console.log('Layout algorithm killed')\n        } else if (typeof layout.stop === 'function') {\n          layout.stop()\n          console.log('Layout algorithm stopped')\n        }\n      } catch (error) {\n        console.error('Error stopping layout algorithm:', error)\n      }\n\n      setIsRunning(false)\n    } else {\n      // Start the animation\n      console.log('Starting layout animation')\n\n      // Initial position update\n      updatePositions()\n\n      // Set up interval for continuous updates\n      animationTimerRef.current = window.setInterval(() => {\n        updatePositions()\n      }, 200) // Reduced interval to create overlapping animations for smoother transitions\n\n      setIsRunning(true)\n\n      // Set a timeout to automatically stop the animation after 3 seconds\n      setTimeout(() => {\n        if (animationTimerRef.current) {\n          console.log('Auto-stopping layout animation after 3 seconds')\n          window.clearInterval(animationTimerRef.current)\n          animationTimerRef.current = null\n          setIsRunning(false)\n\n          // Try to stop the layout algorithm\n          try {\n            if (typeof layout.kill === 'function') {\n              layout.kill()\n            } else if (typeof layout.stop === 'function') {\n              layout.stop()\n            }\n          } catch (error) {\n            console.error('Error stopping layout algorithm:', error)\n          }\n        }\n      }, 3000)\n    }\n  }, [isRunning, layout, updatePositions])\n\n  /**\n   * Init component when Sigma or component settings change.\n   */\n  useEffect(() => {\n    if (!sigma) {\n      console.log('No sigma instance available')\n      return\n    }\n\n    // Auto-run if specified\n    let timeout: number | null = null\n    if (autoRunFor !== undefined && autoRunFor > -1 && sigma.getGraph().order > 0) {\n      console.log('Auto-starting layout animation')\n\n      // Initial position update\n      updatePositions()\n\n      // Set up interval for continuous updates\n      animationTimerRef.current = window.setInterval(() => {\n        updatePositions()\n      }, 200) // Reduced interval to create overlapping animations for smoother transitions\n\n      setIsRunning(true)\n\n      // Set a timeout to stop it if autoRunFor > 0\n      if (autoRunFor > 0) {\n        timeout = window.setTimeout(() => {\n          console.log('Auto-stopping layout animation after timeout')\n          if (animationTimerRef.current) {\n            window.clearInterval(animationTimerRef.current)\n            animationTimerRef.current = null\n          }\n          setIsRunning(false)\n        }, autoRunFor)\n      }\n    }\n\n    // Cleanup function\n    return () => {\n      // console.log('Cleaning up WorkerLayoutControl')\n      if (animationTimerRef.current) {\n        window.clearInterval(animationTimerRef.current)\n        animationTimerRef.current = null\n      }\n      if (timeout) {\n        window.clearTimeout(timeout)\n      }\n      setIsRunning(false)\n    }\n  }, [autoRunFor, sigma, updatePositions])\n\n  return (\n    <Button\n      size=\"icon\"\n      onClick={handleClick}\n      tooltip={isRunning ? t('graphPanel.sideBar.layoutsControl.stopAnimation') : t('graphPanel.sideBar.layoutsControl.startAnimation')}\n      variant={controlButtonVariant}\n    >\n      {isRunning ? <PauseIcon /> : <PlayIcon />}\n    </Button>\n  )\n}\n\n/**\n * Component that controls the layout of the graph.\n */\nconst LayoutsControl = () => {\n  const sigma = useSigma()\n  const { t } = useTranslation()\n  const [layout, setLayout] = useState<LayoutName>('Circular')\n  const [opened, setOpened] = useState<boolean>(false)\n\n  const maxIterations = useSettingsStore.use.graphLayoutMaxIterations()\n\n  const layoutCircular = useLayoutCircular()\n  const layoutCirclepack = useLayoutCirclepack()\n  const layoutRandom = useLayoutRandom()\n  const layoutNoverlap = useLayoutNoverlap({\n    maxIterations: maxIterations,\n    settings: {\n      margin: 5,\n      expansion: 1.1,\n      gridSize: 1,\n      ratio: 1,\n      speed: 3,\n    }\n  })\n  // Add parameters for Force Directed layout to improve convergence\n  const layoutForce = useLayoutForce({\n    maxIterations: maxIterations,\n    settings: {\n      attraction: 0.0003,  // Lower attraction force to reduce oscillation\n      repulsion: 0.02,     // Lower repulsion force to reduce oscillation\n      gravity: 0.02,      // Increase gravity to make nodes converge to center faster\n      inertia: 0.4,        // Lower inertia to add damping effect\n      maxMove: 100         // Limit maximum movement per step to prevent large jumps\n    }\n  })\n  const layoutForceAtlas2 = useLayoutForceAtlas2({ iterations: maxIterations })\n  const workerNoverlap = useWorkerLayoutNoverlap()\n  const workerForce = useWorkerLayoutForce()\n  const workerForceAtlas2 = useWorkerLayoutForceAtlas2()\n\n  const layouts = useMemo(() => {\n    return {\n      Circular: {\n        layout: layoutCircular\n      },\n      Circlepack: {\n        layout: layoutCirclepack\n      },\n      Random: {\n        layout: layoutRandom\n      },\n      Noverlaps: {\n        layout: layoutNoverlap,\n        worker: workerNoverlap\n      },\n      'Force Directed': {\n        layout: layoutForce,\n        worker: workerForce\n      },\n      'Force Atlas': {\n        layout: layoutForceAtlas2,\n        worker: workerForceAtlas2\n      }\n    } as { [key: string]: { layout: LayoutHook; worker?: LayoutWorkerHook } }\n  }, [\n    layoutCirclepack,\n    layoutCircular,\n    layoutForce,\n    layoutForceAtlas2,\n    layoutNoverlap,\n    layoutRandom,\n    workerForce,\n    workerNoverlap,\n    workerForceAtlas2\n  ])\n\n  const runLayout = useCallback(\n    (newLayout: LayoutName) => {\n      console.debug('Running layout:', newLayout)\n      const { positions } = layouts[newLayout].layout\n\n      try {\n        const graph = sigma.getGraph()\n        if (!graph) {\n          console.error('No graph available')\n          return\n        }\n\n        const pos = positions()\n        console.log('Positions calculated, animating nodes')\n        animateNodes(graph, pos, { duration: 400 })\n        setLayout(newLayout)\n      } catch (error) {\n        console.error('Error running layout:', error)\n      }\n    },\n    [layouts, sigma]\n  )\n\n  return (\n    <div>\n      <div>\n        {layouts[layout] && 'worker' in layouts[layout] && (\n          <WorkerLayoutControl\n            layout={layouts[layout].worker!}\n            mainLayout={layouts[layout].layout}\n          />\n        )}\n      </div>\n      <div>\n        <Popover open={opened} onOpenChange={setOpened}>\n          <PopoverTrigger asChild>\n            <Button\n              size=\"icon\"\n              variant={controlButtonVariant}\n              onClick={() => setOpened((e: boolean) => !e)}\n              tooltip={t('graphPanel.sideBar.layoutsControl.layoutGraph')}\n            >\n              <GripIcon />\n            </Button>\n          </PopoverTrigger>\n          <PopoverContent\n            side=\"right\"\n            align=\"start\"\n            sideOffset={8}\n            collisionPadding={5}\n            sticky=\"always\"\n            className=\"p-1 min-w-auto\"\n          >\n            <Command>\n              <CommandList>\n                <CommandGroup>\n                  {Object.keys(layouts).map((name) => (\n                    <CommandItem\n                      onSelect={() => {\n                        runLayout(name as LayoutName)\n                      }}\n                      key={name}\n                      className=\"cursor-pointer text-xs\"\n                    >\n                      {t(`graphPanel.sideBar.layoutsControl.layouts.${name}`)}\n                    </CommandItem>\n                  ))}\n                </CommandGroup>\n              </CommandList>\n            </Command>\n          </PopoverContent>\n        </Popover>\n      </div>\n    </div>\n  )\n}\n\nexport default LayoutsControl\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/Legend.tsx",
    "content": "import React from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { useGraphStore } from '@/stores/graph'\nimport { Card } from '@/components/ui/Card'\nimport { ScrollArea } from '@/components/ui/ScrollArea'\n\ninterface LegendProps {\n  className?: string\n}\n\nconst Legend: React.FC<LegendProps> = ({ className }) => {\n  const { t } = useTranslation()\n  const typeColorMap = useGraphStore.use.typeColorMap()\n\n  if (!typeColorMap || typeColorMap.size === 0) {\n    return null\n  }\n\n  return (\n    <Card className={`p-2 max-w-xs ${className}`}>\n      <h3 className=\"text-sm font-medium mb-2\">{t('graphPanel.legend')}</h3>\n      <ScrollArea className=\"max-h-80\">\n        <div className=\"flex flex-col gap-1\">\n          {Array.from(typeColorMap.entries()).map(([type, color]) => (\n            <div key={type} className=\"flex items-center gap-2\">\n              <div\n                className=\"w-4 h-4 rounded-full\"\n                style={{ backgroundColor: color }}\n              />\n              <span className=\"text-xs truncate\" title={type}>\n                {t(`graphPanel.nodeTypes.${type.toLowerCase().replace(/\\s+/g, '')}`, type)}\n              </span>\n            </div>\n          ))}\n        </div>\n      </ScrollArea>\n    </Card>\n  )\n}\n\nexport default Legend\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/LegendButton.tsx",
    "content": "import { useCallback } from 'react'\nimport { BookOpenIcon } from 'lucide-react'\nimport Button from '@/components/ui/Button'\nimport { controlButtonVariant } from '@/lib/constants'\nimport { useSettingsStore } from '@/stores/settings'\nimport { useTranslation } from 'react-i18next'\n\n/**\n * Component that toggles legend visibility.\n */\nconst LegendButton = () => {\n  const { t } = useTranslation()\n  const showLegend = useSettingsStore.use.showLegend()\n  const setShowLegend = useSettingsStore.use.setShowLegend()\n\n  const toggleLegend = useCallback(() => {\n    setShowLegend(!showLegend)\n  }, [showLegend, setShowLegend])\n\n  return (\n    <Button\n      variant={controlButtonVariant}\n      onClick={toggleLegend}\n      tooltip={t('graphPanel.sideBar.legendControl.toggleLegend')}\n      size=\"icon\"\n    >\n      <BookOpenIcon />\n    </Button>\n  )\n}\n\nexport default LegendButton\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/MergeDialog.tsx",
    "content": "import { useTranslation } from 'react-i18next'\nimport { useSettingsStore } from '@/stores/settings'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle\n} from '@/components/ui/Dialog'\nimport Button from '@/components/ui/Button'\n\ninterface MergeDialogProps {\n  mergeDialogOpen: boolean\n  mergeDialogInfo: {\n    targetEntity: string\n    sourceEntity: string\n  } | null\n  onOpenChange: (open: boolean) => void\n  onRefresh: (useMergedStart: boolean) => void\n}\n\n/**\n * MergeDialog component that appears after a successful entity merge\n * Allows user to choose whether to use the merged entity or keep current start point\n */\nconst MergeDialog = ({\n  mergeDialogOpen,\n  mergeDialogInfo,\n  onOpenChange,\n  onRefresh\n}: MergeDialogProps) => {\n  const { t } = useTranslation()\n  const currentQueryLabel = useSettingsStore.use.queryLabel()\n\n  return (\n    <Dialog open={mergeDialogOpen} onOpenChange={onOpenChange}>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>{t('graphPanel.propertiesView.mergeDialog.title')}</DialogTitle>\n          <DialogDescription>\n            {t('graphPanel.propertiesView.mergeDialog.description', {\n              source: mergeDialogInfo?.sourceEntity ?? '',\n              target: mergeDialogInfo?.targetEntity ?? '',\n            })}\n          </DialogDescription>\n        </DialogHeader>\n        <p className=\"text-sm text-muted-foreground\">\n          {t('graphPanel.propertiesView.mergeDialog.refreshHint')}\n        </p>\n        <DialogFooter className=\"mt-4 flex-col gap-2 sm:flex-row sm:justify-end\">\n          {currentQueryLabel !== mergeDialogInfo?.sourceEntity && (\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              onClick={() => onRefresh(false)}\n            >\n              {t('graphPanel.propertiesView.mergeDialog.keepCurrentStart')}\n            </Button>\n          )}\n          <Button type=\"button\" onClick={() => onRefresh(true)}>\n            {t('graphPanel.propertiesView.mergeDialog.useMergedStart')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\nexport default MergeDialog\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/PropertiesView.tsx",
    "content": "import { useEffect, useState } from 'react'\nimport { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'\nimport Text from '@/components/ui/Text'\nimport Button from '@/components/ui/Button'\nimport useLightragGraph from '@/hooks/useLightragGraph'\nimport { useTranslation } from 'react-i18next'\nimport { GitBranchPlus, Scissors } from 'lucide-react'\nimport EditablePropertyRow from './EditablePropertyRow'\n\n/**\n * Component that view properties of elements in graph.\n */\nconst PropertiesView = () => {\n  const { getNode, getEdge } = useLightragGraph()\n  const selectedNode = useGraphStore.use.selectedNode()\n  const focusedNode = useGraphStore.use.focusedNode()\n  const selectedEdge = useGraphStore.use.selectedEdge()\n  const focusedEdge = useGraphStore.use.focusedEdge()\n  const graphDataVersion = useGraphStore.use.graphDataVersion()\n\n  const [currentElement, setCurrentElement] = useState<NodeType | EdgeType | null>(null)\n  const [currentType, setCurrentType] = useState<'node' | 'edge' | null>(null)\n\n  // This effect will run when selection changes or when graph data is updated\n  useEffect(() => {\n    let type: 'node' | 'edge' | null = null\n    let element: RawNodeType | RawEdgeType | null = null\n    if (focusedNode) {\n      type = 'node'\n      element = getNode(focusedNode)\n    } else if (selectedNode) {\n      type = 'node'\n      element = getNode(selectedNode)\n    } else if (focusedEdge) {\n      type = 'edge'\n      element = getEdge(focusedEdge, true)\n    } else if (selectedEdge) {\n      type = 'edge'\n      element = getEdge(selectedEdge, true)\n    }\n\n    if (element) {\n      if (type == 'node') {\n        setCurrentElement(refineNodeProperties(element as any))\n      } else {\n        setCurrentElement(refineEdgeProperties(element as any))\n      }\n      setCurrentType(type)\n    } else {\n      setCurrentElement(null)\n      setCurrentType(null)\n    }\n  }, [\n    focusedNode,\n    selectedNode,\n    focusedEdge,\n    selectedEdge,\n    graphDataVersion, // Add dependency on graphDataVersion to refresh when data changes\n    setCurrentElement,\n    setCurrentType,\n    getNode,\n    getEdge\n  ])\n\n  if (!currentElement) {\n    return <></>\n  }\n  return (\n    <div className=\"bg-background/80 max-w-xs rounded-lg border-2 p-2 text-xs backdrop-blur-lg\">\n      {currentType == 'node' ? (\n        <NodePropertiesView node={currentElement as any} />\n      ) : (\n        <EdgePropertiesView edge={currentElement as any} />\n      )}\n    </div>\n  )\n}\n\ntype NodeType = RawNodeType & {\n  relationships: {\n    type: string\n    id: string\n    label: string\n  }[]\n}\n\ntype EdgeType = RawEdgeType & {\n  sourceNode?: RawNodeType\n  targetNode?: RawNodeType\n}\n\nconst refineNodeProperties = (node: RawNodeType): NodeType => {\n  const state = useGraphStore.getState()\n  const relationships = []\n\n  if (state.sigmaGraph && state.rawGraph) {\n    try {\n      if (!state.sigmaGraph.hasNode(node.id)) {\n        console.warn('Node not found in sigmaGraph:', node.id)\n        return {\n          ...node,\n          relationships: []\n        }\n      }\n\n      const edges = state.sigmaGraph.edges(node.id)\n\n      for (const edgeId of edges) {\n        if (!state.sigmaGraph.hasEdge(edgeId)) continue;\n\n        const edge = state.rawGraph.getEdge(edgeId, true)\n        if (edge) {\n          const isTarget = node.id === edge.source\n          const neighbourId = isTarget ? edge.target : edge.source\n\n          if (!state.sigmaGraph.hasNode(neighbourId)) continue;\n\n          const neighbour = state.rawGraph.getNode(neighbourId)\n          if (neighbour) {\n            relationships.push({\n              type: 'Neighbour',\n              id: neighbourId,\n              label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ')\n            })\n          }\n        }\n      }\n    } catch (error) {\n      console.error('Error refining node properties:', error)\n    }\n  }\n\n  return {\n    ...node,\n    relationships\n  }\n}\n\nconst refineEdgeProperties = (edge: RawEdgeType): EdgeType => {\n  const state = useGraphStore.getState()\n  let sourceNode: RawNodeType | undefined = undefined\n  let targetNode: RawNodeType | undefined = undefined\n\n  if (state.sigmaGraph && state.rawGraph) {\n    try {\n      if (!state.sigmaGraph.hasEdge(edge.dynamicId)) {\n        console.warn('Edge not found in sigmaGraph:', edge.id, 'dynamicId:', edge.dynamicId)\n        return {\n          ...edge,\n          sourceNode: undefined,\n          targetNode: undefined\n        }\n      }\n\n      if (state.sigmaGraph.hasNode(edge.source)) {\n        sourceNode = state.rawGraph.getNode(edge.source)\n      }\n\n      if (state.sigmaGraph.hasNode(edge.target)) {\n        targetNode = state.rawGraph.getNode(edge.target)\n      }\n    } catch (error) {\n      console.error('Error refining edge properties:', error)\n    }\n  }\n\n  return {\n    ...edge,\n    sourceNode,\n    targetNode\n  }\n}\n\nconst PropertyRow = ({\n  name,\n  value,\n  onClick,\n  tooltip,\n  nodeId,\n  edgeId,\n  dynamicId,\n  entityId,\n  entityType,\n  sourceId,\n  targetId,\n  isEditable = false,\n  truncate\n}: {\n  name: string\n  value: any\n  onClick?: () => void\n  tooltip?: string\n  nodeId?: string\n  entityId?: string\n  edgeId?: string\n  dynamicId?: string\n  entityType?: 'node' | 'edge'\n  sourceId?: string\n  targetId?: string\n  isEditable?: boolean\n  truncate?: string\n}) => {\n  const { t } = useTranslation()\n\n  const getPropertyNameTranslation = (name: string) => {\n    const translationKey = `graphPanel.propertiesView.node.propertyNames.${name}`\n    const translation = t(translationKey)\n    return translation === translationKey ? name : translation\n  }\n\n  // Utility function to convert <SEP> to newlines\n  const formatValueWithSeparators = (value: any): string => {\n    if (typeof value === 'string') {\n      return value.replace(/<SEP>/g, ';\\n')\n    }\n    return typeof value === 'string' ? value : JSON.stringify(value, null, 2)\n  }\n\n  // Format the value to convert <SEP> to newlines\n  const formattedValue = formatValueWithSeparators(value)\n  let formattedTooltip = tooltip || formatValueWithSeparators(value)\n\n  // If this is source_id field and truncate info exists, append it to the tooltip\n  if (name === 'source_id' && truncate) {\n    formattedTooltip += `\\n(Truncated: ${truncate})`\n  }\n\n  // Use EditablePropertyRow for editable fields (description, entity_id and entity_type)\n  if (isEditable && (name === 'description' || name === 'entity_id' || name === 'entity_type'  || name === 'keywords')) {\n    return (\n      <EditablePropertyRow\n        name={name}\n        value={value}\n        onClick={onClick}\n        nodeId={nodeId}\n        entityId={entityId}\n        edgeId={edgeId}\n        dynamicId={dynamicId}\n        entityType={entityType}\n        sourceId={sourceId}\n        targetId={targetId}\n        isEditable={true}\n        tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))}\n      />\n    )\n  }\n\n  // For non-editable fields, use the regular Text component\n  return (\n    <div className=\"flex items-center gap-2\">\n      <span className=\"text-primary/60 tracking-wide whitespace-nowrap\">\n        {getPropertyNameTranslation(name)}\n        {name === 'source_id' && truncate && <sup className=\"text-red-500\">†</sup>}\n      </span>:\n      <Text\n        className=\"hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis\"\n        tooltipClassName=\"max-w-96 -translate-x-13\"\n        text={formattedValue}\n        tooltip={formattedTooltip}\n        side=\"left\"\n        onClick={onClick}\n      />\n    </div>\n  )\n}\n\nconst NodePropertiesView = ({ node }: { node: NodeType }) => {\n  const { t } = useTranslation()\n\n  const handleExpandNode = () => {\n    useGraphStore.getState().triggerNodeExpand(node.id)\n  }\n\n  const handlePruneNode = () => {\n    useGraphStore.getState().triggerNodePrune(node.id)\n  }\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <div className=\"flex justify-between items-center\">\n        <h3 className=\"text-md pl-1 font-bold tracking-wide text-blue-700\">{t('graphPanel.propertiesView.node.title')}</h3>\n        <div className=\"flex gap-3\">\n          <Button\n            size=\"icon\"\n            variant=\"ghost\"\n            className=\"h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700\"\n            onClick={handleExpandNode}\n            tooltip={t('graphPanel.propertiesView.node.expandNode')}\n          >\n            <GitBranchPlus className=\"h-4 w-4 text-gray-700 dark:text-gray-300\" />\n          </Button>\n          <Button\n            size=\"icon\"\n            variant=\"ghost\"\n            className=\"h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700\"\n            onClick={handlePruneNode}\n            tooltip={t('graphPanel.propertiesView.node.pruneNode')}\n          >\n            <Scissors className=\"h-4 w-4 text-gray-900 dark:text-gray-300\" />\n          </Button>\n        </div>\n      </div>\n      <div className=\"bg-primary/5 max-h-96 overflow-auto rounded p-1\">\n        <PropertyRow name={t('graphPanel.propertiesView.node.id')} value={String(node.id)} />\n        <PropertyRow\n          name={t('graphPanel.propertiesView.node.labels')}\n          value={node.labels.join(', ')}\n          onClick={() => {\n            useGraphStore.getState().setSelectedNode(node.id, true)\n          }}\n        />\n        <PropertyRow name={t('graphPanel.propertiesView.node.degree')} value={node.degree} />\n      </div>\n      <h3 className=\"text-md pl-1 font-bold tracking-wide text-amber-700\">{t('graphPanel.propertiesView.node.properties')}</h3>\n      <div className=\"bg-primary/5 max-h-96 overflow-auto rounded p-1\">\n        {Object.keys(node.properties)\n          .sort()\n          .map((name) => {\n            if (name === 'created_at' || name === 'truncate') return null; // Hide created_at and truncate properties\n            return (\n              <PropertyRow\n                key={name}\n                name={name}\n                value={node.properties[name]}\n                nodeId={String(node.id)}\n                entityId={node.properties['entity_id']}\n                entityType=\"node\"\n                isEditable={name === 'description' || name === 'entity_id' || name === 'entity_type'}\n                truncate={node.properties['truncate']}\n              />\n            )\n          })}\n      </div>\n      {node.relationships.length > 0 && (\n        <>\n          <h3 className=\"text-md pl-1 font-bold tracking-wide text-emerald-700\">\n            {t('graphPanel.propertiesView.node.relationships')}\n          </h3>\n          <div className=\"bg-primary/5 max-h-96 overflow-auto rounded p-1\">\n            {node.relationships.map(({ type, id, label }) => {\n              return (\n                <PropertyRow\n                  key={id}\n                  name={type}\n                  value={label}\n                  onClick={() => {\n                    useGraphStore.getState().setSelectedNode(id, true)\n                  }}\n                />\n              )\n            })}\n          </div>\n        </>\n      )}\n    </div>\n  )\n}\n\nconst EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {\n  const { t } = useTranslation()\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <h3 className=\"text-md pl-1 font-bold tracking-wide text-violet-700\">{t('graphPanel.propertiesView.edge.title')}</h3>\n      <div className=\"bg-primary/5 max-h-96 overflow-auto rounded p-1\">\n        <PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} />\n        {edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />}\n        <PropertyRow\n          name={t('graphPanel.propertiesView.edge.source')}\n          value={edge.sourceNode ? edge.sourceNode.labels.join(', ') : edge.source}\n          onClick={() => {\n            useGraphStore.getState().setSelectedNode(edge.source, true)\n          }}\n        />\n        <PropertyRow\n          name={t('graphPanel.propertiesView.edge.target')}\n          value={edge.targetNode ? edge.targetNode.labels.join(', ') : edge.target}\n          onClick={() => {\n            useGraphStore.getState().setSelectedNode(edge.target, true)\n          }}\n        />\n      </div>\n      <h3 className=\"text-md pl-1 font-bold tracking-wide text-amber-700\">{t('graphPanel.propertiesView.edge.properties')}</h3>\n      <div className=\"bg-primary/5 max-h-96 overflow-auto rounded p-1\">\n        {Object.keys(edge.properties)\n          .sort()\n          .map((name) => {\n            if (name === 'created_at' || name === 'truncate') return null; // Hide created_at and truncate properties\n            return (\n              <PropertyRow\n                key={name}\n                name={name}\n                value={edge.properties[name]}\n                edgeId={String(edge.id)}\n                dynamicId={String(edge.dynamicId)}\n                entityType=\"edge\"\n                sourceId={edge.sourceNode?.properties['entity_id'] || edge.source}\n                targetId={edge.targetNode?.properties['entity_id'] || edge.target}\n                isEditable={name === 'description' || name === 'keywords'}\n                truncate={edge.properties['truncate']}\n              />\n            )\n          })}\n      </div>\n    </div>\n  )\n}\n\nexport default PropertiesView\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/PropertyEditDialog.tsx",
    "content": "import { useState, useEffect } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n  DialogDescription\n} from '@/components/ui/Dialog'\nimport Button from '@/components/ui/Button'\nimport Checkbox from '@/components/ui/Checkbox'\n\ninterface PropertyEditDialogProps {\n  isOpen: boolean\n  onClose: () => void\n  onSave: (value: string, options?: { allowMerge?: boolean }) => void\n  propertyName: string\n  initialValue: string\n  isSubmitting?: boolean\n  errorMessage?: string | null\n}\n\n/**\n * Dialog component for editing property values\n * Provides a modal with a title, multi-line text input, and save/cancel buttons\n */\nconst PropertyEditDialog = ({\n  isOpen,\n  onClose,\n  onSave,\n  propertyName,\n  initialValue,\n  isSubmitting = false,\n  errorMessage = null\n}: PropertyEditDialogProps) => {\n  const { t } = useTranslation()\n  const [value, setValue] = useState('')\n  const [allowMerge, setAllowMerge] = useState(false)\n\n  // Initialize value when dialog opens\n  useEffect(() => {\n    if (isOpen) {\n      setValue(initialValue)\n      setAllowMerge(false)\n    }\n  }, [isOpen, initialValue])\n\n  // Get translated property name\n  const getPropertyNameTranslation = (name: string) => {\n    const translationKey = `graphPanel.propertiesView.node.propertyNames.${name}`\n    const translation = t(translationKey)\n    return translation === translationKey ? name : translation\n  }\n\n  // Get textarea configuration based on property name\n  const getTextareaConfig = (propertyName: string) => {\n    switch (propertyName) {\n    case 'description':\n      return {\n        // No rows attribute for description to allow auto-sizing\n        className: 'max-h-[50vh] min-h-[10em] resize-y', // Maximum height 70% of viewport, minimum height ~20 lines, allow vertical resizing\n        style: {\n          height: '70vh', // Set initial height to 70% of viewport\n          minHeight: '20em', // Minimum height ~20 lines\n          resize: 'vertical' as const // Allow vertical resizing, using 'as const' to fix type\n        }\n      };\n    case 'entity_id':\n      return {\n        rows: 2,\n        className: '',\n        style: {}\n      };\n    case 'keywords':\n      return {\n        rows: 4,\n        className: '',\n        style: {}\n      };\n    default:\n      return {\n        rows: 5,\n        className: '',\n        style: {}\n      };\n    }\n  };\n\n  const handleSave = async () => {\n    const trimmedValue = value.trim()\n    if (trimmedValue !== '') {\n      const options = propertyName === 'entity_id' ? { allowMerge } : undefined\n      await onSave(trimmedValue, options)\n    }\n  }\n\n  return (\n    <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle>\n            {t('graphPanel.propertiesView.editProperty', {\n              property: getPropertyNameTranslation(propertyName)\n            })}\n          </DialogTitle>\n          <DialogDescription>\n            {t('graphPanel.propertiesView.editPropertyDescription')}\n          </DialogDescription>\n        </DialogHeader>\n\n        {/* Display error message if save fails */}\n        {errorMessage && (\n          <div className=\"bg-destructive/15 text-destructive px-4 py-2 rounded-md text-sm\">\n            {errorMessage}\n          </div>\n        )}\n\n        {/* Multi-line text input using textarea */}\n        <div className=\"grid gap-4 py-4\">\n          {(() => {\n            const config = getTextareaConfig(propertyName);\n            return propertyName === 'description' ? (\n              <textarea\n                value={value}\n                onChange={(e) => setValue(e.target.value)}\n                className={`border-input focus-visible:ring-ring flex w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${config.className}`}\n                style={config.style}\n                disabled={isSubmitting}\n              />\n            ) : (\n              <textarea\n                value={value}\n                onChange={(e) => setValue(e.target.value)}\n                rows={config.rows}\n                className={`border-input focus-visible:ring-ring flex w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${config.className}`}\n                disabled={isSubmitting}\n              />\n            );\n          })()}\n        </div>\n\n        {propertyName === 'entity_id' && (\n          <div className=\"rounded-md border border-border bg-muted/20 p-3\">\n            <label className=\"flex items-start gap-2 text-sm font-medium\">\n              <Checkbox\n                id=\"allow-merge\"\n                checked={allowMerge}\n                disabled={isSubmitting}\n                onCheckedChange={(checked) => setAllowMerge(checked === true)}\n              />\n              <div>\n                <span>{t('graphPanel.propertiesView.mergeOptionLabel')}</span>\n                <p className=\"text-xs font-normal text-muted-foreground\">\n                  {t('graphPanel.propertiesView.mergeOptionDescription')}\n                </p>\n              </div>\n            </label>\n          </div>\n        )}\n\n        <DialogFooter>\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            onClick={onClose}\n            disabled={isSubmitting}\n          >\n            {t('common.cancel')}\n          </Button>\n          <Button\n            type=\"button\"\n            onClick={handleSave}\n            disabled={isSubmitting}\n          >\n            {isSubmitting ? (\n              <>\n                <span className=\"mr-2\">\n                  <svg className=\"animate-spin h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n                    <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\"></circle>\n                    <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n                  </svg>\n                </span>\n                {t('common.saving')}\n              </>\n            ) : (\n              t('common.save')\n            )}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\nexport default PropertyEditDialog\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/PropertyRowComponents.tsx",
    "content": "import { PencilIcon } from 'lucide-react'\nimport Text from '@/components/ui/Text'\nimport { useTranslation } from 'react-i18next'\n\ninterface PropertyNameProps {\n  name: string\n}\n\nexport const PropertyName = ({ name }: PropertyNameProps) => {\n  const { t } = useTranslation()\n\n  const getPropertyNameTranslation = (propName: string) => {\n    const translationKey = `graphPanel.propertiesView.node.propertyNames.${propName}`\n    const translation = t(translationKey)\n    return translation === translationKey ? propName : translation\n  }\n\n  return (\n    <span className=\"text-primary/60 tracking-wide whitespace-nowrap\">\n      {getPropertyNameTranslation(name)}\n    </span>\n  )\n}\n\ninterface EditIconProps {\n  onClick: () => void\n}\n\nexport const EditIcon = ({ onClick }: EditIconProps) => (\n  <div>\n    <PencilIcon\n      className=\"h-3 w-3 text-gray-500 hover:text-gray-700 cursor-pointer\"\n      onClick={onClick}\n    />\n  </div>\n)\n\ninterface PropertyValueProps {\n  value: any\n  onClick?: () => void\n  tooltip?: string\n}\n\nexport const PropertyValue = ({ value, onClick, tooltip }: PropertyValueProps) => (\n  <div className=\"flex items-center gap-1 overflow-hidden\">\n    <Text\n      className=\"hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis whitespace-nowrap\"\n      tooltipClassName=\"max-w-80 -translate-x-15\"\n      text={value}\n      tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))}\n      side=\"left\"\n      onClick={onClick}\n    />\n  </div>\n)\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/Settings.tsx",
    "content": "import { useState, useCallback, useEffect} from 'react'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'\nimport Checkbox from '@/components/ui/Checkbox'\nimport Button from '@/components/ui/Button'\nimport Separator from '@/components/ui/Separator'\nimport Input from '@/components/ui/Input'\n\nimport { controlButtonVariant } from '@/lib/constants'\nimport { useSettingsStore } from '@/stores/settings'\nimport { useGraphStore } from '@/stores/graph'\nimport useRandomGraph from '@/hooks/useRandomGraph'\n\nimport { SettingsIcon, Undo2, Shuffle } from 'lucide-react'\nimport { useTranslation } from 'react-i18next';\n\n/**\n * Component that displays a checkbox with a label.\n */\nconst LabeledCheckBox = ({\n  checked,\n  onCheckedChange,\n  label\n}: {\n  checked: boolean\n  onCheckedChange: () => void\n  label: string\n}) => {\n  // Create unique ID using the label text converted to lowercase with spaces removed\n  const id = `checkbox-${label.toLowerCase().replace(/\\s+/g, '-')}`;\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      <Checkbox id={id} checked={checked} onCheckedChange={onCheckedChange} />\n      <label\n        htmlFor={id}\n        className=\"text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n      >\n        {label}\n      </label>\n    </div>\n  )\n}\n\n/**\n * Component that displays a number input with a label.\n */\nconst LabeledNumberInput = ({\n  value,\n  onEditFinished,\n  label,\n  min,\n  max,\n  defaultValue\n}: {\n  value: number\n  onEditFinished: (value: number) => void\n  label: string\n  min: number\n  max?: number\n  defaultValue?: number\n}) => {\n  const { t } = useTranslation();\n  const [currentValue, setCurrentValue] = useState<number | null>(value)\n  // Create unique ID using the label text converted to lowercase with spaces removed\n  const id = `input-${label.toLowerCase().replace(/\\s+/g, '-')}`;\n\n  useEffect(() => {\n    setCurrentValue(value)\n  }, [value])\n\n  const onValueChange = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      const text = e.target.value.trim()\n      if (text.length === 0) {\n        setCurrentValue(null)\n        return\n      }\n      const newValue = Number.parseInt(text)\n      if (!isNaN(newValue) && newValue !== currentValue) {\n        if (min !== undefined && newValue < min) {\n          return\n        }\n        if (max !== undefined && newValue > max) {\n          return\n        }\n        setCurrentValue(newValue)\n      }\n    },\n    [currentValue, min, max]\n  )\n\n  const onBlur = useCallback(() => {\n    if (currentValue !== null && value !== currentValue) {\n      onEditFinished(currentValue)\n    }\n  }, [value, currentValue, onEditFinished])\n\n  const handleReset = useCallback(() => {\n    if (defaultValue !== undefined && value !== defaultValue) {\n      setCurrentValue(defaultValue)\n      onEditFinished(defaultValue)\n    }\n  }, [defaultValue, value, onEditFinished])\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <label\n        htmlFor={id}\n        className=\"text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n      >\n        {label}\n      </label>\n      <div className=\"flex items-center gap-1\">\n        <Input\n          id={id}\n          type=\"number\"\n          value={currentValue === null ? '' : currentValue}\n          onChange={onValueChange}\n          className=\"h-6 w-full min-w-0 pr-1\"\n          min={min}\n          max={max}\n          onBlur={onBlur}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter') {\n              onBlur()\n            }\n          }}\n        />\n        {defaultValue !== undefined && (\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"h-6 w-6 flex-shrink-0 hover:bg-muted text-muted-foreground hover:text-foreground\"\n            onClick={handleReset}\n            type=\"button\"\n            title={t('graphPanel.sideBar.settings.resetToDefault')}\n          >\n            <Undo2 className=\"h-3.5 w-3.5\" />\n          </Button>\n        )}\n      </div>\n    </div>\n  )\n}\n\n/**\n * Component that displays a popover with settings options.\n */\nexport default function Settings() {\n  const [opened, setOpened] = useState<boolean>(false)\n\n  const showPropertyPanel = useSettingsStore.use.showPropertyPanel()\n  const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()\n  const showNodeLabel = useSettingsStore.use.showNodeLabel()\n  const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()\n  const enableNodeDrag = useSettingsStore.use.enableNodeDrag()\n  const enableHideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()\n  const showEdgeLabel = useSettingsStore.use.showEdgeLabel()\n  const minEdgeSize = useSettingsStore.use.minEdgeSize()\n  const maxEdgeSize = useSettingsStore.use.maxEdgeSize()\n  const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth()\n  const graphMaxNodes = useSettingsStore.use.graphMaxNodes()\n  const backendMaxGraphNodes = useSettingsStore.use.backendMaxGraphNodes()\n  const graphLayoutMaxIterations = useSettingsStore.use.graphLayoutMaxIterations()\n\n  const enableHealthCheck = useSettingsStore.use.enableHealthCheck()\n\n  // Random graph functionality for development/testing\n  const { randomGraph } = useRandomGraph()\n\n  const setEnableNodeDrag = useCallback(\n    () => useSettingsStore.setState((pre) => ({ enableNodeDrag: !pre.enableNodeDrag })),\n    []\n  )\n  const setEnableEdgeEvents = useCallback(\n    () => useSettingsStore.setState((pre) => ({ enableEdgeEvents: !pre.enableEdgeEvents })),\n    []\n  )\n  const setEnableHideUnselectedEdges = useCallback(\n    () =>\n      useSettingsStore.setState((pre) => ({\n        enableHideUnselectedEdges: !pre.enableHideUnselectedEdges\n      })),\n    []\n  )\n  const setShowEdgeLabel = useCallback(\n    () =>\n      useSettingsStore.setState((pre) => ({\n        showEdgeLabel: !pre.showEdgeLabel\n      })),\n    []\n  )\n\n  //\n  const setShowPropertyPanel = useCallback(\n    () => useSettingsStore.setState((pre) => ({ showPropertyPanel: !pre.showPropertyPanel })),\n    []\n  )\n\n  const setShowNodeSearchBar = useCallback(\n    () => useSettingsStore.setState((pre) => ({ showNodeSearchBar: !pre.showNodeSearchBar })),\n    []\n  )\n\n  const setShowNodeLabel = useCallback(\n    () => useSettingsStore.setState((pre) => ({ showNodeLabel: !pre.showNodeLabel })),\n    []\n  )\n\n  const setEnableHealthCheck = useCallback(\n    () => useSettingsStore.setState((pre) => ({ enableHealthCheck: !pre.enableHealthCheck })),\n    []\n  )\n\n  const setGraphQueryMaxDepth = useCallback((depth: number) => {\n    if (depth < 1) return\n    useSettingsStore.setState({ graphQueryMaxDepth: depth })\n    const currentLabel = useSettingsStore.getState().queryLabel\n    useSettingsStore.getState().setQueryLabel('')\n    setTimeout(() => {\n      useSettingsStore.getState().setQueryLabel(currentLabel)\n    }, 300)\n  }, [])\n\n  const setGraphMaxNodes = useCallback((nodes: number) => {\n    const maxLimit = backendMaxGraphNodes || 1000\n    if (nodes < 1 || nodes > maxLimit) return\n    useSettingsStore.getState().setGraphMaxNodes(nodes, true)\n  }, [backendMaxGraphNodes])\n\n  const setGraphLayoutMaxIterations = useCallback((iterations: number) => {\n    if (iterations < 1) return\n    useSettingsStore.setState({ graphLayoutMaxIterations: iterations })\n  }, [])\n\n  const handleGenerateRandomGraph = useCallback(() => {\n    const graph = randomGraph()\n    useGraphStore.getState().setSigmaGraph(graph)\n  }, [randomGraph])\n\n  const { t } = useTranslation();\n\n  const saveSettings = () => setOpened(false);\n\n  return (\n    <>\n      <Popover open={opened} onOpenChange={setOpened}>\n        <PopoverTrigger asChild>\n          <Button\n            variant={controlButtonVariant}\n            tooltip={t('graphPanel.sideBar.settings.settings')}\n            size=\"icon\"\n          >\n            <SettingsIcon />\n          </Button>\n        </PopoverTrigger>\n        <PopoverContent\n          side=\"right\"\n          align=\"end\"\n          sideOffset={8}\n          collisionPadding={5}\n          className=\"p-2 max-w-[200px]\"\n          onCloseAutoFocus={(e) => e.preventDefault()}\n        >\n          <div className=\"flex flex-col gap-2\">\n            <LabeledCheckBox\n              checked={enableHealthCheck}\n              onCheckedChange={setEnableHealthCheck}\n              label={t('graphPanel.sideBar.settings.healthCheck')}\n            />\n\n            <Separator />\n\n            <LabeledCheckBox\n              checked={showPropertyPanel}\n              onCheckedChange={setShowPropertyPanel}\n              label={t('graphPanel.sideBar.settings.showPropertyPanel')}\n            />\n            <LabeledCheckBox\n              checked={showNodeSearchBar}\n              onCheckedChange={setShowNodeSearchBar}\n              label={t('graphPanel.sideBar.settings.showSearchBar')}\n            />\n\n            <Separator />\n\n            <LabeledCheckBox\n              checked={showNodeLabel}\n              onCheckedChange={setShowNodeLabel}\n              label={t('graphPanel.sideBar.settings.showNodeLabel')}\n            />\n            <LabeledCheckBox\n              checked={enableNodeDrag}\n              onCheckedChange={setEnableNodeDrag}\n              label={t('graphPanel.sideBar.settings.nodeDraggable')}\n            />\n\n            <Separator />\n\n            <LabeledCheckBox\n              checked={showEdgeLabel}\n              onCheckedChange={setShowEdgeLabel}\n              label={t('graphPanel.sideBar.settings.showEdgeLabel')}\n            />\n            <LabeledCheckBox\n              checked={enableHideUnselectedEdges}\n              onCheckedChange={setEnableHideUnselectedEdges}\n              label={t('graphPanel.sideBar.settings.hideUnselectedEdges')}\n            />\n            <LabeledCheckBox\n              checked={enableEdgeEvents}\n              onCheckedChange={setEnableEdgeEvents}\n              label={t('graphPanel.sideBar.settings.edgeEvents')}\n            />\n\n            <div className=\"flex flex-col gap-2\">\n              <label htmlFor=\"edge-size-min\" className=\"text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70\">\n                {t('graphPanel.sideBar.settings.edgeSizeRange')}\n              </label>\n              <div className=\"flex items-center gap-2\">\n                <Input\n                  id=\"edge-size-min\"\n                  type=\"number\"\n                  value={minEdgeSize}\n                  onChange={(e) => {\n                    const newValue = Number(e.target.value);\n                    if (!isNaN(newValue) && newValue >= 1 && newValue <= maxEdgeSize) {\n                      useSettingsStore.setState({ minEdgeSize: newValue });\n                    }\n                  }}\n                  className=\"h-6 w-16 min-w-0 pr-1\"\n                  min={1}\n                  max={Math.min(maxEdgeSize, 10)}\n                />\n                <span>-</span>\n                <div className=\"flex items-center gap-1\">\n                  <Input\n                    id=\"edge-size-max\"\n                    type=\"number\"\n                    value={maxEdgeSize}\n                    onChange={(e) => {\n                      const newValue = Number(e.target.value);\n                      if (!isNaN(newValue) && newValue >= minEdgeSize && newValue >= 1 && newValue <= 10) {\n                        useSettingsStore.setState({ maxEdgeSize: newValue });\n                      }\n                    }}\n                    className=\"h-6 w-16 min-w-0 pr-1\"\n                    min={minEdgeSize}\n                    max={10}\n                  />\n                  <Button\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    className=\"h-6 w-6 flex-shrink-0 hover:bg-muted text-muted-foreground hover:text-foreground\"\n                    onClick={() => useSettingsStore.setState({ minEdgeSize: 1, maxEdgeSize: 5 })}\n                    type=\"button\"\n                    title={t('graphPanel.sideBar.settings.resetToDefault')}\n                  >\n                    <Undo2 className=\"h-3.5 w-3.5\" />\n                  </Button>\n                </div>\n              </div>\n            </div>\n\n            <Separator />\n            <LabeledNumberInput\n              label={t('graphPanel.sideBar.settings.maxQueryDepth')}\n              min={1}\n              value={graphQueryMaxDepth}\n              defaultValue={3}\n              onEditFinished={setGraphQueryMaxDepth}\n            />\n            <LabeledNumberInput\n              label={`${t('graphPanel.sideBar.settings.maxNodes')} (≤ ${backendMaxGraphNodes || 1000})`}\n              min={1}\n              max={backendMaxGraphNodes || 1000}\n              value={graphMaxNodes}\n              defaultValue={backendMaxGraphNodes || 1000}\n              onEditFinished={setGraphMaxNodes}\n            />\n            <LabeledNumberInput\n              label={t('graphPanel.sideBar.settings.maxLayoutIterations')}\n              min={1}\n              max={30}\n              value={graphLayoutMaxIterations}\n              defaultValue={15}\n              onEditFinished={setGraphLayoutMaxIterations}\n            />\n            {/* Development/Testing Section - Only visible in development mode */}\n            {import.meta.env.DEV && (\n              <>\n                <Separator />\n\n                <div className=\"flex flex-col gap-2\">\n                  <label className=\"text-sm leading-none font-medium text-muted-foreground\">\n                    Dev Options\n                  </label>\n                  <Button\n                    onClick={handleGenerateRandomGraph}\n                    variant=\"outline\"\n                    size=\"sm\"\n                    className=\"flex items-center gap-2\"\n                  >\n                    <Shuffle className=\"h-3.5 w-3.5\" />\n                    Gen Random Graph\n                  </Button>\n                </div>\n\n                <Separator />\n              </>\n            )}\n            <Button\n              onClick={saveSettings}\n              variant=\"outline\"\n              size=\"sm\"\n              className=\"ml-auto px-4\"\n            >\n              {t('graphPanel.sideBar.settings.save')}\n            </Button>\n\n          </div>\n        </PopoverContent>\n      </Popover>\n    </>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/SettingsDisplay.tsx",
    "content": "import { useSettingsStore } from '@/stores/settings'\nimport { useTranslation } from 'react-i18next'\n\n/**\n * Component that displays current values of important graph settings\n * Positioned to the right of the toolbar at the bottom-left corner\n */\nconst SettingsDisplay = () => {\n  const { t } = useTranslation()\n  const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth()\n  const graphMaxNodes = useSettingsStore.use.graphMaxNodes()\n\n  return (\n    <div className=\"absolute bottom-4 left-[calc(1rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400\">\n      <div>{t('graphPanel.sideBar.settings.depth')}: {graphQueryMaxDepth}</div>\n      <div>{t('graphPanel.sideBar.settings.max')}: {graphMaxNodes}</div>\n    </div>\n  )\n}\n\nexport default SettingsDisplay\n"
  },
  {
    "path": "lightrag_webui/src/components/graph/ZoomControl.tsx",
    "content": "import { useCamera, useSigma } from '@react-sigma/core'\nimport { useCallback } from 'react'\nimport Button from '@/components/ui/Button'\nimport { ZoomInIcon, ZoomOutIcon, FullscreenIcon, RotateCwIcon, RotateCcwIcon } from 'lucide-react'\nimport { controlButtonVariant } from '@/lib/constants'\nimport { useTranslation } from 'react-i18next';\n\n/**\n * Component that provides zoom controls for the graph viewer.\n */\nconst ZoomControl = () => {\n  const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 })\n  const sigma = useSigma()\n  const { t } = useTranslation();\n\n  const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])\n  const handleZoomOut = useCallback(() => zoomOut(), [zoomOut])\n  const handleResetZoom = useCallback(() => {\n    if (!sigma) return\n\n    try {\n      // First clear any custom bounding box and refresh\n      sigma.setCustomBBox(null)\n      sigma.refresh()\n\n      // Get graph after refresh\n      const graph = sigma.getGraph()\n\n      // Check if graph has nodes before accessing them\n      if (!graph?.order || graph.nodes().length === 0) {\n        // Use reset() for empty graph case\n        reset()\n        return\n      }\n\n      sigma.getCamera().animate(\n        { x: 0.5, y: 0.5, ratio: 1.1 },\n        { duration: 1000 }\n      )\n    } catch (error) {\n      console.error('Error resetting zoom:', error)\n      // Use reset() as fallback on error\n      reset()\n    }\n  }, [sigma, reset])\n\n  const handleRotate = useCallback(() => {\n    if (!sigma) return\n\n    const camera = sigma.getCamera()\n    const currentAngle = camera.angle\n    const newAngle = currentAngle + Math.PI / 8\n\n    camera.animate(\n      { angle: newAngle },\n      { duration: 200 }\n    )\n  }, [sigma])\n\n  const handleRotateCounterClockwise = useCallback(() => {\n    if (!sigma) return\n\n    const camera = sigma.getCamera()\n    const currentAngle = camera.angle\n    const newAngle = currentAngle - Math.PI / 8\n\n    camera.animate(\n      { angle: newAngle },\n      { duration: 200 }\n    )\n  }, [sigma])\n\n  return (\n    <>\n      <Button\n        variant={controlButtonVariant}\n        onClick={handleRotate}\n        tooltip={t('graphPanel.sideBar.zoomControl.rotateCamera')}\n        size=\"icon\"\n      >\n        <RotateCwIcon />\n      </Button>\n      <Button\n        variant={controlButtonVariant}\n        onClick={handleRotateCounterClockwise}\n        tooltip={t('graphPanel.sideBar.zoomControl.rotateCameraCounterClockwise')}\n        size=\"icon\"\n      >\n        <RotateCcwIcon />\n      </Button>\n      <Button\n        variant={controlButtonVariant}\n        onClick={handleResetZoom}\n        tooltip={t('graphPanel.sideBar.zoomControl.resetZoom')}\n        size=\"icon\"\n      >\n        <FullscreenIcon />\n      </Button>\n      <Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip={t('graphPanel.sideBar.zoomControl.zoomIn')} size=\"icon\">\n        <ZoomInIcon />\n      </Button>\n      <Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip={t('graphPanel.sideBar.zoomControl.zoomOut')} size=\"icon\">\n        <ZoomOutIcon />\n      </Button>\n    </>\n  )\n}\n\nexport default ZoomControl\n"
  },
  {
    "path": "lightrag_webui/src/components/retrieval/ChatMessage.tsx",
    "content": "import { ReactNode, useEffect, useMemo, useRef, memo, useState } from 'react' // Import useMemo\nimport { Message } from '@/api/lightrag'\nimport useTheme from '@/hooks/useTheme'\nimport { cn } from '@/lib/utils'\n\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport rehypeReact from 'rehype-react'\nimport rehypeRaw from 'rehype-raw'\nimport remarkMath from 'remark-math'\nimport mermaid from 'mermaid'\nimport { remarkFootnotes } from '@/utils/remarkFootnotes'\n\n\nimport { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'\nimport { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'\n\nimport { LoaderIcon, ChevronDownIcon } from 'lucide-react'\nimport { useTranslation } from 'react-i18next'\n\n// KaTeX configuration options interface\ninterface KaTeXOptions {\n  errorColor?: string;\n  throwOnError?: boolean;\n  displayMode?: boolean;\n  strict?: boolean;\n  trust?: boolean;\n  errorCallback?: (error: string, latex: string) => void;\n}\n\nexport type MessageWithError = Message & {\n  id: string // Unique identifier for stable React keys\n  isError?: boolean\n  isThinking?: boolean // Flag to indicate if the message is in a \"thinking\" state\n  /**\n   * Indicates if the mermaid diagram in this message has been rendered.\n   * Used to persist the rendering state across updates and prevent flickering.\n   */\n  mermaidRendered?: boolean\n  /**\n   * Indicates if the LaTeX formulas in this message are complete and ready for rendering.\n   * Used to prevent red error text during streaming of incomplete LaTeX formulas.\n   */\n  latexRendered?: boolean\n}\n\n// Restore original component definition and export\nexport const ChatMessage = ({\n  message,\n  isTabActive = true\n}: {\n  message: MessageWithError\n  isTabActive?: boolean\n}) => {\n  const { t } = useTranslation()\n  const { theme } = useTheme()\n  const [katexPlugin, setKatexPlugin] = useState<((options?: KaTeXOptions) => any) | null>(null)\n  const [isThinkingExpanded, setIsThinkingExpanded] = useState<boolean>(false)\n\n  // Directly use props passed from the parent.\n  const { thinkingContent, displayContent, thinkingTime, isThinking } = message\n\n  // Reset expansion state when new thinking starts\n  useEffect(() => {\n    if (isThinking) {\n      // When thinking starts, always reset to collapsed state\n      setIsThinkingExpanded(false)\n    }\n  }, [isThinking, message.id])\n\n  // The content to display is now non-ambiguous.\n  const finalThinkingContent = thinkingContent\n  // For user messages, displayContent will be undefined, so we fall back to content.\n  // For assistant messages, we prefer displayContent but fallback to content for backward compatibility\n  const finalDisplayContent = message.role === 'user'\n    ? message.content\n    : (displayContent !== undefined ? displayContent : (message.content || ''))\n\n  // Load KaTeX rehype plugin dynamically\n  // Note: KaTeX extensions (mhchem, copy-tex) are imported statically in main.tsx\n  useEffect(() => {\n    const loadKaTeX = async () => {\n      try {\n        const { default: rehypeKatex } = await import('rehype-katex');\n        setKatexPlugin(() => rehypeKatex);\n      } catch (error) {\n        console.error('Failed to load KaTeX plugin:', error);\n        setKatexPlugin(null);\n      }\n    };\n\n    loadKaTeX();\n  }, []);\n\n  const mainMarkdownComponents = useMemo(() => ({\n    code: (props: any) => {\n      const { inline, className, children, ...restProps } = props;\n      const match = /language-(\\w+)/.exec(className || '');\n      const language = match ? match[1] : undefined;\n\n      // Handle math blocks ($$...$$) - provide better container and styling\n      if (language === 'math' && !inline) {\n        return (\n          <div className=\"katex-display-wrapper my-4 overflow-x-auto\">\n            <div className=\"text-current\">{children}</div>\n          </div>\n        );\n      }\n\n      // Handle inline math ($...$) - ensure proper inline display\n      if (language === 'math' && inline) {\n        return (\n          <span className=\"katex-inline-wrapper\">\n            <span className=\"text-current\">{children}</span>\n          </span>\n        );\n      }\n\n      // Handle all other code (inline and block)\n      return (\n        <CodeHighlight\n          inline={inline}\n          className={className}\n          {...restProps}\n          renderAsDiagram={message.mermaidRendered ?? false}\n          messageRole={message.role}\n        >\n          {children}\n        </CodeHighlight>\n      );\n    },\n    p: ({ children }: { children?: ReactNode }) => <div className=\"my-2\">{children}</div>,\n    h1: ({ children }: { children?: ReactNode }) => <h1 className=\"text-xl font-bold mt-4 mb-2\">{children}</h1>,\n    h2: ({ children }: { children?: ReactNode }) => <h2 className=\"text-lg font-bold mt-4 mb-2\">{children}</h2>,\n    h3: ({ children }: { children?: ReactNode }) => <h3 className=\"text-base font-bold mt-3 mb-2\">{children}</h3>,\n    h4: ({ children }: { children?: ReactNode }) => <h4 className=\"text-base font-semibold mt-3 mb-2\">{children}</h4>,\n    ul: ({ children }: { children?: ReactNode }) => <ul className=\"list-disc pl-5 my-2\">{children}</ul>,\n    ol: ({ children }: { children?: ReactNode }) => <ol className=\"list-decimal pl-5 my-2\">{children}</ol>,\n    li: ({ children }: { children?: ReactNode }) => <li className=\"my-1\">{children}</li>\n  }), [message.mermaidRendered, message.role]);\n\n  const thinkingMarkdownComponents = useMemo(() => ({\n    code: (props: any) => (<CodeHighlight {...props} renderAsDiagram={message.mermaidRendered ?? false} messageRole={message.role} />)\n  }), [message.mermaidRendered, message.role]);\n\n  return (\n    <div\n      className={`${\n        message.role === 'user'\n          ? 'max-w-[80%] bg-primary text-primary-foreground'\n          : message.isError\n            ? 'w-[95%] bg-red-100 text-red-600 dark:bg-red-950 dark:text-red-400'\n            : 'w-[95%] bg-muted'\n      } rounded-lg px-4 py-2`}\n    >\n      {/* Thinking process display - only for assistant messages */}\n      {/* Always render to prevent layout shift when switching tabs */}\n      {message.role === 'assistant' && (isThinking || thinkingTime !== null) && (\n        <div className={cn(\n          'mb-2',\n          // Reduce visual priority in inactive tabs while maintaining layout\n          !isTabActive && 'opacity-50'\n        )}>\n          <div\n            className=\"flex items-center text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors duration-200 text-sm cursor-pointer select-none\"\n            onClick={() => {\n              // Allow expansion when there's thinking content, even during thinking process\n              if (finalThinkingContent && finalThinkingContent.trim() !== '') {\n                setIsThinkingExpanded(!isThinkingExpanded)\n              }\n            }}\n          >\n            {isThinking ? (\n              <>\n                {/* Only show spinner animation in active tab to save resources */}\n                {isTabActive && <LoaderIcon className=\"mr-2 size-4 animate-spin\" />}\n                <span>{t('retrievePanel.chatMessage.thinking')}</span>\n              </>\n            ) : (\n              typeof thinkingTime === 'number' && <span>{t('retrievePanel.chatMessage.thinkingTime', { time: thinkingTime })}</span>\n            )}\n            {/* Show chevron when there's thinking content, even during thinking process */}\n            {finalThinkingContent && finalThinkingContent.trim() !== '' && <ChevronDownIcon className={`ml-2 size-4 shrink-0 transition-transform ${isThinkingExpanded ? 'rotate-180' : ''}`} />}\n          </div>\n          {/* Show thinking content when expanded and content exists, even during thinking process */}\n          {isThinkingExpanded && finalThinkingContent && finalThinkingContent.trim() !== '' && (\n            <div className=\"mt-2 pl-4 border-l-2 border-primary/20 dark:border-primary/40 text-sm prose dark:prose-invert max-w-none break-words prose-p:my-1 prose-headings:my-2 [&_sup]:text-[0.75em] [&_sup]:align-[0.1em] [&_sup]:leading-[0] [&_sub]:text-[0.75em] [&_sub]:align-[-0.2em] [&_sub]:leading-[0] [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-800 [&_u]:underline [&_del]:line-through [&_ins]:underline [&_ins]:decoration-green-500 [&_.footnotes]:mt-6 [&_.footnotes]:pt-3 [&_.footnotes]:border-t [&_.footnotes]:border-border [&_.footnotes_ol]:text-xs [&_.footnotes_li]:my-0.5 [&_a[href^='#fn']]:text-primary [&_a[href^='#fn']]:no-underline [&_a[href^='#fn']]:hover:underline [&_a[href^='#fnref']]:text-primary [&_a[href^='#fnref']]:no-underline [&_a[href^='#fnref']]:hover:underline text-foreground\">\n              {isThinking && (\n                <div className=\"mb-2 text-xs text-gray-400 dark:text-gray-300 italic\">\n                  {t('retrievePanel.chatMessage.thinkingInProgress', 'Thinking in progress...')}\n                </div>\n              )}\n              <ReactMarkdown\n                remarkPlugins={[remarkGfm, remarkFootnotes, remarkMath]}\n                rehypePlugins={[\n                  rehypeRaw,\n                  ...((katexPlugin && (message.latexRendered ?? true)) ? [[katexPlugin, {\n                    errorColor: theme === 'dark' ? '#ef4444' : '#dc2626',\n                    throwOnError: false,\n                    displayMode: false,\n                    strict: false,\n                    trust: true,\n                    // Add silent error handling to avoid console noise\n                    errorCallback: (error: string, latex: string) => {\n                      // Only show detailed errors in development environment\n                      if (process.env.NODE_ENV === 'development') {\n                        console.warn('KaTeX rendering error in thinking content:', error, 'for LaTeX:', latex);\n                      }\n                    }\n                  }] as any] : []),\n                  rehypeReact\n                ]}\n                skipHtml={false}\n                components={thinkingMarkdownComponents}\n              >\n                {finalThinkingContent}\n              </ReactMarkdown>\n            </div>\n          )}\n        </div>\n      )}\n      {/* Main content display */}\n      {finalDisplayContent && (\n        <div className=\"relative\">\n          <div className={`prose dark:prose-invert max-w-none text-sm break-words prose-headings:mt-4 prose-headings:mb-2 prose-p:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 [&_.katex]:text-current [&_.katex-display]:my-4 [&_.katex-display]:max-w-full [&_.katex-display_>.base]:overflow-x-auto [&_sup]:text-[0.75em] [&_sup]:align-[0.1em] [&_sup]:leading-[0] [&_sub]:text-[0.75em] [&_sub]:align-[-0.2em] [&_sub]:leading-[0] [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-800 [&_u]:underline [&_del]:line-through [&_ins]:underline [&_ins]:decoration-green-500 [&_.footnotes]:mt-8 [&_.footnotes]:pt-4 [&_.footnotes]:border-t [&_.footnotes_ol]:text-sm [&_.footnotes_li]:my-1 ${\n            message.role === 'user' ? 'text-primary-foreground' : 'text-foreground'\n          } ${\n            message.role === 'user'\n              ? '[&_.footnotes]:border-primary-foreground/30 [&_a[href^=\"#fn\"]]:text-primary-foreground [&_a[href^=\"#fn\"]]:no-underline [&_a[href^=\"#fn\"]]:hover:underline [&_a[href^=\"#fnref\"]]:text-primary-foreground [&_a[href^=\"#fnref\"]]:no-underline [&_a[href^=\"#fnref\"]]:hover:underline'\n              : '[&_.footnotes]:border-border [&_a[href^=\"#fn\"]]:text-primary [&_a[href^=\"#fn\"]]:no-underline [&_a[href^=\"#fn\"]]:hover:underline [&_a[href^=\"#fnref\"]]:text-primary [&_a[href^=\"#fnref\"]]:no-underline [&_a[href^=\"#fnref\"]]:hover:underline'\n          }`}>\n            <ReactMarkdown\n              remarkPlugins={[remarkGfm, remarkFootnotes, remarkMath]}\n              rehypePlugins={[\n                rehypeRaw,\n                ...((katexPlugin && (message.latexRendered ?? true)) ? [[\n                  katexPlugin,\n                  {\n                    errorColor: theme === 'dark' ? '#ef4444' : '#dc2626',\n                    throwOnError: false,\n                    displayMode: false,\n                    strict: false,\n                    trust: true,\n                    // Add silent error handling to avoid console noise\n                    errorCallback: (error: string, latex: string) => {\n                      // Only show detailed errors in development environment\n                      if (process.env.NODE_ENV === 'development') {\n                        console.warn('KaTeX rendering error in main content:', error, 'for LaTeX:', latex);\n                      }\n                    }\n                  }\n                ] as any] : []),\n                rehypeReact\n              ]}\n              skipHtml={false}\n              components={mainMarkdownComponents}\n            >\n              {finalDisplayContent}\n            </ReactMarkdown>\n          </div>\n        </div>\n      )}\n      {/* Loading indicator - only show in active tab */}\n      {isTabActive && (() => {\n        // More comprehensive loading state check\n        const hasVisibleContent = finalDisplayContent && finalDisplayContent.trim() !== '';\n        const isLoadingState = !hasVisibleContent && !isThinking && !thinkingTime;\n        return isLoadingState && <LoaderIcon className=\"animate-spin duration-2000\" />\n      })()}\n    </div>\n  )\n}\n\n// Remove the incorrect memo export line\n\ninterface CodeHighlightProps {\n  inline?: boolean\n  className?: string\n  children?: ReactNode\n  renderAsDiagram?: boolean // Flag to indicate if rendering as diagram should be attempted\n  messageRole?: 'user' | 'assistant' // Message role for context-aware styling\n}\n\n\n\n// Check if it is a large JSON\nconst isLargeJson = (language: string | undefined, content: string | undefined): boolean => {\n  if (!content || language !== 'json') return false;\n  return content.length > 5000; // JSON larger than 5KB is considered large JSON\n};\n\n// Memoize the CodeHighlight component\nconst CodeHighlight = memo(({ inline, className, children, renderAsDiagram = false, messageRole, ...props }: CodeHighlightProps) => {\n  const { theme } = useTheme();\n  const [hasRendered, setHasRendered] = useState(false); // State to track successful render\n  const match = className?.match(/language-(\\w+)/);\n  const language = match ? match[1] : undefined;\n  const mermaidRef = useRef<HTMLDivElement>(null);\n  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // Use ReturnType for better typing\n\n  // Get the content string, check if it is a large JSON\n  const contentStr = String(children || '').replace(/\\n$/, '');\n  const isLargeJsonBlock = isLargeJson(language, contentStr);\n\n  // Handle Mermaid rendering with debounce\n  useEffect(() => {\n    // Effect should run when renderAsDiagram becomes true or hasRendered changes.\n    // The actual rendering logic inside checks language and hasRendered state.\n    if (renderAsDiagram && !hasRendered && language === 'mermaid' && mermaidRef.current) {\n      const container = mermaidRef.current; // Capture ref value\n\n      // Clear previous timer if dependencies change before timeout (e.g., renderAsDiagram flips quickly)\n      if (debounceTimerRef.current) {\n        clearTimeout(debounceTimerRef.current);\n      }\n\n      debounceTimerRef.current = setTimeout(() => {\n        if (!container) return; // Container might have unmounted\n\n        // Double check hasRendered state inside timeout, in case it changed rapidly\n        if (hasRendered) return;\n\n        try {\n          // Initialize mermaid config\n          mermaid.initialize({\n            startOnLoad: false,\n            theme: theme === 'dark' ? 'dark' : 'default',\n            securityLevel: 'loose',\n            suppressErrorRendering: true,\n          });\n\n          // Show loading indicator\n          container.innerHTML = '<div class=\"flex justify-center items-center p-4\"><svg class=\"animate-spin h-5 w-5 text-primary\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle><path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path></svg></div>';\n\n          // Preprocess mermaid content\n          const rawContent = String(children).replace(/\\n$/, '').trim();\n\n          // Heuristic check for potentially complete graph definition\n          const looksPotentiallyComplete = rawContent.length > 10 && (\n            rawContent.startsWith('graph') ||\n            rawContent.startsWith('sequenceDiagram') ||\n            rawContent.startsWith('classDiagram') ||\n            rawContent.startsWith('stateDiagram') ||\n            rawContent.startsWith('gantt') ||\n            rawContent.startsWith('pie') ||\n            rawContent.startsWith('flowchart') ||\n            rawContent.startsWith('erDiagram')\n          );\n\n          if (!looksPotentiallyComplete) {\n            console.log('Mermaid content might be incomplete, skipping render attempt:', rawContent);\n            // Optionally keep loading indicator or show a message\n            // container.innerHTML = '<p class=\"text-sm text-muted-foreground\">Waiting for complete diagram...</p>';\n            return;\n          }\n\n          const processedContent = rawContent\n            .split('\\n')\n            .map(line => {\n              const trimmedLine = line.trim();\n              if (trimmedLine.startsWith('subgraph')) {\n                const parts = trimmedLine.split(' ');\n                if (parts.length > 1) {\n                  const title = parts.slice(1).join(' ').replace(/[\"']/g, '');\n                  return `subgraph \"${title}\"`;\n                }\n              }\n              return trimmedLine;\n            })\n            .filter(line => !line.trim().startsWith('linkStyle'))\n            .join('\\n');\n\n          const mermaidId = `mermaid-${Date.now()}`;\n          mermaid.render(mermaidId, processedContent)\n            .then(({ svg, bindFunctions }) => {\n              // Check ref and hasRendered state again inside async callback\n              if (mermaidRef.current === container && !hasRendered) {\n                container.innerHTML = svg;\n                setHasRendered(true); // Mark as rendered successfully\n                if (bindFunctions) {\n                  try {\n                    bindFunctions(container);\n                  } catch (bindError) {\n                    console.error('Mermaid bindFunctions error:', bindError);\n                    container.innerHTML += '<p class=\"text-orange-500 text-xs\">Diagram interactions might be limited.</p>';\n                  }\n                }\n              } else if (mermaidRef.current !== container) {\n                console.log('Mermaid container changed before rendering completed.');\n              }\n            })\n            .catch(error => {\n              console.error('Mermaid rendering promise error (debounced):', error);\n              console.error('Failed content (debounced):', processedContent);\n              if (mermaidRef.current === container) {\n                const errorMessage = error instanceof Error ? error.message : String(error);\n                const errorPre = document.createElement('pre');\n                errorPre.className = 'text-red-500 text-xs whitespace-pre-wrap break-words';\n                errorPre.textContent = `Mermaid diagram error: ${errorMessage}\\n\\nContent:\\n${processedContent}`;\n                container.innerHTML = '';\n                container.appendChild(errorPre);\n              }\n            });\n\n        } catch (error) {\n          console.error('Mermaid synchronous error (debounced):', error);\n          console.error('Failed content (debounced):', String(children));\n          if (mermaidRef.current === container) {\n            const errorMessage = error instanceof Error ? error.message : String(error);\n            const errorPre = document.createElement('pre');\n            errorPre.className = 'text-red-500 text-xs whitespace-pre-wrap break-words';\n            errorPre.textContent = `Mermaid diagram setup error: ${errorMessage}`;\n            container.innerHTML = '';\n            container.appendChild(errorPre);\n          }\n        }\n      }, 300); // Debounce delay\n    }\n\n    // Cleanup function to clear the timer on unmount or before re-running effect\n    return () => {\n      if (debounceTimerRef.current) {\n        clearTimeout(debounceTimerRef.current);\n      }\n    };\n  // Dependencies: renderAsDiagram ensures effect runs when diagram should be shown.\n  // Dependencies include all values used inside the effect to satisfy exhaustive-deps.\n  // The !hasRendered check prevents re-execution of render logic after success.\n  }, [renderAsDiagram, hasRendered, language, children, theme]); // Add children and theme back\n\n  // For large JSON, skip syntax highlighting completely and use a simple pre tag\n  if (isLargeJsonBlock) {\n    return (\n      <pre className=\"whitespace-pre-wrap break-words bg-muted p-4 rounded-md overflow-x-auto text-sm font-mono\">\n        {contentStr}\n      </pre>\n    );\n  }\n\n  // Render based on language type\n  // If it's a mermaid language block and rendering as diagram is not requested (e.g., incomplete stream), display as plain text\n  if (language === 'mermaid' && !renderAsDiagram) {\n    return (\n      <SyntaxHighlighter\n        style={theme === 'dark' ? oneDark : oneLight}\n        PreTag=\"div\"\n        language=\"text\" // Use text as language to avoid syntax highlighting errors\n        {...props}\n      >\n        {contentStr}\n      </SyntaxHighlighter>\n    );\n  }\n\n  // If it's a mermaid language block and the message is complete, render as diagram\n  if (language === 'mermaid') {\n    // Container for Mermaid diagram\n    return <div className=\"mermaid-diagram-container my-4 overflow-x-auto\" ref={mermaidRef}></div>;\n  }\n\n\n  // ReactMarkdown determines inline vs block based on markdown syntax\n  // Inline code: `code` (no className with language)\n  // Block code: ```language (has className like \"language-js\")\n  // If there's no language className and no explicit inline prop, it's likely inline code\n  const isInline = inline ?? !className?.startsWith('language-');\n\n  // Generate dynamic inline code styles based on message role and theme\n  const getInlineCodeStyles = () => {\n    if (messageRole === 'user') {\n      // User messages have dark background (bg-primary), need light inline code\n      return theme === 'dark'\n        ? 'bg-primary-foreground/20 text-primary-foreground border border-primary-foreground/30'\n        : 'bg-primary-foreground/20 text-primary-foreground border border-primary-foreground/30';\n    } else {\n      // Assistant messages have light background (bg-muted), need contrasting inline code\n      return theme === 'dark'\n        ? 'bg-muted-foreground/20 text-muted-foreground border border-muted-foreground/30'\n        : 'bg-slate-200 text-slate-800 border border-slate-300';\n    }\n  };\n\n  // Handle non-Mermaid code blocks\n  return !isInline ? (\n    <SyntaxHighlighter\n      style={theme === 'dark' ? oneDark : oneLight}\n      PreTag=\"div\"\n      language={language}\n      {...props}\n    >\n      {contentStr}\n    </SyntaxHighlighter>\n  ) : (\n    // Handle inline code with context-aware styling\n    <code\n      className={cn(\n        className,\n        'mx-1 rounded-sm px-1 py-0.5 font-mono text-sm',\n        getInlineCodeStyles()\n      )}\n      {...props}\n    >\n      {children}\n    </code>\n  );\n});\n\n// Assign display name for React DevTools\nCodeHighlight.displayName = 'CodeHighlight';\n"
  },
  {
    "path": "lightrag_webui/src/components/retrieval/QuerySettings.tsx",
    "content": "import { useCallback, useMemo } from 'react'\nimport { QueryMode, QueryRequest } from '@/api/lightrag'\n// Removed unused import for Text component\nimport Checkbox from '@/components/ui/Checkbox'\nimport Input from '@/components/ui/Input'\nimport UserPromptInputWithHistory from '@/components/ui/UserPromptInputWithHistory'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'\nimport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectTrigger,\n  SelectValue\n} from '@/components/ui/Select'\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'\nimport { useSettingsStore } from '@/stores/settings'\nimport { useTranslation } from 'react-i18next'\nimport { RotateCcw } from 'lucide-react'\n\nexport default function QuerySettings() {\n  const { t } = useTranslation()\n  const querySettings = useSettingsStore((state) => state.querySettings)\n  const userPromptHistory = useSettingsStore((state) => state.userPromptHistory)\n\n  const handleChange = useCallback((key: keyof QueryRequest, value: any) => {\n    useSettingsStore.getState().updateQuerySettings({ [key]: value })\n  }, [])\n\n  const handleSelectFromHistory = useCallback((prompt: string) => {\n    handleChange('user_prompt', prompt)\n  }, [handleChange])\n\n  const handleDeleteFromHistory = useCallback((index: number) => {\n    const newHistory = [...userPromptHistory]\n    newHistory.splice(index, 1)\n    useSettingsStore.getState().setUserPromptHistory(newHistory)\n  }, [userPromptHistory])\n\n  // Default values for reset functionality\n  const defaultValues = useMemo(() => ({\n    mode: 'mix' as QueryMode,\n    top_k: 40,\n    chunk_top_k: 20,\n    max_entity_tokens: 6000,\n    max_relation_tokens: 8000,\n    max_total_tokens: 30000\n  }), [])\n\n  const handleReset = useCallback((key: keyof typeof defaultValues) => {\n    handleChange(key, defaultValues[key])\n  }, [handleChange, defaultValues])\n\n  // Reset button component\n  const ResetButton = ({ onClick, title }: { onClick: () => void; title: string }) => (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <button\n            type=\"button\"\n            onClick={onClick}\n            className=\"mr-1 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors\"\n            title={title}\n          >\n            <RotateCcw className=\"h-3 w-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200\" />\n          </button>\n        </TooltipTrigger>\n        <TooltipContent side=\"left\">\n          <p>{title}</p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  )\n\n  return (\n    <Card className=\"flex shrink-0 flex-col w-[280px]\">\n      <CardHeader className=\"px-4 pt-4 pb-2\">\n        <CardTitle>{t('retrievePanel.querySettings.parametersTitle')}</CardTitle>\n        <CardDescription className=\"sr-only\">{t('retrievePanel.querySettings.parametersDescription')}</CardDescription>\n      </CardHeader>\n      <CardContent className=\"m-0 flex grow flex-col p-0 text-xs\">\n        <div className=\"relative size-full\">\n          <div className=\"absolute inset-0 flex flex-col gap-2 overflow-auto px-2 pr-2\">\n            {/* User Prompt - Moved to top for better dropdown space */}\n            <>\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <label htmlFor=\"user_prompt\" className=\"ml-1 cursor-help\">\n                      {t('retrievePanel.querySettings.userPrompt')}\n                    </label>\n                  </TooltipTrigger>\n                  <TooltipContent side=\"left\">\n                    <p>{t('retrievePanel.querySettings.userPromptTooltip')}</p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n              <div>\n                <UserPromptInputWithHistory\n                  id=\"user_prompt\"\n                  value={querySettings.user_prompt || ''}\n                  onChange={(value) => handleChange('user_prompt', value)}\n                  onSelectFromHistory={handleSelectFromHistory}\n                  onDeleteFromHistory={handleDeleteFromHistory}\n                  history={userPromptHistory}\n                  placeholder={t('retrievePanel.querySettings.userPromptPlaceholder')}\n                  className=\"h-9\"\n                />\n              </div>\n            </>\n\n            {/* Query Mode */}\n            <>\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <label htmlFor=\"query_mode_select\" className=\"ml-1 cursor-help\">\n                      {t('retrievePanel.querySettings.queryMode')}\n                    </label>\n                  </TooltipTrigger>\n                  <TooltipContent side=\"left\">\n                    <p>{t('retrievePanel.querySettings.queryModeTooltip')}</p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n              <div className=\"flex items-center gap-1\">\n                <Select\n                  value={querySettings.mode}\n                  onValueChange={(v) => handleChange('mode', v as QueryMode)}\n                >\n                  <SelectTrigger\n                    id=\"query_mode_select\"\n                    className=\"hover:bg-primary/5 h-9 cursor-pointer focus:ring-0 focus:ring-offset-0 focus:outline-0 active:right-0 flex-1 text-left [&>span]:break-all [&>span]:line-clamp-1\"\n                  >\n                    <SelectValue />\n                  </SelectTrigger>\n                  <SelectContent>\n                    <SelectGroup>\n                      <SelectItem value=\"naive\">{t('retrievePanel.querySettings.queryModeOptions.naive')}</SelectItem>\n                      <SelectItem value=\"local\">{t('retrievePanel.querySettings.queryModeOptions.local')}</SelectItem>\n                      <SelectItem value=\"global\">{t('retrievePanel.querySettings.queryModeOptions.global')}</SelectItem>\n                      <SelectItem value=\"hybrid\">{t('retrievePanel.querySettings.queryModeOptions.hybrid')}</SelectItem>\n                      <SelectItem value=\"mix\">{t('retrievePanel.querySettings.queryModeOptions.mix')}</SelectItem>\n                      <SelectItem value=\"bypass\">{t('retrievePanel.querySettings.queryModeOptions.bypass')}</SelectItem>\n                    </SelectGroup>\n                  </SelectContent>\n                </Select>\n                <ResetButton\n                  onClick={() => handleReset('mode')}\n                  title=\"Reset to default (Mix)\"\n                />\n              </div>\n            </>\n\n            {/* Top K */}\n            <>\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <label htmlFor=\"top_k\" className=\"ml-1 cursor-help\">\n                      {t('retrievePanel.querySettings.topK')}\n                    </label>\n                  </TooltipTrigger>\n                  <TooltipContent side=\"left\">\n                    <p>{t('retrievePanel.querySettings.topKTooltip')}</p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n              <div className=\"flex items-center gap-1\">\n                <Input\n                  id=\"top_k\"\n                  type=\"number\"\n                  value={querySettings.top_k ?? ''}\n                  onChange={(e) => {\n                    const value = e.target.value\n                    handleChange('top_k', value === '' ? '' : parseInt(value) || 0)\n                  }}\n                  onBlur={(e) => {\n                    const value = e.target.value\n                    if (value === '' || isNaN(parseInt(value))) {\n                      handleChange('top_k', 40)\n                    }\n                  }}\n                  min={1}\n                  placeholder={t('retrievePanel.querySettings.topKPlaceholder')}\n                  className=\"h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]\"\n                />\n                <ResetButton\n                  onClick={() => handleReset('top_k')}\n                  title=\"Reset to default\"\n                />\n              </div>\n            </>\n\n            {/* Chunk Top K */}\n            <>\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <label htmlFor=\"chunk_top_k\" className=\"ml-1 cursor-help\">\n                      {t('retrievePanel.querySettings.chunkTopK')}\n                    </label>\n                  </TooltipTrigger>\n                  <TooltipContent side=\"left\">\n                    <p>{t('retrievePanel.querySettings.chunkTopKTooltip')}</p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n              <div className=\"flex items-center gap-1\">\n                <Input\n                  id=\"chunk_top_k\"\n                  type=\"number\"\n                  value={querySettings.chunk_top_k ?? ''}\n                  onChange={(e) => {\n                    const value = e.target.value\n                    handleChange('chunk_top_k', value === '' ? '' : parseInt(value) || 0)\n                  }}\n                  onBlur={(e) => {\n                    const value = e.target.value\n                    if (value === '' || isNaN(parseInt(value))) {\n                      handleChange('chunk_top_k', 20)\n                    }\n                  }}\n                  min={1}\n                  placeholder={t('retrievePanel.querySettings.chunkTopKPlaceholder')}\n                  className=\"h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]\"\n                />\n                <ResetButton\n                  onClick={() => handleReset('chunk_top_k')}\n                  title=\"Reset to default\"\n                />\n              </div>\n            </>\n\n            {/* Max Entity Tokens */}\n            <>\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <label htmlFor=\"max_entity_tokens\" className=\"ml-1 cursor-help\">\n                      {t('retrievePanel.querySettings.maxEntityTokens')}\n                    </label>\n                  </TooltipTrigger>\n                  <TooltipContent side=\"left\">\n                    <p>{t('retrievePanel.querySettings.maxEntityTokensTooltip')}</p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n              <div className=\"flex items-center gap-1\">\n                <Input\n                  id=\"max_entity_tokens\"\n                  type=\"number\"\n                  value={querySettings.max_entity_tokens ?? ''}\n                  onChange={(e) => {\n                    const value = e.target.value\n                    handleChange('max_entity_tokens', value === '' ? '' : parseInt(value) || 0)\n                  }}\n                  onBlur={(e) => {\n                    const value = e.target.value\n                    if (value === '' || isNaN(parseInt(value))) {\n                      handleChange('max_entity_tokens', 6000)\n                    }\n                  }}\n                  min={1}\n                  placeholder={t('retrievePanel.querySettings.maxEntityTokensPlaceholder')}\n                  className=\"h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]\"\n                />\n                <ResetButton\n                  onClick={() => handleReset('max_entity_tokens')}\n                  title=\"Reset to default\"\n                />\n              </div>\n            </>\n\n            {/* Max Relation Tokens */}\n            <>\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <label htmlFor=\"max_relation_tokens\" className=\"ml-1 cursor-help\">\n                      {t('retrievePanel.querySettings.maxRelationTokens')}\n                    </label>\n                  </TooltipTrigger>\n                  <TooltipContent side=\"left\">\n                    <p>{t('retrievePanel.querySettings.maxRelationTokensTooltip')}</p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n              <div className=\"flex items-center gap-1\">\n                <Input\n                  id=\"max_relation_tokens\"\n                  type=\"number\"\n                  value={querySettings.max_relation_tokens ?? ''}\n                  onChange={(e) => {\n                    const value = e.target.value\n                    handleChange('max_relation_tokens', value === '' ? '' : parseInt(value) || 0)\n                  }}\n                  onBlur={(e) => {\n                    const value = e.target.value\n                    if (value === '' || isNaN(parseInt(value))) {\n                      handleChange('max_relation_tokens', 8000)\n                    }\n                  }}\n                  min={1}\n                  placeholder={t('retrievePanel.querySettings.maxRelationTokensPlaceholder')}\n                  className=\"h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]\"\n                />\n                <ResetButton\n                  onClick={() => handleReset('max_relation_tokens')}\n                  title=\"Reset to default\"\n                />\n              </div>\n            </>\n\n            {/* Max Total Tokens */}\n            <>\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <label htmlFor=\"max_total_tokens\" className=\"ml-1 cursor-help\">\n                      {t('retrievePanel.querySettings.maxTotalTokens')}\n                    </label>\n                  </TooltipTrigger>\n                  <TooltipContent side=\"left\">\n                    <p>{t('retrievePanel.querySettings.maxTotalTokensTooltip')}</p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n              <div className=\"flex items-center gap-1\">\n                <Input\n                  id=\"max_total_tokens\"\n                  type=\"number\"\n                  value={querySettings.max_total_tokens ?? ''}\n                  onChange={(e) => {\n                    const value = e.target.value\n                    handleChange('max_total_tokens', value === '' ? '' : parseInt(value) || 0)\n                  }}\n                  onBlur={(e) => {\n                    const value = e.target.value\n                    if (value === '' || isNaN(parseInt(value))) {\n                      handleChange('max_total_tokens', 30000)\n                    }\n                  }}\n                  min={1}\n                  placeholder={t('retrievePanel.querySettings.maxTotalTokensPlaceholder')}\n                  className=\"h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]\"\n                />\n                <ResetButton\n                  onClick={() => handleReset('max_total_tokens')}\n                  title=\"Reset to default\"\n                />\n              </div>\n            </>\n\n            {/* Toggle Options */}\n            <>\n              <div className=\"flex items-center gap-2\">\n                <TooltipProvider>\n                  <Tooltip>\n                    <TooltipTrigger asChild>\n                      <label htmlFor=\"enable_rerank\" className=\"flex-1 ml-1 cursor-help\">\n                        {t('retrievePanel.querySettings.enableRerank')}\n                      </label>\n                    </TooltipTrigger>\n                    <TooltipContent side=\"left\">\n                      <p>{t('retrievePanel.querySettings.enableRerankTooltip')}</p>\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n                <Checkbox\n                  className=\"mr-10 cursor-pointer\"\n                  id=\"enable_rerank\"\n                  checked={querySettings.enable_rerank}\n                  onCheckedChange={(checked) => handleChange('enable_rerank', checked)}\n                />\n              </div>\n\n              <div className=\"flex items-center gap-2\">\n                <TooltipProvider>\n                  <Tooltip>\n                    <TooltipTrigger asChild>\n                      <label htmlFor=\"only_need_context\" className=\"flex-1 ml-1 cursor-help\">\n                        {t('retrievePanel.querySettings.onlyNeedContext')}\n                      </label>\n                    </TooltipTrigger>\n                    <TooltipContent side=\"left\">\n                      <p>{t('retrievePanel.querySettings.onlyNeedContextTooltip')}</p>\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n                <Checkbox\n                  className=\"mr-10 cursor-pointer\"\n                  id=\"only_need_context\"\n                  checked={querySettings.only_need_context}\n                  onCheckedChange={(checked) => {\n                    handleChange('only_need_context', checked)\n                    if (checked) {\n                      handleChange('only_need_prompt', false)\n                    }\n                  }}\n                />\n              </div>\n\n              <div className=\"flex items-center gap-2\">\n                <TooltipProvider>\n                  <Tooltip>\n                    <TooltipTrigger asChild>\n                      <label htmlFor=\"only_need_prompt\" className=\"flex-1 ml-1 cursor-help\">\n                        {t('retrievePanel.querySettings.onlyNeedPrompt')}\n                      </label>\n                    </TooltipTrigger>\n                    <TooltipContent side=\"left\">\n                      <p>{t('retrievePanel.querySettings.onlyNeedPromptTooltip')}</p>\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n                <Checkbox\n                  className=\"mr-10 cursor-pointer\"\n                  id=\"only_need_prompt\"\n                  checked={querySettings.only_need_prompt}\n                  onCheckedChange={(checked) => {\n                    handleChange('only_need_prompt', checked)\n                    if (checked) {\n                      handleChange('only_need_context', false)\n                    }\n                  }}\n                />\n              </div>\n\n              <div className=\"flex items-center gap-2\">\n                <TooltipProvider>\n                  <Tooltip>\n                    <TooltipTrigger asChild>\n                      <label htmlFor=\"stream\" className=\"flex-1 ml-1 cursor-help\">\n                        {t('retrievePanel.querySettings.streamResponse')}\n                      </label>\n                    </TooltipTrigger>\n                    <TooltipContent side=\"left\">\n                      <p>{t('retrievePanel.querySettings.streamResponseTooltip')}</p>\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n                <Checkbox\n                  className=\"mr-10 cursor-pointer\"\n                  id=\"stream\"\n                  checked={querySettings.stream}\n                  onCheckedChange={(checked) => handleChange('stream', checked)}\n                />\n              </div>\n            </>\n\n          </div>\n        </div>\n      </CardContent>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/status/StatusCard.tsx",
    "content": "import { LightragStatus } from '@/api/lightrag'\nimport { useTranslation } from 'react-i18next'\n\nconst StatusCard = ({ status }: { status: LightragStatus | null }) => {\n  const { t } = useTranslation()\n  if (!status) {\n    return <div className=\"text-foreground text-xs\">{t('graphPanel.statusCard.unavailable')}</div>\n  }\n\n  return (\n    <div className=\"min-w-[300px] space-y-2 text-xs\">\n      <div className=\"space-y-1\">\n        <h4 className=\"font-medium\">{t('graphPanel.statusCard.serverInfo')}</h4>\n        <div className=\"text-foreground grid grid-cols-[160px_1fr] gap-1\">\n          <span>{t('graphPanel.statusCard.workingDirectory')}:</span>\n          <span className=\"truncate\">{status.working_directory}</span>\n          <span>{t('graphPanel.statusCard.inputDirectory')}:</span>\n          <span className=\"truncate\">{status.input_directory}</span>\n          <span>{t('graphPanel.statusCard.summarySettings')}:</span>\n          <span>{status.configuration.summary_language} / LLM summary on {status.configuration.force_llm_summary_on_merge.toString()} fragments</span>\n          <span>{t('graphPanel.statusCard.threshold')}:</span>\n          <span>cosine {status.configuration.cosine_threshold} / rerank_score {status.configuration.min_rerank_score} / max_related {status.configuration.related_chunk_number}</span>\n          <span>{t('graphPanel.statusCard.maxParallelInsert')}:</span>\n          <span>{status.configuration.max_parallel_insert}</span>\n        </div>\n      </div>\n\n      <div className=\"space-y-1\">\n        <h4 className=\"font-medium\">{t('graphPanel.statusCard.llmConfig')}</h4>\n        <div className=\"text-foreground grid grid-cols-[160px_1fr] gap-1\">\n          <span>{t('graphPanel.statusCard.llmBindingHost')}:</span>\n          <span>{status.configuration.llm_binding_host}</span>\n          <span>{t('graphPanel.statusCard.llmModel')}:</span>\n          <span>{status.configuration.llm_binding}: {status.configuration.llm_model} (#{status.configuration.max_async} Async)</span>\n        </div>\n      </div>\n\n      <div className=\"space-y-1\">\n        <h4 className=\"font-medium\">{t('graphPanel.statusCard.embeddingConfig')}</h4>\n        <div className=\"text-foreground grid grid-cols-[160px_1fr] gap-1\">\n          <span>{t('graphPanel.statusCard.embeddingBindingHost')}:</span>\n          <span>{status.configuration.embedding_binding_host}</span>\n          <span>{t('graphPanel.statusCard.embeddingModel')}:</span>\n          <span>{status.configuration.embedding_binding}: {status.configuration.embedding_model} (#{status.configuration.embedding_func_max_async} Async * {status.configuration.embedding_batch_num} batches)</span>\n        </div>\n      </div>\n\n      {status.configuration.enable_rerank && (\n        <div className=\"space-y-1\">\n          <h4 className=\"font-medium\">{t('graphPanel.statusCard.rerankerConfig')}</h4>\n          <div className=\"text-foreground grid grid-cols-[160px_1fr] gap-1\">\n            <span>{t('graphPanel.statusCard.rerankerBindingHost')}:</span>\n            <span>{status.configuration.rerank_binding_host || '-'}</span>\n            <span>{t('graphPanel.statusCard.rerankerModel')}:</span>\n            <span>{(status.configuration.rerank_binding || '-')} : {(status.configuration.rerank_model || '-')}</span>\n          </div>\n        </div>\n      )}\n\n      <div className=\"space-y-1\">\n        <h4 className=\"font-medium\">{t('graphPanel.statusCard.storageConfig')}</h4>\n        <div className=\"text-foreground grid grid-cols-[160px_1fr] gap-1\">\n          <span>{t('graphPanel.statusCard.kvStorage')}:</span>\n          <span>{status.configuration.kv_storage}</span>\n          <span>{t('graphPanel.statusCard.docStatusStorage')}:</span>\n          <span>{status.configuration.doc_status_storage}</span>\n          <span>{t('graphPanel.statusCard.graphStorage')}:</span>\n          <span>{status.configuration.graph_storage}</span>\n          <span>{t('graphPanel.statusCard.vectorStorage')}:</span>\n          <span>{status.configuration.vector_storage}</span>\n          <span>{t('graphPanel.statusCard.workspace')}:</span>\n          <span>{status.configuration.workspace || '-'}</span>\n          <span>{t('graphPanel.statusCard.maxGraphNodes')}:</span>\n          <span>{status.configuration.max_graph_nodes || '-'}</span>\n          {status.keyed_locks && (\n            <>\n              <span>{t('graphPanel.statusCard.lockStatus')}:</span>\n              <span>\n                mp {status.keyed_locks.current_status.pending_mp_cleanup}/{status.keyed_locks.current_status.total_mp_locks} |\n                async {status.keyed_locks.current_status.pending_async_cleanup}/{status.keyed_locks.current_status.total_async_locks}\n                (pid: {status.keyed_locks.process_id})\n              </span>\n            </>\n          )}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default StatusCard\n"
  },
  {
    "path": "lightrag_webui/src/components/status/StatusDialog.tsx",
    "content": "import { LightragStatus } from '@/api/lightrag'\nimport { useTranslation } from 'react-i18next'\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogDescription,\n} from '@/components/ui/Dialog'\nimport StatusCard from './StatusCard'\n\ninterface StatusDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  status: LightragStatus | null\n}\n\nconst StatusDialog = ({ open, onOpenChange, status }: StatusDialogProps) => {\n  const { t } = useTranslation()\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-[700px]\">\n        <DialogHeader>\n          <DialogTitle>{t('graphPanel.statusDialog.title')}</DialogTitle>\n          <DialogDescription>\n            {t('graphPanel.statusDialog.description')}\n          </DialogDescription>\n        </DialogHeader>\n        <StatusCard status={status} />\n      </DialogContent>\n    </Dialog>\n  )\n}\n\nexport default StatusDialog\n"
  },
  {
    "path": "lightrag_webui/src/components/status/StatusIndicator.tsx",
    "content": "import { cn } from '@/lib/utils'\nimport { useBackendState } from '@/stores/state'\nimport { useEffect, useState } from 'react'\nimport StatusDialog from './StatusDialog'\nimport { useTranslation } from 'react-i18next'\n\nconst StatusIndicator = () => {\n  const { t } = useTranslation()\n  const health = useBackendState.use.health()\n  const lastCheckTime = useBackendState.use.lastCheckTime()\n  const status = useBackendState.use.status()\n  const [animate, setAnimate] = useState(false)\n  const [dialogOpen, setDialogOpen] = useState(false)\n\n  // listen to health change\n  useEffect(() => {\n    setAnimate(true)\n    const timer = setTimeout(() => setAnimate(false), 300)\n    return () => clearTimeout(timer)\n  }, [lastCheckTime])\n\n  return (\n    <div className=\"fixed right-4 bottom-4 flex items-center gap-2 opacity-80 select-none\">\n      <div\n        className=\"flex cursor-pointer items-center gap-2\"\n        onClick={() => setDialogOpen(true)}\n      >\n        <div\n          className={cn(\n            'h-3 w-3 rounded-full transition-all duration-300',\n            'shadow-[0_0_8px_rgba(0,0,0,0.2)]',\n            health ? 'bg-green-500' : 'bg-red-500',\n            animate && 'scale-125',\n            animate && health && 'shadow-[0_0_12px_rgba(34,197,94,0.4)]',\n            animate && !health && 'shadow-[0_0_12px_rgba(239,68,68,0.4)]'\n          )}\n        />\n        <span className=\"text-muted-foreground text-xs\">\n          {health ? t('graphPanel.statusIndicator.connected') : t('graphPanel.statusIndicator.disconnected')}\n        </span>\n      </div>\n\n      <StatusDialog\n        open={dialogOpen}\n        onOpenChange={setDialogOpen}\n        status={status}\n      />\n    </div>\n  )\n}\n\nexport default StatusIndicator\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Alert.tsx",
    "content": "import * as React from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\n\nimport { cn } from '@/lib/utils'\n\nconst alertVariants = cva(\n  'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',\n  {\n    variants: {\n      variant: {\n        default: 'bg-background text-foreground',\n        destructive:\n          'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive'\n      }\n    },\n    defaultVariants: {\n      variant: 'default'\n    }\n  }\n)\n\nconst Alert = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>\n>(({ className, variant, ...props }, ref) => (\n  <div ref={ref} role=\"alert\" className={cn(alertVariants({ variant }), className)} {...props} />\n))\nAlert.displayName = 'Alert'\n\nconst AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(\n  ({ className, ...props }, ref) => (\n    <h5\n      ref={ref}\n      className={cn('mb-1 leading-none font-medium tracking-tight', className)}\n      {...props}\n    />\n  )\n)\nAlertTitle.displayName = 'AlertTitle'\n\nconst AlertDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />\n))\nAlertDescription.displayName = 'AlertDescription'\n\nexport { Alert, AlertTitle, AlertDescription }\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/AlertDialog.tsx",
    "content": "import * as React from 'react'\nimport * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'\n\nimport { cn } from '@/lib/utils'\nimport { buttonVariants } from '@/components/ui/Button'\n\nconst AlertDialog = AlertDialogPrimitive.Root\n\nconst AlertDialogTrigger = AlertDialogPrimitive.Trigger\n\nconst AlertDialogPortal = AlertDialogPrimitive.Portal\n\nconst AlertDialogOverlay = React.forwardRef<\n  React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Overlay\n    className={cn(\n      'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',\n      className\n    )}\n    {...props}\n    ref={ref}\n  />\n))\nAlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName\n\nconst AlertDialogContent = React.forwardRef<\n  React.ComponentRef<typeof AlertDialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPortal>\n    <AlertDialogOverlay />\n    <AlertDialogPrimitive.Content\n      ref={ref}\n      className={cn(\n        'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',\n        className\n      )}\n      {...props}\n    />\n  </AlertDialogPortal>\n))\nAlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName\n\nconst AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (\n  <div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />\n)\nAlertDialogHeader.displayName = 'AlertDialogHeader'\n\nconst AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}\n    {...props}\n  />\n)\nAlertDialogFooter.displayName = 'AlertDialogFooter'\n\nconst AlertDialogTitle = React.forwardRef<\n  React.ComponentRef<typeof AlertDialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Title\n    ref={ref}\n    className={cn('text-lg font-semibold', className)}\n    {...props}\n  />\n))\nAlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName\n\nconst AlertDialogDescription = React.forwardRef<\n  React.ComponentRef<typeof AlertDialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Description\n    ref={ref}\n    className={cn('text-muted-foreground text-sm', className)}\n    {...props}\n  />\n))\nAlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName\n\nconst AlertDialogAction = React.forwardRef<\n  React.ComponentRef<typeof AlertDialogPrimitive.Action>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />\n))\nAlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName\n\nconst AlertDialogCancel = React.forwardRef<\n  React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Cancel\n    ref={ref}\n    className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}\n    {...props}\n  />\n))\nAlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName\n\nexport {\n  AlertDialog,\n  AlertDialogPortal,\n  AlertDialogOverlay,\n  AlertDialogTrigger,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogFooter,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  AlertDialogAction,\n  AlertDialogCancel\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/AsyncSearch.tsx",
    "content": "import React, { useState, useEffect, useCallback, useRef } from 'react'\nimport { Loader2 } from 'lucide-react'\nimport { useDebounce } from '@/hooks/useDebounce'\n\nimport { cn } from '@/lib/utils'\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList\n} from '@/components/ui/Command'\n\nexport interface Option {\n  value: string\n  label: string\n  disabled?: boolean\n  description?: string\n  icon?: React.ReactNode\n}\n\nexport interface AsyncSearchProps<T> {\n  /** Async function to fetch options */\n  fetcher: (query?: string) => Promise<T[]>\n  /** Preload all data ahead of time */\n  preload?: boolean\n  /** Function to filter options */\n  filterFn?: (option: T, query: string) => boolean\n  /** Function to render each option */\n  renderOption: (option: T) => React.ReactNode\n  /** Function to get the value from an option */\n  getOptionValue: (option: T) => string\n  /** Custom not found message */\n  notFound?: React.ReactNode\n  /** Custom loading skeleton */\n  loadingSkeleton?: React.ReactNode\n  /** Currently selected value */\n  value: string | null\n  /** Callback when selection changes */\n  onChange: (value: string) => void\n  /** Callback when focus changes */\n  onFocus: (value: string) => void\n  /** Accessibility label for the search field */\n  ariaLabel?: string\n  /** Placeholder text when no selection */\n  placeholder?: string\n  /** Disable the entire select */\n  disabled?: boolean\n  /** Custom width for the popover */\n  width?: string | number\n  /** Custom class names */\n  className?: string\n  /** Custom trigger button class names */\n  triggerClassName?: string\n  /** Custom no results message */\n  noResultsMessage?: string\n  /** Allow clearing the selection */\n  clearable?: boolean\n}\n\nexport function AsyncSearch<T>({\n  fetcher,\n  preload,\n  filterFn,\n  renderOption,\n  getOptionValue,\n  notFound,\n  loadingSkeleton,\n  ariaLabel,\n  placeholder = 'Select...',\n  value,\n  onChange,\n  onFocus,\n  disabled = false,\n  className,\n  noResultsMessage\n}: AsyncSearchProps<T>) {\n  const [mounted, setMounted] = useState(false)\n  const [open, setOpen] = useState(false)\n  const [options, setOptions] = useState<T[]>([])\n  const [loading, setLoading] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n  const [searchTerm, setSearchTerm] = useState('')\n  const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)\n  const containerRef = useRef<HTMLDivElement>(null)\n\n  useEffect(() => {\n    setMounted(true)\n  }, [])\n\n  // Handle clicks outside of the component\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (\n        containerRef.current &&\n        !containerRef.current.contains(event.target as Node) &&\n        open\n      ) {\n        setOpen(false)\n      }\n    }\n\n    document.addEventListener('mousedown', handleClickOutside)\n    return () => {\n      document.removeEventListener('mousedown', handleClickOutside)\n    }\n  }, [open])\n\n  const fetchOptions = useCallback(async (query: string) => {\n    try {\n      setLoading(true)\n      setError(null)\n      const data = await fetcher(query)\n      setOptions(data)\n    } catch (err) {\n      setError(err instanceof Error ? err.message : 'Failed to fetch options')\n    } finally {\n      setLoading(false)\n    }\n  }, [fetcher])\n\n  // Load options when search term changes\n  useEffect(() => {\n    if (!mounted) return\n\n    if (preload) {\n      if (debouncedSearchTerm) {\n        setOptions((prev) =>\n          prev.filter((option) =>\n            filterFn ? filterFn(option, debouncedSearchTerm) : true\n          )\n        )\n      }\n    } else {\n      fetchOptions(debouncedSearchTerm)\n    }\n  }, [mounted, debouncedSearchTerm, preload, filterFn, fetchOptions])\n\n  // Load initial value\n  useEffect(() => {\n    if (!mounted || !value) return\n    fetchOptions(value)\n  }, [mounted, value, fetchOptions])\n\n  const handleSelect = useCallback((currentValue: string) => {\n    onChange(currentValue)\n    requestAnimationFrame(() => {\n      // Blur the input to ensure focus event triggers on next click\n      const input = document.activeElement as HTMLElement\n      input?.blur()\n      // Close the dropdown\n      setOpen(false)\n    })\n  }, [onChange])\n\n  const handleFocus = useCallback(() => {\n    setOpen(true)\n    // Use current search term to fetch options\n    fetchOptions(searchTerm)\n  }, [searchTerm, fetchOptions])\n\n  const handleMouseDown = useCallback((e: React.MouseEvent) => {\n    const target = e.target as HTMLElement\n    if (target.closest('.cmd-item')) {\n      e.preventDefault()\n    }\n  }, [])\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(disabled && 'cursor-not-allowed opacity-50', className)}\n      onMouseDown={handleMouseDown}\n    >\n      <Command shouldFilter={false} className=\"bg-transparent\">\n        <div>\n          <CommandInput\n            placeholder={placeholder}\n            value={searchTerm}\n            className=\"max-h-8\"\n            aria-label={ariaLabel}\n            onFocus={handleFocus}\n            onValueChange={(value) => {\n              setSearchTerm(value)\n              if (!open) setOpen(true)\n            }}\n          />\n          {loading && (\n            <div className=\"absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n            </div>\n          )}\n        </div>\n        <CommandList hidden={!open}>\n          {error && <div className=\"text-destructive p-4 text-center\">{error}</div>}\n          {loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}\n          {!loading &&\n            !error &&\n            options.length === 0 &&\n            (notFound || (\n              <CommandEmpty>{noResultsMessage || 'No results found.'}</CommandEmpty>\n            ))}\n          <CommandGroup>\n            {options.map((option, idx) => (\n              <React.Fragment key={getOptionValue(option) + `-fragment-${idx}`}>\n                <CommandItem\n                  key={getOptionValue(option) + `${idx}`}\n                  value={getOptionValue(option)}\n                  onSelect={handleSelect}\n                  onMouseMove={() => onFocus(getOptionValue(option))}\n                  className=\"truncate cmd-item\"\n                >\n                  {renderOption(option)}\n                </CommandItem>\n                {idx !== options.length - 1 && (\n                  <div key={`divider-${idx}`} className=\"bg-foreground/10 h-[1px]\" />\n                )}\n              </React.Fragment>\n            ))}\n          </CommandGroup>\n        </CommandList>\n      </Command>\n    </div>\n  )\n}\n\nfunction DefaultLoadingSkeleton() {\n  return (\n    <CommandGroup>\n      <CommandItem disabled>\n        <div className=\"flex w-full items-center gap-2\">\n          <div className=\"bg-muted h-6 w-6 animate-pulse rounded-full\" />\n          <div className=\"flex flex-1 flex-col gap-1\">\n            <div className=\"bg-muted h-4 w-24 animate-pulse rounded\" />\n            <div className=\"bg-muted h-3 w-16 animate-pulse rounded\" />\n          </div>\n        </div>\n      </CommandItem>\n    </CommandGroup>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/AsyncSelect.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react'\nimport { Check, ChevronsUpDown, Loader2 } from 'lucide-react'\nimport { useDebounce } from '@/hooks/useDebounce'\n\nimport { cn } from '@/lib/utils'\nimport Button from '@/components/ui/Button'\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList\n} from '@/components/ui/Command'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'\n\nexport interface Option {\n  value: string\n  label: string\n  disabled?: boolean\n  description?: string\n  icon?: React.ReactNode\n}\n\nexport interface AsyncSelectProps<T> {\n  /** Async function to fetch options */\n  fetcher: (query?: string) => Promise<T[]>\n  /** Preload all data ahead of time */\n  preload?: boolean\n  /** Function to filter options */\n  filterFn?: (option: T, query: string) => boolean\n  /** Function to render each option */\n  renderOption: (option: T) => React.ReactNode\n  /** Function to get the value from an option */\n  getOptionValue: (option: T) => string\n  /** Function to get the display value for the selected option */\n  getDisplayValue: (option: T) => React.ReactNode\n  /** Custom not found message */\n  notFound?: React.ReactNode\n  /** Custom loading skeleton */\n  loadingSkeleton?: React.ReactNode\n  /** Currently selected value */\n  value: string\n  /** Callback when selection changes */\n  onChange: (value: string) => void\n  /** Callback before opening the dropdown (async supported) */\n  onBeforeOpen?: () => void | Promise<void>\n  /** Accessibility label for the select field */\n  ariaLabel?: string\n  /** Placeholder text when no selection */\n  placeholder?: string\n  /** Display text for search placeholder */\n  searchPlaceholder?: string\n  /** Disable the entire select */\n  disabled?: boolean\n  /** Custom width for the popover *\n  width?: string | number\n  /** Custom class names */\n  className?: string\n  /** Custom trigger button class names */\n  triggerClassName?: string\n  /** Custom search input class names */\n  searchInputClassName?: string\n  /** Custom no results message */\n  noResultsMessage?: string\n  /** Custom trigger tooltip */\n  triggerTooltip?: string\n  /** Allow clearing the selection */\n  clearable?: boolean\n  /** Debounce time in milliseconds */\n  debounceTime?: number\n}\n\nexport function AsyncSelect<T>({\n  fetcher,\n  preload,\n  filterFn,\n  renderOption,\n  getOptionValue,\n  getDisplayValue,\n  notFound,\n  loadingSkeleton,\n  ariaLabel,\n  placeholder = 'Select...',\n  searchPlaceholder,\n  value,\n  onChange,\n  onBeforeOpen,\n  disabled = false,\n  className,\n  triggerClassName,\n  searchInputClassName,\n  noResultsMessage,\n  triggerTooltip,\n  clearable = true,\n  debounceTime = 150\n}: AsyncSelectProps<T>) {\n  const [mounted, setMounted] = useState(false)\n  const [open, setOpen] = useState(false)\n  const [options, setOptions] = useState<T[]>([])\n  const [loading, setLoading] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n  const [selectedValue, setSelectedValue] = useState(value)\n  const [selectedOption, setSelectedOption] = useState<T | null>(null)\n  const [searchTerm, setSearchTerm] = useState('')\n  const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : debounceTime)\n  const [originalOptions, setOriginalOptions] = useState<T[]>([])\n  const [initialValueDisplay, setInitialValueDisplay] = useState<React.ReactNode | null>(null)\n\n  useEffect(() => {\n    setMounted(true)\n    setSelectedValue(value)\n  }, [value])\n\n  // Add an effect to handle initial value display\n  useEffect(() => {\n    if (value && (!options.length || !selectedOption)) {\n      // Create a temporary display until options are loaded\n      setInitialValueDisplay(<div>{value}</div>)\n    } else if (selectedOption) {\n      // Once we find the actual selectedOption, clear the temporary display\n      setInitialValueDisplay(null)\n    }\n  }, [value, options.length, selectedOption])\n\n  // Initialize selectedOption when options are loaded and value exists\n  useEffect(() => {\n    if (value && options.length > 0) {\n      const option = options.find((opt) => getOptionValue(opt) === value)\n      if (option) {\n        setSelectedOption(option)\n      }\n    }\n  }, [value, options, getOptionValue])\n\n  // Effect for initial fetch\n  useEffect(() => {\n    const initializeOptions = async () => {\n      try {\n        setLoading(true)\n        setError(null)\n        // Always use empty query for initial load to show search history\n        const data = await fetcher('')\n        setOriginalOptions(data)\n        setOptions(data)\n      } catch (err) {\n        setError(err instanceof Error ? err.message : 'Failed to fetch options')\n      } finally {\n        setLoading(false)\n      }\n    }\n\n    if (!mounted) {\n      initializeOptions()\n    }\n  }, [mounted, fetcher])\n\n  useEffect(() => {\n    const fetchOptions = async () => {\n      try {\n        setLoading(true)\n        setError(null)\n        const data = await fetcher(debouncedSearchTerm)\n        setOriginalOptions(data)\n        setOptions(data)\n      } catch (err) {\n        setError(err instanceof Error ? err.message : 'Failed to fetch options')\n      } finally {\n        setLoading(false)\n      }\n    }\n\n    if (!mounted) {\n      fetchOptions()\n    } else if (!preload) {\n      fetchOptions()\n    } else if (preload) {\n      if (debouncedSearchTerm) {\n        setOptions(\n          originalOptions.filter((option) =>\n            filterFn ? filterFn(option, debouncedSearchTerm) : true\n          )\n        )\n      } else {\n        setOptions(originalOptions)\n      }\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [fetcher, debouncedSearchTerm, mounted, preload, filterFn])\n\n  const handleSelect = useCallback(\n    (currentValue: string) => {\n      const newValue = clearable && currentValue === selectedValue ? '' : currentValue\n      setSelectedValue(newValue)\n      setSelectedOption(options.find((option) => getOptionValue(option) === newValue) || null)\n      onChange(newValue)\n      setOpen(false)\n    },\n    [selectedValue, onChange, clearable, options, getOptionValue]\n  )\n\n  const handleOpenChange = useCallback(\n    async (newOpen: boolean) => {\n      if (newOpen && onBeforeOpen) {\n        await onBeforeOpen()\n      }\n      setOpen(newOpen)\n    },\n    [onBeforeOpen]\n  )\n\n  return (\n    <Popover open={open} onOpenChange={handleOpenChange}>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          role=\"combobox\"\n          aria-expanded={open}\n          aria-label={ariaLabel}\n          className={cn(\n            'justify-between',\n            disabled && 'cursor-not-allowed opacity-50',\n            triggerClassName\n          )}\n          disabled={disabled}\n          tooltip={triggerTooltip}\n          side=\"bottom\"\n        >\n          {value === '*' ? <div>*</div> : (selectedOption ? getDisplayValue(selectedOption) : (initialValueDisplay || placeholder))}\n          <ChevronsUpDown className=\"opacity-50\" size={10} />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent\n        className={cn('p-0', className)}\n        onCloseAutoFocus={(e) => e.preventDefault()}\n        align=\"start\"\n        sideOffset={8}\n        collisionPadding={5}\n      >\n        <Command shouldFilter={false}>\n          <div className=\"relative w-full border-b\">\n            <CommandInput\n              placeholder={searchPlaceholder || 'Search...'}\n              value={searchTerm}\n              onValueChange={(value) => {\n                setSearchTerm(value)\n              }}\n              className={searchInputClassName}\n            />\n            {loading && options.length > 0 && (\n              <div className=\"absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center\">\n                <Loader2 className=\"h-4 w-4 animate-spin\" />\n              </div>\n            )}\n          </div>\n          <CommandList>\n            {error && <div className=\"text-destructive p-4 text-center\">{error}</div>}\n            {loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}\n            {!loading &&\n              !error &&\n              options.length === 0 &&\n              (notFound || (\n                <CommandEmpty>\n                  {noResultsMessage || 'No results found.'}\n                </CommandEmpty>\n              ))}\n            <CommandGroup>\n              {options.map((option) => {\n                const optionValue = getOptionValue(option);\n                // Fix cmdk filtering issue: use empty string when search is empty\n                // This ensures all items are shown when searchTerm is empty\n                const itemValue = searchTerm.trim() === '' ? '' : optionValue;\n\n                return (\n                  <CommandItem\n                    key={optionValue}\n                    value={itemValue}\n                    onSelect={() => {\n                      handleSelect(optionValue);\n                    }}\n                    className=\"truncate\"\n                  >\n                    {renderOption(option)}\n                    <Check\n                      className={cn(\n                        'ml-auto h-3 w-3',\n                        selectedValue === optionValue ? 'opacity-100' : 'opacity-0'\n                      )}\n                    />\n                  </CommandItem>\n                );\n              })}\n            </CommandGroup>\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\nfunction DefaultLoadingSkeleton() {\n  return (\n    <CommandGroup>\n      <CommandItem disabled>\n        <div className=\"flex w-full items-center gap-2\">\n          <div className=\"bg-muted h-6 w-6 animate-pulse rounded-full\" />\n          <div className=\"flex flex-1 flex-col gap-1\">\n            <div className=\"bg-muted h-4 w-24 animate-pulse rounded\" />\n            <div className=\"bg-muted h-3 w-16 animate-pulse rounded\" />\n          </div>\n        </div>\n      </CommandItem>\n    </CommandGroup>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Badge.tsx",
    "content": "import * as React from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\n\nimport { cn } from '@/lib/utils'\n\nconst badgeVariants = cva(\n  'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',\n  {\n    variants: {\n      variant: {\n        default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',\n        secondary:\n          'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',\n        destructive:\n          'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',\n        outline: 'text-foreground'\n      }\n    },\n    defaultVariants: {\n      variant: 'default'\n    }\n  }\n)\n\nexport interface BadgeProps\n  extends React.HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof badgeVariants> {}\n\nfunction Badge({ className, variant, ...props }: BadgeProps) {\n  return <div className={cn(badgeVariants({ variant }), className)} {...props} />\n}\n\nexport default Badge\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Button.tsx",
    "content": "import * as React from 'react'\nimport { Slot } from '@radix-ui/react-slot'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'\nimport { cn } from '@/lib/utils'\n\n// eslint-disable-next-line react-refresh/only-export-components\nexport const buttonVariants = cva(\n  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n  {\n    variants: {\n      variant: {\n        default: 'bg-primary text-primary-foreground hover:bg-primary/90',\n        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',\n        outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',\n        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n        ghost: 'hover:bg-accent hover:text-accent-foreground',\n        link: 'text-primary underline-offset-4 hover:underline'\n      },\n      size: {\n        default: 'h-10 px-4 py-2',\n        sm: 'h-9 rounded-md px-3',\n        lg: 'h-11 rounded-md px-8',\n        icon: 'size-8'\n      }\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default'\n    }\n  }\n)\n\ninterface ButtonProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean\n  side?: 'top' | 'right' | 'bottom' | 'left'\n  tooltip?: string\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, tooltip, size, side = 'right', asChild = false, ...props }, ref) => {\n    const Comp = asChild ? Slot : 'button'\n    if (!tooltip) {\n      return (\n        <Comp\n          className={cn(buttonVariants({ variant, size, className }), 'cursor-pointer')}\n          ref={ref}\n          {...props}\n        />\n      )\n    }\n\n    return (\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Comp\n              className={cn(buttonVariants({ variant, size, className }), 'cursor-pointer')}\n              ref={ref}\n              {...props}\n            />\n          </TooltipTrigger>\n          <TooltipContent side={side}>{tooltip}</TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    )\n  }\n)\nButton.displayName = 'Button'\n\nexport type ButtonVariantType = Exclude<\n  NonNullable<Parameters<typeof buttonVariants>[0]>['variant'],\n  undefined\n>\n\nexport default Button\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Card.tsx",
    "content": "import * as React from 'react'\n\nimport { cn } from '@/lib/utils'\n\nconst Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div\n      ref={ref}\n      className={cn('bg-card text-card-foreground rounded-xl border shadow', className)}\n      {...props}\n    />\n  )\n)\nCard.displayName = 'Card'\n\nconst CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />\n  )\n)\nCardHeader.displayName = 'CardHeader'\n\nconst CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div\n      ref={ref}\n      className={cn('leading-none font-semibold tracking-tight', className)}\n      {...props}\n    />\n  )\n)\nCardTitle.displayName = 'CardTitle'\n\nconst CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />\n  )\n)\nCardDescription.displayName = 'CardDescription'\n\nconst CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />\n  )\n)\nCardContent.displayName = 'CardContent'\n\nconst CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n  ({ className, ...props }, ref) => (\n    <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />\n  )\n)\nCardFooter.displayName = 'CardFooter'\n\nexport { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Checkbox.tsx",
    "content": "import * as React from 'react'\nimport * as CheckboxPrimitive from '@radix-ui/react-checkbox'\nimport { Check } from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\n\nconst Checkbox = React.forwardRef<\n  React.ComponentRef<typeof CheckboxPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <CheckboxPrimitive.Root\n    ref={ref}\n    className={cn(\n      'peer border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-muted data-[state=checked]:text-muted-foreground h-4 w-4 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',\n      className\n    )}\n    {...props}\n  >\n    <CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>\n      <Check className=\"h-4 w-4\" />\n    </CheckboxPrimitive.Indicator>\n  </CheckboxPrimitive.Root>\n))\nCheckbox.displayName = CheckboxPrimitive.Root.displayName\n\nexport default Checkbox\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Command.tsx",
    "content": "import * as React from 'react'\nimport { type DialogProps } from '@radix-ui/react-dialog'\nimport { Command as CommandPrimitive } from 'cmdk'\nimport { Search } from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\nimport { Dialog, DialogContent } from './Dialog'\n\nconst Command = React.forwardRef<\n  React.ComponentRef<typeof CommandPrimitive>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive\n    ref={ref}\n    className={cn(\n      'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',\n      className\n    )}\n    {...props}\n  />\n))\nCommand.displayName = CommandPrimitive.displayName\n\nconst CommandDialog = ({ children, ...props }: DialogProps) => {\n  return (\n    <Dialog {...props}>\n      <DialogContent className=\"overflow-hidden p-0 shadow-lg\">\n        <Command className=\"[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n          {children}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\nconst CommandInput = React.forwardRef<\n  React.ComponentRef<typeof CommandPrimitive.Input>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>\n>(({ className, ...props }, ref) => (\n  // eslint-disable-next-line react/no-unknown-property\n  <div className=\"flex items-center border-b px-3\" cmdk-input-wrapper=\"\">\n    <Search className=\"mr-2 h-4 w-4 shrink-0 opacity-50\" />\n    <CommandPrimitive.Input\n      ref={ref}\n      className={cn(\n        'placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',\n        className\n      )}\n      {...props}\n    />\n  </div>\n))\n\nCommandInput.displayName = CommandPrimitive.Input.displayName\n\nconst CommandList = React.forwardRef<\n  React.ComponentRef<typeof CommandPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.List\n    ref={ref}\n    className={cn('max-h-[300px] overflow-x-hidden overflow-y-auto', className)}\n    {...props}\n  />\n))\n\nCommandList.displayName = CommandPrimitive.List.displayName\n\nconst CommandEmpty = React.forwardRef<\n  React.ComponentRef<typeof CommandPrimitive.Empty>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>\n>((props, ref) => (\n  <CommandPrimitive.Empty ref={ref} className=\"py-6 text-center text-sm\" {...props} />\n))\n\nCommandEmpty.displayName = CommandPrimitive.Empty.displayName\n\nconst CommandGroup = React.forwardRef<\n  React.ComponentRef<typeof CommandPrimitive.Group>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Group\n    ref={ref}\n    className={cn(\n      'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',\n      className\n    )}\n    {...props}\n  />\n))\n\nCommandGroup.displayName = CommandPrimitive.Group.displayName\n\nconst CommandSeparator = React.forwardRef<\n  React.ComponentRef<typeof CommandPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Separator\n    ref={ref}\n    className={cn('bg-border -mx-1 h-px', className)}\n    {...props}\n  />\n))\nCommandSeparator.displayName = CommandPrimitive.Separator.displayName\n\nconst CommandItem = React.forwardRef<\n  React.ComponentRef<typeof CommandPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Item\n    ref={ref}\n    className={cn(\n      // eslint-disable-next-line @stylistic/js/quotes\n      \"data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n      className\n    )}\n    {...props}\n  />\n))\n\nCommandItem.displayName = CommandPrimitive.Item.displayName\n\nconst CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}\n      {...props}\n    />\n  )\n}\nCommandShortcut.displayName = 'CommandShortcut'\n\nexport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandShortcut,\n  CommandSeparator\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/DataTable.tsx",
    "content": "import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'\n\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow\n} from '@/components/ui/Table'\n\ninterface DataTableProps<TData, TValue> {\n  columns: ColumnDef<TData, TValue>[]\n  data: TData[]\n}\n\nexport default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel()\n  })\n\n  return (\n    <div className=\"rounded-md border\">\n      <Table>\n        <TableHeader>\n          {table.getHeaderGroups().map((headerGroup) => (\n            <TableRow key={headerGroup.id}>\n              {headerGroup.headers.map((header) => {\n                return (\n                  <TableHead key={header.id}>\n                    {header.isPlaceholder\n                      ? null\n                      : flexRender(header.column.columnDef.header, header.getContext())}\n                  </TableHead>\n                )\n              })}\n            </TableRow>\n          ))}\n        </TableHeader>\n        <TableBody>\n          {table.getRowModel().rows?.length ? (\n            table.getRowModel().rows.map((row) => (\n              <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>\n                {row.getVisibleCells().map((cell) => (\n                  <TableCell key={cell.id}>\n                    {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                  </TableCell>\n                ))}\n              </TableRow>\n            ))\n          ) : (\n            <TableRow>\n              <TableCell colSpan={columns.length} className=\"h-24 text-center\">\n                No results.\n              </TableCell>\n            </TableRow>\n          )}\n        </TableBody>\n      </Table>\n    </div>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Dialog.tsx",
    "content": "import * as React from 'react'\nimport * as DialogPrimitive from '@radix-ui/react-dialog'\nimport { X } from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\n\nconst Dialog = DialogPrimitive.Root\n\nconst DialogTrigger = DialogPrimitive.Trigger\n\nconst DialogPortal = DialogPrimitive.Portal\n\nconst DialogClose = DialogPrimitive.Close\n\nconst DialogOverlay = React.forwardRef<\n  React.ComponentRef<typeof DialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Overlay\n    ref={ref}\n    className={cn(\n      'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/30',\n      className\n    )}\n    {...props}\n  />\n))\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName\n\nconst DialogContent = React.forwardRef<\n  React.ComponentRef<typeof DialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <DialogPortal>\n    <DialogOverlay />\n    <DialogPrimitive.Content\n      ref={ref}\n      className={cn(\n        'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <DialogPrimitive.Close className=\"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none\">\n        <X className=\"h-4 w-4\" />\n        <span className=\"sr-only\">Close</span>\n      </DialogPrimitive.Close>\n    </DialogPrimitive.Content>\n  </DialogPortal>\n))\nDialogContent.displayName = DialogPrimitive.Content.displayName\n\nconst DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (\n  <div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />\n)\nDialogHeader.displayName = 'DialogHeader'\n\nconst DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}\n    {...props}\n  />\n)\nDialogFooter.displayName = 'DialogFooter'\n\nconst DialogTitle = React.forwardRef<\n  React.ComponentRef<typeof DialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Title\n    ref={ref}\n    className={cn('text-lg leading-none font-semibold tracking-tight', className)}\n    {...props}\n  />\n))\nDialogTitle.displayName = DialogPrimitive.Title.displayName\n\nconst DialogDescription = React.forwardRef<\n  React.ComponentRef<typeof DialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Description\n    ref={ref}\n    className={cn('text-muted-foreground text-sm', className)}\n    {...props}\n  />\n))\nDialogDescription.displayName = DialogPrimitive.Description.displayName\n\nexport {\n  Dialog,\n  DialogPortal,\n  DialogOverlay,\n  DialogClose,\n  DialogTrigger,\n  DialogContent,\n  DialogHeader,\n  DialogFooter,\n  DialogTitle,\n  DialogDescription\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/EmptyCard.tsx",
    "content": "import { cn } from '@/lib/utils'\nimport { Card, CardDescription, CardTitle } from '@/components/ui/Card'\nimport { FilesIcon } from 'lucide-react'\n\ninterface EmptyCardProps extends React.ComponentPropsWithoutRef<typeof Card> {\n  title: string\n  description?: string\n  action?: React.ReactNode\n  icon?: React.ComponentType<{ className?: string }>\n}\n\nexport default function EmptyCard({\n  title,\n  description,\n  icon: Icon = FilesIcon,\n  action,\n  className,\n  ...props\n}: EmptyCardProps) {\n  return (\n    <Card\n      className={cn(\n        'flex w-full flex-col items-center justify-center space-y-6 bg-transparent p-16',\n        className\n      )}\n      {...props}\n    >\n      <div className=\"mr-4 shrink-0 rounded-full border border-dashed p-4\">\n        <Icon className=\"text-muted-foreground size-8\" aria-hidden=\"true\" />\n      </div>\n      <div className=\"flex flex-col items-center gap-1.5 text-center\">\n        <CardTitle>{title}</CardTitle>\n        {description ? <CardDescription>{description}</CardDescription> : null}\n      </div>\n      {action ? action : null}\n    </Card>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/FileUploader.tsx",
    "content": "/**\n * @see https://github.com/sadmann7/file-uploader\n */\n\nimport * as React from 'react'\nimport { FileText, Upload, X } from 'lucide-react'\nimport Dropzone, { type DropzoneProps, type FileRejection } from 'react-dropzone'\nimport { toast } from 'sonner'\nimport { useTranslation } from 'react-i18next'\n\nimport { cn } from '@/lib/utils'\nimport { useControllableState } from '@radix-ui/react-use-controllable-state'\nimport Button from '@/components/ui/Button'\nimport { ScrollArea } from '@/components/ui/ScrollArea'\nimport { supportedFileTypes } from '@/lib/constants'\n\ninterface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {\n  /**\n   * Value of the uploader.\n   * @type File[]\n   * @default undefined\n   * @example value={files}\n   */\n  value?: File[]\n\n  /**\n   * Function to be called when the value changes.\n   * @type (files: File[]) => void\n   * @default undefined\n   * @example onValueChange={(files) => setFiles(files)}\n   */\n  onValueChange?: (files: File[]) => void\n\n  /**\n   * Function to be called when files are uploaded.\n   * @type (files: File[]) => Promise<void>\n   * @default undefined\n   * @example onUpload={(files) => uploadFiles(files)}\n   */\n  onUpload?: (files: File[]) => Promise<void>\n\n  /**\n   * Function to be called when files are rejected.\n   * @type (rejections: FileRejection[]) => void\n   * @default undefined\n   * @example onReject={(rejections) => handleRejectedFiles(rejections)}\n   */\n  onReject?: (rejections: FileRejection[]) => void\n\n  /**\n   * Progress of the uploaded files.\n   * @type Record<string, number> | undefined\n   * @default undefined\n   * @example progresses={{ \"file1.png\": 50 }}\n   */\n  progresses?: Record<string, number>\n\n  /**\n   * Error messages for failed uploads.\n   * @type Record<string, string> | undefined\n   * @default undefined\n   * @example fileErrors={{ \"file1.png\": \"Upload failed\" }}\n   */\n  fileErrors?: Record<string, string>\n\n  /**\n   * Accepted file types for the uploader.\n   * @type { [key: string]: string[]}\n   * @default\n   * ```ts\n   * { \"text/*\": [] }\n   * ```\n   * @example accept={[\"text/plain\", \"application/pdf\"]}\n   */\n  accept?: DropzoneProps['accept']\n\n  /**\n   * Maximum file size for the uploader.\n   * @type number | undefined\n   * @default 1024 * 1024 * 200 // 200MB\n   * @example maxSize={1024 * 1024 * 2} // 2MB\n   */\n  maxSize?: DropzoneProps['maxSize']\n\n  /**\n   * Maximum number of files for the uploader.\n   * @type number | undefined\n   * @default 1\n   * @example maxFileCount={4}\n   */\n  maxFileCount?: DropzoneProps['maxFiles']\n\n  /**\n   * Whether the uploader should accept multiple files.\n   * @type boolean\n   * @default false\n   * @example multiple\n   */\n  multiple?: boolean\n\n  /**\n   * Whether the uploader is disabled.\n   * @type boolean\n   * @default false\n   * @example disabled\n   */\n  disabled?: boolean\n\n  description?: string\n}\n\nfunction formatBytes(\n  bytes: number,\n  opts: {\n    decimals?: number\n    sizeType?: 'accurate' | 'normal'\n  } = {}\n) {\n  const { decimals = 0, sizeType = 'normal' } = opts\n\n  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']\n  const accurateSizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB']\n  if (bytes === 0) return '0 Byte'\n  const i = Math.floor(Math.log(bytes) / Math.log(1024))\n  return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${\n    sizeType === 'accurate' ? (accurateSizes[i] ?? 'Bytes') : (sizes[i] ?? 'Bytes')\n  }`\n}\n\nfunction FileUploader(props: FileUploaderProps) {\n  const { t } = useTranslation()\n  const {\n    value: valueProp,\n    onValueChange,\n    onUpload,\n    onReject,\n    progresses,\n    fileErrors,\n    accept = supportedFileTypes,\n    maxSize = 1024 * 1024 * 200,\n    maxFileCount = 1,\n    multiple = false,\n    disabled = false,\n    description,\n    className,\n    ...dropzoneProps\n  } = props\n\n  const [files, setFiles] = useControllableState({\n    prop: valueProp,\n    onChange: onValueChange\n  })\n\n  const onDrop = React.useCallback(\n    (acceptedFiles: File[], rejectedFiles: FileRejection[]) => {\n      // Calculate total file count including both accepted and rejected files\n      const totalFileCount = (files?.length ?? 0) + acceptedFiles.length + rejectedFiles.length\n\n      // Check file count limits\n      if (!multiple && maxFileCount === 1 && (acceptedFiles.length + rejectedFiles.length) > 1) {\n        toast.error(t('documentPanel.uploadDocuments.fileUploader.singleFileLimit'))\n        return\n      }\n\n      if (totalFileCount > maxFileCount) {\n        toast.error(t('documentPanel.uploadDocuments.fileUploader.maxFilesLimit', { count: maxFileCount }))\n        return\n      }\n\n      // Handle rejected files first - this will set error states\n      if (rejectedFiles.length > 0) {\n        if (onReject) {\n          // Use the onReject callback if provided\n          onReject(rejectedFiles)\n        } else {\n          // Fall back to toast notifications if no callback is provided\n          rejectedFiles.forEach(({ file }) => {\n            toast.error(t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name }))\n          })\n        }\n      }\n\n      // Process accepted files\n      const newAcceptedFiles = acceptedFiles.map((file) =>\n        Object.assign(file, {\n          preview: URL.createObjectURL(file)\n        })\n      )\n\n      // Process rejected files for UI display\n      const newRejectedFiles = rejectedFiles.map(({ file }) =>\n        Object.assign(file, {\n          preview: URL.createObjectURL(file),\n          rejected: true\n        })\n      )\n\n      // Combine all files for display\n      const allNewFiles = [...newAcceptedFiles, ...newRejectedFiles]\n      const updatedFiles = files ? [...files, ...allNewFiles] : allNewFiles\n\n      // Update the files state with all files\n      setFiles(updatedFiles)\n\n      // Only upload accepted files - make sure we're not uploading rejected files\n      if (onUpload && acceptedFiles.length > 0) {\n        // Filter out any files that might have been rejected by our custom validator\n        const validFiles = acceptedFiles.filter(file => {\n          // Skip files without a name\n          if (!file.name) {\n            return false;\n          }\n\n          // Check if file type is accepted\n          const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`;\n          const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => {\n            return file.type === mimeType || (Array.isArray(extensions) && extensions.includes(fileExt));\n          });\n\n          // Check file size\n          const isSizeValid = file.size <= maxSize;\n\n          return isAccepted && isSizeValid;\n        });\n\n        if (validFiles.length > 0) {\n          onUpload(validFiles);\n        }\n      }\n    },\n    [files, maxFileCount, multiple, onUpload, onReject, setFiles, t, accept, maxSize]\n  )\n\n  function onRemove(index: number) {\n    if (!files) return\n    const newFiles = files.filter((_, i) => i !== index)\n    setFiles(newFiles)\n    onValueChange?.(newFiles)\n  }\n\n  // Revoke preview url when component unmounts\n  React.useEffect(() => {\n    return () => {\n      if (!files) return\n      files.forEach((file) => {\n        if (isFileWithPreview(file)) {\n          URL.revokeObjectURL(file.preview)\n        }\n      })\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount\n\n  return (\n    <div className=\"relative flex flex-col gap-6 overflow-hidden\">\n      <Dropzone\n        onDrop={onDrop}\n        // remove accept，use customizd validator\n        noClick={false}\n        noKeyboard={false}\n        maxSize={maxSize}\n        maxFiles={maxFileCount}\n        multiple={maxFileCount > 1 || multiple}\n        disabled={isDisabled}\n        validator={(file) => {\n          // Ensure file name exists\n          if (!file.name) {\n            return {\n              code: 'invalid-file-name',\n              message: t('documentPanel.uploadDocuments.fileUploader.invalidFileName',\n                { fallback: 'Invalid file name' })\n            };\n          }\n\n          // Safely extract file extension\n          const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`;\n\n          // Ensure accept object exists and has correct format\n          const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => {\n            // Ensure extensions is an array before calling includes\n            return file.type === mimeType || (Array.isArray(extensions) && extensions.includes(fileExt));\n          });\n\n          if (!isAccepted) {\n            return {\n              code: 'file-invalid-type',\n              message: t('documentPanel.uploadDocuments.fileUploader.unsupportedType')\n            };\n          }\n\n          // Check file size\n          if (file.size > maxSize) {\n            return {\n              code: 'file-too-large',\n              message: t('documentPanel.uploadDocuments.fileUploader.fileTooLarge', {\n                maxSize: formatBytes(maxSize)\n              })\n            };\n          }\n\n          return null;\n        }}\n      >\n        {({ getRootProps, getInputProps, isDragActive }) => (\n          <div\n            {...getRootProps()}\n            className={cn(\n              'group border-muted-foreground/25 hover:bg-muted/25 relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed px-5 py-2.5 text-center transition',\n              'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',\n              isDragActive && 'border-muted-foreground/50',\n              isDisabled && 'pointer-events-none opacity-60',\n              className\n            )}\n            {...dropzoneProps}\n          >\n            <input {...getInputProps()} />\n            {isDragActive ? (\n              <div className=\"flex flex-col items-center justify-center gap-4 sm:px-5\">\n                <div className=\"rounded-full border border-dashed p-3\">\n                  <Upload className=\"text-muted-foreground size-7\" aria-hidden=\"true\" />\n                </div>\n                <p className=\"text-muted-foreground font-medium\">{t('documentPanel.uploadDocuments.fileUploader.dropHere')}</p>\n              </div>\n            ) : (\n              <div className=\"flex flex-col items-center justify-center gap-4 sm:px-5\">\n                <div className=\"rounded-full border border-dashed p-3\">\n                  <Upload className=\"text-muted-foreground size-7\" aria-hidden=\"true\" />\n                </div>\n                <div className=\"flex flex-col gap-px\">\n                  <p className=\"text-muted-foreground font-medium\">\n                    {t('documentPanel.uploadDocuments.fileUploader.dragAndDrop')}\n                  </p>\n                  {description ? (\n                    <p className=\"text-muted-foreground/70 text-sm\">{description}</p>\n                  ) : (\n                    <p className=\"text-muted-foreground/70 text-sm\">\n                      {t('documentPanel.uploadDocuments.fileUploader.uploadDescription', {\n                        count: maxFileCount,\n                        isMultiple: maxFileCount === Infinity,\n                        maxSize: formatBytes(maxSize)\n                      })}\n                      {t('documentPanel.uploadDocuments.fileTypes')}\n                    </p>\n                  )}\n                </div>\n              </div>\n            )}\n          </div>\n        )}\n      </Dropzone>\n      {files?.length ? (\n        <ScrollArea className=\"h-fit w-full px-3\">\n          <div className=\"flex max-h-48 flex-col gap-4\">\n            {files?.map((file, index) => (\n              <FileCard\n                key={index}\n                file={file}\n                onRemove={() => onRemove(index)}\n                progress={progresses?.[file.name]}\n                error={fileErrors?.[file.name]}\n              />\n            ))}\n          </div>\n        </ScrollArea>\n      ) : null}\n    </div>\n  )\n}\n\ninterface ProgressProps {\n  value: number\n  error?: boolean\n  showIcon?: boolean  // New property to control icon display\n}\n\nfunction Progress({ value, error }: ProgressProps) {\n  return (\n    <div className=\"relative h-2 w-full\">\n      <div className=\"h-full w-full overflow-hidden rounded-full bg-secondary\">\n        <div\n          className={cn(\n            'h-full transition-all',\n            error ? 'bg-red-400' : 'bg-primary'\n          )}\n          style={{ width: `${value}%` }}\n        />\n      </div>\n    </div>\n  )\n}\n\ninterface FileCardProps {\n  file: File\n  onRemove: () => void\n  progress?: number\n  error?: string\n}\n\nfunction FileCard({ file, progress, error, onRemove }: FileCardProps) {\n  const { t } = useTranslation()\n  return (\n    <div className=\"relative flex items-center gap-2.5\">\n      <div className=\"flex flex-1 gap-2.5\">\n        {error ? (\n          <FileText className=\"text-red-400 size-10\" aria-hidden=\"true\" />\n        ) : (\n          isFileWithPreview(file) ? <FilePreview file={file} /> : null\n        )}\n        <div className=\"flex w-full flex-col gap-2\">\n          <div className=\"flex flex-col gap-px\">\n            <p className=\"text-foreground/80 line-clamp-1 text-sm font-medium\">{file.name}</p>\n            <p className=\"text-muted-foreground text-xs\">{formatBytes(file.size)}</p>\n          </div>\n          {error ? (\n            <div className=\"text-red-400 text-sm\">\n              <div className=\"relative mb-2\">\n                <Progress value={100} error={true} />\n              </div>\n              <p>{error}</p>\n            </div>\n          ) : (\n            progress ? <Progress value={progress} /> : null\n          )}\n        </div>\n      </div>\n      <div className=\"flex items-center gap-2\">\n        <Button type=\"button\" variant=\"outline\" size=\"icon\" className=\"size-7\" onClick={onRemove}>\n          <X className=\"size-4\" aria-hidden=\"true\" />\n          <span className=\"sr-only\">{t('documentPanel.uploadDocuments.fileUploader.removeFile')}</span>\n        </Button>\n      </div>\n    </div>\n  )\n}\n\nfunction isFileWithPreview(file: File): file is File & { preview: string } {\n  return 'preview' in file && typeof file.preview === 'string'\n}\n\ninterface FilePreviewProps {\n  file: File & { preview: string }\n}\n\nfunction FilePreview({ file }: FilePreviewProps) {\n  if (file.type.startsWith('image/')) {\n    return <div className=\"aspect-square shrink-0 rounded-md object-cover\" />\n  }\n\n  return <FileText className=\"text-muted-foreground size-10\" aria-hidden=\"true\" />\n}\n\nexport default FileUploader\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Input.tsx",
    "content": "import * as React from 'react'\nimport { cn } from '@/lib/utils'\n\nconst Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(\n  ({ className, type, ...props }, ref) => {\n    return (\n      <input\n        type={type}\n        className={cn(\n          'border-input file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm [&::-webkit-inner-spin-button]:opacity-50 [&::-webkit-outer-spin-button]:opacity-50',\n          className\n        )}\n        ref={ref}\n        {...props}\n      />\n    )\n  }\n)\nInput.displayName = 'Input'\n\nexport default Input\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/NumberInput.tsx",
    "content": "import { ChevronDown, ChevronUp } from 'lucide-react'\nimport { forwardRef, useCallback, useEffect, useState } from 'react'\nimport { NumericFormat, NumericFormatProps } from 'react-number-format'\nimport Button from '@/components/ui/Button'\nimport Input from '@/components/ui/Input'\nimport { cn } from '@/lib/utils'\n\nexport interface NumberInputProps extends Omit<NumericFormatProps, 'value' | 'onValueChange'> {\n  stepper?: number\n  thousandSeparator?: string\n  placeholder?: string\n  defaultValue?: number\n  min?: number\n  max?: number\n  value?: number // Controlled value\n  suffix?: string\n  prefix?: string\n  onValueChange?: (value: number | undefined) => void\n  fixedDecimalScale?: boolean\n  decimalScale?: number\n}\n\nconst NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(\n  (\n    {\n      stepper,\n      thousandSeparator,\n      placeholder,\n      defaultValue,\n      min = -Infinity,\n      max = Infinity,\n      onValueChange,\n      fixedDecimalScale = false,\n      decimalScale = 0,\n      className = undefined,\n      suffix,\n      prefix,\n      value: controlledValue,\n      ...props\n    },\n    ref\n  ) => {\n    const [value, setValue] = useState<number | undefined>(controlledValue ?? defaultValue)\n\n    const handleIncrement = useCallback(() => {\n      setValue((prev) =>\n        prev === undefined ? (stepper ?? 1) : Math.min(prev + (stepper ?? 1), max)\n      )\n    }, [stepper, max])\n\n    const handleDecrement = useCallback(() => {\n      setValue((prev) =>\n        prev === undefined ? -(stepper ?? 1) : Math.max(prev - (stepper ?? 1), min)\n      )\n    }, [stepper, min])\n\n    useEffect(() => {\n      if (controlledValue !== undefined) {\n        setValue(controlledValue)\n      }\n    }, [controlledValue])\n\n    const handleChange = (values: { value: string; floatValue: number | undefined }) => {\n      const newValue = values.floatValue === undefined ? undefined : values.floatValue\n      setValue(newValue)\n      if (onValueChange) {\n        onValueChange(newValue)\n      }\n    }\n\n    const handleBlur = () => {\n      if (value !== undefined) {\n        if (value < min) {\n          setValue(min)\n          ;(ref as React.RefObject<HTMLInputElement>).current!.value = String(min)\n        } else if (value > max) {\n          setValue(max)\n          ;(ref as React.RefObject<HTMLInputElement>).current!.value = String(max)\n        }\n      }\n    }\n\n    return (\n      <div className=\"relative flex\">\n        <NumericFormat\n          value={value}\n          onValueChange={handleChange}\n          thousandSeparator={thousandSeparator}\n          decimalScale={decimalScale}\n          fixedDecimalScale={fixedDecimalScale}\n          allowNegative={min < 0}\n          valueIsNumericString\n          onBlur={handleBlur}\n          max={max}\n          min={min}\n          suffix={suffix}\n          prefix={prefix}\n          customInput={(props) => <Input {...props} className={cn('w-full', className)} />}\n          placeholder={placeholder}\n          className=\"[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\"\n          getInputRef={ref}\n          {...props}\n        />\n        <div className=\"absolute top-0 right-0 bottom-0 flex flex-col\">\n          <Button\n            aria-label=\"Increase value\"\n            className=\"border-input h-1/2 rounded-l-none rounded-br-none border-b border-l px-2 focus-visible:relative\"\n            variant=\"outline\"\n            onClick={handleIncrement}\n            disabled={value === max}\n          >\n            <ChevronUp size={15} />\n          </Button>\n          <Button\n            aria-label=\"Decrease value\"\n            className=\"border-input h-1/2 rounded-l-none rounded-tr-none border-b border-l px-2 focus-visible:relative\"\n            variant=\"outline\"\n            onClick={handleDecrement}\n            disabled={value === min}\n          >\n            <ChevronDown size={15} />\n          </Button>\n        </div>\n      </div>\n    )\n  }\n)\n\nNumberInput.displayName = 'NumberInput'\n\nexport default NumberInput\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/PaginationControls.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport Button from './Button'\nimport Input from './Input'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './Select'\nimport { cn } from '@/lib/utils'\nimport { ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon } from 'lucide-react'\n\nexport type PaginationControlsProps = {\n  currentPage: number\n  totalPages: number\n  pageSize: number\n  totalCount: number\n  onPageChange: (page: number) => void\n  onPageSizeChange: (pageSize: number) => void\n  isLoading?: boolean\n  compact?: boolean\n  className?: string\n}\n\nconst PAGE_SIZE_OPTIONS = [\n  { value: 10, label: '10' },\n  { value: 20, label: '20' },\n  { value: 50, label: '50' },\n  { value: 100, label: '100' },\n  { value: 200, label: '200' }\n]\n\nexport default function PaginationControls({\n  currentPage,\n  totalPages,\n  pageSize,\n  totalCount,\n  onPageChange,\n  onPageSizeChange,\n  isLoading = false,\n  compact = false,\n  className\n}: PaginationControlsProps) {\n  const { t } = useTranslation()\n  const [inputPage, setInputPage] = useState(currentPage.toString())\n\n  // Update input when currentPage changes\n  useEffect(() => {\n    setInputPage(currentPage.toString())\n  }, [currentPage])\n\n  // Handle page input change with debouncing\n  const handlePageInputChange = useCallback((value: string) => {\n    setInputPage(value)\n  }, [])\n\n  // Handle page input submit\n  const handlePageInputSubmit = useCallback(() => {\n    const pageNum = parseInt(inputPage, 10)\n    if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) {\n      onPageChange(pageNum)\n    } else {\n      // Reset to current page if invalid\n      setInputPage(currentPage.toString())\n    }\n  }, [inputPage, totalPages, onPageChange, currentPage])\n\n  // Handle page input key press\n  const handlePageInputKeyPress = useCallback((e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      handlePageInputSubmit()\n    }\n  }, [handlePageInputSubmit])\n\n  // Handle page size change\n  const handlePageSizeChange = useCallback((value: string) => {\n    const newPageSize = parseInt(value, 10)\n    if (!isNaN(newPageSize)) {\n      onPageSizeChange(newPageSize)\n    }\n  }, [onPageSizeChange])\n\n  // Navigation handlers\n  const goToFirstPage = useCallback(() => {\n    if (currentPage > 1 && !isLoading) {\n      onPageChange(1)\n    }\n  }, [currentPage, onPageChange, isLoading])\n\n  const goToPrevPage = useCallback(() => {\n    if (currentPage > 1 && !isLoading) {\n      onPageChange(currentPage - 1)\n    }\n  }, [currentPage, onPageChange, isLoading])\n\n  const goToNextPage = useCallback(() => {\n    if (currentPage < totalPages && !isLoading) {\n      onPageChange(currentPage + 1)\n    }\n  }, [currentPage, totalPages, onPageChange, isLoading])\n\n  const goToLastPage = useCallback(() => {\n    if (currentPage < totalPages && !isLoading) {\n      onPageChange(totalPages)\n    }\n  }, [currentPage, totalPages, onPageChange, isLoading])\n\n  if (totalPages <= 1) {\n    return null\n  }\n\n  if (compact) {\n    return (\n      <div className={cn('flex items-center gap-2', className)}>\n        <div className=\"flex items-center gap-1\">\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={goToPrevPage}\n            disabled={currentPage <= 1 || isLoading}\n            className=\"h-8 w-8 p-0\"\n          >\n            <ChevronLeftIcon className=\"h-4 w-4\" />\n          </Button>\n\n          <div className=\"flex items-center gap-1\">\n            <Input\n              type=\"text\"\n              value={inputPage}\n              onChange={(e) => handlePageInputChange(e.target.value)}\n              onBlur={handlePageInputSubmit}\n              onKeyPress={handlePageInputKeyPress}\n              disabled={isLoading}\n              className=\"h-8 w-12 text-center text-sm\"\n            />\n            <span className=\"text-sm text-gray-500\">/ {totalPages}</span>\n          </div>\n\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={goToNextPage}\n            disabled={currentPage >= totalPages || isLoading}\n            className=\"h-8 w-8 p-0\"\n          >\n            <ChevronRightIcon className=\"h-4 w-4\" />\n          </Button>\n        </div>\n\n        <Select\n          value={pageSize.toString()}\n          onValueChange={handlePageSizeChange}\n          disabled={isLoading}\n        >\n          <SelectTrigger className=\"h-8 w-16\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {PAGE_SIZE_OPTIONS.map((option) => (\n              <SelectItem key={option.value} value={option.value.toString()}>\n                {option.label}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </div>\n    )\n  }\n\n  return (\n    <div className={cn('flex items-center justify-between gap-4', className)}>\n      <div className=\"text-sm text-gray-500\">\n        {t('pagination.showing', {\n          start: Math.min((currentPage - 1) * pageSize + 1, totalCount),\n          end: Math.min(currentPage * pageSize, totalCount),\n          total: totalCount\n        })}\n      </div>\n\n      <div className=\"flex items-center gap-2\">\n        <div className=\"flex items-center gap-1\">\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={goToFirstPage}\n            disabled={currentPage <= 1 || isLoading}\n            className=\"h-8 w-8 p-0\"\n            tooltip={t('pagination.firstPage')}\n          >\n            <ChevronsLeftIcon className=\"h-4 w-4\" />\n          </Button>\n\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={goToPrevPage}\n            disabled={currentPage <= 1 || isLoading}\n            className=\"h-8 w-8 p-0\"\n            tooltip={t('pagination.prevPage')}\n          >\n            <ChevronLeftIcon className=\"h-4 w-4\" />\n          </Button>\n\n          <div className=\"flex items-center gap-1\">\n            <span className=\"text-sm\">{t('pagination.page')}</span>\n            <Input\n              type=\"text\"\n              value={inputPage}\n              onChange={(e) => handlePageInputChange(e.target.value)}\n              onBlur={handlePageInputSubmit}\n              onKeyPress={handlePageInputKeyPress}\n              disabled={isLoading}\n              className=\"h-8 w-16 text-center text-sm\"\n            />\n            <span className=\"text-sm\">/ {totalPages}</span>\n          </div>\n\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={goToNextPage}\n            disabled={currentPage >= totalPages || isLoading}\n            className=\"h-8 w-8 p-0\"\n            tooltip={t('pagination.nextPage')}\n          >\n            <ChevronRightIcon className=\"h-4 w-4\" />\n          </Button>\n\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={goToLastPage}\n            disabled={currentPage >= totalPages || isLoading}\n            className=\"h-8 w-8 p-0\"\n            tooltip={t('pagination.lastPage')}\n          >\n            <ChevronsRightIcon className=\"h-4 w-4\" />\n          </Button>\n        </div>\n\n        <div className=\"flex items-center gap-2\">\n          <span className=\"text-sm\">{t('pagination.pageSize')}</span>\n          <Select\n            value={pageSize.toString()}\n            onValueChange={handlePageSizeChange}\n            disabled={isLoading}\n          >\n            <SelectTrigger className=\"h-8 w-16\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n              {PAGE_SIZE_OPTIONS.map((option) => (\n                <SelectItem key={option.value} value={option.value.toString()}>\n                  {option.label}\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Popover.tsx",
    "content": "import * as React from 'react'\nimport * as PopoverPrimitive from '@radix-ui/react-popover'\n\nimport { cn } from '@/lib/utils'\n\nconst Popover = PopoverPrimitive.Root\n\nconst PopoverTrigger = PopoverPrimitive.Trigger\n\n// Define the props type to include positioning props\ntype PopoverContentProps = React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {\n  collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>;\n  sticky?: 'partial' | 'always';\n  avoidCollisions?: boolean;\n};\n\nconst PopoverContent = React.forwardRef<\n  React.ComponentRef<typeof PopoverPrimitive.Content>,\n  PopoverContentProps\n>(({ className, align = 'center', sideOffset = 4, collisionPadding, sticky, avoidCollisions = false, ...props }, ref) => (\n  <PopoverPrimitive.Portal>\n    <PopoverPrimitive.Content\n      ref={ref}\n      align={align}\n      sideOffset={sideOffset}\n      collisionPadding={collisionPadding}\n      sticky={sticky}\n      avoidCollisions={avoidCollisions}\n      className={cn(\n        'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 rounded-md border p-4 shadow-md outline-none',\n        className\n      )}\n      {...props}\n    />\n  </PopoverPrimitive.Portal>\n))\nPopoverContent.displayName = PopoverPrimitive.Content.displayName\n\nexport { Popover, PopoverTrigger, PopoverContent }\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Progress.tsx",
    "content": "import * as React from 'react'\nimport * as ProgressPrimitive from '@radix-ui/react-progress'\n\nimport { cn } from '@/lib/utils'\n\nconst Progress = React.forwardRef<\n  React.ComponentRef<typeof ProgressPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>\n>(({ className, value, ...props }, ref) => (\n  <ProgressPrimitive.Root\n    ref={ref}\n    className={cn('bg-secondary relative h-4 w-full overflow-hidden rounded-full', className)}\n    {...props}\n  >\n    <ProgressPrimitive.Indicator\n      className=\"bg-primary h-full w-full flex-1 transition-all\"\n      style={{ transform: `translateX(-${100 - (value || 0)}%)` }}\n    />\n  </ProgressPrimitive.Root>\n))\nProgress.displayName = ProgressPrimitive.Root.displayName\n\nexport default Progress\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/ScrollArea.tsx",
    "content": "import * as React from 'react'\nimport * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'\n\nimport { cn } from '@/lib/utils'\n\nconst ScrollArea = React.forwardRef<\n  React.ComponentRef<typeof ScrollAreaPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>\n>(({ className, children, ...props }, ref) => (\n  <ScrollAreaPrimitive.Root\n    ref={ref}\n    className={cn('relative overflow-hidden', className)}\n    {...props}\n  >\n    <ScrollAreaPrimitive.Viewport className=\"h-full w-full rounded-[inherit]\">\n      {children}\n    </ScrollAreaPrimitive.Viewport>\n    <ScrollBar />\n    <ScrollAreaPrimitive.Corner />\n  </ScrollAreaPrimitive.Root>\n))\nScrollArea.displayName = ScrollAreaPrimitive.Root.displayName\n\nconst ScrollBar = React.forwardRef<\n  React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,\n  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>\n>(({ className, orientation = 'vertical', ...props }, ref) => (\n  <ScrollAreaPrimitive.ScrollAreaScrollbar\n    ref={ref}\n    orientation={orientation}\n    className={cn(\n      'flex touch-none transition-colors select-none',\n      orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',\n      orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',\n      className\n    )}\n    {...props}\n  >\n    <ScrollAreaPrimitive.ScrollAreaThumb className=\"bg-border relative flex-1 rounded-full\" />\n  </ScrollAreaPrimitive.ScrollAreaScrollbar>\n))\nScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName\n\nexport { ScrollArea, ScrollBar }\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Select.tsx",
    "content": "import * as React from 'react'\nimport * as SelectPrimitive from '@radix-ui/react-select'\nimport { Check, ChevronDown, ChevronUp } from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\n\nconst Select = SelectPrimitive.Root\n\nconst SelectGroup = SelectPrimitive.Group\n\nconst SelectValue = SelectPrimitive.Value\n\nconst SelectTrigger = React.forwardRef<\n  React.ComponentRef<typeof SelectPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <SelectPrimitive.Icon asChild>\n      <ChevronDown className=\"h-4 w-4 opacity-50\" />\n    </SelectPrimitive.Icon>\n  </SelectPrimitive.Trigger>\n))\nSelectTrigger.displayName = SelectPrimitive.Trigger.displayName\n\nconst SelectScrollUpButton = React.forwardRef<\n  React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollUpButton\n    ref={ref}\n    className={cn('flex cursor-default items-center justify-center py-1', className)}\n    {...props}\n  >\n    <ChevronUp className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollUpButton>\n))\nSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName\n\nconst SelectScrollDownButton = React.forwardRef<\n  React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollDownButton\n    ref={ref}\n    className={cn('flex cursor-default items-center justify-center py-1', className)}\n    {...props}\n  >\n    <ChevronDown className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollDownButton>\n))\nSelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName\n\nconst SelectContent = React.forwardRef<\n  React.ComponentRef<typeof SelectPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>\n>(({ className, children, position = 'popper', ...props }, ref) => (\n  <SelectPrimitive.Portal>\n    <SelectPrimitive.Content\n      ref={ref}\n      className={cn(\n        'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',\n        position === 'popper' &&\n          'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',\n        className\n      )}\n      position={position}\n      {...props}\n    >\n      <SelectScrollUpButton />\n      <SelectPrimitive.Viewport\n        className={cn(\n          'p-1',\n          position === 'popper' &&\n            'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'\n        )}\n      >\n        {children}\n      </SelectPrimitive.Viewport>\n      <SelectScrollDownButton />\n    </SelectPrimitive.Content>\n  </SelectPrimitive.Portal>\n))\nSelectContent.displayName = SelectPrimitive.Content.displayName\n\nconst SelectLabel = React.forwardRef<\n  React.ComponentRef<typeof SelectPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Label\n    ref={ref}\n    className={cn('py-1.5 pr-2 pl-8 text-sm font-semibold', className)}\n    {...props}\n  />\n))\nSelectLabel.displayName = SelectPrimitive.Label.displayName\n\nconst SelectItem = React.forwardRef<\n  React.ComponentRef<typeof SelectPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Item\n    ref={ref}\n    className={cn(\n      'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <SelectPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </SelectPrimitive.ItemIndicator>\n    </span>\n\n    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n  </SelectPrimitive.Item>\n))\nSelectItem.displayName = SelectPrimitive.Item.displayName\n\nconst SelectSeparator = React.forwardRef<\n  React.ComponentRef<typeof SelectPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Separator\n    ref={ref}\n    className={cn('bg-muted -mx-1 my-1 h-px', className)}\n    {...props}\n  />\n))\nSelectSeparator.displayName = SelectPrimitive.Separator.displayName\n\nexport {\n  Select,\n  SelectGroup,\n  SelectValue,\n  SelectTrigger,\n  SelectContent,\n  SelectLabel,\n  SelectItem,\n  SelectSeparator,\n  SelectScrollUpButton,\n  SelectScrollDownButton\n}\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Separator.tsx",
    "content": "import * as React from 'react'\nimport * as SeparatorPrimitive from '@radix-ui/react-separator'\n\nimport { cn } from '@/lib/utils'\n\nconst Separator = React.forwardRef<\n  React.ComponentRef<typeof SeparatorPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>\n>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (\n  <SeparatorPrimitive.Root\n    ref={ref}\n    decorative={decorative}\n    orientation={orientation}\n    className={cn(\n      'bg-border shrink-0',\n      orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',\n      className\n    )}\n    {...props}\n  />\n))\nSeparator.displayName = SeparatorPrimitive.Root.displayName\n\nexport default Separator\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/TabContent.tsx",
    "content": "import React, { useEffect } from 'react';\nimport { useTabVisibility } from '@/contexts/useTabVisibility';\n\ninterface TabContentProps {\n  tabId: string;\n  children: React.ReactNode;\n  className?: string;\n}\n\n/**\n * TabContent component that manages visibility based on tab selection\n * Works with the TabVisibilityContext to show/hide content based on active tab\n */\nconst TabContent: React.FC<TabContentProps> = ({ tabId, children, className = '' }) => {\n  const { isTabVisible, setTabVisibility } = useTabVisibility();\n  const isVisible = isTabVisible(tabId);\n\n  // Register this tab with the context when mounted\n  useEffect(() => {\n    setTabVisibility(tabId, true);\n\n    // Cleanup when unmounted\n    return () => {\n      setTabVisibility(tabId, false);\n    };\n  }, [tabId, setTabVisibility]);\n\n  // Use CSS to hide content instead of not rendering it\n  // This prevents components from unmounting when tabs are switched\n  return (\n    <div className={`${className} ${isVisible ? '' : 'hidden'}`}>\n      {children}\n    </div>\n  );\n};\n\nexport default TabContent;\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Table.tsx",
    "content": "import * as React from 'react'\n\nimport { cn } from '@/lib/utils'\n\nconst Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(\n  ({ className, ...props }, ref) => (\n    <div className=\"relative w-full overflow-auto\">\n      <table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />\n    </div>\n  )\n)\nTable.displayName = 'Table'\n\nconst TableHeader = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />\n))\nTableHeader.displayName = 'TableHeader'\n\nconst TableBody = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />\n))\nTableBody.displayName = 'TableBody'\n\nconst TableFooter = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tfoot\n    ref={ref}\n    className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}\n    {...props}\n  />\n))\nTableFooter.displayName = 'TableFooter'\n\nconst TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(\n  ({ className, ...props }, ref) => (\n    <tr\n      ref={ref}\n      className={cn(\n        'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',\n        className\n      )}\n      {...props}\n    />\n  )\n)\nTableRow.displayName = 'TableRow'\n\nconst TableHead = React.forwardRef<\n  HTMLTableCellElement,\n  React.ThHTMLAttributes<HTMLTableCellElement>\n// eslint-disable-next-line react/prop-types\n>(({ className, ...props }, ref) => (\n  <th\n    ref={ref}\n    className={cn(\n      'text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',\n      className\n    )}\n    {...props}\n  />\n))\nTableHead.displayName = 'TableHead'\n\nconst TableCell = React.forwardRef<\n  HTMLTableCellElement,\n  React.TdHTMLAttributes<HTMLTableCellElement>\n  // eslint-disable-next-line react/prop-types\n>(({ className, ...props }, ref) => (\n  <td\n    ref={ref}\n    className={cn(\n      'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',\n      className\n    )}\n    {...props}\n  />\n))\nTableCell.displayName = 'TableCell'\n\nconst TableCaption = React.forwardRef<\n  HTMLTableCaptionElement,\n  React.HTMLAttributes<HTMLTableCaptionElement>\n>(({ className, ...props }, ref) => (\n  <caption ref={ref} className={cn('text-muted-foreground mt-4 text-sm', className)} {...props} />\n))\nTableCaption.displayName = 'TableCaption'\n\nexport { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Tabs.tsx",
    "content": "import * as React from 'react'\nimport * as TabsPrimitive from '@radix-ui/react-tabs'\n\nimport { cn } from '@/lib/utils'\n\nconst Tabs = TabsPrimitive.Root\n\nconst TabsList = React.forwardRef<\n  React.ComponentRef<typeof TabsPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.List\n    ref={ref}\n    className={cn(\n      'bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1',\n      className\n    )}\n    {...props}\n  />\n))\nTabsList.displayName = TabsPrimitive.List.displayName\n\nconst TabsTrigger = React.forwardRef<\n  React.ComponentRef<typeof TabsPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-sm px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm',\n      className\n    )}\n    {...props}\n  />\n))\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName\n\nconst TabsContent = React.forwardRef<\n  React.ComponentRef<typeof TabsPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    className={cn(\n      'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',\n      'data-[state=inactive]:invisible data-[state=active]:visible',\n      'h-full w-full',\n      className\n    )}\n    // Force mounting of inactive tabs to preserve WebGL contexts\n    forceMount\n    {...props}\n  />\n))\nTabsContent.displayName = TabsPrimitive.Content.displayName\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent }\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Text.tsx",
    "content": "import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'\nimport { cn } from '@/lib/utils'\n\nconst Text = ({\n  text,\n  className,\n  tooltipClassName,\n  tooltip,\n  side,\n  onClick\n}: {\n  text: string\n  className?: string\n  tooltipClassName?: string\n  tooltip?: string\n  side?: 'top' | 'right' | 'bottom' | 'left'\n  onClick?: () => void\n}) => {\n  if (!tooltip) {\n    return (\n      <label\n        className={cn(className, onClick !== undefined ? 'cursor-pointer' : undefined)}\n        onClick={onClick}\n      >\n        {text}\n      </label>\n    )\n  }\n\n  return (\n    <TooltipProvider delayDuration={200}>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <label\n            className={cn(className, onClick !== undefined ? 'cursor-pointer' : undefined)}\n            onClick={onClick}\n          >\n            {text}\n          </label>\n        </TooltipTrigger>\n        <TooltipContent side={side} className={tooltipClassName}>\n          {tooltip}\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  )\n}\n\nexport default Text\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Textarea.tsx",
    "content": "import * as React from 'react'\nimport { cn } from '@/lib/utils'\n\nexport interface TextareaProps\n  extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {\n  className?: string\n}\n\nconst Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\n  ({ className, ...props }, ref) => {\n    return (\n      <textarea\n        className={cn(\n          'border-input file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none',\n          className\n        )}\n        ref={ref}\n        {...props}\n      />\n    )\n  }\n)\nTextarea.displayName = 'Textarea'\n\nexport default Textarea\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/Tooltip.tsx",
    "content": "import * as React from 'react'\nimport * as TooltipPrimitive from '@radix-ui/react-tooltip'\nimport { cn } from '@/lib/utils'\n\nconst TooltipProvider = TooltipPrimitive.Provider\n\nconst Tooltip = TooltipPrimitive.Root\n\nconst TooltipTrigger = TooltipPrimitive.Trigger\n\nconst processTooltipContent = (content: string) => {\n  if (typeof content !== 'string') return content\n  return (\n    <div className=\"relative top-0 pt-1 whitespace-pre-wrap break-words\">\n      {content}\n    </div>\n  )\n}\n\nconst TooltipContent = React.forwardRef<\n  React.ComponentRef<typeof TooltipPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {\n    side?: 'top' | 'right' | 'bottom' | 'left'\n    align?: 'start' | 'center' | 'end'\n  }\n>(({ className, side = 'left', align = 'start', children, ...props }, ref) => {\n  const contentRef = React.useRef<HTMLDivElement>(null);\n\n  React.useEffect(() => {\n    if (contentRef.current) {\n      contentRef.current.scrollTop = 0;\n    }\n  }, [children]);\n\n  return (\n    <TooltipPrimitive.Content\n      ref={ref}\n      side={side}\n      align={align}\n      className={cn(\n        'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-[60vh] overflow-y-auto whitespace-pre-wrap break-words rounded-md border px-3 py-2 text-sm shadow-md z-60',\n        className\n      )}\n      {...props}\n    >\n      {typeof children === 'string' ? processTooltipContent(children) : children}\n    </TooltipPrimitive.Content>\n  );\n})\nTooltipContent.displayName = TooltipPrimitive.Content.displayName\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }\n"
  },
  {
    "path": "lightrag_webui/src/components/ui/UserPromptInputWithHistory.tsx",
    "content": "import React, { useState, useRef, useEffect, useCallback } from 'react'\nimport { ChevronDown, X } from 'lucide-react'\nimport { cn } from '@/lib/utils'\nimport Input from './Input'\n\ninterface UserPromptInputWithHistoryProps {\n  value: string\n  onChange: (value: string) => void\n  placeholder?: string\n  className?: string\n  id?: string\n  history: string[]\n  onSelectFromHistory: (prompt: string) => void\n  onDeleteFromHistory?: (index: number) => void\n}\n\nexport default function UserPromptInputWithHistory({\n  value,\n  onChange,\n  placeholder,\n  className,\n  id,\n  history,\n  onSelectFromHistory,\n  onDeleteFromHistory\n}: UserPromptInputWithHistoryProps) {\n  const [isOpen, setIsOpen] = useState(false)\n  const [selectedIndex, setSelectedIndex] = useState(-1)\n  const [isHovered, setIsHovered] = useState(false)\n  const dropdownRef = useRef<HTMLDivElement>(null)\n  const inputRef = useRef<HTMLInputElement>(null)\n\n  // Close dropdown when clicking outside\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {\n        setIsOpen(false)\n        setSelectedIndex(-1)\n      }\n    }\n\n    document.addEventListener('mousedown', handleClickOutside)\n    return () => {\n      document.removeEventListener('mousedown', handleClickOutside)\n    }\n  }, [])\n\n  // Handle keyboard navigation\n  const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (!isOpen) {\n      if (e.key === 'ArrowDown' && history.length > 0) {\n        e.preventDefault()\n        setIsOpen(true)\n        setSelectedIndex(0)\n      }\n      return\n    }\n\n    switch (e.key) {\n    case 'ArrowDown':\n      e.preventDefault()\n      setSelectedIndex(prev =>\n        prev < history.length - 1 ? prev + 1 : prev\n      )\n      break\n    case 'ArrowUp':\n      e.preventDefault()\n      setSelectedIndex(prev => prev > 0 ? prev - 1 : -1)\n      if (selectedIndex === 0) {\n        setSelectedIndex(-1)\n      }\n      break\n    case 'Enter':\n      if (selectedIndex >= 0 && selectedIndex < history.length) {\n        e.preventDefault()\n        const selectedPrompt = history[selectedIndex]\n        onSelectFromHistory(selectedPrompt)\n        setIsOpen(false)\n        setSelectedIndex(-1)\n      }\n      break\n    case 'Escape':\n      e.preventDefault()\n      setIsOpen(false)\n      setSelectedIndex(-1)\n      break\n    }\n  }, [isOpen, selectedIndex, history, onSelectFromHistory])\n\n  const handleInputClick = () => {\n    if (history.length > 0) {\n      setIsOpen(!isOpen)\n      setSelectedIndex(-1)\n    }\n  }\n\n  const handleDropdownItemClick = (prompt: string) => {\n    onSelectFromHistory(prompt)\n    setIsOpen(false)\n    setSelectedIndex(-1)\n    inputRef.current?.focus()\n  }\n\n  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    onChange(e.target.value)\n  }\n\n  const handleMouseEnter = () => {\n    setIsHovered(true)\n  }\n\n  const handleMouseLeave = () => {\n    setIsHovered(false)\n  }\n\n  // Handle delete history item with boundary cases\n  const handleDeleteHistoryItem = useCallback((index: number, e: React.MouseEvent) => {\n    e.stopPropagation() // Prevent triggering item selection\n    onDeleteFromHistory?.(index)\n\n    // Handle boundary cases\n    if (history.length === 1) {\n      // Deleting the last item, close dropdown\n      setIsOpen(false)\n      setSelectedIndex(-1)\n    } else if (selectedIndex === index) {\n      // Deleting currently selected item, adjust selection\n      setSelectedIndex(prev => prev > 0 ? prev - 1 : -1)\n    } else if (selectedIndex > index) {\n      // Deleting item before selected item, adjust index\n      setSelectedIndex(prev => prev - 1)\n    }\n  }, [onDeleteFromHistory, history.length, selectedIndex])\n\n  return (\n    <div className=\"relative\" ref={dropdownRef} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>\n      <div className=\"relative\">\n        <Input\n          ref={inputRef}\n          id={id}\n          value={value}\n          onChange={handleInputChange}\n          onKeyDown={handleKeyDown}\n          onClick={handleInputClick}\n          placeholder={placeholder}\n          autoComplete=\"off\"\n          className={cn(isHovered && history.length > 0 ? 'pr-5' : 'pr-2', 'w-full', className)}\n        />\n        {isHovered && history.length > 0 && (\n          <button\n            type=\"button\"\n            onClick={handleInputClick}\n            className=\"absolute right-2 top-1/2 -translate-y-1/2 p-0 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors\"\n            tabIndex={-1}\n          >\n            <ChevronDown\n              className={cn(\n                'h-3 w-3 transition-transform duration-200 text-gray-500',\n                isOpen && 'rotate-180'\n              )}\n            />\n          </button>\n        )}\n      </div>\n\n      {/* Dropdown */}\n      {isOpen && history.length > 0 && (\n        <div className=\"absolute top-full left-0 right-0 z-50 mt-0.5 bg-gray-100 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-96 overflow-auto min-w-0\">\n          {history.map((prompt, index) => (\n            <div\n              key={index}\n              className={cn(\n                'flex items-center justify-between pl-3 pr-1 py-2 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors',\n                'border-b border-gray-100 dark:border-gray-700 last:border-b-0',\n                'focus-within:bg-gray-100 dark:focus-within:bg-gray-700',\n                selectedIndex === index && 'bg-gray-100 dark:bg-gray-700'\n              )}\n            >\n              <button\n                type=\"button\"\n                onClick={() => handleDropdownItemClick(prompt)}\n                className=\"flex-1 text-left truncate focus:outline-none mr-0\"\n                title={prompt}\n              >\n                {prompt}\n              </button>\n              {onDeleteFromHistory && (\n                <button\n                  type=\"button\"\n                  onClick={(e) => handleDeleteHistoryItem(index, e)}\n                  className=\"flex-shrink-0 p-0 rounded hover:bg-red-100 dark:hover:bg-red-900 transition-colors focus:outline-none ml-auto\"\n                  title=\"Delete this history item\"\n                >\n                  <X className=\"h-3 w-3 text-gray-400 hover:text-red-500\" />\n                </button>\n              )}\n            </div>\n          ))}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/contexts/TabVisibilityProvider.tsx",
    "content": "import React, { useState, useEffect, useMemo } from 'react';\nimport { TabVisibilityContext } from './context';\nimport { TabVisibilityContextType } from './types';\nimport { useSettingsStore } from '@/stores/settings';\n\ninterface TabVisibilityProviderProps {\n  children: React.ReactNode;\n}\n\n/**\n * Provider component for the TabVisibility context\n * Manages the visibility state of tabs throughout the application\n */\nexport const TabVisibilityProvider: React.FC<TabVisibilityProviderProps> = ({ children }) => {\n  // Get current tab from settings store\n  const currentTab = useSettingsStore.use.currentTab();\n\n  // Initialize visibility state with all tabs visible\n  const [visibleTabs, setVisibleTabs] = useState<Record<string, boolean>>(() => ({\n    'documents': true,\n    'knowledge-graph': true,\n    'retrieval': true,\n    'api': true\n  }));\n\n  // Keep all tabs visible because we use CSS to control TAB visibility instead of React\n  useEffect(() => {\n    setVisibleTabs((prev) => ({\n      ...prev,\n      'documents': true,\n      'knowledge-graph': true,\n      'retrieval': true,\n      'api': true\n    }));\n  }, [currentTab]);\n\n  // Create the context value with memoization to prevent unnecessary re-renders\n  const contextValue = useMemo<TabVisibilityContextType>(\n    () => ({\n      visibleTabs,\n      setTabVisibility: (tabId: string, isVisible: boolean) => {\n        setVisibleTabs((prev) => ({\n          ...prev,\n          [tabId]: isVisible,\n        }));\n      },\n      isTabVisible: (tabId: string) => !!visibleTabs[tabId],\n    }),\n    [visibleTabs]\n  );\n\n  return (\n    <TabVisibilityContext.Provider value={contextValue}>\n      {children}\n    </TabVisibilityContext.Provider>\n  );\n};\n\nexport default TabVisibilityProvider;\n"
  },
  {
    "path": "lightrag_webui/src/contexts/context.ts",
    "content": "import { createContext } from 'react';\nimport { TabVisibilityContextType } from './types';\n\n// Default context value\nconst defaultContext: TabVisibilityContextType = {\n  visibleTabs: {},\n  setTabVisibility: () => {},\n  isTabVisible: () => false,\n};\n\n// Create the context\nexport const TabVisibilityContext = createContext<TabVisibilityContextType>(defaultContext);\n"
  },
  {
    "path": "lightrag_webui/src/contexts/types.ts",
    "content": "export interface TabVisibilityContextType {\n  visibleTabs: Record<string, boolean>;\n  setTabVisibility: (tabId: string, isVisible: boolean) => void;\n  isTabVisible: (tabId: string) => boolean;\n}\n"
  },
  {
    "path": "lightrag_webui/src/contexts/useTabVisibility.ts",
    "content": "import { useContext } from 'react';\nimport { TabVisibilityContext } from './context';\nimport { TabVisibilityContextType } from './types';\n\n/**\n * Custom hook to access the tab visibility context\n * @returns The tab visibility context\n */\nexport const useTabVisibility = (): TabVisibilityContextType => {\n  const context = useContext(TabVisibilityContext);\n\n  if (!context) {\n    throw new Error('useTabVisibility must be used within a TabVisibilityProvider');\n  }\n\n  return context;\n};\n"
  },
  {
    "path": "lightrag_webui/src/features/ApiSite.tsx",
    "content": "import { useState, useEffect } from 'react'\nimport { useTabVisibility } from '@/contexts/useTabVisibility'\nimport { backendBaseUrl } from '@/lib/constants'\nimport { useTranslation } from 'react-i18next'\n\nexport default function ApiSite() {\n  const { t } = useTranslation()\n  const { isTabVisible } = useTabVisibility()\n  const isApiTabVisible = isTabVisible('api')\n  const [iframeLoaded, setIframeLoaded] = useState(false)\n\n  // Load the iframe once on component mount\n  useEffect(() => {\n    if (!iframeLoaded) {\n      setIframeLoaded(true)\n    }\n  }, [iframeLoaded])\n\n  // Use CSS to hide content when tab is not visible\n  return (\n    <div className={`size-full ${isApiTabVisible ? '' : 'hidden'}`}>\n      {iframeLoaded ? (\n        <iframe\n          src={backendBaseUrl + '/docs'}\n          className=\"size-full w-full h-full\"\n          style={{ width: '100%', height: '100%', border: 'none' }}\n          // Use key to ensure iframe doesn't reload\n          key=\"api-docs-iframe\"\n        />\n      ) : (\n        <div className=\"flex h-full w-full items-center justify-center bg-background\">\n          <div className=\"text-center\">\n            <div className=\"mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent\"></div>\n            <p>{t('apiSite.loading')}</p>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/features/DocumentManager.tsx",
    "content": "import { useState, useEffect, useCallback, useMemo, useRef } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { useSettingsStore } from '@/stores/settings'\nimport Button from '@/components/ui/Button'\nimport { cn } from '@/lib/utils'\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow\n} from '@/components/ui/Table'\nimport { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/Card'\nimport EmptyCard from '@/components/ui/EmptyCard'\nimport Checkbox from '@/components/ui/Checkbox'\nimport UploadDocumentsDialog from '@/components/documents/UploadDocumentsDialog'\nimport ClearDocumentsDialog from '@/components/documents/ClearDocumentsDialog'\nimport DeleteDocumentsDialog from '@/components/documents/DeleteDocumentsDialog'\nimport PaginationControls from '@/components/ui/PaginationControls'\n\nimport {\n  scanNewDocuments,\n  getDocumentsPaginated,\n  DocsStatusesResponse,\n  DocStatus,\n  DocStatusResponse,\n  DocumentsRequest,\n  PaginationInfo\n} from '@/api/lightrag'\nimport { errorMessage } from '@/lib/utils'\nimport { toast } from 'sonner'\nimport { useBackendState } from '@/stores/state'\n\nimport { RefreshCwIcon, ActivityIcon, ArrowUpIcon, ArrowDownIcon, RotateCcwIcon, CheckSquareIcon, XIcon, AlertTriangle, Info } from 'lucide-react'\nimport PipelineStatusDialog from '@/components/documents/PipelineStatusDialog'\n\ntype StatusFilter = DocStatus | 'all';\n\n// Utility functions defined outside component for better performance and to avoid dependency issues\nconst getCountValue = (counts: Record<string, number>, ...keys: string[]): number => {\n  for (const key of keys) {\n    const value = counts[key]\n    if (typeof value === 'number') {\n      return value\n    }\n  }\n  return 0\n}\n\nconst hasActiveDocumentsStatus = (counts: Record<string, number>): boolean =>\n  getCountValue(counts, 'PROCESSING', 'processing') > 0 ||\n  getCountValue(counts, 'PENDING', 'pending') > 0 ||\n  getCountValue(counts, 'PREPROCESSED', 'preprocessed') > 0\n\nconst getDisplayFileName = (doc: DocStatusResponse, maxLength: number = 20): string => {\n  // Check if file_path exists and is a non-empty string\n  if (!doc.file_path || typeof doc.file_path !== 'string' || doc.file_path.trim() === '') {\n    return doc.id;\n  }\n\n  // Try to extract filename from path\n  const parts = doc.file_path.split('/');\n  const fileName = parts[parts.length - 1];\n\n  // Ensure extracted filename is valid\n  if (!fileName || fileName.trim() === '') {\n    return doc.id;\n  }\n\n  // If filename is longer than maxLength, truncate it and add ellipsis\n  return fileName.length > maxLength\n    ? fileName.slice(0, maxLength) + '...'\n    : fileName;\n};\n\nconst formatMetadata = (metadata: Record<string, any>): string => {\n  const formattedMetadata = { ...metadata };\n\n  if (formattedMetadata.processing_start_time && typeof formattedMetadata.processing_start_time === 'number') {\n    const date = new Date(formattedMetadata.processing_start_time * 1000);\n    if (!isNaN(date.getTime())) {\n      formattedMetadata.processing_start_time = date.toLocaleString();\n    }\n  }\n\n  if (formattedMetadata.processing_end_time && typeof formattedMetadata.processing_end_time === 'number') {\n    const date = new Date(formattedMetadata.processing_end_time * 1000);\n    if (!isNaN(date.getTime())) {\n      formattedMetadata.processing_end_time = date.toLocaleString();\n    }\n  }\n\n  // Format JSON and remove outer braces and indentation\n  const jsonStr = JSON.stringify(formattedMetadata, null, 2);\n  const lines = jsonStr.split('\\n');\n  // Remove first line ({) and last line (}), and remove leading indentation (2 spaces)\n  return lines.slice(1, -1)\n    .map(line => line.replace(/^ {2}/, ''))\n    .join('\\n');\n};\n\nconst pulseStyle = `\n/* Tooltip styles */\n.tooltip-container {\n  position: relative;\n  overflow: visible !important;\n}\n\n.tooltip {\n  position: fixed; /* Use fixed positioning to escape overflow constraints */\n  z-index: 9999; /* Ensure tooltip appears above all other elements */\n  max-width: 600px;\n  white-space: normal;\n  word-break: break-word;\n  overflow-wrap: break-word;\n  border-radius: 0.375rem;\n  padding: 0.5rem 0.75rem;\n  font-size: 0.75rem; /* 12px */\n  background-color: rgba(0, 0, 0, 0.95);\n  color: white;\n  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n  pointer-events: none; /* Prevent tooltip from interfering with mouse events */\n  opacity: 0;\n  visibility: hidden;\n  transition: opacity 0.15s, visibility 0.15s;\n}\n\n.tooltip.visible {\n  opacity: 1;\n  visibility: visible;\n}\n\n.dark .tooltip {\n  background-color: rgba(255, 255, 255, 0.95);\n  color: black;\n}\n\n.tooltip pre {\n  white-space: pre-wrap;\n  word-break: break-word;\n  overflow-wrap: break-word;\n}\n\n/* Position tooltip helper class */\n.tooltip-helper {\n  position: absolute;\n  visibility: hidden;\n  pointer-events: none;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 0;\n}\n\n@keyframes pulse {\n  0% {\n    background-color: rgb(255 0 0 / 0.1);\n    border-color: rgb(255 0 0 / 0.2);\n  }\n  50% {\n    background-color: rgb(255 0 0 / 0.2);\n    border-color: rgb(255 0 0 / 0.4);\n  }\n  100% {\n    background-color: rgb(255 0 0 / 0.1);\n    border-color: rgb(255 0 0 / 0.2);\n  }\n}\n\n.dark .pipeline-busy {\n  animation: dark-pulse 2s infinite;\n}\n\n@keyframes dark-pulse {\n  0% {\n    background-color: rgb(255 0 0 / 0.2);\n    border-color: rgb(255 0 0 / 0.4);\n  }\n  50% {\n    background-color: rgb(255 0 0 / 0.3);\n    border-color: rgb(255 0 0 / 0.6);\n  }\n  100% {\n    background-color: rgb(255 0 0 / 0.2);\n    border-color: rgb(255 0 0 / 0.4);\n  }\n}\n\n.pipeline-busy {\n  animation: pulse 2s infinite;\n  border: 1px solid;\n}\n`;\n\n// Type definitions for sort field and direction\ntype SortField = 'created_at' | 'updated_at' | 'id' | 'file_path';\ntype SortDirection = 'asc' | 'desc';\n\nexport default function DocumentManager() {\n  // Track component mount status\n  const isMountedRef = useRef(true);\n\n  // Set up mount/unmount status tracking\n  useEffect(() => {\n    isMountedRef.current = true;\n\n    // Handle page reload/unload\n    const handleBeforeUnload = () => {\n      isMountedRef.current = false;\n    };\n\n    window.addEventListener('beforeunload', handleBeforeUnload);\n\n    return () => {\n      isMountedRef.current = false;\n      window.removeEventListener('beforeunload', handleBeforeUnload);\n    };\n  }, []);\n\n  const [showPipelineStatus, setShowPipelineStatus] = useState(false)\n  const { t, i18n } = useTranslation()\n  const health = useBackendState.use.health()\n  const pipelineBusy = useBackendState.use.pipelineBusy()\n\n  // Legacy state for backward compatibility\n  const [docs, setDocs] = useState<DocsStatusesResponse | null>(null)\n\n  const currentTab = useSettingsStore.use.currentTab()\n  const showFileName = useSettingsStore.use.showFileName()\n  const setShowFileName = useSettingsStore.use.setShowFileName()\n  const documentsPageSize = useSettingsStore.use.documentsPageSize()\n  const setDocumentsPageSize = useSettingsStore.use.setDocumentsPageSize()\n\n  // New pagination state\n  const [currentPageDocs, setCurrentPageDocs] = useState<DocStatusResponse[]>([])\n  const [pagination, setPagination] = useState<PaginationInfo>({\n    page: 1,\n    page_size: documentsPageSize,\n    total_count: 0,\n    total_pages: 0,\n    has_next: false,\n    has_prev: false\n  })\n  const [statusCounts, setStatusCounts] = useState<Record<string, number>>({ all: 0 })\n  const [isRefreshing, setIsRefreshing] = useState(false)\n\n  // Sort state\n  const [sortField, setSortField] = useState<SortField>('updated_at')\n  const [sortDirection, setSortDirection] = useState<SortDirection>('desc')\n\n  // State for document status filter\n  const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');\n\n  // State to store page number for each status filter\n  const [pageByStatus, setPageByStatus] = useState<Record<StatusFilter, number>>({\n    all: 1,\n    processed: 1,\n    preprocessed: 1,\n    processing: 1,\n    pending: 1,\n    failed: 1,\n  });\n\n  // State for document selection\n  const [selectedDocIds, setSelectedDocIds] = useState<string[]>([])\n  const isSelectionMode = selectedDocIds.length > 0\n\n  // Add refs to track previous pipelineBusy state and current interval\n  const prevPipelineBusyRef = useRef<boolean | undefined>(undefined);\n  const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);\n\n  // Add retry mechanism state\n  const [retryState, setRetryState] = useState({\n    count: 0,\n    lastError: null as Error | null,\n    isBackingOff: false\n  });\n\n  // Add circuit breaker state\n  const [circuitBreakerState, setCircuitBreakerState] = useState({\n    isOpen: false,\n    failureCount: 0,\n    lastFailureTime: null as number | null,\n    nextRetryTime: null as number | null\n  });\n\n\n  // Handle checkbox change for individual documents\n  const handleDocumentSelect = useCallback((docId: string, checked: boolean) => {\n    setSelectedDocIds(prev => {\n      if (checked) {\n        return [...prev, docId]\n      } else {\n        return prev.filter(id => id !== docId)\n      }\n    })\n  }, [])\n\n  // Handle deselect all documents\n  const handleDeselectAll = useCallback(() => {\n    setSelectedDocIds([])\n  }, [])\n\n  // Handle sort column click\n  const handleSort = (field: SortField) => {\n    let actualField = field;\n\n    // When clicking the first column, determine the actual sort field based on showFileName\n    if (field === 'id') {\n      actualField = showFileName ? 'file_path' : 'id';\n    }\n\n    const newDirection = (sortField === actualField && sortDirection === 'desc') ? 'asc' : 'desc';\n\n    setSortField(actualField);\n    setSortDirection(newDirection);\n\n    // Reset page to 1 when sorting changes\n    setPagination(prev => ({ ...prev, page: 1 }));\n\n    // Reset all status filters' page memory since sorting affects all\n    setPageByStatus({\n      all: 1,\n      processed: 1,\n      preprocessed: 1,\n      processing: 1,\n      pending: 1,\n      failed: 1,\n    });\n  };\n\n  // Sort documents based on current sort field and direction\n  const sortDocuments = useCallback((documents: DocStatusResponse[]) => {\n    return [...documents].sort((a, b) => {\n      let valueA, valueB;\n\n      // Special handling for ID field based on showFileName setting\n      if (sortField === 'id' && showFileName) {\n        valueA = getDisplayFileName(a);\n        valueB = getDisplayFileName(b);\n      } else if (sortField === 'id') {\n        valueA = a.id;\n        valueB = b.id;\n      } else {\n        // Date fields\n        valueA = new Date(a[sortField]).getTime();\n        valueB = new Date(b[sortField]).getTime();\n      }\n\n      // Apply sort direction\n      const sortMultiplier = sortDirection === 'asc' ? 1 : -1;\n\n      // Compare values\n      if (typeof valueA === 'string' && typeof valueB === 'string') {\n        return sortMultiplier * valueA.localeCompare(valueB);\n      } else {\n        return sortMultiplier * (valueA > valueB ? 1 : valueA < valueB ? -1 : 0);\n      }\n    });\n  }, [sortField, sortDirection, showFileName]);\n\n  // Define a new type that includes status information\n  type DocStatusWithStatus = DocStatusResponse & { status: DocStatus };\n\n  const filteredAndSortedDocs = useMemo(() => {\n    // Use currentPageDocs directly if available (from paginated API)\n    // This preserves the backend's sort order and prevents status grouping\n    if (currentPageDocs && currentPageDocs.length > 0) {\n      return currentPageDocs.map(doc => ({\n        ...doc,\n        status: doc.status as DocStatus\n      })) as DocStatusWithStatus[];\n    }\n\n    // Fallback to legacy docs structure for backward compatibility\n    if (!docs) return null;\n\n    // Create a flat array of documents with status information\n    const allDocuments: DocStatusWithStatus[] = [];\n\n    if (statusFilter === 'all') {\n      // When filter is 'all', include documents from all statuses\n      Object.entries(docs.statuses).forEach(([status, documents]) => {\n        documents.forEach(doc => {\n          allDocuments.push({\n            ...doc,\n            status: status as DocStatus\n          });\n        });\n      });\n    } else {\n      // When filter is specific status, only include documents from that status\n      const documents = docs.statuses[statusFilter] || [];\n      documents.forEach(doc => {\n        allDocuments.push({\n          ...doc,\n          status: statusFilter\n        });\n      });\n    }\n\n    // Sort all documents together if sort field and direction are specified\n    if (sortField && sortDirection) {\n      return sortDocuments(allDocuments);\n    }\n\n    return allDocuments;\n  }, [currentPageDocs, docs, sortField, sortDirection, statusFilter, sortDocuments]);\n\n  // Calculate current page selection state (after filteredAndSortedDocs is defined)\n  const currentPageDocIds = useMemo(() => {\n    return filteredAndSortedDocs?.map(doc => doc.id) || []\n  }, [filteredAndSortedDocs])\n\n  const selectedCurrentPageCount = useMemo(() => {\n    return currentPageDocIds.filter(id => selectedDocIds.includes(id)).length\n  }, [currentPageDocIds, selectedDocIds])\n\n  const isCurrentPageFullySelected = useMemo(() => {\n    return currentPageDocIds.length > 0 && selectedCurrentPageCount === currentPageDocIds.length\n  }, [currentPageDocIds, selectedCurrentPageCount])\n\n  const hasCurrentPageSelection = useMemo(() => {\n    return selectedCurrentPageCount > 0\n  }, [selectedCurrentPageCount])\n\n  // Handle select current page\n  const handleSelectCurrentPage = useCallback(() => {\n    setSelectedDocIds(currentPageDocIds)\n  }, [currentPageDocIds])\n\n\n  // Get selection button properties\n  const getSelectionButtonProps = useCallback(() => {\n    if (!hasCurrentPageSelection) {\n      return {\n        text: t('documentPanel.selectDocuments.selectCurrentPage', { count: currentPageDocIds.length }),\n        action: handleSelectCurrentPage,\n        icon: CheckSquareIcon\n      }\n    } else if (isCurrentPageFullySelected) {\n      return {\n        text: t('documentPanel.selectDocuments.deselectAll', { count: currentPageDocIds.length }),\n        action: handleDeselectAll,\n        icon: XIcon\n      }\n    } else {\n      return {\n        text: t('documentPanel.selectDocuments.selectCurrentPage', { count: currentPageDocIds.length }),\n        action: handleSelectCurrentPage,\n        icon: CheckSquareIcon\n      }\n    }\n  }, [hasCurrentPageSelection, isCurrentPageFullySelected, currentPageDocIds.length, handleSelectCurrentPage, handleDeselectAll, t])\n\n  // Calculate document counts for each status\n  const documentCounts = useMemo(() => {\n    if (!docs) return { all: 0 } as Record<string, number>;\n\n    const counts: Record<string, number> = { all: 0 };\n\n    Object.entries(docs.statuses).forEach(([status, documents]) => {\n      counts[status as DocStatus] = documents.length;\n      counts.all += documents.length;\n    });\n\n    return counts;\n  }, [docs]);\n\n  const processedCount = getCountValue(statusCounts, 'PROCESSED', 'processed') || documentCounts.processed || 0;\n  const preprocessedCount =\n    getCountValue(statusCounts, 'PREPROCESSED', 'preprocessed') ||\n    documentCounts.preprocessed ||\n    0;\n  const processingCount = getCountValue(statusCounts, 'PROCESSING', 'processing') || documentCounts.processing || 0;\n  const pendingCount = getCountValue(statusCounts, 'PENDING', 'pending') || documentCounts.pending || 0;\n  const failedCount = getCountValue(statusCounts, 'FAILED', 'failed') || documentCounts.failed || 0;\n\n  // Store previous status counts\n  const prevStatusCounts = useRef({\n    processed: 0,\n    preprocessed: 0,\n    processing: 0,\n    pending: 0,\n    failed: 0\n  })\n\n  // Add pulse style to document\n  useEffect(() => {\n    const style = document.createElement('style')\n    style.textContent = pulseStyle\n    document.head.appendChild(style)\n    return () => {\n      document.head.removeChild(style)\n    }\n  }, [])\n\n  // Reference to the card content element\n  const cardContentRef = useRef<HTMLDivElement>(null);\n\n  // Add tooltip position adjustment for fixed positioning\n  useEffect(() => {\n    if (!docs) return;\n\n    // Function to position tooltips\n    const positionTooltips = () => {\n      // Get all tooltip containers\n      const containers = document.querySelectorAll<HTMLElement>('.tooltip-container');\n\n      containers.forEach(container => {\n        const tooltip = container.querySelector<HTMLElement>('.tooltip');\n        if (!tooltip) return;\n\n        // Skip tooltips that aren't visible\n        if (!tooltip.classList.contains('visible')) return;\n\n        // Get container position\n        const rect = container.getBoundingClientRect();\n\n        // Position tooltip above the container\n        tooltip.style.left = `${rect.left}px`;\n        tooltip.style.top = `${rect.top - 5}px`;\n        tooltip.style.transform = 'translateY(-100%)';\n      });\n    };\n\n    // Set up event listeners\n    const handleMouseOver = (e: MouseEvent) => {\n      // Check if target or its parent is a tooltip container\n      const target = e.target as HTMLElement;\n      const container = target.closest('.tooltip-container');\n      if (!container) return;\n\n      // Find tooltip and make it visible\n      const tooltip = container.querySelector<HTMLElement>('.tooltip');\n      if (tooltip) {\n        tooltip.classList.add('visible');\n        // Position immediately without delay\n        positionTooltips();\n      }\n    };\n\n    const handleMouseOut = (e: MouseEvent) => {\n      const target = e.target as HTMLElement;\n      const container = target.closest('.tooltip-container');\n      if (!container) return;\n\n      const tooltip = container.querySelector<HTMLElement>('.tooltip');\n      if (tooltip) {\n        tooltip.classList.remove('visible');\n      }\n    };\n\n    document.addEventListener('mouseover', handleMouseOver);\n    document.addEventListener('mouseout', handleMouseOut);\n\n    return () => {\n      document.removeEventListener('mouseover', handleMouseOver);\n      document.removeEventListener('mouseout', handleMouseOut);\n    };\n  }, [docs]);\n\n  // Utility function to update component state\n  const updateComponentState = useCallback((response: any) => {\n    setPagination(response.pagination);\n    setCurrentPageDocs(response.documents);\n    setStatusCounts(response.status_counts);\n\n    // Update legacy docs state for backward compatibility\n    const legacyDocs: DocsStatusesResponse = {\n      statuses: {\n        processed: response.documents.filter((doc: DocStatusResponse) => doc.status === 'processed'),\n        preprocessed: response.documents.filter((doc: DocStatusResponse) => doc.status === 'preprocessed'),\n        processing: response.documents.filter((doc: DocStatusResponse) => doc.status === 'processing'),\n        pending: response.documents.filter((doc: DocStatusResponse) => doc.status === 'pending'),\n        failed: response.documents.filter((doc: DocStatusResponse) => doc.status === 'failed')\n      }\n    };\n\n    setDocs(response.pagination.total_count > 0 ? legacyDocs : null);\n  }, []);\n\n  // Utility function to create timeout wrapper for API calls\n  const withTimeout = useCallback((\n    promise: Promise<any>,\n    timeoutMs: number = 30000, // Default 30s timeout for normal operations\n    errorMsg: string = 'Request timeout'\n  ): Promise<any> => {\n    const timeoutPromise = new Promise((_, reject) => {\n      setTimeout(() => reject(new Error(errorMsg)), timeoutMs)\n    });\n    return Promise.race([promise, timeoutPromise]);\n  }, []);\n\n\n  // Enhanced error classification\n  const classifyError = useCallback((error: any) => {\n    if (error.name === 'AbortError') {\n      return { type: 'cancelled', shouldRetry: false, shouldShowToast: false };\n    }\n\n    if (error.message === 'Request timeout') {\n      return { type: 'timeout', shouldRetry: true, shouldShowToast: true };\n    }\n\n    if (error.message?.includes('Network Error') || error.code === 'NETWORK_ERROR') {\n      return { type: 'network', shouldRetry: true, shouldShowToast: true };\n    }\n\n    if (error.status >= 500) {\n      return { type: 'server', shouldRetry: true, shouldShowToast: true };\n    }\n\n    if (error.status >= 400 && error.status < 500) {\n      return { type: 'client', shouldRetry: false, shouldShowToast: true };\n    }\n\n    return { type: 'unknown', shouldRetry: true, shouldShowToast: true };\n  }, []);\n\n  // Circuit breaker utility functions\n  const isCircuitBreakerOpen = useCallback(() => {\n    if (!circuitBreakerState.isOpen) return false;\n\n    const now = Date.now();\n    if (circuitBreakerState.nextRetryTime && now >= circuitBreakerState.nextRetryTime) {\n      // Reset circuit breaker to half-open state\n      setCircuitBreakerState(prev => ({\n        ...prev,\n        isOpen: false,\n        failureCount: Math.max(0, prev.failureCount - 1)\n      }));\n      return false;\n    }\n\n    return true;\n  }, [circuitBreakerState]);\n\n  const recordFailure = useCallback((error: Error) => {\n    const now = Date.now();\n    setCircuitBreakerState(prev => {\n      const newFailureCount = prev.failureCount + 1;\n      const shouldOpen = newFailureCount >= 3; // Open after 3 failures\n\n      return {\n        isOpen: shouldOpen,\n        failureCount: newFailureCount,\n        lastFailureTime: now,\n        nextRetryTime: shouldOpen ? now + (Math.pow(2, newFailureCount) * 1000) : null\n      };\n    });\n\n    setRetryState(prev => ({\n      count: prev.count + 1,\n      lastError: error,\n      isBackingOff: true\n    }));\n  }, []);\n\n  const recordSuccess = useCallback(() => {\n    setCircuitBreakerState({\n      isOpen: false,\n      failureCount: 0,\n      lastFailureTime: null,\n      nextRetryTime: null\n    });\n\n    setRetryState({\n      count: 0,\n      lastError: null,\n      isBackingOff: false\n    });\n  }, []);\n\n  // Intelligent refresh function: handles all boundary cases\n  const handleIntelligentRefresh = useCallback(async (\n    targetPage?: number, // Optional target page, defaults to current page\n    resetToFirst?: boolean, // Whether to force reset to first page\n    customTimeout?: number // Optional custom timeout in milliseconds (uses withTimeout default if not provided)\n  ) => {\n    try {\n      if (!isMountedRef.current) return;\n\n      setIsRefreshing(true);\n\n      // Determine target page\n      const pageToFetch = resetToFirst ? 1 : (targetPage || pagination.page);\n\n      const request: DocumentsRequest = {\n        status_filter: statusFilter === 'all' ? null : statusFilter,\n        page: pageToFetch,\n        page_size: pagination.page_size,\n        sort_field: sortField,\n        sort_direction: sortDirection\n      };\n\n      // Use timeout wrapper for the API call (uses customTimeout if provided, otherwise withTimeout default)\n      const response = await withTimeout(\n        getDocumentsPaginated(request),\n        customTimeout, // Pass undefined to use default 30s, or explicit timeout for special cases\n        'Document fetch timeout'\n      );\n\n      if (!isMountedRef.current) return;\n\n      // Boundary case handling: if target page has no data but total count > 0\n      if (response.documents.length === 0 && response.pagination.total_count > 0) {\n        // Calculate last page\n        const lastPage = Math.max(1, response.pagination.total_pages);\n\n        if (pageToFetch !== lastPage) {\n          // Re-request last page\n          const lastPageRequest: DocumentsRequest = {\n            ...request,\n            page: lastPage\n          };\n\n          const lastPageResponse = await withTimeout(\n            getDocumentsPaginated(lastPageRequest),\n            customTimeout, // Use same timeout for consistency\n            'Document fetch timeout'\n          );\n\n          if (!isMountedRef.current) return;\n\n          // Update page state to last page\n          setPageByStatus(prev => ({ ...prev, [statusFilter]: lastPage }));\n          updateComponentState(lastPageResponse);\n          return;\n        }\n      }\n\n      // Normal case: update state\n      if (pageToFetch !== pagination.page) {\n        setPageByStatus(prev => ({ ...prev, [statusFilter]: pageToFetch }));\n      }\n      updateComponentState(response);\n\n    } catch (err) {\n      if (isMountedRef.current) {\n        const errorClassification = classifyError(err);\n\n        if (errorClassification.shouldShowToast) {\n          toast.error(t('documentPanel.documentManager.errors.loadFailed', { error: errorMessage(err) }));\n        }\n\n        if (errorClassification.shouldRetry) {\n          recordFailure(err as Error);\n        }\n      }\n    } finally {\n      if (isMountedRef.current) {\n        setIsRefreshing(false);\n      }\n    }\n  }, [statusFilter, pagination.page, pagination.page_size, sortField, sortDirection, t, updateComponentState, withTimeout, classifyError, recordFailure]);\n\n  // New paginated data fetching function\n  const fetchPaginatedDocuments = useCallback(async (\n    page: number,\n    pageSize: number,\n    _statusFilter: StatusFilter // eslint-disable-line @typescript-eslint/no-unused-vars\n  ) => {\n    // Update pagination state\n    setPagination(prev => ({ ...prev, page, page_size: pageSize }));\n\n    // Use intelligent refresh\n    await handleIntelligentRefresh(page);\n  }, [handleIntelligentRefresh]);\n\n  // Legacy fetchDocuments function for backward compatibility\n  const fetchDocuments = useCallback(async () => {\n    await fetchPaginatedDocuments(pagination.page, pagination.page_size, statusFilter);\n  }, [fetchPaginatedDocuments, pagination.page, pagination.page_size, statusFilter]);\n\n  // Function to clear current polling interval\n  const clearPollingInterval = useCallback(() => {\n    if (pollingIntervalRef.current) {\n      clearInterval(pollingIntervalRef.current);\n      pollingIntervalRef.current = null;\n    }\n  }, []);\n\n  // Function to start polling with given interval\n  const startPollingInterval = useCallback((intervalMs: number) => {\n    clearPollingInterval();\n\n    pollingIntervalRef.current = setInterval(async () => {\n      try {\n        // Check circuit breaker before making request\n        if (isCircuitBreakerOpen()) {\n          return; // Skip this polling cycle\n        }\n\n        // Only perform fetch if component is still mounted\n        if (isMountedRef.current) {\n          await fetchDocuments();\n          recordSuccess(); // Record successful operation\n        }\n      } catch (err) {\n        // Only handle error if component is still mounted\n        if (isMountedRef.current) {\n          const errorClassification = classifyError(err);\n\n          // Always reset isRefreshing state on error\n          setIsRefreshing(false);\n\n          if (errorClassification.shouldShowToast) {\n            toast.error(t('documentPanel.documentManager.errors.scanProgressFailed', { error: errorMessage(err) }));\n          }\n\n          if (errorClassification.shouldRetry) {\n            recordFailure(err as Error);\n\n            // Implement exponential backoff for retries\n            const backoffDelay = Math.min(Math.pow(2, retryState.count) * 1000, 30000); // Max 30s\n\n            if (retryState.count < 3) { // Max 3 retries\n              setTimeout(() => {\n                if (isMountedRef.current) {\n                  setRetryState(prev => ({ ...prev, isBackingOff: false }));\n                }\n              }, backoffDelay);\n            }\n          } else {\n            // For non-retryable errors, stop polling\n            clearPollingInterval();\n          }\n        }\n      }\n    }, intervalMs);\n  }, [fetchDocuments, t, clearPollingInterval, isCircuitBreakerOpen, recordSuccess, recordFailure, classifyError, retryState.count]);\n\n  const scanDocuments = useCallback(async () => {\n    try {\n      // Check if component is still mounted before starting the request\n      if (!isMountedRef.current) return;\n\n      const { status, message, track_id: _track_id } = await scanNewDocuments(); // eslint-disable-line @typescript-eslint/no-unused-vars\n\n      // Check again if component is still mounted after the request completes\n      if (!isMountedRef.current) return;\n\n      // Note: _track_id is available for future use (e.g., progress tracking)\n      toast.message(message || status);\n\n      // Reset health check timer with 1 second delay to avoid race condition\n      useBackendState.getState().resetHealthCheckTimerDelayed(1000);\n\n      // Perform immediate refresh with 90s timeout after scan (tolerates PostgreSQL switchover)\n      await handleIntelligentRefresh(undefined, false, 90000);\n\n      // Start fast refresh with 2-second interval after initial refresh\n      startPollingInterval(2000);\n\n      // Set recovery timer to restore normal polling interval after 15 seconds\n      setTimeout(() => {\n        if (isMountedRef.current && currentTab === 'documents' && health) {\n          // Restore intelligent polling interval based on document status\n          const hasActiveDocuments = hasActiveDocumentsStatus(statusCounts);\n          const normalInterval = hasActiveDocuments ? 5000 : 30000;\n          startPollingInterval(normalInterval);\n        }\n      }, 15000); // Restore after 15 seconds\n    } catch (err) {\n      // Only show error if component is still mounted\n      if (isMountedRef.current) {\n        toast.error(t('documentPanel.documentManager.errors.scanFailed', { error: errorMessage(err) }));\n      }\n    }\n  }, [t, startPollingInterval, currentTab, health, statusCounts, handleIntelligentRefresh])\n\n  // Handle page size change - update state and save to store\n  const handlePageSizeChange = useCallback((newPageSize: number) => {\n    if (newPageSize === pagination.page_size) return;\n\n    // Save the new page size to the store\n    setDocumentsPageSize(newPageSize);\n\n    // Reset all status filters to page 1 when page size changes\n    setPageByStatus({\n      all: 1,\n      processed: 1,\n      preprocessed: 1,\n      processing: 1,\n      pending: 1,\n      failed: 1,\n    });\n\n    setPagination(prev => ({ ...prev, page: 1, page_size: newPageSize }));\n  }, [pagination.page_size, setDocumentsPageSize]);\n\n  // Handle manual refresh with pagination reset logic\n  const handleManualRefresh = useCallback(async () => {\n    try {\n      setIsRefreshing(true);\n\n      // Fetch documents from the first page\n      const request: DocumentsRequest = {\n        status_filter: statusFilter === 'all' ? null : statusFilter,\n        page: 1,\n        page_size: pagination.page_size,\n        sort_field: sortField,\n        sort_direction: sortDirection\n      };\n\n      const response = await getDocumentsPaginated(request);\n\n      if (!isMountedRef.current) return;\n\n      // Check if total count is less than current page size and page size is not already 10\n      if (response.pagination.total_count < pagination.page_size && pagination.page_size !== 10) {\n        // Reset page size to 10 which will trigger a new fetch\n        handlePageSizeChange(10);\n      } else {\n        // Update pagination state\n        setPagination(response.pagination);\n        setCurrentPageDocs(response.documents);\n        setStatusCounts(response.status_counts);\n\n        // Update legacy docs state for backward compatibility\n        const legacyDocs: DocsStatusesResponse = {\n          statuses: {\n            processed: response.documents.filter(doc => doc.status === 'processed'),\n            preprocessed: response.documents.filter(doc => doc.status === 'preprocessed'),\n            processing: response.documents.filter(doc => doc.status === 'processing'),\n            pending: response.documents.filter(doc => doc.status === 'pending'),\n            failed: response.documents.filter(doc => doc.status === 'failed')\n          }\n        };\n\n        if (response.pagination.total_count > 0) {\n          setDocs(legacyDocs);\n        } else {\n          setDocs(null);\n        }\n      }\n\n    } catch (err) {\n      if (isMountedRef.current) {\n        toast.error(t('documentPanel.documentManager.errors.loadFailed', { error: errorMessage(err) }));\n      }\n    } finally {\n      if (isMountedRef.current) {\n        setIsRefreshing(false);\n      }\n    }\n  }, [statusFilter, pagination.page_size, sortField, sortDirection, handlePageSizeChange, t]);\n\n  // Monitor pipelineBusy changes and trigger immediate refresh with timer reset\n  useEffect(() => {\n    // Skip the first render when prevPipelineBusyRef is undefined\n    if (prevPipelineBusyRef.current !== undefined && prevPipelineBusyRef.current !== pipelineBusy) {\n      // pipelineBusy state has changed, trigger immediate refresh\n      if (currentTab === 'documents' && health && isMountedRef.current) {\n        // Use intelligent refresh to preserve current page\n        handleIntelligentRefresh();\n\n        // Reset polling timer after intelligent refresh\n        const hasActiveDocuments = hasActiveDocumentsStatus(statusCounts);\n        const pollingInterval = hasActiveDocuments ? 5000 : 30000;\n        startPollingInterval(pollingInterval);\n      }\n    }\n    // Update the previous state\n    prevPipelineBusyRef.current = pipelineBusy;\n  }, [\n    pipelineBusy,\n    currentTab,\n    health,\n    handleIntelligentRefresh,\n    statusCounts,\n    startPollingInterval\n  ]);\n\n  // Set up intelligent polling with dynamic interval based on document status\n  useEffect(() => {\n    if (currentTab !== 'documents' || !health) {\n      clearPollingInterval();\n      return\n    }\n\n    // Determine polling interval based on document status\n    const hasActiveDocuments = hasActiveDocumentsStatus(statusCounts);\n    const pollingInterval = hasActiveDocuments ? 5000 : 30000; // 5s if active, 30s if idle\n\n    startPollingInterval(pollingInterval);\n\n    return () => {\n      clearPollingInterval();\n    }\n  }, [health, t, currentTab, statusCounts, startPollingInterval, clearPollingInterval])\n\n  // Monitor docs changes to check status counts and trigger health check if needed\n  useEffect(() => {\n    if (!docs) return;\n\n    // Get new status counts\n    const newStatusCounts = {\n      processed: docs?.statuses?.processed?.length || 0,\n      preprocessed: docs?.statuses?.preprocessed?.length || 0,\n      processing: docs?.statuses?.processing?.length || 0,\n      pending: docs?.statuses?.pending?.length || 0,\n      failed: docs?.statuses?.failed?.length || 0\n    }\n\n    // Check if any status count has changed\n    const hasStatusCountChange = (Object.keys(newStatusCounts) as Array<keyof typeof newStatusCounts>).some(\n      status => newStatusCounts[status] !== prevStatusCounts.current[status]\n    )\n\n    // Trigger health check if changes detected and component is still mounted\n    if (hasStatusCountChange && isMountedRef.current) {\n      useBackendState.getState().check()\n    }\n\n    // Update previous status counts\n    prevStatusCounts.current = newStatusCounts\n  }, [docs]);\n\n  // Handle page change - only update state\n  const handlePageChange = useCallback((newPage: number) => {\n    if (newPage === pagination.page) return;\n\n    // Save the new page for current status filter\n    setPageByStatus(prev => ({ ...prev, [statusFilter]: newPage }));\n    setPagination(prev => ({ ...prev, page: newPage }));\n  }, [pagination.page, statusFilter]);\n\n  // Handle status filter change - only update state\n  const handleStatusFilterChange = useCallback((newStatusFilter: StatusFilter) => {\n    if (newStatusFilter === statusFilter) return;\n\n    // Save current page for the current status filter\n    setPageByStatus(prev => ({ ...prev, [statusFilter]: pagination.page }));\n\n    // Get the saved page for the new status filter\n    const newPage = pageByStatus[newStatusFilter];\n\n    // Update status filter and restore the saved page\n    setStatusFilter(newStatusFilter);\n    setPagination(prev => ({ ...prev, page: newPage }));\n  }, [statusFilter, pagination.page, pageByStatus]);\n\n  // Handle documents deleted callback\n  const handleDocumentsDeleted = useCallback(async () => {\n    setSelectedDocIds([])\n\n    // Reset health check timer with 1 second delay to avoid race condition\n    useBackendState.getState().resetHealthCheckTimerDelayed(1000)\n\n    // Schedule a health check 2 seconds after successful clear\n    startPollingInterval(2000)\n  }, [startPollingInterval])\n\n  // Handle documents cleared callback with proper interval reset\n  const handleDocumentsCleared = useCallback(async () => {\n    // Clear current polling interval\n    clearPollingInterval();\n\n    // Reset status counts to ensure proper state\n    setStatusCounts({\n      all: 0,\n      processed: 0,\n      processing: 0,\n      pending: 0,\n      failed: 0\n    });\n\n    // Perform one immediate refresh to confirm clear operation\n    if (isMountedRef.current) {\n      try {\n        await fetchDocuments();\n      } catch (err) {\n        console.error('Error fetching documents after clear:', err);\n      }\n    }\n\n    // Set appropriate polling interval based on current state\n    // Since documents are cleared, use idle interval (30 seconds)\n    if (currentTab === 'documents' && health && isMountedRef.current) {\n      startPollingInterval(30000); // 30 seconds for idle state\n    }\n  }, [clearPollingInterval, setStatusCounts, fetchDocuments, currentTab, health, startPollingInterval])\n\n\n  // Handle showFileName change - switch sort field if currently sorting by first column\n  useEffect(() => {\n    // Only switch if currently sorting by the first column (id or file_path)\n    if (sortField === 'id' || sortField === 'file_path') {\n      const newSortField = showFileName ? 'file_path' : 'id';\n      if (sortField !== newSortField) {\n        setSortField(newSortField);\n      }\n    }\n  }, [showFileName, sortField]);\n\n  // Reset selection state when page, status filter, or sort changes\n  useEffect(() => {\n    setSelectedDocIds([])\n  }, [pagination.page, statusFilter, sortField, sortDirection]);\n\n  // Central effect to handle all data fetching\n  useEffect(() => {\n    if (currentTab === 'documents') {\n      fetchPaginatedDocuments(pagination.page, pagination.page_size, statusFilter);\n    }\n  }, [\n    currentTab,\n    pagination.page,\n    pagination.page_size,\n    statusFilter,\n    sortField,\n    sortDirection,\n    fetchPaginatedDocuments\n  ]);\n\n  return (\n    <Card className=\"!rounded-none !overflow-hidden flex flex-col h-full min-h-0\">\n      <CardHeader className=\"py-2 px-6\">\n        <CardTitle className=\"text-lg\">{t('documentPanel.documentManager.title')}</CardTitle>\n      </CardHeader>\n      <CardContent className=\"flex-1 flex flex-col min-h-0 overflow-auto\">\n        <div className=\"flex justify-between items-center gap-2 mb-2\">\n          <div className=\"flex gap-2\">\n            <Button\n              variant=\"outline\"\n              onClick={scanDocuments}\n              side=\"bottom\"\n              tooltip={t('documentPanel.documentManager.scanTooltip')}\n              size=\"sm\"\n            >\n              <RefreshCwIcon /> {t('documentPanel.documentManager.scanButton')}\n            </Button>\n            <Button\n              variant=\"outline\"\n              onClick={() => setShowPipelineStatus(true)}\n              side=\"bottom\"\n              tooltip={t('documentPanel.documentManager.pipelineStatusTooltip')}\n              size=\"sm\"\n              className={cn(\n                pipelineBusy && 'pipeline-busy'\n              )}\n            >\n              <ActivityIcon /> {t('documentPanel.documentManager.pipelineStatusButton')}\n            </Button>\n          </div>\n\n          {/* Pagination Controls in the middle */}\n          {pagination.total_pages > 1 && (\n            <PaginationControls\n              currentPage={pagination.page}\n              totalPages={pagination.total_pages}\n              pageSize={pagination.page_size}\n              totalCount={pagination.total_count}\n              onPageChange={handlePageChange}\n              onPageSizeChange={handlePageSizeChange}\n              isLoading={isRefreshing}\n              compact={true}\n            />\n          )}\n\n          <div className=\"flex gap-2\">\n            {isSelectionMode && (\n              <DeleteDocumentsDialog\n                selectedDocIds={selectedDocIds}\n                onDocumentsDeleted={handleDocumentsDeleted}\n              />\n            )}\n            {isSelectionMode && hasCurrentPageSelection ? (\n              (() => {\n                const buttonProps = getSelectionButtonProps();\n                const IconComponent = buttonProps.icon;\n                return (\n                  <Button\n                    variant=\"outline\"\n                    size=\"sm\"\n                    onClick={buttonProps.action}\n                    side=\"bottom\"\n                    tooltip={buttonProps.text}\n                  >\n                    <IconComponent className=\"h-4 w-4\" />\n                    {buttonProps.text}\n                  </Button>\n                );\n              })()\n            ) : !isSelectionMode ? (\n              <ClearDocumentsDialog onDocumentsCleared={handleDocumentsCleared} />\n            ) : null}\n            <UploadDocumentsDialog onDocumentsUploaded={() => handleIntelligentRefresh(undefined, false, 120000)} />\n            <PipelineStatusDialog\n              open={showPipelineStatus}\n              onOpenChange={setShowPipelineStatus}\n            />\n          </div>\n        </div>\n\n        <Card className=\"flex-1 flex flex-col border rounded-md min-h-0 mb-2\">\n          <CardHeader className=\"flex-none py-2 px-4\">\n            <div className=\"flex justify-between items-center\">\n              <CardTitle>{t('documentPanel.documentManager.uploadedTitle')}</CardTitle>\n              <div className=\"flex items-center gap-2\">\n                <div className=\"flex gap-1\" dir={i18n.dir()}>\n                  <Button\n                    size=\"sm\"\n                    variant={statusFilter === 'all' ? 'secondary' : 'outline'}\n                    onClick={() => handleStatusFilterChange('all')}\n                    disabled={isRefreshing}\n                    className={cn(\n                      statusFilter === 'all' && 'bg-gray-100 dark:bg-gray-900 font-medium border border-gray-400 dark:border-gray-500 shadow-sm'\n                    )}\n                  >\n                    {t('documentPanel.documentManager.status.all')} ({statusCounts.all || documentCounts.all})\n                  </Button>\n                  <Button\n                    size=\"sm\"\n                    variant={statusFilter === 'processed' ? 'secondary' : 'outline'}\n                    onClick={() => handleStatusFilterChange('processed')}\n                    disabled={isRefreshing}\n                    className={cn(\n                      processedCount > 0 ? 'text-green-600' : 'text-gray-500',\n                      statusFilter === 'processed' && 'bg-green-100 dark:bg-green-900/30 font-medium border border-green-400 dark:border-green-600 shadow-sm'\n                    )}\n                  >\n                    {t('documentPanel.documentManager.status.completed')} ({processedCount})\n                  </Button>\n                  <Button\n                    size=\"sm\"\n                    variant={statusFilter === 'preprocessed' ? 'secondary' : 'outline'}\n                    onClick={() => handleStatusFilterChange('preprocessed')}\n                    disabled={isRefreshing}\n                    className={cn(\n                      preprocessedCount > 0 ? 'text-purple-600' : 'text-gray-500',\n                      statusFilter === 'preprocessed' && 'bg-purple-100 dark:bg-purple-900/30 font-medium border border-purple-400 dark:border-purple-600 shadow-sm'\n                    )}\n                  >\n                    {t('documentPanel.documentManager.status.preprocessed')} ({preprocessedCount})\n                  </Button>\n                  <Button\n                    size=\"sm\"\n                    variant={statusFilter === 'processing' ? 'secondary' : 'outline'}\n                    onClick={() => handleStatusFilterChange('processing')}\n                    disabled={isRefreshing}\n                    className={cn(\n                      processingCount > 0 ? 'text-blue-600' : 'text-gray-500',\n                      statusFilter === 'processing' && 'bg-blue-100 dark:bg-blue-900/30 font-medium border border-blue-400 dark:border-blue-600 shadow-sm'\n                    )}\n                  >\n                    {t('documentPanel.documentManager.status.processing')} ({processingCount})\n                  </Button>\n                  <Button\n                    size=\"sm\"\n                    variant={statusFilter === 'pending' ? 'secondary' : 'outline'}\n                    onClick={() => handleStatusFilterChange('pending')}\n                    disabled={isRefreshing}\n                    className={cn(\n                      pendingCount > 0 ? 'text-yellow-600' : 'text-gray-500',\n                      statusFilter === 'pending' && 'bg-yellow-100 dark:bg-yellow-900/30 font-medium border border-yellow-400 dark:border-yellow-600 shadow-sm'\n                    )}\n                  >\n                    {t('documentPanel.documentManager.status.pending')} ({pendingCount})\n                  </Button>\n                  <Button\n                    size=\"sm\"\n                    variant={statusFilter === 'failed' ? 'secondary' : 'outline'}\n                    onClick={() => handleStatusFilterChange('failed')}\n                    disabled={isRefreshing}\n                    className={cn(\n                      failedCount > 0 ? 'text-red-600' : 'text-gray-500',\n                      statusFilter === 'failed' && 'bg-red-100 dark:bg-red-900/30 font-medium border border-red-400 dark:border-red-600 shadow-sm'\n                    )}\n                  >\n                    {t('documentPanel.documentManager.status.failed')} ({failedCount})\n                  </Button>\n                </div>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={handleManualRefresh}\n                  disabled={isRefreshing}\n                  side=\"bottom\"\n                  tooltip={t('documentPanel.documentManager.refreshTooltip')}\n                >\n                  <RotateCcwIcon className=\"h-4 w-4\" />\n                </Button>\n              </div>\n              <div className=\"flex items-center gap-2\">\n                <label\n                  htmlFor=\"toggle-filename-btn\"\n                  className=\"text-sm text-gray-500\"\n                >\n                  {t('documentPanel.documentManager.fileNameLabel')}\n                </label>\n                <Button\n                  id=\"toggle-filename-btn\"\n                  variant=\"outline\"\n                  size=\"sm\"\n                  onClick={() => setShowFileName(!showFileName)}\n                  className=\"border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800\"\n                >\n                  {showFileName\n                    ? t('documentPanel.documentManager.hideButton')\n                    : t('documentPanel.documentManager.showButton')\n                  }\n                </Button>\n              </div>\n            </div>\n            <CardDescription aria-hidden=\"true\" className=\"hidden\">{t('documentPanel.documentManager.uploadedDescription')}</CardDescription>\n          </CardHeader>\n\n          <CardContent className=\"flex-1 relative p-0\" ref={cardContentRef}>\n            {!docs && (\n              <div className=\"absolute inset-0 p-0\">\n                <EmptyCard\n                  title={t('documentPanel.documentManager.emptyTitle')}\n                  description={t('documentPanel.documentManager.emptyDescription')}\n                />\n              </div>\n            )}\n            {docs && (\n              <div className=\"absolute inset-0 flex flex-col p-0\">\n                <div className=\"absolute inset-[-1px] flex flex-col p-0 border rounded-md border-gray-200 dark:border-gray-700 overflow-hidden\">\n                  <Table className=\"w-full\">\n                    <TableHeader className=\"sticky top-0 bg-background z-10 shadow-sm\">\n                      <TableRow className=\"border-b bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75 shadow-[inset_0_-1px_0_rgba(0,0,0,0.1)]\">\n                        <TableHead\n                          onClick={() => handleSort('id')}\n                          className=\"cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none\"\n                        >\n                          <div className=\"flex items-center\">\n                            {showFileName\n                              ? t('documentPanel.documentManager.columns.fileName')\n                              : t('documentPanel.documentManager.columns.id')\n                            }\n                            {((sortField === 'id' && !showFileName) || (sortField === 'file_path' && showFileName)) && (\n                              <span className=\"ml-1\">\n                                {sortDirection === 'asc' ? <ArrowUpIcon size={14} /> : <ArrowDownIcon size={14} />}\n                              </span>\n                            )}\n                          </div>\n                        </TableHead>\n                        <TableHead>{t('documentPanel.documentManager.columns.summary')}</TableHead>\n                        <TableHead>{t('documentPanel.documentManager.columns.status')}</TableHead>\n                        <TableHead>{t('documentPanel.documentManager.columns.length')}</TableHead>\n                        <TableHead>{t('documentPanel.documentManager.columns.chunks')}</TableHead>\n                        <TableHead\n                          onClick={() => handleSort('created_at')}\n                          className=\"cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none\"\n                        >\n                          <div className=\"flex items-center\">\n                            {t('documentPanel.documentManager.columns.created')}\n                            {sortField === 'created_at' && (\n                              <span className=\"ml-1\">\n                                {sortDirection === 'asc' ? <ArrowUpIcon size={14} /> : <ArrowDownIcon size={14} />}\n                              </span>\n                            )}\n                          </div>\n                        </TableHead>\n                        <TableHead\n                          onClick={() => handleSort('updated_at')}\n                          className=\"cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none\"\n                        >\n                          <div className=\"flex items-center\">\n                            {t('documentPanel.documentManager.columns.updated')}\n                            {sortField === 'updated_at' && (\n                              <span className=\"ml-1\">\n                                {sortDirection === 'asc' ? <ArrowUpIcon size={14} /> : <ArrowDownIcon size={14} />}\n                              </span>\n                            )}\n                          </div>\n                        </TableHead>\n                        <TableHead className=\"w-16 text-center\">\n                          {t('documentPanel.documentManager.columns.select')}\n                        </TableHead>\n                      </TableRow>\n                    </TableHeader>\n                    <TableBody className=\"text-sm overflow-auto\">\n                      {filteredAndSortedDocs && filteredAndSortedDocs.map((doc) => (\n                        <TableRow key={doc.id}>\n                          <TableCell className=\"truncate font-mono overflow-visible max-w-[250px]\">\n                            {showFileName ? (\n                              <>\n                                <div className=\"group relative overflow-visible tooltip-container\">\n                                  <div className=\"truncate\">\n                                    {getDisplayFileName(doc, 30)}\n                                  </div>\n                                  <div className=\"invisible group-hover:visible tooltip\">\n                                    {doc.file_path}\n                                  </div>\n                                </div>\n                                <div className=\"text-xs text-gray-500\">{doc.id}</div>\n                              </>\n                            ) : (\n                              <div className=\"group relative overflow-visible tooltip-container\">\n                                <div className=\"truncate\">\n                                  {doc.id}\n                                </div>\n                                <div className=\"invisible group-hover:visible tooltip\">\n                                  {doc.file_path}\n                                </div>\n                              </div>\n                            )}\n                          </TableCell>\n                          <TableCell className=\"max-w-xs min-w-45 truncate overflow-visible\">\n                            <div className=\"group relative overflow-visible tooltip-container\">\n                              <div className=\"truncate\">\n                                {doc.content_summary}\n                              </div>\n                              <div className=\"invisible group-hover:visible tooltip\">\n                                {doc.content_summary}\n                              </div>\n                            </div>\n                          </TableCell>\n                          <TableCell>\n                            <div className=\"group relative flex items-center overflow-visible tooltip-container\">\n                              {doc.status === 'processed' && (\n                                <span className=\"text-green-600\">{t('documentPanel.documentManager.status.completed')}</span>\n                              )}\n                              {doc.status === 'preprocessed' && (\n                                <span className=\"text-purple-600\">{t('documentPanel.documentManager.status.preprocessed')}</span>\n                              )}\n                              {doc.status === 'processing' && (\n                                <span className=\"text-blue-600\">{t('documentPanel.documentManager.status.processing')}</span>\n                              )}\n                              {doc.status === 'pending' && (\n                                <span className=\"text-yellow-600\">{t('documentPanel.documentManager.status.pending')}</span>\n                              )}\n                              {doc.status === 'failed' && (\n                                <span className=\"text-red-600\">{t('documentPanel.documentManager.status.failed')}</span>\n                              )}\n\n                              {/* Icon rendering logic */}\n                              {doc.error_msg ? (\n                                <AlertTriangle className=\"ml-2 h-4 w-4 text-yellow-500\" />\n                              ) : (doc.metadata && Object.keys(doc.metadata).length > 0) && (\n                                <Info className=\"ml-2 h-4 w-4 text-blue-500\" />\n                              )}\n\n                              {/* Tooltip rendering logic */}\n                              {(doc.error_msg || (doc.metadata && Object.keys(doc.metadata).length > 0) || doc.track_id) && (\n                                <div className=\"invisible group-hover:visible tooltip\">\n                                  {doc.track_id && (\n                                    <div className=\"mt-1\">Track ID: {doc.track_id}</div>\n                                  )}\n                                  {doc.metadata && Object.keys(doc.metadata).length > 0 && (\n                                    <pre>{formatMetadata(doc.metadata)}</pre>\n                                  )}\n                                  {doc.error_msg && (\n                                    <pre>{doc.error_msg}</pre>\n                                  )}\n                                </div>\n                              )}\n                            </div>\n                          </TableCell>\n                          <TableCell>{doc.content_length ?? '-'}</TableCell>\n                          <TableCell>{doc.chunks_count ?? '-'}</TableCell>\n                          <TableCell className=\"truncate\">\n                            {new Date(doc.created_at).toLocaleString()}\n                          </TableCell>\n                          <TableCell className=\"truncate\">\n                            {new Date(doc.updated_at).toLocaleString()}\n                          </TableCell>\n                          <TableCell className=\"text-center\">\n                            <Checkbox\n                              checked={selectedDocIds.includes(doc.id)}\n                              onCheckedChange={(checked) => handleDocumentSelect(doc.id, checked === true)}\n                              // disabled={doc.status !== 'processed'}\n                              className=\"mx-auto\"\n                            />\n                          </TableCell>\n                        </TableRow>\n                      ))}\n                    </TableBody>\n                  </Table>\n                </div>\n              </div>\n            )}\n          </CardContent>\n        </Card>\n      </CardContent>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/features/GraphViewer.tsx",
    "content": "import { useEffect, useState, useCallback, useMemo, useRef } from 'react'\n// import { MiniMap } from '@react-sigma/minimap'\nimport { SigmaContainer, useRegisterEvents, useSigma } from '@react-sigma/core'\nimport { Settings as SigmaSettings } from 'sigma/settings'\nimport { GraphSearchOption, OptionItem } from '@react-sigma/graph-search'\nimport { EdgeArrowProgram, NodePointProgram, NodeCircleProgram } from 'sigma/rendering'\nimport { NodeBorderProgram } from '@sigma/node-border'\nimport { EdgeCurvedArrowProgram, createEdgeCurveProgram } from '@sigma/edge-curve'\n\nimport FocusOnNode from '@/components/graph/FocusOnNode'\nimport LayoutsControl from '@/components/graph/LayoutsControl'\nimport GraphControl from '@/components/graph/GraphControl'\n// import ThemeToggle from '@/components/ThemeToggle'\nimport ZoomControl from '@/components/graph/ZoomControl'\nimport FullScreenControl from '@/components/graph/FullScreenControl'\nimport Settings from '@/components/graph/Settings'\nimport GraphSearch from '@/components/graph/GraphSearch'\nimport GraphLabels from '@/components/graph/GraphLabels'\nimport PropertiesView from '@/components/graph/PropertiesView'\nimport SettingsDisplay from '@/components/graph/SettingsDisplay'\nimport Legend from '@/components/graph/Legend'\nimport LegendButton from '@/components/graph/LegendButton'\n\nimport { useSettingsStore } from '@/stores/settings'\nimport { useGraphStore } from '@/stores/graph'\nimport { labelColorDarkTheme, labelColorLightTheme } from '@/lib/constants'\n\nimport '@react-sigma/core/lib/style.css'\nimport '@react-sigma/graph-search/lib/style.css'\n\n// Function to create sigma settings based on theme\nconst createSigmaSettings = (isDarkTheme: boolean): Partial<SigmaSettings> => ({\n  allowInvalidContainer: true,\n  defaultNodeType: 'default',\n  defaultEdgeType: 'curvedNoArrow',\n  renderEdgeLabels: false,\n  edgeProgramClasses: {\n    arrow: EdgeArrowProgram,\n    curvedArrow: EdgeCurvedArrowProgram,\n    curvedNoArrow: createEdgeCurveProgram()\n  },\n  nodeProgramClasses: {\n    default: NodeBorderProgram,\n    circel: NodeCircleProgram,\n    point: NodePointProgram\n  },\n  labelGridCellSize: 60,\n  labelRenderedSizeThreshold: 12,\n  enableEdgeEvents: true,\n  labelColor: {\n    color: isDarkTheme ? labelColorDarkTheme : labelColorLightTheme,\n    attribute: 'labelColor'\n  },\n  edgeLabelColor: {\n    color: isDarkTheme ? labelColorDarkTheme : labelColorLightTheme,\n    attribute: 'labelColor'\n  },\n  edgeLabelSize: 8,\n  labelSize: 12\n  // minEdgeThickness: 2\n  // labelFont: 'Lato, sans-serif'\n})\n\nconst GraphEvents = () => {\n  const registerEvents = useRegisterEvents()\n  const sigma = useSigma()\n  const [draggedNode, setDraggedNode] = useState<string | null>(null)\n\n  useEffect(() => {\n    // Register the events\n    registerEvents({\n      downNode: (e) => {\n        setDraggedNode(e.node)\n        sigma.getGraph().setNodeAttribute(e.node, 'highlighted', true)\n      },\n      // On mouse move, if the drag mode is enabled, we change the position of the draggedNode\n      mousemovebody: (e) => {\n        if (!draggedNode) return\n        // Get new position of node\n        const pos = sigma.viewportToGraph(e)\n        sigma.getGraph().setNodeAttribute(draggedNode, 'x', pos.x)\n        sigma.getGraph().setNodeAttribute(draggedNode, 'y', pos.y)\n\n        // Prevent sigma to move camera:\n        e.preventSigmaDefault()\n        e.original.preventDefault()\n        e.original.stopPropagation()\n      },\n      // On mouse up, we reset the autoscale and the dragging mode\n      mouseup: () => {\n        if (draggedNode) {\n          setDraggedNode(null)\n          sigma.getGraph().removeNodeAttribute(draggedNode, 'highlighted')\n        }\n      },\n      // Disable the autoscale at the first down interaction\n      mousedown: (e) => {\n        // Only set custom BBox if it's a drag operation (mouse button is pressed)\n        const mouseEvent = e.original as MouseEvent;\n        if (mouseEvent.buttons !== 0 && !sigma.getCustomBBox()) {\n          sigma.setCustomBBox(sigma.getBBox())\n        }\n      }\n    })\n  }, [registerEvents, sigma, draggedNode])\n\n  return null\n}\n\nconst GraphViewer = () => {\n  const [isThemeSwitching, setIsThemeSwitching] = useState(false)\n  const sigmaRef = useRef<any>(null)\n  const prevTheme = useRef<string>('')\n\n  const selectedNode = useGraphStore.use.selectedNode()\n  const focusedNode = useGraphStore.use.focusedNode()\n  const moveToSelectedNode = useGraphStore.use.moveToSelectedNode()\n  const isFetching = useGraphStore.use.isFetching()\n\n  const showPropertyPanel = useSettingsStore.use.showPropertyPanel()\n  const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()\n  const enableNodeDrag = useSettingsStore.use.enableNodeDrag()\n  const showLegend = useSettingsStore.use.showLegend()\n  const theme = useSettingsStore.use.theme()\n\n  // Memoize sigma settings to prevent unnecessary re-creation\n  const memoizedSigmaSettings = useMemo(() => {\n    const isDarkTheme = theme === 'dark'\n    return createSigmaSettings(isDarkTheme)\n  }, [theme])\n\n  // Initialize sigma settings based on theme with theme switching protection\n  useEffect(() => {\n    // Detect theme change\n    const isThemeChange = prevTheme.current && prevTheme.current !== theme\n    if (isThemeChange) {\n      setIsThemeSwitching(true)\n      console.log('Theme switching detected:', prevTheme.current, '->', theme)\n\n      // Reset theme switching state after a short delay\n      const timer = setTimeout(() => {\n        setIsThemeSwitching(false)\n        console.log('Theme switching completed')\n      }, 150)\n\n      return () => clearTimeout(timer)\n    }\n    prevTheme.current = theme\n    console.log('Initialized sigma settings for theme:', theme)\n  }, [theme])\n\n  // Clean up sigma instance when component unmounts\n  useEffect(() => {\n    return () => {\n      // TAB is mount twice in vite dev mode, this is a workaround\n\n      const sigma = useGraphStore.getState().sigmaInstance;\n      if (sigma) {\n        try {\n          // Destroy sigma，and clear WebGL context\n          sigma.kill();\n          useGraphStore.getState().setSigmaInstance(null);\n          console.log('Cleared sigma instance on Graphviewer unmount');\n        } catch (error) {\n          console.error('Error cleaning up sigma instance:', error);\n        }\n      }\n    };\n  }, []);\n\n  // Note: There was a useLayoutEffect hook here to set up the sigma instance and graph data,\n  // but testing showed it wasn't executing or having any effect, while the backup mechanism\n  // in GraphControl was sufficient. This code was removed to simplify implementation\n\n  const onSearchFocus = useCallback((value: GraphSearchOption | null) => {\n    if (value === null) useGraphStore.getState().setFocusedNode(null)\n    else if (value.type === 'nodes') useGraphStore.getState().setFocusedNode(value.id)\n  }, [])\n\n  const onSearchSelect = useCallback((value: GraphSearchOption | null) => {\n    if (value === null) {\n      useGraphStore.getState().setSelectedNode(null)\n    } else if (value.type === 'nodes') {\n      useGraphStore.getState().setSelectedNode(value.id, true)\n    }\n  }, [])\n\n  const autoFocusedNode = useMemo(() => focusedNode ?? selectedNode, [focusedNode, selectedNode])\n  const searchInitSelectedNode = useMemo(\n    (): OptionItem | null => (selectedNode ? { type: 'nodes', id: selectedNode } : null),\n    [selectedNode]\n  )\n\n  // Always render SigmaContainer but control its visibility with CSS\n  return (\n    <div className=\"relative h-full w-full overflow-hidden\">\n      <SigmaContainer\n        settings={memoizedSigmaSettings}\n        className=\"!bg-background !size-full overflow-hidden\"\n        ref={sigmaRef}\n      >\n        <GraphControl />\n\n        {enableNodeDrag && <GraphEvents />}\n\n        <FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />\n\n        <div className=\"absolute top-2 left-2 flex items-start gap-2\">\n          <GraphLabels />\n          {showNodeSearchBar && !isThemeSwitching && (\n            <GraphSearch\n              value={searchInitSelectedNode}\n              onFocus={onSearchFocus}\n              onChange={onSearchSelect}\n            />\n          )}\n        </div>\n\n        <div className=\"bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg\">\n          <LayoutsControl />\n          <ZoomControl />\n          <FullScreenControl />\n          <LegendButton />\n          <Settings />\n          {/* <ThemeToggle /> */}\n        </div>\n\n        {showPropertyPanel && (\n          <div className=\"absolute top-2 right-2 z-10\">\n            <PropertiesView />\n          </div>\n        )}\n\n        {showLegend && (\n          <div className=\"absolute bottom-10 right-2 z-0\">\n            <Legend className=\"bg-background/60 backdrop-blur-lg\" />\n          </div>\n        )}\n\n        {/* <div className=\"absolute bottom-2 right-2 flex flex-col rounded-xl border-2\">\n          <MiniMap width=\"100px\" height=\"100px\" />\n        </div> */}\n\n        <SettingsDisplay />\n      </SigmaContainer>\n\n      {/* Loading overlay - shown when data is loading or theme is switching */}\n      {(isFetching || isThemeSwitching) && (\n        <div className=\"absolute inset-0 flex items-center justify-center bg-background/80 z-10\">\n          <div className=\"text-center\">\n            <div className=\"mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent mx-auto\"></div>\n            <p>{isThemeSwitching ? 'Switching Theme...' : 'Loading Graph Data...'}</p>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport default GraphViewer\n"
  },
  {
    "path": "lightrag_webui/src/features/LoginPage.tsx",
    "content": "import { useState, useEffect, useRef } from 'react'\nimport { useNavigate } from 'react-router-dom'\nimport { useAuthStore } from '@/stores/state'\nimport { useSettingsStore } from '@/stores/settings'\nimport { loginToServer, getAuthStatus } from '@/api/lightrag'\nimport { toast } from 'sonner'\nimport { useTranslation } from 'react-i18next'\nimport { Card, CardContent, CardHeader } from '@/components/ui/Card'\nimport Input from '@/components/ui/Input'\nimport Button from '@/components/ui/Button'\nimport { ZapIcon } from 'lucide-react'\nimport AppSettings from '@/components/AppSettings'\n\nconst LoginPage = () => {\n  const navigate = useNavigate()\n  const { login, isAuthenticated } = useAuthStore()\n  const { t } = useTranslation()\n  const [loading, setLoading] = useState(false)\n  const [username, setUsername] = useState('')\n  const [password, setPassword] = useState('')\n  const [checkingAuth, setCheckingAuth] = useState(true)\n  const authCheckRef = useRef(false); // Prevent duplicate calls in Vite dev mode\n\n  useEffect(() => {\n    console.log('LoginPage mounted')\n  }, []);\n\n  // Check if authentication is configured, skip login if not\n  useEffect(() => {\n\n    const checkAuthConfig = async () => {\n      // Prevent duplicate calls in Vite dev mode\n      if (authCheckRef.current) {\n        return;\n      }\n      authCheckRef.current = true;\n\n      try {\n        // If already authenticated, redirect to home\n        if (isAuthenticated) {\n          navigate('/')\n          return\n        }\n\n        // Check auth status\n        const status = await getAuthStatus()\n\n        // Set session flag for version check to avoid duplicate checks in App component\n        if (status.core_version || status.api_version) {\n          sessionStorage.setItem('VERSION_CHECKED_FROM_LOGIN', 'true');\n        }\n\n        if (!status.auth_configured && status.access_token) {\n          // If auth is not configured, use the guest token and redirect\n          login(status.access_token, true, status.core_version, status.api_version, status.webui_title || null, status.webui_description || null)\n          if (status.message) {\n            toast.info(status.message)\n          }\n          navigate('/')\n          return\n        }\n\n        // Only set checkingAuth to false if we need to show the login page\n        setCheckingAuth(false);\n\n      } catch (error) {\n        console.error('Failed to check auth configuration:', error)\n        // Also set checkingAuth to false in case of error\n        setCheckingAuth(false);\n      }\n      // Removed finally block as we're setting checkingAuth earlier\n    }\n\n    // Execute immediately\n    checkAuthConfig()\n\n    // Cleanup function to prevent state updates after unmount\n    return () => {\n    }\n  }, [isAuthenticated, login, navigate])\n\n  // Don't render anything while checking auth\n  if (checkingAuth) {\n    return null\n  }\n\n  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault()\n    if (!username || !password) {\n      toast.error(t('login.errorEmptyFields'))\n      return\n    }\n\n    try {\n      setLoading(true)\n      const response = await loginToServer(username, password)\n\n      // Get previous username from localStorage\n      const previousUsername = localStorage.getItem('LIGHTRAG-PREVIOUS-USER')\n\n      // Check if it's the same user logging in again\n      const isSameUser = previousUsername === username\n\n      // If it's not the same user, clear chat history\n      if (isSameUser) {\n        console.log('Same user logging in, preserving chat history')\n      } else {\n        console.log('Different user logging in, clearing chat history')\n        // Directly clear chat history instead of setting a flag\n        useSettingsStore.getState().setRetrievalHistory([])\n      }\n\n      // Update previous username\n      localStorage.setItem('LIGHTRAG-PREVIOUS-USER', username)\n\n      // Check authentication mode\n      const isGuestMode = response.auth_mode === 'disabled'\n      login(response.access_token, isGuestMode, response.core_version, response.api_version, response.webui_title || null, response.webui_description || null)\n\n      // Set session flag for version check\n      if (response.core_version || response.api_version) {\n        sessionStorage.setItem('VERSION_CHECKED_FROM_LOGIN', 'true');\n      }\n\n      if (isGuestMode) {\n        // Show authentication disabled notification\n        toast.info(response.message || t('login.authDisabled', 'Authentication is disabled. Using guest access.'))\n      } else {\n        toast.success(t('login.successMessage'))\n      }\n\n      // Navigate to home page after successful login\n      navigate('/')\n    } catch (error) {\n      console.error('Login failed...', error)\n      toast.error(t('login.errorInvalidCredentials'))\n\n      // Clear any existing auth state\n      useAuthStore.getState().logout()\n      // Clear local storage\n      localStorage.removeItem('LIGHTRAG-API-TOKEN')\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  return (\n    <div className=\"flex h-screen w-screen items-center justify-center bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-gray-800\">\n      <div className=\"absolute top-4 right-4 flex items-center gap-2\">\n        <AppSettings className=\"bg-white/30 dark:bg-gray-800/30 backdrop-blur-sm rounded-md\" />\n      </div>\n      <Card className=\"w-full max-w-[480px] shadow-lg mx-4\">\n        <CardHeader className=\"flex items-center justify-center space-y-2 pb-8 pt-6\">\n          <div className=\"flex flex-col items-center space-y-4\">\n            <div className=\"flex items-center gap-3\">\n              <img src=\"logo.svg\" alt=\"LightRAG Logo\" className=\"h-12 w-12\" />\n              <ZapIcon className=\"size-10 text-emerald-400\" aria-hidden=\"true\" />\n            </div>\n            <div className=\"text-center space-y-2\">\n              <h1 className=\"text-3xl font-bold tracking-tight\">LightRAG</h1>\n              <p className=\"text-muted-foreground text-sm\">\n                {t('login.description')}\n              </p>\n            </div>\n          </div>\n        </CardHeader>\n        <CardContent className=\"px-8 pb-8\">\n          <form onSubmit={handleSubmit} className=\"space-y-6\">\n            <div className=\"flex items-center gap-4\">\n              <label htmlFor=\"username-input\" className=\"text-sm font-medium w-16 shrink-0\">\n                {t('login.username')}\n              </label>\n              <Input\n                id=\"username-input\"\n                placeholder={t('login.usernamePlaceholder')}\n                value={username}\n                onChange={(e) => setUsername(e.target.value)}\n                required\n                className=\"h-11 flex-1\"\n              />\n            </div>\n            <div className=\"flex items-center gap-4\">\n              <label htmlFor=\"password-input\" className=\"text-sm font-medium w-16 shrink-0\">\n                {t('login.password')}\n              </label>\n              <Input\n                id=\"password-input\"\n                type=\"password\"\n                placeholder={t('login.passwordPlaceholder')}\n                value={password}\n                onChange={(e) => setPassword(e.target.value)}\n                required\n                className=\"h-11 flex-1\"\n              />\n            </div>\n            <Button\n              type=\"submit\"\n              className=\"w-full h-11 text-base font-medium mt-2\"\n              disabled={loading}\n            >\n              {loading ? t('login.loggingIn') : t('login.loginButton')}\n            </Button>\n          </form>\n        </CardContent>\n      </Card>\n    </div>\n  )\n}\n\nexport default LoginPage\n"
  },
  {
    "path": "lightrag_webui/src/features/RetrievalTesting.tsx",
    "content": "import Textarea from '@/components/ui/Textarea'\nimport Input from '@/components/ui/Input'\nimport Button from '@/components/ui/Button'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { throttle } from '@/lib/utils'\nimport { queryText, queryTextStream } from '@/api/lightrag'\nimport { errorMessage } from '@/lib/utils'\nimport { useSettingsStore } from '@/stores/settings'\nimport { useDebounce } from '@/hooks/useDebounce'\nimport QuerySettings from '@/components/retrieval/QuerySettings'\nimport { ChatMessage, MessageWithError } from '@/components/retrieval/ChatMessage'\nimport { EraserIcon, SendIcon, CopyIcon } from 'lucide-react'\nimport { useTranslation } from 'react-i18next'\nimport { toast } from 'sonner'\nimport { copyToClipboard } from '@/utils/clipboard'\nimport type { QueryMode } from '@/api/lightrag'\n\n// Helper function to generate unique IDs with browser compatibility\nconst generateUniqueId = () => {\n  // Use crypto.randomUUID() if available\n  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n    return crypto.randomUUID();\n  }\n  // Fallback to timestamp + random string for browsers without crypto.randomUUID\n  return `id-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;\n};\n\n// LaTeX completeness detection function\nconst detectLatexCompleteness = (content: string): boolean => {\n  // Check for unclosed block-level LaTeX formulas ($$...$$)\n  const blockLatexMatches = content.match(/\\$\\$/g) || []\n  const hasUnclosedBlock = blockLatexMatches.length % 2 !== 0\n\n  // Check for unclosed inline LaTeX formulas ($...$, but not $$)\n  // Remove all block formulas first to avoid interference\n  const contentWithoutBlocks = content.replace(/\\$\\$[\\s\\S]*?\\$\\$/g, '')\n  const inlineLatexMatches = contentWithoutBlocks.match(/(?<!\\$)\\$(?!\\$)/g) || []\n  const hasUnclosedInline = inlineLatexMatches.length % 2 !== 0\n\n  // LaTeX is complete if there are no unclosed formulas\n  return !hasUnclosedBlock && !hasUnclosedInline\n}\n\n// Robust COT parsing function to handle multiple think blocks and edge cases\nconst parseCOTContent = (content: string) => {\n  const thinkStartTag = '<think>'\n  const thinkEndTag = '</think>'\n\n  // Find all <think> and </think> tag positions\n  const startMatches: number[] = []\n  const endMatches: number[] = []\n\n  let startIndex = 0\n  while ((startIndex = content.indexOf(thinkStartTag, startIndex)) !== -1) {\n    startMatches.push(startIndex)\n    startIndex += thinkStartTag.length\n  }\n\n  let endIndex = 0\n  while ((endIndex = content.indexOf(thinkEndTag, endIndex)) !== -1) {\n    endMatches.push(endIndex)\n    endIndex += thinkEndTag.length\n  }\n\n  // Analyze COT state\n  const hasThinkStart = startMatches.length > 0\n  const hasThinkEnd = endMatches.length > 0\n  const isThinking = hasThinkStart && (startMatches.length > endMatches.length)\n\n  let thinkingContent = ''\n  let displayContent = content\n\n  if (hasThinkStart) {\n    if (hasThinkEnd && startMatches.length === endMatches.length) {\n      // Complete thinking blocks: extract the last complete thinking content\n      const lastStartIndex = startMatches[startMatches.length - 1]\n      const lastEndIndex = endMatches[endMatches.length - 1]\n\n      if (lastEndIndex > lastStartIndex) {\n        thinkingContent = content.substring(\n          lastStartIndex + thinkStartTag.length,\n          lastEndIndex\n        ).trim()\n\n        // Remove all thinking blocks, keep only the final display content\n        displayContent = content.substring(lastEndIndex + thinkEndTag.length).trim()\n      }\n    } else if (isThinking) {\n      // Currently thinking: extract current thinking content\n      const lastStartIndex = startMatches[startMatches.length - 1]\n      thinkingContent = content.substring(lastStartIndex + thinkStartTag.length)\n      displayContent = ''\n    }\n  }\n\n  return {\n    isThinking,\n    thinkingContent,\n    displayContent,\n    hasValidThinkBlock: hasThinkStart && hasThinkEnd && startMatches.length === endMatches.length\n  }\n}\n\nexport default function RetrievalTesting() {\n  const { t } = useTranslation()\n  // Get current tab to determine if this tab is active (for performance optimization)\n  const currentTab = useSettingsStore.use.currentTab()\n  const isRetrievalTabActive = currentTab === 'retrieval'\n\n  const [messages, setMessages] = useState<MessageWithError[]>(() => {\n    try {\n      const history = useSettingsStore.getState().retrievalHistory || []\n      // Ensure each message from history has a unique ID and mermaidRendered status\n      return history.map((msg, index) => {\n        try {\n          const msgWithError = msg as MessageWithError // Cast to access potential properties\n          return {\n            ...msg,\n            id: msgWithError.id || `hist-${Date.now()}-${index}`, // Add ID if missing\n            mermaidRendered: msgWithError.mermaidRendered ?? true, // Assume historical mermaid is rendered\n            latexRendered: msgWithError.latexRendered ?? true // Assume historical LaTeX is rendered\n          }\n        } catch (error) {\n          console.error('Error processing message:', error)\n          // Return a default message if there's an error\n          return {\n            role: 'system',\n            content: 'Error loading message',\n            id: `error-${Date.now()}-${index}`,\n            isError: true,\n            mermaidRendered: true\n          }\n        }\n      })\n    } catch (error) {\n      console.error('Error loading history:', error)\n      return [] // Return an empty array if there's an error\n    }\n  })\n  const [inputValue, setInputValue] = useState('')\n  const [isLoading, setIsLoading] = useState(false)\n  const [inputError, setInputError] = useState('') // Error message for input\n  const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)\n\n  // Smart switching logic: use Input for single line, Textarea for multi-line\n  const hasMultipleLines = inputValue.includes('\\n')\n\n  // Enhanced event handlers for smart switching\n  const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {\n    setInputValue(e.target.value)\n    if (inputError) setInputError('')\n  }, [inputError])\n\n  // Unified height adjustment function for textarea\n  const adjustTextareaHeight = useCallback((element: HTMLTextAreaElement) => {\n    requestAnimationFrame(() => {\n      element.style.height = 'auto'\n      element.style.height = Math.min(element.scrollHeight, 120) + 'px'\n    })\n  }, [])\n\n  // Scroll to bottom function - restored smooth scrolling with better handling\n  const scrollToBottom = useCallback(() => {\n    // Set flag to indicate this is a programmatic scroll\n    programmaticScrollRef.current = true\n    // Use requestAnimationFrame for better performance\n    requestAnimationFrame(() => {\n      if (messagesEndRef.current) {\n        // Use smooth scrolling for better user experience\n        messagesEndRef.current.scrollIntoView({ behavior: 'auto' })\n      }\n    })\n  }, [])\n\n  const handleSubmit = useCallback(\n    async (e: React.FormEvent) => {\n      e.preventDefault()\n      if (!inputValue.trim() || isLoading) return\n\n      // Parse query mode prefix\n      const allowedModes: QueryMode[] = ['naive', 'local', 'global', 'hybrid', 'mix', 'bypass']\n      const prefixMatch = inputValue.match(/^\\/(\\w+)\\s+([\\s\\S]+)/)\n      let modeOverride: QueryMode | undefined = undefined\n      let actualQuery = inputValue\n\n      // If input starts with a slash, but does not match the valid prefix pattern, treat as error\n      if (/^\\/\\S+/.test(inputValue) && !prefixMatch) {\n        setInputError(t('retrievePanel.retrieval.queryModePrefixInvalid'))\n        return\n      }\n\n      if (prefixMatch) {\n        const mode = prefixMatch[1] as QueryMode\n        const query = prefixMatch[2]\n        if (!allowedModes.includes(mode)) {\n          setInputError(\n            t('retrievePanel.retrieval.queryModeError', {\n              modes: 'naive, local, global, hybrid, mix, bypass',\n            })\n          )\n          return\n        }\n        modeOverride = mode\n        actualQuery = query\n      }\n\n      // Clear error message\n      setInputError('')\n\n      // Reset thinking timer state for new query to prevent confusion\n      thinkingStartTime.current = null\n      thinkingProcessed.current = false\n\n      // Create messages\n      // Save the original input (with prefix if any) in userMessage.content for display\n      const userMessage: MessageWithError = {\n        id: generateUniqueId(), // Use browser-compatible ID generation\n        content: inputValue,\n        role: 'user'\n      }\n\n      const assistantMessage: MessageWithError = {\n        id: generateUniqueId(), // Use browser-compatible ID generation\n        content: '',\n        role: 'assistant',\n        mermaidRendered: false,\n        latexRendered: false,      // Explicitly initialize to false\n        thinkingTime: null,        // Explicitly initialize to null\n        thinkingContent: undefined, // Explicitly initialize to undefined\n        displayContent: undefined,  // Explicitly initialize to undefined\n        isThinking: false          // Explicitly initialize to false\n      }\n\n      const prevMessages = [...messages]\n\n      // Add messages to chatbox\n      setMessages([...prevMessages, userMessage, assistantMessage])\n\n      // Reset scroll following state for new query\n      shouldFollowScrollRef.current = true\n      // Set flag to indicate we're receiving a response\n      isReceivingResponseRef.current = true\n\n      // Force scroll to bottom after messages are rendered\n      setTimeout(() => {\n        scrollToBottom()\n      }, 0)\n\n      // Clear input and set loading\n      setInputValue('')\n      setIsLoading(true)\n\n      // Reset input height to minimum after clearing input\n      if (inputRef.current) {\n        if ('style' in inputRef.current) {\n          inputRef.current.style.height = '40px'\n        }\n      }\n\n      // Create a function to update the assistant's message\n      const updateAssistantMessage = (chunk: string, isError?: boolean) => {\n        assistantMessage.content += chunk\n\n        // Start thinking timer on first sight of think tag\n        if (assistantMessage.content.includes('<think>') && !thinkingStartTime.current) {\n          thinkingStartTime.current = Date.now()\n        }\n\n        // Use the new robust COT parsing function\n        const cotResult = parseCOTContent(assistantMessage.content)\n\n        // Update thinking state\n        assistantMessage.isThinking = cotResult.isThinking\n\n        // Only calculate time and extract thinking content once when thinking is complete\n        if (cotResult.hasValidThinkBlock && !thinkingProcessed.current) {\n          if (thinkingStartTime.current && !assistantMessage.thinkingTime) {\n            const duration = (Date.now() - thinkingStartTime.current) / 1000\n            assistantMessage.thinkingTime = parseFloat(duration.toFixed(2))\n          }\n          thinkingProcessed.current = true\n        }\n\n        // Update content based on parsing results\n        assistantMessage.thinkingContent = cotResult.thinkingContent\n        // Only fallback to full content if not in a thinking state.\n        if (cotResult.isThinking) {\n          assistantMessage.displayContent = ''\n        } else {\n          assistantMessage.displayContent = cotResult.displayContent || assistantMessage.content\n        }\n\n        // Detect if the assistant message contains a complete mermaid code block\n        // Simple heuristic: look for ```mermaid ... ```\n        const mermaidBlockRegex = /```mermaid\\s+([\\s\\S]+?)```/g\n        let mermaidRendered = false\n        let match\n        while ((match = mermaidBlockRegex.exec(assistantMessage.content)) !== null) {\n          // If the block is not too short, consider it complete\n          if (match[1] && match[1].trim().length > 10) {\n            mermaidRendered = true\n            break\n          }\n        }\n        assistantMessage.mermaidRendered = mermaidRendered\n\n        // Detect if the assistant message contains complete LaTeX formulas\n        const latexRendered = detectLatexCompleteness(assistantMessage.content)\n        assistantMessage.latexRendered = latexRendered\n\n        // Single unified update to avoid race conditions\n        setMessages((prev) => {\n          const newMessages = [...prev]\n          const lastMessage = newMessages[newMessages.length - 1]\n          if (lastMessage && lastMessage.id === assistantMessage.id) {\n            // Update all properties at once to maintain consistency\n            Object.assign(lastMessage, {\n              content: assistantMessage.content,\n              thinkingContent: assistantMessage.thinkingContent,\n              displayContent: assistantMessage.displayContent,\n              isThinking: assistantMessage.isThinking,\n              isError: isError,\n              mermaidRendered: assistantMessage.mermaidRendered,\n              latexRendered: assistantMessage.latexRendered,\n              thinkingTime: assistantMessage.thinkingTime\n            })\n          }\n          return newMessages\n        })\n\n        // After updating content, scroll to bottom if auto-scroll is enabled\n        // Use a longer delay to ensure DOM has updated\n        if (shouldFollowScrollRef.current) {\n          setTimeout(() => {\n            scrollToBottom()\n          }, 30)\n        }\n      }\n\n      // Prepare query parameters\n      const state = useSettingsStore.getState()\n\n      // Add user prompt to history if it exists and is not empty\n      if (state.querySettings.user_prompt && state.querySettings.user_prompt.trim()) {\n        state.addUserPromptToHistory(state.querySettings.user_prompt.trim())\n      }\n\n      // Determine the effective mode\n      const effectiveMode = modeOverride || state.querySettings.mode\n\n      // Determine effective history turns with bypass override\n      const configuredHistoryTurns = state.querySettings.history_turns || 0\n      const effectiveHistoryTurns = (effectiveMode === 'bypass' && configuredHistoryTurns === 0)\n        ? 3\n        : configuredHistoryTurns\n\n      const queryParams = {\n        ...state.querySettings,\n        query: actualQuery,\n        response_type: 'Multiple Paragraphs',\n        conversation_history: effectiveHistoryTurns > 0\n          ? prevMessages\n            .filter((m) => m.isError !== true)\n            .slice(-effectiveHistoryTurns * 2)\n            .map((m) => ({ role: m.role, content: m.content }))\n          : [],\n        ...(modeOverride ? { mode: modeOverride } : {})\n      }\n\n      try {\n        // Run query\n        if (state.querySettings.stream) {\n          let errorMessage = ''\n          await queryTextStream(queryParams, updateAssistantMessage, (error) => {\n            errorMessage += error\n          })\n          if (errorMessage) {\n            if (assistantMessage.content) {\n              errorMessage = assistantMessage.content + '\\n' + errorMessage\n            }\n            updateAssistantMessage(errorMessage, true)\n          }\n        } else {\n          const response = await queryText(queryParams)\n          updateAssistantMessage(response.response)\n        }\n      } catch (err) {\n        // Handle error\n        updateAssistantMessage(`${t('retrievePanel.retrieval.error')}\\n${errorMessage(err)}`, true)\n      } finally {\n        // Clear loading and add messages to state\n        setIsLoading(false)\n        isReceivingResponseRef.current = false\n\n        // Enhanced cleanup with error handling to prevent memory leaks\n        try {\n          // Final COT state validation and cleanup\n          const finalCotResult = parseCOTContent(assistantMessage.content)\n\n          // Force set final state - stream ended so thinking must be false\n          assistantMessage.isThinking = false\n\n          // If we have a complete thinking block but time wasn't calculated, do final calculation\n          if (finalCotResult.hasValidThinkBlock && thinkingStartTime.current && !assistantMessage.thinkingTime) {\n            const duration = (Date.now() - thinkingStartTime.current) / 1000\n            assistantMessage.thinkingTime = parseFloat(duration.toFixed(2))\n          }\n\n          // Ensure display content is correctly set based on final parsing\n          if (finalCotResult.displayContent !== undefined) {\n            assistantMessage.displayContent = finalCotResult.displayContent\n          }\n\n        } catch (error) {\n          console.error('Error in final COT state validation:', error)\n          // Force reset state on error\n          assistantMessage.isThinking = false\n        } finally {\n          // Ensure cleanup happens regardless of errors\n          thinkingStartTime.current = null\n        }\n\n        // Save history with error handling\n        try {\n          useSettingsStore\n            .getState()\n            .setRetrievalHistory([...prevMessages, userMessage, assistantMessage])\n        } catch (error) {\n          console.error('Error saving retrieval history:', error)\n        }\n      }\n    },\n    [inputValue, isLoading, messages, setMessages, t, scrollToBottom]\n  )\n\n  const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {\n    if (e.key === 'Enter' && e.shiftKey) {\n      // Shift+Enter: Insert newline\n      e.preventDefault()\n      const target = e.target as HTMLInputElement | HTMLTextAreaElement\n      const start = target.selectionStart || 0\n      const end = target.selectionEnd || 0\n      const newValue = inputValue.slice(0, start) + '\\n' + inputValue.slice(end)\n      setInputValue(newValue)\n\n      // Set cursor position after the newline and adjust height if needed\n      setTimeout(() => {\n        if (target.setSelectionRange) {\n          target.setSelectionRange(start + 1, start + 1)\n        }\n\n        // Manually trigger height adjustment for textarea after component switch\n        if (inputRef.current && inputRef.current.tagName === 'TEXTAREA') {\n          adjustTextareaHeight(inputRef.current as HTMLTextAreaElement)\n        }\n      }, 0)\n    } else if (e.key === 'Enter' && !e.shiftKey) {\n      // Enter: Submit form\n      e.preventDefault()\n      handleSubmit(e as any)\n    }\n  }, [inputValue, handleSubmit, adjustTextareaHeight])\n\n  const handlePaste = useCallback((e: React.ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {\n    // Get pasted text content\n    const pastedText = e.clipboardData.getData('text')\n\n    // Check if it contains newlines\n    if (pastedText.includes('\\n')) {\n      e.preventDefault() // Prevent default paste behavior\n\n      // Get current cursor position\n      const target = e.target as HTMLInputElement | HTMLTextAreaElement\n      const start = target.selectionStart || 0\n      const end = target.selectionEnd || 0\n\n      // Build new value\n      const newValue = inputValue.slice(0, start) + pastedText + inputValue.slice(end)\n\n      // Update state (this will trigger component switch to Textarea)\n      setInputValue(newValue)\n\n      // Set cursor position to end of pasted content\n      setTimeout(() => {\n        if (inputRef.current && inputRef.current.setSelectionRange) {\n          const newCursorPosition = start + pastedText.length\n          inputRef.current.setSelectionRange(newCursorPosition, newCursorPosition)\n        }\n      }, 0)\n    }\n    // If no newlines, let default paste behavior continue\n  }, [inputValue])\n\n  // Effect to handle component switching and maintain focus\n  useEffect(() => {\n    if (inputRef.current) {\n      // When component type changes, restore focus and cursor position\n      const currentElement = inputRef.current\n      const cursorPosition = currentElement.selectionStart || inputValue.length\n\n      // Use requestAnimationFrame to ensure DOM update is complete\n      requestAnimationFrame(() => {\n        currentElement.focus()\n        if (currentElement.setSelectionRange) {\n          currentElement.setSelectionRange(cursorPosition, cursorPosition)\n        }\n      })\n    }\n  }, [hasMultipleLines, inputValue.length]) // Include inputValue.length dependency\n\n  // Effect to adjust textarea height when switching to multi-line mode\n  useEffect(() => {\n    if (hasMultipleLines && inputRef.current && inputRef.current.tagName === 'TEXTAREA') {\n      adjustTextareaHeight(inputRef.current as HTMLTextAreaElement)\n    }\n  }, [hasMultipleLines, inputValue, adjustTextareaHeight])\n\n  // Reference to track if we should follow scroll during streaming (using ref for synchronous updates)\n  const shouldFollowScrollRef = useRef(true)\n  const thinkingStartTime = useRef<number | null>(null)\n  const thinkingProcessed = useRef(false)\n  // Reference to track if user interaction is from the form area\n  const isFormInteractionRef = useRef(false)\n  // Reference to track if scroll was triggered programmatically\n  const programmaticScrollRef = useRef(false)\n  // Reference to track if we're currently receiving a streaming response\n  const isReceivingResponseRef = useRef(false)\n  const messagesEndRef = useRef<HTMLDivElement>(null)\n  const messagesContainerRef = useRef<HTMLDivElement>(null)\n\n  // Add cleanup effect for memory leak prevention\n  useEffect(() => {\n    // Component cleanup - reset timer state to prevent memory leaks\n    return () => {\n      if (thinkingStartTime.current) {\n        thinkingStartTime.current = null;\n      }\n    };\n  }, []);\n\n  // Add event listeners to detect when user manually interacts with the container\n  useEffect(() => {\n    const container = messagesContainerRef.current;\n    if (!container) return;\n\n    // Handle significant mouse wheel events - only disable auto-scroll for deliberate scrolling\n    const handleWheel = (e: WheelEvent) => {\n      // Only consider significant wheel movements (more than 10px)\n      if (Math.abs(e.deltaY) > 10 && !isFormInteractionRef.current) {\n        shouldFollowScrollRef.current = false;\n      }\n    };\n\n    // Handle scroll events - only disable auto-scroll if not programmatically triggered\n    // and if it's a significant scroll\n    const handleScroll = throttle(() => {\n      // If this is a programmatic scroll, don't disable auto-scroll\n      if (programmaticScrollRef.current) {\n        programmaticScrollRef.current = false;\n        return;\n      }\n\n      // Check if scrolled to bottom or very close to bottom\n      const container = messagesContainerRef.current;\n      if (container) {\n        const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 20;\n\n        // If at bottom, enable auto-scroll, otherwise disable it\n        if (isAtBottom) {\n          shouldFollowScrollRef.current = true;\n        } else if (!isFormInteractionRef.current && !isReceivingResponseRef.current) {\n          shouldFollowScrollRef.current = false;\n        }\n      }\n    }, 30);\n\n    // Add event listeners - only listen for wheel and scroll events\n    container.addEventListener('wheel', handleWheel as EventListener);\n    container.addEventListener('scroll', handleScroll as EventListener);\n\n    return () => {\n      container.removeEventListener('wheel', handleWheel as EventListener);\n      container.removeEventListener('scroll', handleScroll as EventListener);\n    };\n  }, []);\n\n  // Add event listeners to the form area to prevent disabling auto-scroll when interacting with form\n  useEffect(() => {\n    const form = document.querySelector('form');\n    if (!form) return;\n\n    const handleFormMouseDown = () => {\n      // Set flag to indicate form interaction\n      isFormInteractionRef.current = true;\n\n      // Reset the flag after a short delay\n      setTimeout(() => {\n        isFormInteractionRef.current = false;\n      }, 500); // Give enough time for the form interaction to complete\n    };\n\n    form.addEventListener('mousedown', handleFormMouseDown);\n\n    return () => {\n      form.removeEventListener('mousedown', handleFormMouseDown);\n    };\n  }, []);\n\n  // Use a longer debounce time for better performance with large message updates\n  const debouncedMessages = useDebounce(messages, 150)\n  useEffect(() => {\n    // Only auto-scroll if enabled\n    if (shouldFollowScrollRef.current) {\n      // Force scroll to bottom when messages change\n      scrollToBottom()\n    }\n  }, [debouncedMessages, scrollToBottom])\n\n\n  const clearMessages = useCallback(() => {\n    setMessages([])\n    useSettingsStore.getState().setRetrievalHistory([])\n  }, [setMessages])\n\n  // Handle copying message content with robust clipboard support\n  const handleCopyMessage = useCallback(async (message: MessageWithError) => {\n    let contentToCopy = '';\n\n    if (message.role === 'user') {\n      // User messages: copy original content\n      contentToCopy = message.content || '';\n    } else {\n      // Assistant messages: prefer processed display content, fallback to original content\n      const finalDisplayContent = message.displayContent !== undefined\n        ? message.displayContent\n        : (message.content || '');\n      contentToCopy = finalDisplayContent;\n    }\n\n    if (!contentToCopy.trim()) {\n      toast.error(t('retrievePanel.chatMessage.copyEmpty', 'No content to copy'));\n      return;\n    }\n\n    try {\n      const result = await copyToClipboard(contentToCopy);\n\n      if (result.success) {\n        // Show success message with method used\n        const methodMessages: Record<string, string> = {\n          'clipboard-api': t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard'),\n          'execCommand': t('retrievePanel.chatMessage.copySuccessLegacy', 'Content copied (legacy method)'),\n          'manual-select': t('retrievePanel.chatMessage.copySuccessManual', 'Content copied (manual method)'),\n          'fallback': t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard')\n        };\n\n        toast.success(methodMessages[result.method] || t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard'));\n      } else {\n        // Show error with fallback instructions\n        if (result.method === 'fallback') {\n          toast.error(\n            result.error || t('retrievePanel.chatMessage.copyFailed', 'Failed to copy content'),\n            {\n              description: t('retrievePanel.chatMessage.copyManualInstruction', 'Please select and copy the text manually')\n            }\n          );\n        } else {\n          toast.error(\n            t('retrievePanel.chatMessage.copyFailed', 'Failed to copy content'),\n            {\n              description: result.error\n            }\n          );\n        }\n      }\n    } catch (err) {\n      console.error('Clipboard operation failed:', err);\n      toast.error(\n        t('retrievePanel.chatMessage.copyError', 'Copy operation failed'),\n        {\n          description: err instanceof Error ? err.message : 'Unknown error occurred'\n        }\n      );\n    }\n  }, [t])\n\n  return (\n    <div className=\"flex size-full gap-2 px-2 pb-12 overflow-hidden\">\n      <div className=\"flex grow flex-col gap-4\">\n        <div className=\"relative grow\">\n          <div\n            ref={messagesContainerRef}\n            className=\"bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2\"\n            onClick={() => {\n              if (shouldFollowScrollRef.current) {\n                shouldFollowScrollRef.current = false;\n              }\n            }}\n          >\n            <div className=\"flex min-h-0 flex-1 flex-col gap-2\">\n              {messages.length === 0 ? (\n                <div className=\"text-muted-foreground flex h-full items-center justify-center text-lg\">\n                  {t('retrievePanel.retrieval.startPrompt')}\n                </div>\n              ) : (\n                messages.map((message) => { // Remove unused idx\n                  // isComplete logic is now handled internally based on message.mermaidRendered\n                  return (\n                    <div\n                      key={message.id} // Use stable ID for key\n                      className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} items-end gap-2`}\n                    >\n                      {message.role === 'user' && (\n                        <Button\n                          onClick={() => handleCopyMessage(message)}\n                          className=\"mb-2 size-6 rounded-md opacity-60 transition-opacity hover:opacity-100 shrink-0\"\n                          tooltip={t('retrievePanel.chatMessage.copyTooltip')}\n                          variant=\"ghost\"\n                          size=\"icon\"\n                        >\n                          <CopyIcon className=\"size-4\" />\n                        </Button>\n                      )}\n                      <ChatMessage message={message} isTabActive={isRetrievalTabActive} />\n                      {message.role === 'assistant' && (\n                        <Button\n                          onClick={() => handleCopyMessage(message)}\n                          className=\"mb-2 size-6 rounded-md opacity-60 transition-opacity hover:opacity-100 shrink-0\"\n                          tooltip={t('retrievePanel.chatMessage.copyTooltip')}\n                          variant=\"ghost\"\n                          size=\"icon\"\n                        >\n                          <CopyIcon className=\"size-4\" />\n                        </Button>\n                      )}\n                    </div>\n                  );\n                })\n              )}\n              <div ref={messagesEndRef} className=\"pb-1\" />\n            </div>\n          </div>\n        </div>\n\n        <form\n          onSubmit={handleSubmit}\n          className=\"flex shrink-0 items-center gap-2\"\n          autoComplete=\"on\"\n          method=\"post\"\n          action=\"#\"\n          role=\"search\"\n        >\n          {/* Hidden submit button to ensure form meets HTML standards */}\n          <input type=\"submit\" style={{ display: 'none' }} tabIndex={-1} />\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            onClick={clearMessages}\n            disabled={isLoading}\n            size=\"sm\"\n          >\n            <EraserIcon />\n            {t('retrievePanel.retrieval.clear')}\n          </Button>\n          <div className=\"flex-1 relative\">\n            <label htmlFor=\"query-input\" className=\"sr-only\">\n              {t('retrievePanel.retrieval.placeholder')}\n            </label>\n            {hasMultipleLines ? (\n              <Textarea\n                ref={inputRef as React.RefObject<HTMLTextAreaElement>}\n                id=\"query-input\"\n                autoComplete=\"on\"\n                className=\"w-full min-h-[40px] max-h-[120px] overflow-y-auto\"\n                value={inputValue}\n                onChange={handleChange}\n                onKeyDown={handleKeyDown}\n                onPaste={handlePaste}\n                placeholder={t('retrievePanel.retrieval.placeholder')}\n                disabled={isLoading}\n                rows={1}\n                style={{\n                  resize: 'none',\n                  height: 'auto',\n                  minHeight: '40px',\n                  maxHeight: '120px'\n                }}\n                onInput={(e: React.FormEvent<HTMLTextAreaElement>) => {\n                  const target = e.target as HTMLTextAreaElement\n                  requestAnimationFrame(() => {\n                    target.style.height = 'auto'\n                    target.style.height = Math.min(target.scrollHeight, 120) + 'px'\n                  })\n                }}\n              />\n            ) : (\n              <Input\n                ref={inputRef as React.RefObject<HTMLInputElement>}\n                id=\"query-input\"\n                autoComplete=\"on\"\n                className=\"w-full\"\n                value={inputValue}\n                onChange={handleChange}\n                onKeyDown={handleKeyDown}\n                onPaste={handlePaste}\n                placeholder={t('retrievePanel.retrieval.placeholder')}\n                disabled={isLoading}\n              />\n            )}\n            {/* Error message below input */}\n            {inputError && (\n              <div className=\"absolute left-0 top-full mt-1 text-xs text-red-500\">{inputError}</div>\n            )}\n          </div>\n          <Button type=\"submit\" variant=\"default\" disabled={isLoading} size=\"sm\">\n            <SendIcon />\n            {t('retrievePanel.retrieval.send')}\n          </Button>\n        </form>\n      </div>\n      <QuerySettings />\n    </div>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/features/SiteHeader.tsx",
    "content": "import Button from '@/components/ui/Button'\nimport { SiteInfo, webuiPrefix } from '@/lib/constants'\nimport AppSettings from '@/components/AppSettings'\nimport { TabsList, TabsTrigger } from '@/components/ui/Tabs'\nimport { useSettingsStore } from '@/stores/settings'\nimport { useAuthStore } from '@/stores/state'\nimport { cn } from '@/lib/utils'\nimport { useTranslation } from 'react-i18next'\nimport { navigationService } from '@/services/navigation'\nimport { ZapIcon, GithubIcon, LogOutIcon } from 'lucide-react'\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'\n\ninterface NavigationTabProps {\n  value: string\n  currentTab: string\n  children: React.ReactNode\n}\n\nfunction NavigationTab({ value, currentTab, children }: NavigationTabProps) {\n  return (\n    <TabsTrigger\n      value={value}\n      className={cn(\n        'cursor-pointer px-2 py-1 transition-all',\n        currentTab === value ? '!bg-emerald-400 !text-zinc-50' : 'hover:bg-background/60'\n      )}\n    >\n      {children}\n    </TabsTrigger>\n  )\n}\n\nfunction TabsNavigation() {\n  const currentTab = useSettingsStore.use.currentTab()\n  const { t } = useTranslation()\n\n  return (\n    <div className=\"flex h-8 self-center\">\n      <TabsList className=\"h-full gap-2\">\n        <NavigationTab value=\"documents\" currentTab={currentTab}>\n          {t('header.documents')}\n        </NavigationTab>\n        <NavigationTab value=\"knowledge-graph\" currentTab={currentTab}>\n          {t('header.knowledgeGraph')}\n        </NavigationTab>\n        <NavigationTab value=\"retrieval\" currentTab={currentTab}>\n          {t('header.retrieval')}\n        </NavigationTab>\n        <NavigationTab value=\"api\" currentTab={currentTab}>\n          {t('header.api')}\n        </NavigationTab>\n      </TabsList>\n    </div>\n  )\n}\n\nexport default function SiteHeader() {\n  const { t } = useTranslation()\n  const { isGuestMode, coreVersion, apiVersion, username, webuiTitle, webuiDescription } = useAuthStore()\n\n  const versionDisplay = (coreVersion && apiVersion)\n    ? `${coreVersion}/${apiVersion}`\n    : null;\n\n  // Check if frontend needs rebuild (apiVersion ends with warning symbol)\n  const hasWarning = apiVersion?.endsWith('⚠️');\n  const versionTooltip = hasWarning\n    ? t('header.frontendNeedsRebuild')\n    : versionDisplay ? `v${versionDisplay}` : '';\n\n  const handleLogout = () => {\n    navigationService.navigateToLogin();\n  }\n\n  return (\n    <header className=\"border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur\">\n      <div className=\"min-w-[200px] w-auto flex items-center\">\n        <a href={webuiPrefix} className=\"flex items-center gap-2\">\n          <ZapIcon className=\"size-4 text-emerald-400\" aria-hidden=\"true\" />\n          <span className=\"font-bold md:inline-block\">{SiteInfo.name}</span>\n        </a>\n        {webuiTitle && (\n          <div className=\"flex items-center\">\n            <span className=\"mx-1 text-xs text-gray-500 dark:text-gray-400\">|</span>\n            <TooltipProvider>\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <span className=\"font-medium text-sm cursor-default\">\n                    {webuiTitle}\n                  </span>\n                </TooltipTrigger>\n                {webuiDescription && (\n                  <TooltipContent side=\"bottom\">\n                    {webuiDescription}\n                  </TooltipContent>\n                )}\n              </Tooltip>\n            </TooltipProvider>\n          </div>\n        )}\n      </div>\n\n      <div className=\"flex h-10 flex-1 items-center justify-center\">\n        <TabsNavigation />\n        {isGuestMode && (\n          <div className=\"ml-2 self-center px-2 py-1 text-xs bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 rounded-md\">\n            {t('login.guestMode', 'Guest Mode')}\n          </div>\n        )}\n      </div>\n\n      <nav className=\"w-[200px] flex items-center justify-end\">\n        <div className=\"flex items-center gap-2\">\n          {versionDisplay && (\n            <TooltipProvider>\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <span className=\"text-xs text-gray-500 dark:text-gray-400 mr-1 cursor-default\">\n                    v{versionDisplay}\n                  </span>\n                </TooltipTrigger>\n                <TooltipContent side=\"bottom\">\n                  {versionTooltip}\n                </TooltipContent>\n              </Tooltip>\n            </TooltipProvider>\n          )}\n          <Button variant=\"ghost\" size=\"icon\" side=\"bottom\" tooltip={t('header.projectRepository')}>\n            <a href={SiteInfo.github} target=\"_blank\" rel=\"noopener noreferrer\">\n              <GithubIcon className=\"size-4\" aria-hidden=\"true\" />\n            </a>\n          </Button>\n          <AppSettings />\n          {!isGuestMode && (\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              side=\"bottom\"\n              tooltip={`${t('header.logout')} (${username})`}\n              onClick={handleLogout}\n            >\n              <LogOutIcon className=\"size-4\" aria-hidden=\"true\" />\n            </Button>\n          )}\n        </div>\n      </nav>\n    </header>\n  )\n}\n"
  },
  {
    "path": "lightrag_webui/src/hooks/useDebounce.tsx",
    "content": "import { useState, useEffect } from 'react'\n\nexport function useDebounce<T>(value: T, delay: number): T {\n  const [debouncedValue, setDebouncedValue] = useState<T>(value)\n\n  useEffect(() => {\n    const timer = setTimeout(() => {\n      setDebouncedValue(value)\n    }, delay)\n\n    return () => {\n      clearTimeout(timer)\n    }\n  }, [value, delay])\n\n  return debouncedValue\n}\n"
  },
  {
    "path": "lightrag_webui/src/hooks/useLightragGraph.tsx",
    "content": "import Graph, { UndirectedGraph } from 'graphology'\nimport { useCallback, useEffect, useRef } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { errorMessage } from '@/lib/utils'\nimport * as Constants from '@/lib/constants'\nimport { useGraphStore, RawGraph, RawNodeType, RawEdgeType } from '@/stores/graph'\nimport { toast } from 'sonner'\nimport { queryGraphs } from '@/api/lightrag'\nimport { useBackendState } from '@/stores/state'\nimport { useSettingsStore } from '@/stores/settings'\n\nimport seedrandom from 'seedrandom'\nimport { resolveNodeColor, DEFAULT_NODE_COLOR } from '@/utils/graphColor'\n\n// Select color based on node type\nconst getNodeColorByType = (nodeType: string | undefined): string => {\n  const state = useGraphStore.getState()\n  const { color, map, updated } = resolveNodeColor(nodeType, state.typeColorMap)\n\n  if (updated) {\n    useGraphStore.setState({ typeColorMap: map })\n  }\n\n  return color || DEFAULT_NODE_COLOR\n};\n\n\nconst validateGraph = (graph: RawGraph) => {\n  // Check if graph exists\n  if (!graph) {\n    console.log('Graph validation failed: graph is null');\n    return false;\n  }\n\n  // Check if nodes and edges are arrays\n  if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {\n    console.log('Graph validation failed: nodes or edges is not an array');\n    return false;\n  }\n\n  // Check if nodes array is empty\n  if (graph.nodes.length === 0) {\n    console.log('Graph validation failed: nodes array is empty');\n    return false;\n  }\n\n  // Validate each node\n  for (const node of graph.nodes) {\n    if (!node.id || !node.labels || !node.properties) {\n      console.log('Graph validation failed: invalid node structure');\n      return false;\n    }\n  }\n\n  // Validate each edge\n  for (const edge of graph.edges) {\n    if (!edge.id || !edge.source || !edge.target) {\n      console.log('Graph validation failed: invalid edge structure');\n      return false;\n    }\n  }\n\n  // Validate edge connections\n  for (const edge of graph.edges) {\n    const source = graph.getNode(edge.source);\n    const target = graph.getNode(edge.target);\n    if (source == undefined || target == undefined) {\n      console.log('Graph validation failed: edge references non-existent node');\n      return false;\n    }\n  }\n\n  console.log('Graph validation passed');\n  return true;\n}\n\nexport type NodeType = {\n  x: number\n  y: number\n  label: string\n  size: number\n  color: string\n  highlighted?: boolean\n}\nexport type EdgeType = {\n  label: string\n  originalWeight?: number\n  size?: number\n  color?: string\n  hidden?: boolean\n}\n\nconst fetchGraph = async (label: string, maxDepth: number, maxNodes: number) => {\n  let rawData: any = null;\n\n  // Trigger GraphLabels component to check if the label is valid\n  // console.log('Setting labelsFetchAttempted to true');\n  useGraphStore.getState().setLabelsFetchAttempted(true)\n\n  // If label is empty, use default label '*'\n  const queryLabel = label || '*';\n\n  try {\n    console.log(`Fetching graph label: ${queryLabel}, depth: ${maxDepth}, nodes: ${maxNodes}`);\n    rawData = await queryGraphs(queryLabel, maxDepth, maxNodes);\n  } catch (e) {\n    useBackendState.getState().setErrorMessage(errorMessage(e), 'Query Graphs Error!');\n    return null;\n  }\n\n  let rawGraph = null;\n\n  if (rawData) {\n    const nodeIdMap: Record<string, number> = {}\n    const edgeIdMap: Record<string, number> = {}\n\n    for (let i = 0; i < rawData.nodes.length; i++) {\n      const node = rawData.nodes[i]\n      nodeIdMap[node.id] = i\n\n      node.x = Math.random()\n      node.y = Math.random()\n      node.degree = 0\n      node.size = 10\n    }\n\n    for (let i = 0; i < rawData.edges.length; i++) {\n      const edge = rawData.edges[i]\n      edgeIdMap[edge.id] = i\n\n      const source = nodeIdMap[edge.source]\n      const target = nodeIdMap[edge.target]\n      if (source !== undefined && target !== undefined) {\n        const sourceNode = rawData.nodes[source]\n        if (!sourceNode) {\n          console.error(`Source node ${edge.source} is undefined`)\n          continue\n        }\n\n        const targetNode = rawData.nodes[target]\n        if (!targetNode) {\n          console.error(`Target node ${edge.target} is undefined`)\n          continue\n        }\n        sourceNode.degree += 1\n        targetNode.degree += 1\n      }\n    }\n\n    // generate node size\n    let minDegree = Number.MAX_SAFE_INTEGER\n    let maxDegree = 0\n\n    for (const node of rawData.nodes) {\n      minDegree = Math.min(minDegree, node.degree)\n      maxDegree = Math.max(maxDegree, node.degree)\n    }\n    const range = maxDegree - minDegree\n    if (range > 0) {\n      const scale = Constants.maxNodeSize - Constants.minNodeSize\n      for (const node of rawData.nodes) {\n        node.size = Math.round(\n          Constants.minNodeSize + scale * Math.pow((node.degree - minDegree) / range, 0.5)\n        )\n      }\n    }\n\n    rawGraph = new RawGraph()\n    rawGraph.nodes = rawData.nodes\n    rawGraph.edges = rawData.edges\n    rawGraph.nodeIdMap = nodeIdMap\n    rawGraph.edgeIdMap = edgeIdMap\n\n    if (!validateGraph(rawGraph)) {\n      rawGraph = null\n      console.warn('Invalid graph data')\n    }\n    console.log('Graph data loaded')\n  }\n\n  // console.debug({ data: JSON.parse(JSON.stringify(rawData)) })\n  return { rawGraph, is_truncated: rawData.is_truncated }\n}\n\n// Create a new graph instance with the raw graph data\nconst createSigmaGraph = (rawGraph: RawGraph | null) => {\n  // Get edge size settings from store\n  const minEdgeSize = useSettingsStore.getState().minEdgeSize\n  const maxEdgeSize = useSettingsStore.getState().maxEdgeSize\n  // Skip graph creation if no data or empty nodes\n  if (!rawGraph || !rawGraph.nodes.length) {\n    console.log('No graph data available, skipping sigma graph creation');\n    return null;\n  }\n\n  // Create new graph instance\n  const graph = new UndirectedGraph()\n\n  // Add nodes from raw graph data\n  for (const rawNode of rawGraph?.nodes ?? []) {\n    // Ensure we have fresh random positions for nodes\n    seedrandom(rawNode.id + Date.now().toString(), { global: true })\n    const x = Math.random()\n    const y = Math.random()\n\n    graph.addNode(rawNode.id, {\n      label: rawNode.labels.join(', '),\n      color: rawNode.color,\n      x: x,\n      y: y,\n      size: rawNode.size,\n      // for node-border\n      borderColor: Constants.nodeBorderColor,\n      borderSize: 0.2\n    })\n  }\n\n  // Add edges from raw graph data\n  for (const rawEdge of rawGraph?.edges ?? []) {\n    // Get weight from edge properties or default to 1\n    const weight = rawEdge.properties?.weight !== undefined ? Number(rawEdge.properties.weight) : 1\n\n    rawEdge.dynamicId = graph.addEdge(rawEdge.source, rawEdge.target, {\n      label: rawEdge.properties?.keywords || undefined,\n      size: weight, // Set initial size based on weight\n      originalWeight: weight, // Store original weight for recalculation\n      type: 'curvedNoArrow' // Explicitly set edge type to no arrow\n    })\n  }\n\n  // Calculate edge size based on weight range, similar to node size calculation\n  let minWeight = Number.MAX_SAFE_INTEGER\n  let maxWeight = 0\n\n  // Find min and max weight values\n  graph.forEachEdge(edge => {\n    const weight = graph.getEdgeAttribute(edge, 'originalWeight') || 1\n    minWeight = Math.min(minWeight, weight)\n    maxWeight = Math.max(maxWeight, weight)\n  })\n\n  // Scale edge sizes based on weight range\n  const weightRange = maxWeight - minWeight\n  if (weightRange > 0) {\n    const sizeScale = maxEdgeSize - minEdgeSize\n    graph.forEachEdge(edge => {\n      const weight = graph.getEdgeAttribute(edge, 'originalWeight') || 1\n      const scaledSize = minEdgeSize + sizeScale * Math.pow((weight - minWeight) / weightRange, 0.5)\n      graph.setEdgeAttribute(edge, 'size', scaledSize)\n    })\n  } else {\n    // If all weights are the same, use default size\n    graph.forEachEdge(edge => {\n      graph.setEdgeAttribute(edge, 'size', minEdgeSize)\n    })\n  }\n\n  return graph\n}\n\nconst useLightrangeGraph = () => {\n  const { t } = useTranslation()\n  const queryLabel = useSettingsStore.use.queryLabel()\n  const rawGraph = useGraphStore.use.rawGraph()\n  const sigmaGraph = useGraphStore.use.sigmaGraph()\n  const maxQueryDepth = useSettingsStore.use.graphQueryMaxDepth()\n  const maxNodes = useSettingsStore.use.graphMaxNodes()\n  const isFetching = useGraphStore.use.isFetching()\n  const nodeToExpand = useGraphStore.use.nodeToExpand()\n  const nodeToPrune = useGraphStore.use.nodeToPrune()\n  const graphDataVersion = useGraphStore.use.graphDataVersion()\n\n\n  // Use ref to track if data has been loaded and initial load\n  const dataLoadedRef = useRef(false)\n  const initialLoadRef = useRef(false)\n  // Use ref to track if empty data has been handled\n  const emptyDataHandledRef = useRef(false)\n\n  const getNode = useCallback(\n    (nodeId: string) => {\n      return rawGraph?.getNode(nodeId) || null\n    },\n    [rawGraph]\n  )\n\n  const getEdge = useCallback(\n    (edgeId: string, dynamicId: boolean = true) => {\n      return rawGraph?.getEdge(edgeId, dynamicId) || null\n    },\n    [rawGraph]\n  )\n\n  // Track if a fetch is in progress to prevent multiple simultaneous fetches\n  const fetchInProgressRef = useRef(false)\n\n  // Reset graph when query label is cleared\n  useEffect(() => {\n    if (!queryLabel && (rawGraph !== null || sigmaGraph !== null)) {\n      const state = useGraphStore.getState()\n      state.reset()\n      state.setGraphDataFetchAttempted(false)\n      state.setLabelsFetchAttempted(false)\n      dataLoadedRef.current = false\n      initialLoadRef.current = false\n    }\n  }, [queryLabel, rawGraph, sigmaGraph])\n\n  // Graph data fetching logic\n  useEffect(() => {\n    // Skip if fetch is already in progress\n    if (fetchInProgressRef.current) {\n      return\n    }\n\n    // Empty queryLabel should be only handle once(avoid infinite loop)\n    if (!queryLabel && emptyDataHandledRef.current) {\n      return;\n    }\n\n    // Only fetch data when graphDataFetchAttempted is false (avoids re-fetching on vite dev mode)\n    // GraphDataFetchAttempted must set to false when queryLabel is changed\n    if (!isFetching && !useGraphStore.getState().graphDataFetchAttempted) {\n      // Set flags\n      fetchInProgressRef.current = true\n      useGraphStore.getState().setGraphDataFetchAttempted(true)\n\n      const state = useGraphStore.getState()\n      state.setIsFetching(true)\n\n      // Clear selection and highlighted nodes before fetching new graph\n      state.clearSelection()\n      if (state.sigmaGraph) {\n        state.sigmaGraph.forEachNode((node) => {\n          state.sigmaGraph?.setNodeAttribute(node, 'highlighted', false)\n        })\n      }\n\n      console.log('Preparing graph data...')\n\n      // Use a local copy of the parameters\n      const currentQueryLabel = queryLabel\n      const currentMaxQueryDepth = maxQueryDepth\n      const currentMaxNodes = maxNodes\n\n      // Declare a variable to store data promise\n      let dataPromise: Promise<{ rawGraph: RawGraph | null; is_truncated: boolean | undefined } | null>;\n\n      // 1. If query label is not empty, use fetchGraph\n      if (currentQueryLabel) {\n        dataPromise = fetchGraph(currentQueryLabel, currentMaxQueryDepth, currentMaxNodes);\n      } else {\n        // 2. If query label is empty, set data to null\n        console.log('Query label is empty, show empty graph')\n        dataPromise = Promise.resolve({ rawGraph: null, is_truncated: false });\n      }\n\n      // 3. Process data\n      dataPromise.then((result) => {\n        const state = useGraphStore.getState()\n        const data = result?.rawGraph;\n\n        // Assign colors based on entity_type *after* fetching\n        if (data && data.nodes) {\n          data.nodes.forEach(node => {\n            // Use entity_type instead of type\n            const nodeEntityType = node.properties?.entity_type as string | undefined;\n            node.color = getNodeColorByType(nodeEntityType);\n          });\n        }\n\n        if (result?.is_truncated) {\n          toast.info(t('graphPanel.dataIsTruncated', 'Graph data is truncated to Max Nodes'));\n        }\n\n        // Reset state\n        state.reset()\n\n        // Check if data is empty or invalid\n        if (!data || !data.nodes || data.nodes.length === 0) {\n          // Create a graph with a single \"Graph Is Empty\" node\n          const emptyGraph = new UndirectedGraph();\n\n          // Add a single node with \"Graph Is Empty\" label\n          emptyGraph.addNode('empty-graph-node', {\n            label: t('graphPanel.emptyGraph'),\n            color: '#5D6D7E', // gray color\n            x: 0.5,\n            y: 0.5,\n            size: 15,\n            borderColor: Constants.nodeBorderColor,\n            borderSize: 0.2\n          });\n\n          // Set graph to store\n          state.setSigmaGraph(emptyGraph);\n          state.setRawGraph(null);\n\n          // Still mark graph as empty for other logic\n          state.setGraphIsEmpty(true);\n\n          // Check if the empty graph is due to 401 authentication error\n          const errorMessage = useBackendState.getState().message;\n          const isAuthError = errorMessage && errorMessage.includes('Authentication required');\n\n          // Only clear queryLabel if it's not an auth error and current label is not empty\n          if (!isAuthError && currentQueryLabel) {\n            useSettingsStore.getState().setQueryLabel('');\n          }\n\n          // Only clear last successful query label if it's not an auth error\n          if (!isAuthError) {\n            state.setLastSuccessfulQueryLabel('');\n          } else {\n            console.log('Keep queryLabel for post-login reload');\n          }\n\n          console.log(`Graph data is empty, created graph with empty graph node. Auth error: ${isAuthError}`);\n        } else {\n          // Create and set new graph\n          const newSigmaGraph = createSigmaGraph(data);\n          data.buildDynamicMap();\n\n          // Set new graph data\n          state.setSigmaGraph(newSigmaGraph);\n          state.setRawGraph(data);\n          state.setGraphIsEmpty(false);\n\n          // Update last successful query label\n          state.setLastSuccessfulQueryLabel(currentQueryLabel);\n\n          // Reset camera view\n          state.setMoveToSelectedNode(true);\n        }\n\n        // Update flags\n        dataLoadedRef.current = true\n        initialLoadRef.current = true\n        fetchInProgressRef.current = false\n        state.setIsFetching(false)\n\n        // Mark empty data as handled if data is empty and query label is empty\n        if ((!data || !data.nodes || data.nodes.length === 0) && !currentQueryLabel) {\n          emptyDataHandledRef.current = true;\n        }\n      }).catch((error) => {\n        console.error('Error fetching graph data:', error)\n\n        // Reset state on error\n        const state = useGraphStore.getState()\n        state.setIsFetching(false)\n        dataLoadedRef.current = false;\n        fetchInProgressRef.current = false\n        state.setGraphDataFetchAttempted(false)\n        state.setLastSuccessfulQueryLabel('') // Clear last successful query label on error\n      })\n    }\n  }, [queryLabel, maxQueryDepth, maxNodes, isFetching, t, graphDataVersion])\n\n  // Handle node expansion\n  useEffect(() => {\n    const handleNodeExpand = async (nodeId: string | null) => {\n      if (!nodeId || !sigmaGraph || !rawGraph) return;\n\n      try {\n        // Get the node to expand\n        const nodeToExpand = rawGraph.getNode(nodeId);\n        if (!nodeToExpand) {\n          console.error('Node not found:', nodeId);\n          return;\n        }\n\n        // Get the label of the node to expand\n        const label = nodeToExpand.labels[0];\n        if (!label) {\n          console.error('Node has no label:', nodeId);\n          return;\n        }\n\n        // Fetch the extended subgraph with depth 2\n        const extendedGraph = await queryGraphs(label, 2, 1000);\n\n        if (!extendedGraph || !extendedGraph.nodes || !extendedGraph.edges) {\n          console.error('Failed to fetch extended graph');\n          return;\n        }\n\n        // Process nodes to add required properties for RawNodeType\n        const processedNodes: RawNodeType[] = [];\n        for (const node of extendedGraph.nodes) {\n          // Generate random color values\n          seedrandom(node.id, { global: true });\n          const nodeEntityType = node.properties?.entity_type as string | undefined;\n          const color = getNodeColorByType(nodeEntityType);\n\n          // Create a properly typed RawNodeType\n          processedNodes.push({\n            id: node.id,\n            labels: node.labels,\n            properties: node.properties,\n            size: 10, // Default size, will be calculated later\n            x: Math.random(), // Random position, will be adjusted later\n            y: Math.random(), // Random position, will be adjusted later\n            color: color, // Random color\n            degree: 0 // Initial degree, will be calculated later\n          });\n        }\n\n        // Process edges to add required properties for RawEdgeType\n        const processedEdges: RawEdgeType[] = [];\n        for (const edge of extendedGraph.edges) {\n          // Create a properly typed RawEdgeType\n          processedEdges.push({\n            id: edge.id,\n            source: edge.source,\n            target: edge.target,\n            type: edge.type,\n            properties: edge.properties,\n            dynamicId: '' // Will be set when adding to sigma graph\n          });\n        }\n\n        // Store current node positions\n        const nodePositions: Record<string, {x: number, y: number}> = {};\n        sigmaGraph.forEachNode((node) => {\n          nodePositions[node] = {\n            x: sigmaGraph.getNodeAttribute(node, 'x'),\n            y: sigmaGraph.getNodeAttribute(node, 'y')\n          };\n        });\n\n        // Get existing node IDs\n        const existingNodeIds = new Set(sigmaGraph.nodes());\n\n        // Identify nodes and edges to keep\n        const nodesToAdd = new Set<string>();\n        const edgesToAdd = new Set<string>();\n\n        // Get degree maxDegree from existing graph for size calculations\n        const minDegree = 1;\n        let maxDegree = 0;\n\n        // Initialize edge weight min and max values\n        let minWeight = Number.MAX_SAFE_INTEGER;\n        let maxWeight = 0;\n\n        // Calculate node degrees and edge weights from existing graph\n        sigmaGraph.forEachNode(node => {\n          const degree = sigmaGraph.degree(node);\n          maxDegree = Math.max(maxDegree, degree);\n        });\n\n        // Calculate edge weights from existing graph\n        sigmaGraph.forEachEdge(edge => {\n          const weight = sigmaGraph.getEdgeAttribute(edge, 'originalWeight') || 1;\n          minWeight = Math.min(minWeight, weight);\n          maxWeight = Math.max(maxWeight, weight);\n        });\n\n        // First identify connectable nodes (nodes connected to the expanded node)\n        for (const node of processedNodes) {\n          // Skip if node already exists\n          if (existingNodeIds.has(node.id)) {\n            continue;\n          }\n\n          // Check if this node is connected to the selected node\n          const isConnected = processedEdges.some(\n            edge => (edge.source === nodeId && edge.target === node.id) ||\n                   (edge.target === nodeId && edge.source === node.id)\n          );\n\n          if (isConnected) {\n            nodesToAdd.add(node.id);\n          }\n        }\n\n        // Calculate node degrees and track discarded edges in one pass\n        const nodeDegrees = new Map<string, number>();\n        const existingNodeDegreeIncrements = new Map<string, number>(); // Track degree increments for existing nodes\n        const nodesWithDiscardedEdges = new Set<string>();\n\n        for (const edge of processedEdges) {\n          const sourceExists = existingNodeIds.has(edge.source) || nodesToAdd.has(edge.source);\n          const targetExists = existingNodeIds.has(edge.target) || nodesToAdd.has(edge.target);\n\n          if (sourceExists && targetExists) {\n            edgesToAdd.add(edge.id);\n            // Add degrees for both new and existing nodes\n            if (nodesToAdd.has(edge.source)) {\n              nodeDegrees.set(edge.source, (nodeDegrees.get(edge.source) || 0) + 1);\n            } else if (existingNodeIds.has(edge.source)) {\n              // Track degree increments for existing nodes\n              existingNodeDegreeIncrements.set(edge.source, (existingNodeDegreeIncrements.get(edge.source) || 0) + 1);\n            }\n\n            if (nodesToAdd.has(edge.target)) {\n              nodeDegrees.set(edge.target, (nodeDegrees.get(edge.target) || 0) + 1);\n            } else if (existingNodeIds.has(edge.target)) {\n              // Track degree increments for existing nodes\n              existingNodeDegreeIncrements.set(edge.target, (existingNodeDegreeIncrements.get(edge.target) || 0) + 1);\n            }\n          } else {\n            // Track discarded edges for both new and existing nodes\n            if (sigmaGraph.hasNode(edge.source)) {\n              nodesWithDiscardedEdges.add(edge.source);\n            } else if (nodesToAdd.has(edge.source)) {\n              nodesWithDiscardedEdges.add(edge.source);\n              nodeDegrees.set(edge.source, (nodeDegrees.get(edge.source) || 0) + 1); // +1 for discarded edge\n            }\n            if (sigmaGraph.hasNode(edge.target)) {\n              nodesWithDiscardedEdges.add(edge.target);\n            } else if (nodesToAdd.has(edge.target)) {\n              nodesWithDiscardedEdges.add(edge.target);\n              nodeDegrees.set(edge.target, (nodeDegrees.get(edge.target) || 0) + 1); // +1 for discarded edge\n            }\n          }\n        }\n\n        // Helper function to update node sizes\n        const updateNodeSizes = (\n          sigmaGraph: UndirectedGraph,\n          nodesWithDiscardedEdges: Set<string>,\n          minDegree: number,\n          maxDegree: number\n        ) => {\n          // Calculate derived values inside the function\n          const range = maxDegree - minDegree || 1; // Avoid division by zero\n          const scale = Constants.maxNodeSize - Constants.minNodeSize;\n\n          // Update node sizes\n          for (const nodeId of nodesWithDiscardedEdges) {\n            if (sigmaGraph.hasNode(nodeId)) {\n              let newDegree = sigmaGraph.degree(nodeId);\n              newDegree += 1; // Add +1 for discarded edges\n              // Limit newDegree to maxDegree + 1 to prevent nodes from being too large\n              const limitedDegree = Math.min(newDegree, maxDegree + 1);\n\n              const newSize = Math.round(\n                Constants.minNodeSize + scale * Math.pow((limitedDegree - minDegree) / range, 0.5)\n              );\n\n              sigmaGraph.setNodeAttribute(nodeId, 'size', newSize);\n            }\n          }\n        };\n\n        // Helper function to update edge sizes\n        const updateEdgeSizes = (\n          sigmaGraph: UndirectedGraph,\n          minWeight: number,\n          maxWeight: number\n        ) => {\n          // Update edge sizes\n          const minEdgeSize = useSettingsStore.getState().minEdgeSize;\n          const maxEdgeSize = useSettingsStore.getState().maxEdgeSize;\n          const weightRange = maxWeight - minWeight || 1; // Avoid division by zero\n          const sizeScale = maxEdgeSize - minEdgeSize;\n\n          sigmaGraph.forEachEdge(edge => {\n            const weight = sigmaGraph.getEdgeAttribute(edge, 'originalWeight') || 1;\n            const scaledSize = minEdgeSize + sizeScale * Math.pow((weight - minWeight) / weightRange, 0.5);\n            sigmaGraph.setEdgeAttribute(edge, 'size', scaledSize);\n          });\n        };\n\n        // If no new connectable nodes found, show toast and return\n        if (nodesToAdd.size === 0) {\n          updateNodeSizes(sigmaGraph, nodesWithDiscardedEdges, minDegree, maxDegree);\n          toast.info(t('graphPanel.propertiesView.node.noNewNodes'));\n          return;\n        }\n\n        // Update maxDegree considering all nodes (both new and existing)\n        // 1. Consider degrees of new nodes\n        for (const [, degree] of nodeDegrees.entries()) {\n          maxDegree = Math.max(maxDegree, degree);\n        }\n\n        // 2. Consider degree increments for existing nodes\n        for (const [nodeId, increment] of existingNodeDegreeIncrements.entries()) {\n          const currentDegree = sigmaGraph.degree(nodeId);\n          const projectedDegree = currentDegree + increment;\n          maxDegree = Math.max(maxDegree, projectedDegree);\n        }\n\n        const range = maxDegree - minDegree || 1; // Avoid division by zero\n        const scale = Constants.maxNodeSize - Constants.minNodeSize;\n\n        // SAdd nodes and edges to the graph\n        // Calculate camera ratio and spread factor once before the loop\n        const cameraRatio = useGraphStore.getState().sigmaInstance?.getCamera().ratio || 1;\n        const spreadFactor = Math.max(\n          Math.sqrt(nodeToExpand.size) * 4, // Base on node size\n          Math.sqrt(nodesToAdd.size) * 3 // Scale with number of nodes\n        ) / cameraRatio; // Adjust for zoom level\n        seedrandom(Date.now().toString(), { global: true });\n        const randomAngle = Math.random() * 2 * Math.PI\n\n        console.log('nodeSize:', nodeToExpand.size, 'nodesToAdd:', nodesToAdd.size);\n        console.log('cameraRatio:', Math.round(cameraRatio*100)/100, 'spreadFactor:', Math.round(spreadFactor*100)/100);\n\n        // Add new nodes\n        for (const nodeId of nodesToAdd) {\n          const newNode = processedNodes.find(n => n.id === nodeId)!;\n          const nodeDegree = nodeDegrees.get(nodeId) || 0;\n\n          // Calculate node size\n          // Limit nodeDegree to maxDegree + 1 to prevent new nodes from being too large\n          const limitedDegree = Math.min(nodeDegree, maxDegree + 1);\n          const nodeSize = Math.round(\n            Constants.minNodeSize + scale * Math.pow((limitedDegree - minDegree) / range, 0.5)\n          );\n\n          // Calculate angle for polar coordinates\n          const angle = 2 * Math.PI * (Array.from(nodesToAdd).indexOf(nodeId) / nodesToAdd.size);\n\n          // Calculate final position\n          const x = nodePositions[nodeId]?.x ||\n                    (nodePositions[nodeToExpand.id].x + Math.cos(randomAngle + angle) * spreadFactor);\n          const y = nodePositions[nodeId]?.y ||\n                    (nodePositions[nodeToExpand.id].y + Math.sin(randomAngle + angle) * spreadFactor);\n\n          // Add the new node to the sigma graph with calculated position\n          sigmaGraph.addNode(nodeId, {\n            label: newNode.labels.join(', '),\n            color: newNode.color,\n            x: x,\n            y: y,\n            size: nodeSize,\n            borderColor: Constants.nodeBorderColor,\n            borderSize: 0.2\n          });\n\n          // Add the node to the raw graph\n          if (!rawGraph.getNode(nodeId)) {\n            // Update node properties\n            newNode.size = nodeSize;\n            newNode.x = x;\n            newNode.y = y;\n            newNode.degree = nodeDegree;\n\n            // Add to nodes array\n            rawGraph.nodes.push(newNode);\n            // Update nodeIdMap\n            rawGraph.nodeIdMap[nodeId] = rawGraph.nodes.length - 1;\n          }\n        }\n\n        // Add new edges\n        for (const edgeId of edgesToAdd) {\n          const newEdge = processedEdges.find(e => e.id === edgeId)!;\n\n          // Skip if edge already exists\n          if (sigmaGraph.hasEdge(newEdge.source, newEdge.target)) {\n            continue;\n          }\n\n          // Get weight from edge properties or default to 1\n          const weight = newEdge.properties?.weight !== undefined ? Number(newEdge.properties.weight) : 1;\n\n          // Update min and max weight values\n          minWeight = Math.min(minWeight, weight);\n          maxWeight = Math.max(maxWeight, weight);\n\n          // Add the edge to the sigma graph\n          newEdge.dynamicId = sigmaGraph.addEdge(newEdge.source, newEdge.target, {\n            label: newEdge.properties?.keywords || undefined,\n            size: weight, // Set initial size based on weight\n            originalWeight: weight, // Store original weight for recalculation\n            type: 'curvedNoArrow' // Explicitly set edge type to no arrow\n          });\n\n          // Add the edge to the raw graph\n          if (!rawGraph.getEdge(newEdge.id, false)) {\n            // Add to edges array\n            rawGraph.edges.push(newEdge);\n            // Update edgeIdMap\n            rawGraph.edgeIdMap[newEdge.id] = rawGraph.edges.length - 1;\n            // Update dynamic edge map\n            rawGraph.edgeDynamicIdMap[newEdge.dynamicId] = rawGraph.edges.length - 1;\n          } else {\n            console.error('Edge already exists in rawGraph:', newEdge.id);\n          }\n        }\n\n        // Update the dynamic edge map and invalidate search cache\n        rawGraph.buildDynamicMap();\n\n        // Reset search engine to force rebuild\n        useGraphStore.getState().resetSearchEngine();\n\n        // Update sizes for all nodes and edges\n        updateNodeSizes(sigmaGraph, nodesWithDiscardedEdges, minDegree, maxDegree);\n        updateEdgeSizes(sigmaGraph, minWeight, maxWeight);\n\n        // Final update for the expanded node\n        if (sigmaGraph.hasNode(nodeId)) {\n          const finalDegree = sigmaGraph.degree(nodeId);\n          const limitedDegree = Math.min(finalDegree, maxDegree + 1);\n          const newSize = Math.round(\n            Constants.minNodeSize + scale * Math.pow((limitedDegree - minDegree) / range, 0.5)\n          );\n          sigmaGraph.setNodeAttribute(nodeId, 'size', newSize);\n          nodeToExpand.size = newSize;\n          nodeToExpand.degree = finalDegree;\n        }\n\n      } catch (error) {\n        console.error('Error expanding node:', error);\n      }\n    };\n\n    // If there's a node to expand, handle it\n    if (nodeToExpand) {\n      handleNodeExpand(nodeToExpand);\n      // Reset the nodeToExpand state after handling\n      window.setTimeout(() => {\n        useGraphStore.getState().triggerNodeExpand(null);\n      }, 0);\n    }\n  }, [nodeToExpand, sigmaGraph, rawGraph, t]);\n\n  // Helper function to get all nodes that will be deleted\n  const getNodesThatWillBeDeleted = useCallback((nodeId: string, graph: UndirectedGraph) => {\n    const nodesToDelete = new Set<string>([nodeId]);\n\n    // Find all nodes that would become isolated after deletion\n    graph.forEachNode((node) => {\n      if (node === nodeId) return; // Skip the node being deleted\n\n      // Get all neighbors of this node\n      const neighbors = graph.neighbors(node);\n\n      // If this node has only one neighbor and that neighbor is the node being deleted,\n      // this node will become isolated, so we should delete it too\n      if (neighbors.length === 1 && neighbors[0] === nodeId) {\n        nodesToDelete.add(node);\n      }\n    });\n\n    return nodesToDelete;\n  }, []);\n\n  // Handle node pruning\n  useEffect(() => {\n    const handleNodePrune = (nodeId: string | null) => {\n      if (!nodeId || !sigmaGraph || !rawGraph) return;\n\n      try {\n        const state = useGraphStore.getState();\n\n        // 1. Check if node exists\n        if (!sigmaGraph.hasNode(nodeId)) {\n          console.error('Node not found:', nodeId);\n          return;\n        }\n\n        // 2. Get nodes to delete\n        const nodesToDelete = getNodesThatWillBeDeleted(nodeId, sigmaGraph);\n\n        // 3. Check if this would delete all nodes\n        if (nodesToDelete.size === sigmaGraph.nodes().length) {\n          toast.error(t('graphPanel.propertiesView.node.deleteAllNodesError'));\n          return;\n        }\n\n        // 4. Clear selection - this will cause PropertiesView to close immediately\n        state.clearSelection();\n\n        // 5. Delete nodes and related edges\n        for (const nodeToDelete of nodesToDelete) {\n          // Remove the node from the sigma graph (this will also remove connected edges)\n          sigmaGraph.dropNode(nodeToDelete);\n\n          // Remove the node from the raw graph\n          const nodeIndex = rawGraph.nodeIdMap[nodeToDelete];\n          if (nodeIndex !== undefined) {\n            // Find all edges connected to this node\n            const edgesToRemove = rawGraph.edges.filter(\n              edge => edge.source === nodeToDelete || edge.target === nodeToDelete\n            );\n\n            // Remove edges from raw graph\n            for (const edge of edgesToRemove) {\n              const edgeIndex = rawGraph.edgeIdMap[edge.id];\n              if (edgeIndex !== undefined) {\n                // Remove from edges array\n                rawGraph.edges.splice(edgeIndex, 1);\n                // Update edgeIdMap for all edges after this one\n                for (const [id, idx] of Object.entries(rawGraph.edgeIdMap)) {\n                  if (idx > edgeIndex) {\n                    rawGraph.edgeIdMap[id] = idx - 1;\n                  }\n                }\n                // Remove from edgeIdMap\n                delete rawGraph.edgeIdMap[edge.id];\n                // Remove from edgeDynamicIdMap\n                delete rawGraph.edgeDynamicIdMap[edge.dynamicId];\n              }\n            }\n\n            // Remove node from nodes array\n            rawGraph.nodes.splice(nodeIndex, 1);\n\n            // Update nodeIdMap for all nodes after this one\n            for (const [id, idx] of Object.entries(rawGraph.nodeIdMap)) {\n              if (idx > nodeIndex) {\n                rawGraph.nodeIdMap[id] = idx - 1;\n              }\n            }\n\n            // Remove from nodeIdMap\n            delete rawGraph.nodeIdMap[nodeToDelete];\n          }\n        }\n\n        // Rebuild the dynamic edge map and invalidate search cache\n        rawGraph.buildDynamicMap();\n\n        // Reset search engine to force rebuild\n        useGraphStore.getState().resetSearchEngine();\n\n        // Show notification if we deleted more than just the selected node\n        if (nodesToDelete.size > 1) {\n          toast.info(t('graphPanel.propertiesView.node.nodesRemoved', { count: nodesToDelete.size }));\n        }\n\n\n      } catch (error) {\n        console.error('Error pruning node:', error);\n      }\n    };\n\n    // If there's a node to prune, handle it\n    if (nodeToPrune) {\n      handleNodePrune(nodeToPrune);\n      // Reset the nodeToPrune state after handling\n      window.setTimeout(() => {\n        useGraphStore.getState().triggerNodePrune(null);\n      }, 0);\n    }\n  }, [nodeToPrune, sigmaGraph, rawGraph, getNodesThatWillBeDeleted, t]);\n\n  const lightrageGraph = useCallback(() => {\n    // If we already have a graph instance, return it\n    if (sigmaGraph) {\n      return sigmaGraph as Graph<NodeType, EdgeType>\n    }\n\n    // If no graph exists yet, create a new one and store it\n    console.log('Creating new Sigma graph instance')\n    const graph = new UndirectedGraph()\n    useGraphStore.getState().setSigmaGraph(graph)\n    return graph as Graph<NodeType, EdgeType>\n  }, [sigmaGraph])\n\n  return { lightrageGraph, getNode, getEdge }\n}\n\nexport default useLightrangeGraph\n"
  },
  {
    "path": "lightrag_webui/src/hooks/useRandomGraph.tsx",
    "content": "import { Faker, en, faker as fak } from '@faker-js/faker'\nimport Graph, { UndirectedGraph } from 'graphology'\nimport erdosRenyi from 'graphology-generators/random/erdos-renyi'\nimport { useCallback, useEffect, useState } from 'react'\nimport seedrandom from 'seedrandom'\nimport { randomColor } from '@/lib/utils'\nimport * as Constants from '@/lib/constants'\nimport { useGraphStore } from '@/stores/graph'\n\nexport type NodeType = {\n  x: number\n  y: number\n  label: string\n  size: number\n  color: string\n  highlighted?: boolean\n}\nexport type EdgeType = { label: string }\n\n/**\n * The goal of this file is to seed random generators if the query params 'seed' is present.\n */\nconst useRandomGraph = () => {\n  const [faker, setFaker] = useState<Faker>(fak)\n\n  useEffect(() => {\n    // Globally seed the Math.random\n    const params = new URLSearchParams(document.location.search)\n    const seed = params.get('seed') // is the string \"Jonathan\"\n    if (seed) {\n      seedrandom(seed, { global: true })\n      // seed faker with the random function\n      const f = new Faker({ locale: en })\n      f.seed(Math.random())\n      setFaker(f)\n    }\n  }, [])\n\n  const randomGraph = useCallback(() => {\n    useGraphStore.getState().reset()\n\n    // Create the graph\n    const graph = erdosRenyi(UndirectedGraph, { order: 100, probability: 0.1 })\n    graph.nodes().forEach((node: string) => {\n      graph.mergeNodeAttributes(node, {\n        label: faker.person.fullName(),\n        size: faker.number.int({ min: Constants.minNodeSize, max: Constants.maxNodeSize }),\n        color: randomColor(),\n        x: Math.random(),\n        y: Math.random(),\n        // for node-border\n        borderColor: randomColor(),\n        borderSize: faker.number.float({ min: 0, max: 1, multipleOf: 0.1 }),\n        // for node-image\n        pictoColor: randomColor(),\n        image: faker.image.urlLoremFlickr()\n      })\n    })\n\n    // Add edge attributes\n    graph.edges().forEach((edge: string) => {\n      graph.mergeEdgeAttributes(edge, {\n        label: faker.lorem.words(faker.number.int({ min: 1, max: 3 })),\n        size: faker.number.float({ min: 1, max: 5 }),\n        color: randomColor()\n      })\n    })\n\n    return graph as Graph<NodeType, EdgeType>\n  }, [faker])\n\n  return { faker, randomColor, randomGraph }\n}\n\nexport default useRandomGraph\n"
  },
  {
    "path": "lightrag_webui/src/hooks/useTheme.tsx",
    "content": "import { useContext } from 'react'\nimport { ThemeProviderContext } from '@/components/ThemeProvider'\n\nconst useTheme = () => {\n  const context = useContext(ThemeProviderContext)\n\n  if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider')\n\n  return context\n}\n\nexport default useTheme\n"
  },
  {
    "path": "lightrag_webui/src/i18n.ts",
    "content": "import i18n from 'i18next'\nimport { initReactI18next } from 'react-i18next'\nimport { useSettingsStore } from '@/stores/settings'\n\nimport en from './locales/en.json'\nimport zh from './locales/zh.json'\nimport fr from './locales/fr.json'\nimport ar from './locales/ar.json'\nimport zh_TW from './locales/zh_TW.json'\nimport ru from './locales/ru.json'\nimport ja from './locales/ja.json'\nimport de from './locales/de.json'\nimport uk from './locales/uk.json'\nimport ko from './locales/ko.json'\nimport vi from './locales/vi.json'\n\nconst getStoredLanguage = () => {\n  try {\n    const settingsString = localStorage.getItem('settings-storage')\n    if (settingsString) {\n      const settings = JSON.parse(settingsString)\n      return settings.state?.language || 'en'\n    }\n  } catch (e) {\n    console.error('Failed to get stored language:', e)\n  }\n  return 'en'\n}\n\ni18n\n  .use(initReactI18next)\n  .init({\n    resources: {\n      en: { translation: en },\n      zh: { translation: zh },\n      fr: { translation: fr },\n      ar: { translation: ar },\n      zh_TW: { translation: zh_TW },\n      ru: { translation: ru },\n      ja: { translation: ja },\n      de: { translation: de },\n      uk: { translation: uk },\n      ko: { translation: ko },\n      vi: { translation: vi }\n    },\n    lng: getStoredLanguage(), // Use stored language settings\n    fallbackLng: 'en',\n    interpolation: {\n      escapeValue: false\n    },\n    // Configuration to handle missing translations\n    returnEmptyString: false,\n    returnNull: false,\n  })\n\n// Subscribe to language changes\nuseSettingsStore.subscribe((state) => {\n  const currentLanguage = state.language\n  if (i18n.language !== currentLanguage) {\n    i18n.changeLanguage(currentLanguage)\n  }\n})\n\nexport default i18n\n"
  },
  {
    "path": "lightrag_webui/src/index.css",
    "content": "@import 'tailwindcss';\n\n@plugin 'tailwindcss-animate';\n@plugin 'tailwind-scrollbar';\n\n@source '../index.html';\n@source './**/*.{ts,tsx}';\n\n@custom-variant dark (&:is(.dark *));\n\n:root {\n  --background: hsl(0 0% 100%);\n  --foreground: hsl(240 10% 3.9%);\n  --card: hsl(0 0% 100%);\n  --card-foreground: hsl(240 10% 3.9%);\n  --popover: hsl(0 0% 100%);\n  --popover-foreground: hsl(240 10% 3.9%);\n  --primary: hsl(240 5.9% 10%);\n  --primary-foreground: hsl(0 0% 98%);\n  --secondary: hsl(240 4.8% 95.9%);\n  --secondary-foreground: hsl(240 5.9% 10%);\n  --muted: hsl(240 4.8% 95.9%);\n  --muted-foreground: hsl(240 3.8% 46.1%);\n  --accent: hsl(240 4.8% 95.9%);\n  --accent-foreground: hsl(240 5.9% 10%);\n  --destructive: hsl(0 84.2% 60.2%);\n  --destructive-foreground: hsl(0 0% 98%);\n  --border: hsl(240 5.9% 90%);\n  --input: hsl(240 5.9% 90%);\n  --ring: hsl(240 10% 3.9%);\n  --chart-1: hsl(12 76% 61%);\n  --chart-2: hsl(173 58% 39%);\n  --chart-3: hsl(197 37% 24%);\n  --chart-4: hsl(43 74% 66%);\n  --chart-5: hsl(27 87% 67%);\n  --radius: 0.6rem;\n  --sidebar-background: hsl(0 0% 98%);\n  --sidebar-foreground: hsl(240 5.3% 26.1%);\n  --sidebar-primary: hsl(240 5.9% 10%);\n  --sidebar-primary-foreground: hsl(0 0% 98%);\n  --sidebar-accent: hsl(240 4.8% 95.9%);\n  --sidebar-accent-foreground: hsl(240 5.9% 10%);\n  --sidebar-border: hsl(220 13% 91%);\n  --sidebar-ring: hsl(217.2 91.2% 59.8%);\n}\n\n.dark {\n  --background: hsl(240 10% 3.9%);\n  --foreground: hsl(0 0% 98%);\n  --card: hsl(240 10% 3.9%);\n  --card-foreground: hsl(0 0% 98%);\n  --popover: hsl(240 10% 3.9%);\n  --popover-foreground: hsl(0 0% 98%);\n  --primary: hsl(0 0% 98%);\n  --primary-foreground: hsl(240 5.9% 10%);\n  --secondary: hsl(240 3.7% 15.9%);\n  --secondary-foreground: hsl(0 0% 98%);\n  --muted: hsl(240 3.7% 15.9%);\n  --muted-foreground: hsl(240 5% 64.9%);\n  --accent: hsl(240 3.7% 15.9%);\n  --accent-foreground: hsl(0 0% 98%);\n  --destructive: hsl(0 62.8% 30.6%);\n  --destructive-foreground: hsl(0 0% 98%);\n  --border: hsl(240 3.7% 15.9%);\n  --input: hsl(240 3.7% 15.9%);\n  --ring: hsl(240 4.9% 83.9%);\n  --chart-1: hsl(220 70% 50%);\n  --chart-2: hsl(160 60% 45%);\n  --chart-3: hsl(30 80% 55%);\n  --chart-4: hsl(280 65% 60%);\n  --chart-5: hsl(340 75% 55%);\n  --sidebar-background: hsl(240 5.9% 10%);\n  --sidebar-foreground: hsl(240 4.8% 95.9%);\n  --sidebar-primary: hsl(224.3 76.3% 48%);\n  --sidebar-primary-foreground: hsl(0 0% 100%);\n  --sidebar-accent: hsl(240 3.7% 15.9%);\n  --sidebar-accent-foreground: hsl(240 4.8% 95.9%);\n  --sidebar-border: hsl(240 3.7% 15.9%);\n  --sidebar-ring: hsl(217.2 91.2% 59.8%);\n}\n\n@theme inline {\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-destructive-foreground: var(--destructive-foreground);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n  --color-chart-1: var(--chart-1);\n  --color-chart-2: var(--chart-2);\n  --color-chart-3: var(--chart-3);\n  --color-chart-4: var(--chart-4);\n  --color-chart-5: var(--chart-5);\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n  --color-sidebar-ring: var(--sidebar-ring);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar: var(--sidebar-background);\n  --animate-accordion-down: accordion-down 0.2s ease-out;\n  --animate-accordion-up: accordion-up 0.2s ease-out;\n\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n\n::-webkit-scrollbar {\n  width: 10px;\n  height: 10px;\n}\n\n::-webkit-scrollbar-thumb {\n  background-color: hsl(0 0% 80%);\n  border-radius: 5px;\n}\n\n::-webkit-scrollbar-track {\n  background-color: hsl(0 0% 95%);\n}\n\n.dark {\n  ::-webkit-scrollbar-thumb {\n    background-color: hsl(0 0% 90%);\n  }\n\n  ::-webkit-scrollbar-track {\n    background-color: hsl(0 0% 0%);\n  }\n}\n\n/* KaTeX Math Formula Styles */\n.katex-display-wrapper {\n  text-align: center;\n  position: relative;\n}\n\n.katex-display-wrapper .katex-display {\n  margin: 0.5em 0;\n  text-align: center;\n}\n\n.katex-inline-wrapper .katex {\n  font-size: inherit;\n  line-height: inherit;\n}\n\n/* Ensure KaTeX formulas inherit color properly */\n.katex .base {\n  color: inherit;\n}\n\n/* Improve KaTeX display for different themes */\n.katex .mord,\n.katex .mop,\n.katex .mbin,\n.katex .mrel,\n.katex .mpunct,\n.katex .mopen,\n.katex .mclose,\n.katex .minner {\n  color: inherit;\n}\n\n/* Fix KaTeX display overflow issues */\n.katex-display {\n  overflow-x: auto;\n  overflow-y: hidden;\n  max-width: 100%;\n}\n\n.katex-display > .katex {\n  white-space: nowrap;\n}\n\n/* Improve KaTeX error display */\n.katex .katex-error {\n  background-color: rgba(255, 0, 0, 0.1);\n  border: 1px solid rgba(255, 0, 0, 0.3);\n  border-radius: 4px;\n  padding: 2px 4px;\n  color: #dc2626;\n}\n\n.dark .katex .katex-error {\n  background-color: rgba(255, 0, 0, 0.2);\n  border-color: rgba(255, 0, 0, 0.4);\n  color: #ef4444;\n}\n"
  },
  {
    "path": "lightrag_webui/src/lib/constants.ts",
    "content": "import { ButtonVariantType } from '@/components/ui/Button'\n\nexport const backendBaseUrl = ''\nexport const webuiPrefix = '/webui/'\n\nexport const controlButtonVariant: ButtonVariantType = 'ghost'\n\nexport const labelColorDarkTheme = '#FFFFFF'\nexport const LabelColorHighlightedDarkTheme = '#000000'\nexport const labelColorLightTheme = '#000'\n\nexport const nodeColorDisabled = '#E2E2E2'\nexport const nodeBorderColor = '#EEEEEE'\nexport const nodeBorderColorSelected = '#F57F17'\n\nexport const edgeColorDarkTheme = '#888888'\nexport const edgeColorSelected = '#F57F17'\nexport const edgeColorHighlightedDarkTheme = '#F57F17'\nexport const edgeColorHighlightedLightTheme = '#F57F17'\n\nexport const searchResultLimit = 50\nexport const labelListLimit = 100\n\n// Search History Configuration\nexport const searchHistoryMaxItems = 500\nexport const searchHistoryVersion = '1.0'\n\n// API Request Limits\nexport const popularLabelsDefaultLimit = 300\nexport const searchLabelsDefaultLimit = 50\n\n// UI Display Limits\nexport const dropdownDisplayLimit = 300\n\nexport const minNodeSize = 4\nexport const maxNodeSize = 20\n\nexport const healthCheckInterval = 15 // seconds\n\nexport const defaultQueryLabel = '*'\n\n// reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/MIME_types/Common_types\nexport const supportedFileTypes = {\n  'text/plain': [\n    '.txt',\n    '.md',\n    '.mdx', // # MDX (Markdown + JSX)\n    '.rtf', // # Rich Text Format\n    '.odt', // # OpenDocument Text\n    '.tex', // # LaTeX\n    '.epub', // # Electronic Publication\n    '.html', // # HyperText Markup Language\n    '.htm', // # HyperText Markup Language\n    '.csv', // # Comma-Separated Values\n    '.json', // # JavaScript Object Notation\n    '.xml', // # eXtensible Markup Language\n    '.yaml', // # YAML Ain't Markup Language\n    '.yml', // # YAML\n    '.log', // # Log files\n    '.conf', // # Configuration files\n    '.ini', // # Initialization files\n    '.properties', // # Java properties files\n    '.sql', // # SQL scripts\n    '.bat', // # Batch files\n    '.sh', // # Shell scripts\n    '.c', // # C source code\n    '.h', // # C header\n    '.cpp', // # C++ source code\n    '.hpp', // # C++ header\n    '.py', // # Python source code\n    '.java', // # Java source code\n    '.js', // # JavaScript source code\n    '.ts', // # TypeScript source code\n    '.swift', // # Swift source code\n    '.go', // # Go source code\n    '.rb', // # Ruby source code\n    '.php', // # PHP source code\n    '.css', // # Cascading Style Sheets\n    '.scss', // # Sassy CSS\n    '.less'\n  ],\n  'application/pdf': ['.pdf'],\n  'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],\n  'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],\n  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx']\n}\n\nexport const SiteInfo = {\n  name: 'LightRAG',\n  home: '/',\n  github: 'https://github.com/HKUDS/LightRAG'\n}\n"
  },
  {
    "path": "lightrag_webui/src/lib/extensions.ts",
    "content": "// This file is for importing libraries that have global side effects.\n\n// Load KaTeX mhchem extension globally\nimport 'katex/contrib/mhchem';\n"
  },
  {
    "path": "lightrag_webui/src/lib/utils.ts",
    "content": "import { clsx, type ClassValue } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\nimport { StoreApi, UseBoundStore } from 'zustand'\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n\nexport function randomColor() {\n  const digits = '0123456789abcdef'\n  let code = '#'\n  for (let i = 0; i < 6; i++) {\n    code += digits.charAt(Math.floor(Math.random() * 16))\n  }\n  return code\n}\n\nexport function errorMessage(error: any) {\n  return error instanceof Error ? error.message : `${error}`\n}\n\n/**\n * Creates a throttled function that limits how often the original function can be called\n * @param fn The function to throttle\n * @param delay The delay in milliseconds\n * @returns A throttled version of the function\n */\nexport function throttle<T extends (...args: any[]) => any>(fn: T, delay: number): (...args: Parameters<T>) => void {\n  let lastCall = 0\n  let timeoutId: ReturnType<typeof setTimeout> | null = null\n\n  return function(this: any, ...args: Parameters<T>) {\n    const now = Date.now()\n    const remaining = delay - (now - lastCall)\n\n    if (remaining <= 0) {\n      // If enough time has passed, execute the function immediately\n      if (timeoutId) {\n        clearTimeout(timeoutId)\n        timeoutId = null\n      }\n      lastCall = now\n      fn.apply(this, args)\n    } else if (!timeoutId) {\n      // If not enough time has passed, set a timeout to execute after the remaining time\n      timeoutId = setTimeout(() => {\n        lastCall = Date.now()\n        timeoutId = null\n        fn.apply(this, args)\n      }, remaining)\n    }\n  }\n}\n\ntype WithSelectors<S> = S extends { getState: () => infer T }\n  ? S & { use: { [K in keyof T]: () => T[K] } }\n  : never\n\nexport const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(_store: S) => {\n  const store = _store as WithSelectors<typeof _store>\n  store.use = {}\n  for (const k of Object.keys(store.getState())) {\n    ;(store.use as any)[k] = () => store((s) => s[k as keyof typeof s])\n  }\n\n  return store\n}\n"
  },
  {
    "path": "lightrag_webui/src/locales/ar.json",
    "content": "{\n  \"settings\": {\n    \"language\": \"اللغة\",\n    \"theme\": \"السمة\",\n    \"light\": \"فاتح\",\n    \"dark\": \"داكن\",\n    \"system\": \"النظام\"\n  },\n  \"header\": {\n    \"documents\": \"المستندات\",\n    \"knowledgeGraph\": \"شبكة المعرفة\",\n    \"retrieval\": \"الاسترجاع\",\n    \"api\": \"واجهة برمجة التطبيقات\",\n    \"projectRepository\": \"مستودع المشروع\",\n    \"logout\": \"تسجيل الخروج\",\n    \"frontendNeedsRebuild\": \"الواجهة الأمامية تحتاج إلى إعادة البناء\",\n    \"themeToggle\": {\n      \"switchToLight\": \"التحويل إلى السمة الفاتحة\",\n      \"switchToDark\": \"التحويل إلى السمة الداكنة\"\n    }\n  },\n  \"login\": {\n    \"description\": \"الرجاء إدخال حسابك وكلمة المرور لتسجيل الدخول إلى النظام\",\n    \"username\": \"اسم المستخدم\",\n    \"usernamePlaceholder\": \"الرجاء إدخال اسم المستخدم\",\n    \"password\": \"كلمة المرور\",\n    \"passwordPlaceholder\": \"الرجاء إدخال كلمة المرور\",\n    \"loginButton\": \"تسجيل الدخول\",\n    \"loggingIn\": \"جاري تسجيل الدخول...\",\n    \"successMessage\": \"تم تسجيل الدخول بنجاح\",\n    \"errorEmptyFields\": \"الرجاء إدخال اسم المستخدم وكلمة المرور\",\n    \"errorInvalidCredentials\": \"فشل تسجيل الدخول، يرجى التحقق من اسم المستخدم وكلمة المرور\",\n    \"authDisabled\": \"تم تعطيل المصادقة. استخدام وضع بدون تسجيل دخول.\",\n    \"guestMode\": \"وضع بدون تسجيل دخول\"\n  },\n  \"common\": {\n    \"cancel\": \"إلغاء\",\n    \"save\": \"حفظ\",\n    \"saving\": \"جارٍ الحفظ...\",\n    \"saveFailed\": \"فشل الحفظ\"\n  },\n  \"documentPanel\": {\n    \"clearDocuments\": {\n      \"button\": \"مسح\",\n      \"tooltip\": \"مسح المستندات\",\n      \"title\": \"مسح المستندات\",\n      \"description\": \"سيؤدي هذا إلى إزالة جميع المستندات من النظام\",\n      \"warning\": \"تحذير: سيؤدي هذا الإجراء إلى حذف جميع المستندات بشكل دائم ولا يمكن التراجع عنه!\",\n      \"confirm\": \"هل تريد حقًا مسح جميع المستندات؟\",\n      \"confirmPrompt\": \"اكتب 'yes' لتأكيد هذا الإجراء\",\n      \"confirmPlaceholder\": \"اكتب yes للتأكيد\",\n      \"clearCache\": \"مسح كاش نموذج اللغة\",\n      \"confirmButton\": \"نعم\",\n      \"clearing\": \"جارٍ المسح...\",\n      \"timeout\": \"انتهت مهلة عملية المسح، يرجى المحاولة مرة أخرى\",\n      \"success\": \"تم مسح المستندات بنجاح\",\n      \"cacheCleared\": \"تم مسح ذاكرة التخزين المؤقت بنجاح\",\n      \"cacheClearFailed\": \"فشل مسح ذاكرة التخزين المؤقت:\\n{{error}}\",\n      \"failed\": \"فشل مسح المستندات:\\n{{message}}\",\n      \"error\": \"فشل مسح المستندات:\\n{{error}}\"\n    },\n    \"deleteDocuments\": {\n      \"button\": \"حذف\",\n      \"tooltip\": \"حذف المستندات المحددة\",\n      \"title\": \"حذف المستندات\",\n      \"description\": \"سيؤدي هذا إلى حذف المستندات المحددة نهائيًا من النظام\",\n      \"warning\": \"تحذير: سيؤدي هذا الإجراء إلى حذف المستندات المحددة نهائيًا ولا يمكن التراجع عنه!\",\n      \"confirm\": \"هل تريد حقًا حذف {{count}} مستند(ات) محدد(ة)؟\",\n      \"confirmPrompt\": \"اكتب 'yes' لتأكيد هذا الإجراء\",\n      \"confirmPlaceholder\": \"اكتب yes للتأكيد\",\n      \"confirmButton\": \"نعم\",\n      \"deleteFileOption\": \"حذف الملفات المرفوعة أيضًا\",\n      \"deleteFileTooltip\": \"حدد هذا الخيار لحذف الملفات المرفوعة المقابلة على الخادم أيضًا\",\n      \"deleteLLMCacheOption\": \"حذف ذاكرة LLM المؤقتة للاستخراج أيضًا\",\n      \"success\": \"تم بدء تشغيل خط معالجة حذف المستندات بنجاح\",\n      \"failed\": \"فشل حذف المستندات:\\n{{message}}\",\n      \"error\": \"فشل حذف المستندات:\\n{{error}}\",\n      \"busy\": \"خط المعالجة مشغول، يرجى المحاولة مرة أخرى لاحقًا\",\n      \"notAllowed\": \"لا توجد صلاحية لتنفيذ هذه العملية\"\n    },\n    \"selectDocuments\": {\n      \"selectCurrentPage\": \"تحديد الصفحة الحالية ({{count}})\",\n      \"deselectAll\": \"إلغاء تحديد الكل ({{count}})\"\n    },\n    \"uploadDocuments\": {\n      \"button\": \"رفع\",\n      \"tooltip\": \"رفع المستندات\",\n      \"title\": \"رفع المستندات\",\n      \"description\": \"اسحب وأفلت مستنداتك هنا أو انقر للتصفح.\",\n      \"single\": {\n        \"uploading\": \"جارٍ الرفع {{name}}: {{percent}}%\",\n        \"success\": \"نجاح الرفع:\\nتم رفع {{name}} بنجاح\",\n        \"failed\": \"فشل الرفع:\\n{{name}}\\n{{message}}\",\n        \"error\": \"فشل الرفع:\\n{{name}}\\n{{error}}\"\n      },\n      \"batch\": {\n        \"uploading\": \"جارٍ رفع الملفات...\",\n        \"success\": \"تم رفع الملفات بنجاح\",\n        \"error\": \"فشل رفع بعض الملفات\"\n      },\n      \"generalError\": \"فشل الرفع\\n{{error}}\",\n      \"fileTypes\": \"الأنواع المدعومة: TXT، MD، MDX، DOCX، PDF، PPTX، XLSX، RTF، ODT، EPUB، HTML، HTM، TEX، JSON، XML، YAML، YML، CSV، LOG، CONF، INI، PROPERTIES، SQL، BAT، SH، C، H، CPP، HPP، PY، JAVA، JS، TS، SWIFT، GO، RB، PHP، CSS، SCSS، LESS\",\n      \"fileUploader\": {\n        \"singleFileLimit\": \"لا يمكن رفع أكثر من ملف واحد في المرة الواحدة\",\n        \"maxFilesLimit\": \"لا يمكن رفع أكثر من {{count}} ملفات\",\n        \"fileRejected\": \"تم رفض الملف {{name}}\",\n        \"unsupportedType\": \"نوع الملف غير مدعوم\",\n        \"fileTooLarge\": \"حجم الملف كبير جدًا، الحد الأقصى {{maxSize}}\",\n        \"dropHere\": \"أفلت الملفات هنا\",\n        \"dragAndDrop\": \"اسحب وأفلت الملفات هنا، أو انقر للاختيار\",\n        \"removeFile\": \"إزالة الملف\",\n        \"uploadDescription\": \"يمكنك رفع {{isMultiple ? 'عدة' : count}} ملفات (حتى {{maxSize}} لكل منها)\",\n        \"duplicateFile\": \"اسم الملف موجود بالفعل في ذاكرة التخزين المؤقت للخادم\"\n      }\n    },\n    \"documentManager\": {\n      \"title\": \"إدارة المستندات\",\n      \"scanButton\": \"مسح/إعادة محاولة\",\n      \"scanTooltip\": \"مسح ومعالجة المستندات في مجلد الإدخال، وإعادة معالجة جميع المستندات الفاشلة أيضًا\",\n      \"refreshTooltip\": \"إعادة تعيين قائمة المستندات\",\n      \"pipelineStatusButton\": \"خط المعالجة\",\n      \"pipelineStatusTooltip\": \"عرض حالة خط معالجة المستندات\",\n      \"uploadedTitle\": \"المستندات المرفوعة\",\n      \"uploadedDescription\": \"قائمة المستندات المرفوعة وحالاتها.\",\n      \"emptyTitle\": \"لا توجد مستندات\",\n      \"emptyDescription\": \"لا توجد مستندات مرفوعة بعد.\",\n      \"columns\": {\n        \"id\": \"المعرف\",\n        \"fileName\": \"اسم الملف\",\n        \"summary\": \"الملخص\",\n        \"status\": \"الحالة\",\n        \"length\": \"الطول\",\n        \"chunks\": \"الأجزاء\",\n        \"created\": \"تم الإنشاء\",\n        \"updated\": \"تم التحديث\",\n        \"metadata\": \"البيانات الوصفية\",\n        \"select\": \"اختيار\"\n      },\n      \"status\": {\n        \"all\": \"الكل\",\n        \"completed\": \"مكتمل\",\n        \"preprocessed\": \"مُعالج مسبقًا\",\n        \"processing\": \"قيد المعالجة\",\n        \"pending\": \"معلق\",\n        \"failed\": \"فشل\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"فشل تحميل المستندات\\n{{error}}\",\n        \"scanFailed\": \"فشل مسح المستندات\\n{{error}}\",\n        \"scanProgressFailed\": \"فشل الحصول على تقدم المسح\\n{{error}}\"\n      },\n      \"fileNameLabel\": \"اسم الملف\",\n      \"showButton\": \"عرض\",\n      \"hideButton\": \"إخفاء\",\n      \"showFileNameTooltip\": \"عرض اسم الملف\",\n      \"hideFileNameTooltip\": \"إخفاء اسم الملف\"\n    },\n    \"pipelineStatus\": {\n      \"title\": \"حالة خط الأنابيب\",\n      \"busy\": \"خط الأنابيب مشغول\",\n      \"requestPending\": \"طلب معلق\",\n      \"cancellationRequested\": \"طلب الإلغاء\",\n      \"jobName\": \"اسم المهمة\",\n      \"startTime\": \"وقت البدء\",\n      \"progress\": \"التقدم\",\n      \"unit\": \"دفعة\",\n      \"pipelineMessages\": \"رسائل خط الأنابيب\",\n      \"cancelButton\": \"إلغاء\",\n      \"cancelTooltip\": \"إلغاء معالجة خط الأنابيب\",\n      \"cancelConfirmTitle\": \"تأكيد إلغاء خط الأنابيب\",\n      \"cancelConfirmDescription\": \"سيؤدي هذا الإجراء إلى إيقاف معالجة خط الأنابيب الجارية. هل أنت متأكد من أنك تريد المتابعة؟\",\n      \"cancelConfirmButton\": \"تأكيد الإلغاء\",\n      \"cancelInProgress\": \"الإلغاء قيد التقدم...\",\n      \"pipelineNotRunning\": \"خط الأنابيب غير قيد التشغيل\",\n      \"cancelSuccess\": \"تم طلب إلغاء خط الأنابيب\",\n      \"cancelFailed\": \"فشل إلغاء خط الأنابيب\\n{{error}}\",\n      \"cancelNotBusy\": \"خط الأنابيب غير قيد التشغيل، لا حاجة للإلغاء\",\n      \"errors\": {\n        \"fetchFailed\": \"فشل في جلب حالة خط الأنابيب\\n{{error}}\"\n      }\n    }\n  },\n  \"graphPanel\": {\n    \"dataIsTruncated\": \"تم اقتصار بيانات الرسم البياني على الحد الأقصى للعقد\",\n    \"statusDialog\": {\n      \"title\": \"إعدادات خادم LightRAG\",\n      \"description\": \"عرض حالة النظام الحالية ومعلومات الاتصال\"\n    },\n    \"legend\": \"المفتاح\",\n    \"nodeTypes\": {\n      \"person\": \"شخص\",\n      \"category\": \"فئة\",\n      \"geo\": \"كيان جغرافي\",\n      \"location\": \"موقع\",\n      \"organization\": \"منظمة\",\n      \"event\": \"حدث\",\n      \"equipment\": \"معدات\",\n      \"weapon\": \"سلاح\",\n      \"animal\": \"حيوان\",\n      \"unknown\": \"غير معروف\",\n      \"object\": \"مصنوع\",\n      \"group\": \"مجموعة\",\n      \"technology\": \"العلوم\",\n      \"product\": \"منتج\",\n      \"document\": \"وثيقة\",\n      \"content\": \"محتوى\",\n      \"data\": \"بيانات\",\n      \"artifact\": \"قطعة أثرية\",\n      \"concept\": \"مفهوم\",\n      \"naturalobject\": \"كائن طبيعي\",\n      \"method\": \"عملية\",\n      \"creature\": \"مخلوق\",\n      \"plant\": \"نبات\",\n      \"disease\": \"مرض\",\n      \"drug\": \"دواء\",\n      \"food\": \"طعام\",\n      \"other\": \"أخرى\"\n    },\n    \"sideBar\": {\n      \"settings\": {\n        \"settings\": \"الإعدادات\",\n        \"healthCheck\": \"فحص الحالة\",\n        \"showPropertyPanel\": \"إظهار لوحة الخصائص\",\n        \"showSearchBar\": \"إظهار شريط البحث\",\n        \"showNodeLabel\": \"إظهار تسمية العقدة\",\n        \"nodeDraggable\": \"العقدة قابلة للسحب\",\n        \"showEdgeLabel\": \"إظهار تسمية الحافة\",\n        \"hideUnselectedEdges\": \"إخفاء الحواف غير المحددة\",\n        \"edgeEvents\": \"أحداث الحافة\",\n        \"maxQueryDepth\": \"أقصى عمق للاستعلام\",\n        \"maxNodes\": \"الحد الأقصى للعقد\",\n        \"maxLayoutIterations\": \"أقصى تكرارات التخطيط\",\n        \"resetToDefault\": \"إعادة التعيين إلى الافتراضي\",\n        \"edgeSizeRange\": \"نطاق حجم الحافة\",\n        \"depth\": \"D\",\n        \"max\": \"Max\",\n        \"degree\": \"الدرجة\",\n        \"apiKey\": \"مفتاح واجهة برمجة التطبيقات\",\n        \"enterYourAPIkey\": \"أدخل مفتاح واجهة برمجة التطبيقات الخاص بك\",\n        \"save\": \"حفظ\",\n        \"refreshLayout\": \"تحديث التخطيط\"\n      },\n      \"zoomControl\": {\n        \"zoomIn\": \"تكبير\",\n        \"zoomOut\": \"تصغير\",\n        \"resetZoom\": \"إعادة تعيين التكبير\",\n        \"rotateCamera\": \"تدوير في اتجاه عقارب الساعة\",\n        \"rotateCameraCounterClockwise\": \"تدوير عكس اتجاه عقارب الساعة\"\n      },\n      \"layoutsControl\": {\n        \"startAnimation\": \"بدء حركة التخطيط\",\n        \"stopAnimation\": \"إيقاف حركة التخطيط\",\n        \"layoutGraph\": \"تخطيط الرسم البياني\",\n        \"layouts\": {\n          \"Circular\": \"دائري\",\n          \"Circlepack\": \"حزمة دائرية\",\n          \"Random\": \"عشوائي\",\n          \"Noverlaps\": \"بدون تداخل\",\n          \"Force Directed\": \"موجه بالقوة\",\n          \"Force Atlas\": \"أطلس القوة\"\n        }\n      },\n      \"fullScreenControl\": {\n        \"fullScreen\": \"شاشة كاملة\",\n        \"windowed\": \"نوافذ\"\n      },\n      \"legendControl\": {\n        \"toggleLegend\": \"تبديل المفتاح\"\n      }\n    },\n    \"statusIndicator\": {\n      \"connected\": \"متصل\",\n      \"disconnected\": \"غير متصل\"\n    },\n    \"statusCard\": {\n      \"unavailable\": \"معلومات الحالة غير متوفرة\",\n      \"serverInfo\": \"معلومات الخادم\",\n      \"workingDirectory\": \"دليل العمل\",\n      \"inputDirectory\": \"دليل الإدخال\",\n      \"maxParallelInsert\": \"معالجة المستندات المتزامنة\",\n      \"summarySettings\": \"إعدادات الملخص\",\n      \"llmConfig\": \"تكوين نموذج اللغة الكبير\",\n      \"llmBinding\": \"ربط نموذج اللغة الكبير\",\n      \"llmBindingHost\": \"نقطة نهاية نموذج اللغة الكبير\",\n      \"llmModel\": \"نموذج اللغة الكبير\",\n      \"embeddingConfig\": \"تكوين التضمين\",\n      \"embeddingBinding\": \"ربط التضمين\",\n      \"embeddingBindingHost\": \"نقطة نهاية التضمين\",\n      \"embeddingModel\": \"نموذج التضمين\",\n      \"storageConfig\": \"تكوين التخزين\",\n      \"kvStorage\": \"تخزين المفتاح-القيمة\",\n      \"docStatusStorage\": \"تخزين حالة المستند\",\n      \"graphStorage\": \"تخزين الرسم البياني\",\n      \"vectorStorage\": \"تخزين المتجهات\",\n      \"workspace\": \"مساحة العمل\",\n      \"maxGraphNodes\": \"الحد الأقصى لعقد الرسم البياني\",\n      \"rerankerConfig\": \"تكوين إعادة الترتيب\",\n      \"rerankerBindingHost\": \"نقطة نهاية إعادة الترتيب\",\n      \"rerankerModel\": \"نموذج إعادة الترتيب\",\n      \"lockStatus\": \"حالة القفل\",\n      \"threshold\": \"العتبة\"\n    },\n    \"propertiesView\": {\n      \"editProperty\": \"تعديل {{property}}\",\n      \"editPropertyDescription\": \"قم بتحرير قيمة الخاصية في منطقة النص أدناه.\",\n      \"errors\": {\n        \"duplicateName\": \"اسم العقدة موجود بالفعل\",\n        \"updateFailed\": \"فشل تحديث العقدة\",\n        \"tryAgainLater\": \"يرجى المحاولة مرة أخرى لاحقًا\",\n        \"updateSuccessButMergeFailed\": \"تم تحديث الخصائص، لكن الدمج فشل: {{error}}\",\n        \"mergeFailed\": \"فشل الدمج: {{error}}\"\n      },\n      \"success\": {\n        \"entityUpdated\": \"تم تحديث العقدة بنجاح\",\n        \"relationUpdated\": \"تم تحديث العلاقة بنجاح\",\n        \"entityMerged\": \"تم دمج العقد بنجاح\"\n      },\n      \"mergeOptionLabel\": \"دمج تلقائي عند العثور على اسم مكرر\",\n      \"mergeOptionDescription\": \"عند التفعيل، سيتم دمج هذه العقدة تلقائيًا في العقدة الموجودة بدلاً من ظهور خطأ عند إعادة التسمية بنفس الاسم.\",\n      \"mergeDialog\": {\n        \"title\": \"تم دمج العقدة\",\n        \"description\": \"\\\"{{source}}\\\" تم دمجها في \\\"{{target}}\\\".\",\n        \"refreshHint\": \"يجب تحديث الرسم البياني لتحميل البنية الأحدث.\",\n        \"keepCurrentStart\": \"تحديث مع الحفاظ على عقدة البدء الحالية\",\n        \"useMergedStart\": \"تحديث واستخدام العقدة المدمجة كنقطة بدء\",\n        \"refreshing\": \"جارٍ تحديث الرسم البياني...\"\n      },\n      \"node\": {\n        \"title\": \"عقدة\",\n        \"id\": \"المعرف\",\n        \"labels\": \"التسميات\",\n        \"degree\": \"الدرجة\",\n        \"properties\": \"الخصائص\",\n        \"relationships\": \"العلاقات (داخل الرسم الفرعي)\",\n        \"expandNode\": \"توسيع العقدة\",\n        \"pruneNode\": \"تقليم العقدة\",\n        \"deleteAllNodesError\": \"رفض حذف جميع العقد في الرسم البياني\",\n        \"nodesRemoved\": \"تم إزالة {{count}} عقدة، بما في ذلك العقد اليتيمة\",\n        \"noNewNodes\": \"لم يتم العثور على عقد قابلة للتوسيع\",\n        \"propertyNames\": {\n          \"description\": \"الوصف\",\n          \"entity_id\": \"الاسم\",\n          \"entity_type\": \"النوع\",\n          \"source_id\": \"C-ID\",\n          \"Neighbour\": \"الجار\",\n          \"file_path\": \"File\",\n          \"keywords\": \"Keyword\",\n          \"weight\": \"الوزن\"\n        }\n      },\n      \"edge\": {\n        \"title\": \"علاقة\",\n        \"id\": \"المعرف\",\n        \"type\": \"النوع\",\n        \"source\": \"المصدر\",\n        \"target\": \"الهدف\",\n        \"properties\": \"الخصائص\"\n      }\n    },\n    \"search\": {\n      \"placeholder\": \"ابحث في العقد في الصفحة...\",\n      \"message\": \"و {{count}} آخرون\"\n    },\n    \"graphLabels\": {\n      \"selectTooltip\": \"الحصول على الرسم البياني الفرعي لعقدة (تسمية)\",\n      \"noLabels\": \"لم يتم العثور على عقد مطابقة\",\n      \"label\": \"البحث عن اسم العقدة\",\n      \"placeholder\": \"البحث عن اسم العقدة...\",\n      \"andOthers\": \"و {{count}} آخرون\",\n      \"refreshGlobalTooltip\": \"تحديث بيانات الرسم البياني العالمي وإعادة تعيين سجل البحث\",\n      \"refreshCurrentLabelTooltip\": \"تحديث بيانات الرسم البياني للصفحة الحالية\",\n      \"refreshingTooltip\": \"جارٍ تحديث البيانات...\"\n    },\n    \"emptyGraph\": \"فارغ (حاول إعادة التحميل)\"\n  },\n  \"retrievePanel\": {\n    \"chatMessage\": {\n      \"copyTooltip\": \"نسخ إلى الحافظة\",\n      \"copyError\": \"فشل نسخ النص إلى الحافظة\",\n      \"copyEmpty\": \"لا يوجد محتوى للنسخ\",\n      \"copySuccess\": \"تم نسخ المحتوى إلى الحافظة\",\n      \"copySuccessLegacy\": \"تم نسخ المحتوى (الطريقة التقليدية)\",\n      \"copySuccessManual\": \"تم نسخ المحتوى (الطريقة اليدوية)\",\n      \"copyFailed\": \"فشل نسخ المحتوى\",\n      \"copyManualInstruction\": \"يرجى تحديد ونسخ النص يدوياً\",\n      \"thinking\": \"جاري التفكير...\",\n      \"thinkingTime\": \"وقت التفكير {{time}} ثانية\",\n      \"thinkingInProgress\": \"التفكير قيد التقدم...\"\n    },\n    \"retrieval\": {\n      \"startPrompt\": \"ابدأ الاسترجاع بكتابة استفسارك أدناه\",\n      \"clear\": \"مسح\",\n      \"send\": \"إرسال\",\n      \"placeholder\": \"اكتب استفسارك (بادئة وضع الاستعلام: /<Query Mode>)\",\n      \"error\": \"خطأ: فشل الحصول على الرد\",\n      \"queryModeError\": \"يُسمح فقط بأنماط الاستعلام التالية: {{modes}}\",\n      \"queryModePrefixInvalid\": \"بادئة وضع الاستعلام غير صالحة. استخدم: /<الوضع> [مسافة] استفسارك\"\n    },\n    \"querySettings\": {\n      \"parametersTitle\": \"المعلمات\",\n      \"parametersDescription\": \"تكوين معلمات الاستعلام الخاص بك\",\n      \"queryMode\": \"وضع الاستعلام\",\n      \"queryModeTooltip\": \"حدد استراتيجية الاسترجاع:\\n• ساذج: استرجاع متجهي تقليدي لقطع النص\\n• محلي: يركز على استرجاع الكيانات\\n• عالمي: يركز على استرجاع العلاقات\\n• مختلط: محلي+عالمي\\n• مزيج: محلي+عالمي+ساذج\\n• تجاوز: تخطي الاسترجاع، إرسال تاريخ المحادثة والسؤال الحالي إلى LLM\",\n      \"queryModeOptions\": {\n        \"naive\": \"ساذج\",\n        \"local\": \"محلي\",\n        \"global\": \"عالمي\",\n        \"hybrid\": \"مختلط\",\n        \"mix\": \"مزيج\",\n        \"bypass\": \"تجاوز\"\n      },\n      \"responseFormat\": \"تنسيق الرد\",\n      \"responseFormatTooltip\": \"يحدد تنسيق الرد. أمثلة:\\n• فقرات متعددة\\n• فقرة واحدة\\n• نقاط نقطية\",\n      \"responseFormatOptions\": {\n        \"multipleParagraphs\": \"فقرات متعددة\",\n        \"singleParagraph\": \"فقرة واحدة\",\n        \"bulletPoints\": \"نقاط نقطية\"\n      },\n      \"topK\": \"KG أعلى K\",\n      \"topKTooltip\": \"عدد الكيانات والعلاقات المطلوب استردادها، لا ينطبق على الوضع наивный.\",\n      \"topKPlaceholder\": \"أدخل قيمة top_k\",\n      \"chunkTopK\": \"أعلى K للقطع\",\n      \"chunkTopKTooltip\": \"عدد أجزاء النص المطلوب استردادها، وينطبق على جميع الأوضاع.\",\n      \"chunkTopKPlaceholder\": \"أدخل قيمة chunk_top_k\",\n      \"maxEntityTokens\": \"الحد الأقصى لرموز الكيان\",\n      \"maxEntityTokensTooltip\": \"الحد الأقصى لعدد الرموز المخصصة لسياق الكيان في نظام التحكم الموحد في الرموز\",\n      \"maxRelationTokens\": \"الحد الأقصى لرموز العلاقة\",\n      \"maxRelationTokensTooltip\": \"الحد الأقصى لعدد الرموز المخصصة لسياق العلاقة في نظام التحكم الموحد في الرموز\",\n      \"maxTotalTokens\": \"إجمالي الحد الأقصى للرموز\",\n      \"maxTotalTokensTooltip\": \"الحد الأقصى الإجمالي لميزانية الرموز لسياق الاستعلام بالكامل (الكيانات + العلاقات + الأجزاء + موجه النظام)\",\n      \"historyTurns\": \"أدوار التاريخ\",\n      \"historyTurnsTooltip\": \"عدد الدورات الكاملة للمحادثة (أزواج المستخدم-المساعد) التي يجب مراعاتها في سياق الرد\",\n      \"historyTurnsPlaceholder\": \"عدد دورات التاريخ\",\n      \"onlyNeedContext\": \"تحتاج فقط إلى السياق\",\n      \"onlyNeedContextTooltip\": \"إذا كان صحيحًا، يتم إرجاع السياق المسترجع فقط دون إنشاء رد\",\n      \"onlyNeedPrompt\": \"تحتاج فقط إلى المطالبة\",\n      \"onlyNeedPromptTooltip\": \"إذا كان صحيحًا، يتم إرجاع المطالبة المولدة فقط دون إنتاج رد\",\n      \"streamResponse\": \"تدفق الرد\",\n      \"streamResponseTooltip\": \"إذا كان صحيحًا، يتيح إخراج التدفق للردود في الوقت الفعلي\",\n      \"userPrompt\": \"مطالبة إخراج إضافية\",\n      \"userPromptTooltip\": \"تقديم متطلبات استجابة إضافية إلى نموذج اللغة الكبير (غير متعلقة بمحتوى الاستعلام، فقط لمعالجة المخرجات).\",\n      \"userPromptPlaceholder\": \"أدخل مطالبة مخصصة (اختياري)\",\n      \"enableRerank\": \"تمكين إعادة الترتيب\",\n      \"enableRerankTooltip\": \"تمكين إعادة ترتيب أجزاء النص المسترجعة. إذا كان True ولكن لم يتم تكوين نموذج إعادة الترتيب، فسيتم إصدار تحذير. افتراضي True.\"\n    }\n  },\n  \"apiSite\": {\n    \"loading\": \"جارٍ تحميل وثائق واجهة برمجة التطبيقات...\"\n  },\n  \"apiKeyAlert\": {\n    \"title\": \"مفتاح واجهة برمجة التطبيقات مطلوب\",\n    \"description\": \"الرجاء إدخال مفتاح واجهة برمجة التطبيقات للوصول إلى الخدمة\",\n    \"placeholder\": \"أدخل مفتاح واجهة برمجة التطبيقات\",\n    \"save\": \"حفظ\"\n  },\n  \"pagination\": {\n    \"showing\": \"عرض {{start}} إلى {{end}} من أصل {{total}} إدخالات\",\n    \"page\": \"الصفحة\",\n    \"pageSize\": \"حجم الصفحة\",\n    \"firstPage\": \"الصفحة الأولى\",\n    \"prevPage\": \"الصفحة السابقة\",\n    \"nextPage\": \"الصفحة التالية\",\n    \"lastPage\": \"الصفحة الأخيرة\"\n  }\n}\n"
  },
  {
    "path": "lightrag_webui/src/locales/de.json",
    "content": "{\n  \"settings\": {\n    \"language\": \"Sprache\",\n    \"theme\": \"Design\",\n    \"light\": \"Hell\",\n    \"dark\": \"Dunkel\",\n    \"system\": \"System\"\n  },\n  \"header\": {\n    \"documents\": \"Dokumente\",\n    \"knowledgeGraph\": \"Wissensgraph\",\n    \"retrieval\": \"Abruf\",\n    \"api\": \"API\",\n    \"projectRepository\": \"Projekt-Repository\",\n    \"logout\": \"Abmelden\",\n    \"frontendNeedsRebuild\": \"Frontend muss neu erstellt werden\",\n    \"themeToggle\": {\n      \"switchToLight\": \"Zu hellem Design wechseln\",\n      \"switchToDark\": \"Zu dunklem Design wechseln\"\n    }\n  },\n  \"login\": {\n    \"description\": \"Bitte geben Sie Ihr Konto und Passwort ein, um sich im System anzumelden\",\n    \"username\": \"Benutzername\",\n    \"usernamePlaceholder\": \"Bitte geben Sie einen Benutzernamen ein\",\n    \"password\": \"Passwort\",\n    \"passwordPlaceholder\": \"Bitte geben Sie ein Passwort ein\",\n    \"loginButton\": \"Anmelden\",\n    \"loggingIn\": \"Anmeldung läuft...\",\n    \"successMessage\": \"Anmeldung erfolgreich\",\n    \"errorEmptyFields\": \"Bitte geben Sie Ihren Benutzernamen und Ihr Passwort ein\",\n    \"errorInvalidCredentials\": \"Anmeldung fehlgeschlagen, bitte überprüfen Sie Benutzername und Passwort\",\n    \"authDisabled\": \"Authentifizierung ist deaktiviert. Verwenden des anmeldefreien Modus.\",\n    \"guestMode\": \"Anmeldefrei\"\n  },\n  \"common\": {\n    \"cancel\": \"Abbrechen\",\n    \"save\": \"Speichern\",\n    \"saving\": \"Speichern...\",\n    \"saveFailed\": \"Speichern fehlgeschlagen\"\n  },\n  \"documentPanel\": {\n    \"clearDocuments\": {\n      \"button\": \"Löschen\",\n      \"tooltip\": \"Dokumente löschen\",\n      \"title\": \"Dokumente löschen\",\n      \"description\": \"Dies entfernt alle Dokumente aus dem System\",\n      \"warning\": \"WARNUNG: Diese Aktion löscht alle Dokumente dauerhaft und kann nicht rückgängig gemacht werden!\",\n      \"confirm\": \"Möchten Sie wirklich alle Dokumente löschen?\",\n      \"confirmPrompt\": \"Geben Sie 'yes' ein, um diese Aktion zu bestätigen\",\n      \"confirmPlaceholder\": \"Geben Sie yes ein, um zu bestätigen\",\n      \"clearCache\": \"LLM-Cache löschen\",\n      \"confirmButton\": \"JA\",\n      \"clearing\": \"Löschen...\",\n      \"timeout\": \"Löschvorgang hat das Zeitlimit überschritten, bitte versuchen Sie es erneut\",\n      \"success\": \"Dokumente erfolgreich gelöscht\",\n      \"cacheCleared\": \"Cache erfolgreich gelöscht\",\n      \"cacheClearFailed\": \"Cache konnte nicht gelöscht werden:\\n{{error}}\",\n      \"failed\": \"Dokumente löschen fehlgeschlagen:\\n{{message}}\",\n      \"error\": \"Dokumente löschen fehlgeschlagen:\\n{{error}}\"\n    },\n    \"deleteDocuments\": {\n      \"button\": \"Löschen\",\n      \"tooltip\": \"Ausgewählte Dokumente löschen\",\n      \"title\": \"Dokumente löschen\",\n      \"description\": \"Dies löscht die ausgewählten Dokumente dauerhaft aus dem System\",\n      \"warning\": \"WARNUNG: Diese Aktion löscht die ausgewählten Dokumente dauerhaft und kann nicht rückgängig gemacht werden!\",\n      \"confirm\": \"Möchten Sie wirklich {{count}} ausgewählte(s) Dokument(e) löschen?\",\n      \"confirmPrompt\": \"Geben Sie 'yes' ein, um diese Aktion zu bestätigen\",\n      \"confirmPlaceholder\": \"Geben Sie yes ein, um zu bestätigen\",\n      \"confirmButton\": \"JA\",\n      \"deleteFileOption\": \"Auch hochgeladene Dateien löschen\",\n      \"deleteFileTooltip\": \"Aktivieren Sie diese Option, um auch die entsprechenden hochgeladenen Dateien auf dem Server zu löschen\",\n      \"deleteLLMCacheOption\": \"Auch extrahierten LLM-Cache löschen\",\n      \"success\": \"Dokumentlösch-Pipeline erfolgreich gestartet\",\n      \"failed\": \"Dokumente löschen fehlgeschlagen:\\n{{message}}\",\n      \"error\": \"Dokumente löschen fehlgeschlagen:\\n{{error}}\",\n      \"busy\": \"Pipeline ist beschäftigt, bitte versuchen Sie es später erneut\",\n      \"notAllowed\": \"Keine Berechtigung, diese Operation auszuführen\"\n    },\n    \"selectDocuments\": {\n      \"selectCurrentPage\": \"Aktuelle Seite auswählen ({{count}})\",\n      \"deselectAll\": \"Alle Auswahl aufheben ({{count}})\"\n    },\n    \"uploadDocuments\": {\n      \"button\": \"Hochladen\",\n      \"tooltip\": \"Dokumente hochladen\",\n      \"title\": \"Dokumente hochladen\",\n      \"description\": \"Ziehen Sie Ihre Dokumente hierher oder klicken Sie zum Durchsuchen.\",\n      \"single\": {\n        \"uploading\": \"Hochladen {{name}}: {{percent}}%\",\n        \"success\": \"Upload erfolgreich:\\n{{name}} erfolgreich hochgeladen\",\n        \"failed\": \"Upload fehlgeschlagen:\\n{{name}}\\n{{message}}\",\n        \"error\": \"Upload fehlgeschlagen:\\n{{name}}\\n{{error}}\"\n      },\n      \"batch\": {\n        \"uploading\": \"Dateien werden hochgeladen...\",\n        \"success\": \"Dateien erfolgreich hochgeladen\",\n        \"error\": \"Einige Dateien konnten nicht hochgeladen werden\"\n      },\n      \"generalError\": \"Upload fehlgeschlagen\\n{{error}}\",\n      \"fileTypes\": \"Unterstützte Typen: TXT, MD, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, H, CPP, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS\",\n      \"fileUploader\": {\n        \"singleFileLimit\": \"Es kann nicht mehr als 1 Datei gleichzeitig hochgeladen werden\",\n        \"maxFilesLimit\": \"Es können nicht mehr als {{count}} Dateien hochgeladen werden\",\n        \"fileRejected\": \"Datei {{name}} wurde abgelehnt\",\n        \"unsupportedType\": \"Nicht unterstützter Dateityp\",\n        \"fileTooLarge\": \"Datei zu groß, maximale Größe ist {{maxSize}}\",\n        \"dropHere\": \"Dateien hier ablegen\",\n        \"dragAndDrop\": \"Dateien hierher ziehen oder klicken, um Dateien auszuwählen\",\n        \"removeFile\": \"Datei entfernen\",\n        \"uploadDescription\": \"Sie können {{isMultiple ? 'mehrere' : count}} Dateien hochladen (bis zu {{maxSize}} pro Datei)\",\n        \"duplicateFile\": \"Dateiname existiert bereits im Server-Cache\"\n      }\n    },\n    \"documentManager\": {\n      \"title\": \"Dokumentenverwaltung\",\n      \"scanButton\": \"Scannen/Wiederholen\",\n      \"scanTooltip\": \"Dokumente im Eingabeordner scannen und verarbeiten sowie alle fehlgeschlagenen Dokumente erneut verarbeiten\",\n      \"refreshTooltip\": \"Dokumentenliste zurücksetzen\",\n      \"pipelineStatusButton\": \"Pipeline\",\n      \"pipelineStatusTooltip\": \"Status der Dokumentverarbeitungs-Pipeline anzeigen\",\n      \"uploadedTitle\": \"Hochgeladene Dokumente\",\n      \"uploadedDescription\": \"Liste der hochgeladenen Dokumente und ihrer Status.\",\n      \"emptyTitle\": \"Keine Dokumente\",\n      \"emptyDescription\": \"Es wurden noch keine Dokumente hochgeladen.\",\n      \"columns\": {\n        \"id\": \"ID\",\n        \"fileName\": \"Dateiname\",\n        \"summary\": \"Zusammenfassung\",\n        \"status\": \"Status\",\n        \"length\": \"Länge\",\n        \"chunks\": \"Chunks\",\n        \"created\": \"Erstellt\",\n        \"updated\": \"Aktualisiert\",\n        \"metadata\": \"Metadaten\",\n        \"select\": \"Auswählen\"\n      },\n      \"status\": {\n        \"all\": \"Alle\",\n        \"completed\": \"Abgeschlossen\",\n        \"preprocessed\": \"Vorverarbeitet\",\n        \"processing\": \"Verarbeitung\",\n        \"pending\": \"Ausstehend\",\n        \"failed\": \"Fehlgeschlagen\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"Dokumente konnten nicht geladen werden\\n{{error}}\",\n        \"scanFailed\": \"Dokumente konnten nicht gescannt werden\\n{{error}}\",\n        \"scanProgressFailed\": \"Scan-Fortschritt konnte nicht abgerufen werden\\n{{error}}\"\n      },\n      \"fileNameLabel\": \"Dateiname\",\n      \"showButton\": \"Anzeigen\",\n      \"hideButton\": \"Ausblenden\",\n      \"showFileNameTooltip\": \"Dateiname anzeigen\",\n      \"hideFileNameTooltip\": \"Dateiname ausblenden\"\n    },\n    \"pipelineStatus\": {\n      \"title\": \"Pipeline-Status\",\n      \"busy\": \"Pipeline beschäftigt\",\n      \"requestPending\": \"Anfrage ausstehend\",\n      \"cancellationRequested\": \"Stornierung angefordert\",\n      \"jobName\": \"Auftragsname\",\n      \"startTime\": \"Startzeit\",\n      \"progress\": \"Fortschritt\",\n      \"unit\": \"Batch\",\n      \"pipelineMessages\": \"Pipeline-Nachrichten\",\n      \"cancelButton\": \"Abbrechen\",\n      \"cancelTooltip\": \"Pipeline-Verarbeitung abbrechen\",\n      \"cancelConfirmTitle\": \"Pipeline-Stornierung bestätigen\",\n      \"cancelConfirmDescription\": \"Dies unterbricht die laufende Pipeline-Verarbeitung. Möchten Sie wirklich fortfahren?\",\n      \"cancelConfirmButton\": \"Stornierung bestätigen\",\n      \"cancelInProgress\": \"Stornierung läuft...\",\n      \"pipelineNotRunning\": \"Pipeline läuft nicht\",\n      \"cancelSuccess\": \"Pipeline-Stornierung angefordert\",\n      \"cancelFailed\": \"Pipeline konnte nicht abgebrochen werden\\n{{error}}\",\n      \"cancelNotBusy\": \"Pipeline läuft nicht, keine Stornierung erforderlich\",\n      \"errors\": {\n        \"fetchFailed\": \"Pipeline-Status konnte nicht abgerufen werden\\n{{error}}\"\n      }\n    }\n  },\n  \"graphPanel\": {\n    \"dataIsTruncated\": \"Graphdaten wurden auf maximale Knotenanzahl gekürzt\",\n    \"statusDialog\": {\n      \"title\": \"LightRAG Server-Einstellungen\",\n      \"description\": \"Aktuellen Systemstatus und Verbindungsinformationen anzeigen\"\n    },\n    \"legend\": \"Legende\",\n    \"nodeTypes\": {\n      \"person\": \"Person\",\n      \"category\": \"Kategorie\",\n      \"geo\": \"Geografisch\",\n      \"location\": \"Ort\",\n      \"organization\": \"Organisation\",\n      \"event\": \"Ereignis\",\n      \"equipment\": \"Ausrüstung\",\n      \"weapon\": \"Waffe\",\n      \"animal\": \"Tier\",\n      \"unknown\": \"Unbekannt\",\n      \"object\": \"Objekt\",\n      \"group\": \"Gruppe\",\n      \"technology\": \"Technologie\",\n      \"product\": \"Produkt\",\n      \"document\": \"Dokument\",\n      \"content\": \"Inhalt\",\n      \"data\": \"Daten\",\n      \"artifact\": \"Artefakt\",\n      \"concept\": \"Konzept\",\n      \"naturalobject\": \"Natürliches Objekt\",\n      \"method\": \"Methode\",\n      \"creature\": \"Kreatur\",\n      \"plant\": \"Pflanze\",\n      \"disease\": \"Krankheit\",\n      \"drug\": \"Medikament\",\n      \"food\": \"Lebensmittel\",\n      \"other\": \"Sonstiges\"\n    },\n    \"sideBar\": {\n      \"settings\": {\n        \"settings\": \"Einstellungen\",\n        \"healthCheck\": \"Gesundheitsprüfung\",\n        \"showPropertyPanel\": \"Eigenschaften-Panel anzeigen\",\n        \"showSearchBar\": \"Suchleiste anzeigen\",\n        \"showNodeLabel\": \"Knotenbezeichnung anzeigen\",\n        \"nodeDraggable\": \"Knoten verschiebbar\",\n        \"showEdgeLabel\": \"Kantenbezeichnung anzeigen\",\n        \"hideUnselectedEdges\": \"Nicht ausgewählte Kanten ausblenden\",\n        \"edgeEvents\": \"Kanten-Ereignisse\",\n        \"maxQueryDepth\": \"Maximale Abfragetiefe\",\n        \"maxNodes\": \"Maximale Knotenanzahl\",\n        \"maxLayoutIterations\": \"Maximale Layout-Iterationen\",\n        \"resetToDefault\": \"Auf Standard zurücksetzen\",\n        \"edgeSizeRange\": \"Kantengrößenbereich\",\n        \"depth\": \"T\",\n        \"max\": \"Max\",\n        \"degree\": \"Grad\",\n        \"apiKey\": \"API-Schlüssel\",\n        \"enterYourAPIkey\": \"Geben Sie Ihren API-Schlüssel ein\",\n        \"save\": \"Speichern\",\n        \"refreshLayout\": \"Layout aktualisieren\"\n      },\n      \"zoomControl\": {\n        \"zoomIn\": \"Vergrößern\",\n        \"zoomOut\": \"Verkleinern\",\n        \"resetZoom\": \"Zoom zurücksetzen\",\n        \"rotateCamera\": \"Im Uhrzeigersinn drehen\",\n        \"rotateCameraCounterClockwise\": \"Gegen den Uhrzeigersinn drehen\"\n      },\n      \"layoutsControl\": {\n        \"startAnimation\": \"Layout-Animation fortsetzen\",\n        \"stopAnimation\": \"Layout-Animation stoppen\",\n        \"layoutGraph\": \"Graph layouten\",\n        \"layouts\": {\n          \"Circular\": \"Kreisförmig\",\n          \"Circlepack\": \"Kreis-Packung\",\n          \"Random\": \"Zufällig\",\n          \"Noverlaps\": \"Keine Überlappungen\",\n          \"Force Directed\": \"Kraftgerichtet\",\n          \"Force Atlas\": \"Force Atlas\"\n        }\n      },\n      \"fullScreenControl\": {\n        \"fullScreen\": \"Vollbild\",\n        \"windowed\": \"Fenstermodus\"\n      },\n      \"legendControl\": {\n        \"toggleLegend\": \"Legende umschalten\"\n      }\n    },\n    \"statusIndicator\": {\n      \"connected\": \"Verbunden\",\n      \"disconnected\": \"Getrennt\"\n    },\n    \"statusCard\": {\n      \"unavailable\": \"Statusinformationen nicht verfügbar\",\n      \"serverInfo\": \"Server-Informationen\",\n      \"workingDirectory\": \"Arbeitsverzeichnis\",\n      \"inputDirectory\": \"Eingabeverzeichnis\",\n      \"maxParallelInsert\": \"Gleichzeitige Dokumentenverarbeitung\",\n      \"summarySettings\": \"Zusammenfassungseinstellungen\",\n      \"llmConfig\": \"LLM-Konfiguration\",\n      \"llmBinding\": \"LLM-Bindung\",\n      \"llmBindingHost\": \"LLM-Endpunkt\",\n      \"llmModel\": \"LLM-Modell\",\n      \"embeddingConfig\": \"Einbettungskonfiguration\",\n      \"embeddingBinding\": \"Einbettungsbindung\",\n      \"embeddingBindingHost\": \"Einbettungs-Endpunkt\",\n      \"embeddingModel\": \"Einbettungsmodell\",\n      \"storageConfig\": \"Speicherkonfiguration\",\n      \"kvStorage\": \"KV-Speicher\",\n      \"docStatusStorage\": \"Dokumentstatus-Speicher\",\n      \"graphStorage\": \"Graph-Speicher\",\n      \"vectorStorage\": \"Vektor-Speicher\",\n      \"workspace\": \"Arbeitsbereich\",\n      \"maxGraphNodes\": \"Maximale Graph-Knotenanzahl\",\n      \"rerankerConfig\": \"Reranker-Konfiguration\",\n      \"rerankerBindingHost\": \"Reranker-Endpunkt\",\n      \"rerankerModel\": \"Reranker-Modell\",\n      \"lockStatus\": \"Sperrstatus\",\n      \"threshold\": \"Schwellenwert\"\n    },\n    \"propertiesView\": {\n      \"editProperty\": \"{{property}} bearbeiten\",\n      \"editPropertyDescription\": \"Bearbeiten Sie den Eigenschaftswert im Textbereich unten.\",\n      \"errors\": {\n        \"duplicateName\": \"Knotenname existiert bereits\",\n        \"updateFailed\": \"Knoten konnte nicht aktualisiert werden\",\n        \"tryAgainLater\": \"Bitte versuchen Sie es später erneut\",\n        \"updateSuccessButMergeFailed\": \"Eigenschaften aktualisiert, aber Zusammenführung fehlgeschlagen: {{error}}\",\n        \"mergeFailed\": \"Zusammenführung fehlgeschlagen: {{error}}\"\n      },\n      \"success\": {\n        \"entityUpdated\": \"Knoten erfolgreich aktualisiert\",\n        \"relationUpdated\": \"Beziehung erfolgreich aktualisiert\",\n        \"entityMerged\": \"Knoten erfolgreich zusammengeführt\"\n      },\n      \"mergeOptionLabel\": \"Automatisch zusammenführen, wenn ein doppelter Name gefunden wird\",\n      \"mergeOptionDescription\": \"Wenn aktiviert, wird beim Umbenennen in einen bestehenden Namen dieser Knoten in den bestehenden zusammengeführt, anstatt zu scheitern.\",\n      \"mergeDialog\": {\n        \"title\": \"Knoten zusammengeführt\",\n        \"description\": \"\\\"{{source}}\\\" wurde in \\\"{{target}}\\\" zusammengeführt.\",\n        \"refreshHint\": \"Aktualisieren Sie den Graph, um die neueste Struktur zu laden.\",\n        \"keepCurrentStart\": \"Aktualisieren und aktuellen Startknoten beibehalten\",\n        \"useMergedStart\": \"Aktualisieren und zusammengeführten Knoten verwenden\",\n        \"refreshing\": \"Graph wird aktualisiert...\"\n      },\n      \"node\": {\n        \"title\": \"Knoten\",\n        \"id\": \"ID\",\n        \"labels\": \"Bezeichnungen\",\n        \"degree\": \"Grad\",\n        \"properties\": \"Eigenschaften\",\n        \"relationships\": \"Beziehungen (innerhalb des Teilgraphen)\",\n        \"expandNode\": \"Knoten erweitern\",\n        \"pruneNode\": \"Knoten beschneiden\",\n        \"deleteAllNodesError\": \"Löschen aller Knoten im Graph verweigert\",\n        \"nodesRemoved\": \"{{count}} Knoten entfernt, einschließlich verwaister Knoten\",\n        \"noNewNodes\": \"Keine erweiterbaren Knoten gefunden\",\n        \"propertyNames\": {\n          \"description\": \"Beschreibung\",\n          \"entity_id\": \"Name\",\n          \"entity_type\": \"Typ\",\n          \"source_id\": \"C-ID\",\n          \"Neighbour\": \"Nachbar\",\n          \"file_path\": \"Datei\",\n          \"keywords\": \"Schlüssel\",\n          \"weight\": \"Gewicht\"\n        }\n      },\n      \"edge\": {\n        \"title\": \"Beziehung\",\n        \"id\": \"ID\",\n        \"type\": \"Typ\",\n        \"source\": \"Quelle\",\n        \"target\": \"Ziel\",\n        \"properties\": \"Eigenschaften\"\n      }\n    },\n    \"search\": {\n      \"placeholder\": \"Knoten auf der Seite suchen...\",\n      \"message\": \"Und {{count}} weitere\"\n    },\n    \"graphLabels\": {\n      \"selectTooltip\": \"Teilgraph eines Knotens (Bezeichnung) abrufen\",\n      \"noLabels\": \"Keine passenden Knoten gefunden\",\n      \"label\": \"Knotenname suchen\",\n      \"placeholder\": \"Knotenname suchen...\",\n      \"andOthers\": \"Und {{count}} weitere\",\n      \"refreshGlobalTooltip\": \"Globale Graphdaten aktualisieren und Suchverlauf zurücksetzen\",\n      \"refreshCurrentLabelTooltip\": \"Graphdaten der aktuellen Seite aktualisieren\",\n      \"refreshingTooltip\": \"Daten werden aktualisiert...\"\n    },\n    \"emptyGraph\": \"Leer (Bitte erneut laden)\"\n  },\n  \"retrievePanel\": {\n    \"chatMessage\": {\n      \"copyTooltip\": \"In Zwischenablage kopieren\",\n      \"copyError\": \"Text konnte nicht in die Zwischenablage kopiert werden\",\n      \"copyEmpty\": \"Kein Inhalt zum Kopieren\",\n      \"copySuccess\": \"Inhalt in Zwischenablage kopiert\",\n      \"copySuccessLegacy\": \"Inhalt kopiert (Legacy-Methode)\",\n      \"copySuccessManual\": \"Inhalt kopiert (manuelle Methode)\",\n      \"copyFailed\": \"Inhalt konnte nicht kopiert werden\",\n      \"copyManualInstruction\": \"Bitte wählen Sie den Text manuell aus und kopieren Sie ihn\",\n      \"thinking\": \"Denken...\",\n      \"thinkingTime\": \"Denkzeit {{time}}s\",\n      \"thinkingInProgress\": \"Denken läuft...\"\n    },\n    \"retrieval\": {\n      \"startPrompt\": \"Starten Sie eine Abfrage, indem Sie Ihre Frage unten eingeben\",\n      \"clear\": \"Löschen\",\n      \"send\": \"Senden\",\n      \"placeholder\": \"Geben Sie Ihre Abfrage ein (Präfix unterstützt: /<Abfragemodus>)\",\n      \"error\": \"Fehler: Antwort konnte nicht abgerufen werden\",\n      \"queryModeError\": \"Nur die folgenden Abfragemodi werden unterstützt: {{modes}}\",\n      \"queryModePrefixInvalid\": \"Ungültiges Abfragemodus-Präfix. Verwenden Sie: /<mode> [Leerzeichen] Ihre Abfrage\"\n    },\n    \"querySettings\": {\n      \"parametersTitle\": \"Parameter\",\n      \"parametersDescription\": \"Konfigurieren Sie Ihre Abfrageparameter\",\n      \"queryMode\": \"Abfragemodus\",\n      \"queryModeTooltip\": \"Abfragestrategie auswählen:\\n• Naive: Traditionelle Text-Chunk-Vektorabfrage\\n• Local: Fokus auf Entitätsabfrage\\n• Global: Fokus auf Beziehungsabfrage\\n• Hybrid: Local+Global\\n• Mix: Local+Global+Naive\\n• Bypass: Abfrage überspringen, Konversationsverlauf und aktuelle Frage an LLM senden\",\n      \"queryModeOptions\": {\n        \"naive\": \"Naive\",\n        \"local\": \"Local\",\n        \"global\": \"Global\",\n        \"hybrid\": \"Hybrid\",\n        \"mix\": \"Mix\",\n        \"bypass\": \"Bypass\"\n      },\n      \"responseFormat\": \"Antwortformat\",\n      \"responseFormatTooltip\": \"Definiert das Antwortformat. Beispiele:\\n• Mehrere Absätze\\n• Einzelner Absatz\\n• Aufzählungspunkte\",\n      \"responseFormatOptions\": {\n        \"multipleParagraphs\": \"Mehrere Absätze\",\n        \"singleParagraph\": \"Einzelner Absatz\",\n        \"bulletPoints\": \"Aufzählungspunkte\"\n      },\n      \"topK\": \"KG Top K\",\n      \"topKTooltip\": \"Anzahl der abzurufenden Entitäten und Beziehungen. Gilt für Nicht-Naive-Modi.\",\n      \"topKPlaceholder\": \"top_k-Wert eingeben\",\n      \"chunkTopK\": \"Chunk Top K\",\n      \"chunkTopKTooltip\": \"Anzahl der abzurufenden Text-Chunks, gilt für alle Modi.\",\n      \"chunkTopKPlaceholder\": \"chunk_top_k-Wert eingeben\",\n      \"maxEntityTokens\": \"Max. Entitäts-Tokens\",\n      \"maxEntityTokensTooltip\": \"Maximale Anzahl von Tokens, die für den Entitätskontext im einheitlichen Token-Kontrollsystem zugewiesen werden\",\n      \"maxRelationTokens\": \"Max. Beziehungs-Tokens\",\n      \"maxRelationTokensTooltip\": \"Maximale Anzahl von Tokens, die für den Beziehungskontext im einheitlichen Token-Kontrollsystem zugewiesen werden\",\n      \"maxTotalTokens\": \"Max. Gesamt-Tokens\",\n      \"maxTotalTokensTooltip\": \"Maximales Gesamt-Token-Budget für den gesamten Abfragekontext (Entitäten + Beziehungen + Chunks + System-Prompt)\",\n      \"historyTurns\": \"Verlaufsturns\",\n      \"historyTurnsTooltip\": \"Anzahl der vollständigen Konversationsturns (Benutzer-Assistenten-Paare), die im Antwortkontext berücksichtigt werden sollen\",\n      \"historyTurnsPlaceholder\": \"Anzahl der Verlaufsturns\",\n      \"onlyNeedContext\": \"Nur Kontext benötigt\",\n      \"onlyNeedContextTooltip\": \"Wenn True, wird nur der abgerufene Kontext ohne Generierung einer Antwort zurückgegeben\",\n      \"onlyNeedPrompt\": \"Nur Prompt benötigt\",\n      \"onlyNeedPromptTooltip\": \"Wenn True, wird nur der generierte Prompt ohne Erzeugung einer Antwort zurückgegeben\",\n      \"streamResponse\": \"Stream-Antwort\",\n      \"streamResponseTooltip\": \"Wenn True, aktiviert Streaming-Ausgabe für Echtzeit-Antworten\",\n      \"userPrompt\": \"Zusätzlicher Ausgabe-Prompt\",\n      \"userPromptTooltip\": \"Geben Sie zusätzliche Antwortanforderungen an das LLM an (unabhängig vom Abfrageinhalt, nur für die Ausgabeverarbeitung).\",\n      \"userPromptPlaceholder\": \"Benutzerdefinierten Prompt eingeben (optional)\",\n      \"enableRerank\": \"Rerank aktivieren\",\n      \"enableRerankTooltip\": \"Reranking für abgerufene Text-Chunks aktivieren. Wenn True, aber kein Rerank-Modell konfiguriert ist, wird eine Warnung ausgegeben. Standard ist True.\"\n    }\n  },\n  \"apiSite\": {\n    \"loading\": \"API-Dokumentation wird geladen...\"\n  },\n  \"apiKeyAlert\": {\n    \"title\": \"API-Schlüssel ist erforderlich\",\n    \"description\": \"Bitte geben Sie Ihren API-Schlüssel ein, um auf den Dienst zuzugreifen\",\n    \"placeholder\": \"API-Schlüssel eingeben\",\n    \"save\": \"Speichern\"\n  },\n  \"pagination\": {\n    \"showing\": \"Zeige {{start}} bis {{end}} von {{total}} Einträgen\",\n    \"page\": \"Seite\",\n    \"pageSize\": \"Seitengröße\",\n    \"firstPage\": \"Erste Seite\",\n    \"prevPage\": \"Vorherige Seite\",\n    \"nextPage\": \"Nächste Seite\",\n    \"lastPage\": \"Letzte Seite\"\n  }\n}\n"
  },
  {
    "path": "lightrag_webui/src/locales/en.json",
    "content": "{\n  \"settings\": {\n    \"language\": \"Language\",\n    \"theme\": \"Theme\",\n    \"light\": \"Light\",\n    \"dark\": \"Dark\",\n    \"system\": \"System\"\n  },\n  \"header\": {\n    \"documents\": \"Documents\",\n    \"knowledgeGraph\": \"Knowledge Graph\",\n    \"retrieval\": \"Retrieval\",\n    \"api\": \"API\",\n    \"projectRepository\": \"Project Repository\",\n    \"logout\": \"Logout\",\n    \"frontendNeedsRebuild\": \"Frontend needs rebuild\",\n    \"themeToggle\": {\n      \"switchToLight\": \"Switch to light theme\",\n      \"switchToDark\": \"Switch to dark theme\"\n    }\n  },\n  \"login\": {\n    \"description\": \"Please enter your account and password to log in to the system\",\n    \"username\": \"Username\",\n    \"usernamePlaceholder\": \"Please input a username\",\n    \"password\": \"Password\",\n    \"passwordPlaceholder\": \"Please input a password\",\n    \"loginButton\": \"Login\",\n    \"loggingIn\": \"Logging in...\",\n    \"successMessage\": \"Login succeeded\",\n    \"errorEmptyFields\": \"Please enter your username and password\",\n    \"errorInvalidCredentials\": \"Login failed, please check username and password\",\n    \"authDisabled\": \"Authentication is disabled. Using login free mode.\",\n    \"guestMode\": \"Login Free\"\n  },\n  \"common\": {\n    \"cancel\": \"Cancel\",\n    \"save\": \"Save\",\n    \"saving\": \"Saving...\",\n    \"saveFailed\": \"Save failed\"\n  },\n  \"documentPanel\": {\n    \"clearDocuments\": {\n      \"button\": \"Clear\",\n      \"tooltip\": \"Clear documents\",\n      \"title\": \"Clear Documents\",\n      \"description\": \"This will remove all documents from the system\",\n      \"warning\": \"WARNING: This action will permanently delete all documents and cannot be undone!\",\n      \"confirm\": \"Do you really want to clear all documents?\",\n      \"confirmPrompt\": \"Type 'yes' to confirm this action\",\n      \"confirmPlaceholder\": \"Type yes to confirm\",\n      \"clearCache\": \"Clear LLM cache\",\n      \"confirmButton\": \"YES\",\n      \"clearing\": \"Clearing...\",\n      \"timeout\": \"Clear operation timed out, please try again\",\n      \"success\": \"Documents cleared successfully\",\n      \"cacheCleared\": \"Cache cleared successfully\",\n      \"cacheClearFailed\": \"Failed to clear cache:\\n{{error}}\",\n      \"failed\": \"Clear Documents Failed:\\n{{message}}\",\n      \"error\": \"Clear Documents Failed:\\n{{error}}\"\n    },\n    \"deleteDocuments\": {\n      \"button\": \"Delete\",\n      \"tooltip\": \"Delete selected documents\",\n      \"title\": \"Delete Documents\",\n      \"description\": \"This will permanently delete the selected documents from the system\",\n      \"warning\": \"WARNING: This action will permanently delete the selected documents and cannot be undone!\",\n      \"confirm\": \"Do you really want to delete {{count}} selected document(s)?\",\n      \"confirmPrompt\": \"Type 'yes' to confirm this action\",\n      \"confirmPlaceholder\": \"Type yes to confirm\",\n      \"confirmButton\": \"YES\",\n      \"deleteFileOption\": \"Also delete uploaded files\",\n      \"deleteFileTooltip\": \"Check this option to also delete the corresponding uploaded files on the server\",\n      \"deleteLLMCacheOption\": \"Also delete extracted LLM cache\",\n      \"success\": \"Document deletion pipeline started successfully\",\n      \"failed\": \"Delete Documents Failed:\\n{{message}}\",\n      \"error\": \"Delete Documents Failed:\\n{{error}}\",\n      \"busy\": \"Pipeline is busy, please try again later\",\n      \"notAllowed\": \"No permission to perform this operation\"\n    },\n    \"selectDocuments\": {\n      \"selectCurrentPage\": \"Select Current Page ({{count}})\",\n      \"deselectAll\": \"Deselect All ({{count}})\"\n    },\n    \"uploadDocuments\": {\n      \"button\": \"Upload\",\n      \"tooltip\": \"Upload documents\",\n      \"title\": \"Upload Documents\",\n      \"description\": \"Drag and drop your documents here or click to browse.\",\n      \"single\": {\n        \"uploading\": \"Uploading {{name}}: {{percent}}%\",\n        \"success\": \"Upload Success:\\n{{name}} uploaded successfully\",\n        \"failed\": \"Upload Failed:\\n{{name}}\\n{{message}}\",\n        \"error\": \"Upload Failed:\\n{{name}}\\n{{error}}\"\n      },\n      \"batch\": {\n        \"uploading\": \"Uploading files...\",\n        \"success\": \"Files uploaded successfully\",\n        \"error\": \"Some files failed to upload\"\n      },\n      \"generalError\": \"Upload Failed\\n{{error}}\",\n      \"fileTypes\": \"Supported types: TXT, MD, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, H, CPP, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS\",\n      \"fileUploader\": {\n        \"singleFileLimit\": \"Cannot upload more than 1 file at a time\",\n        \"maxFilesLimit\": \"Cannot upload more than {{count}} files\",\n        \"fileRejected\": \"File {{name}} was rejected\",\n        \"unsupportedType\": \"Unsupported file type\",\n        \"fileTooLarge\": \"File too large, maximum size is {{maxSize}}\",\n        \"dropHere\": \"Drop the files here\",\n        \"dragAndDrop\": \"Drag and drop files here, or click to select files\",\n        \"removeFile\": \"Remove file\",\n        \"uploadDescription\": \"You can upload {{isMultiple ? 'multiple' : count}} files (up to {{maxSize}} each)\",\n        \"duplicateFile\": \"File name already exists in server cache\"\n      }\n    },\n    \"documentManager\": {\n      \"title\": \"Document Management\",\n      \"scanButton\": \"Scan/Retry\",\n      \"scanTooltip\": \"Scan and process documents in input folder, and also reprocess all failed documents\",\n      \"refreshTooltip\": \"Reset document list\",\n      \"pipelineStatusButton\": \"Pipeline\",\n      \"pipelineStatusTooltip\": \"View document processing pipeline status\",\n      \"uploadedTitle\": \"Uploaded Documents\",\n      \"uploadedDescription\": \"List of uploaded documents and their statuses.\",\n      \"emptyTitle\": \"No Documents\",\n      \"emptyDescription\": \"There are no uploaded documents yet.\",\n      \"columns\": {\n        \"id\": \"ID\",\n        \"fileName\": \"File Name\",\n        \"summary\": \"Summary\",\n        \"status\": \"Status\",\n        \"length\": \"Length\",\n        \"chunks\": \"Chunks\",\n        \"created\": \"Created\",\n        \"updated\": \"Updated\",\n        \"metadata\": \"Metadata\",\n        \"select\": \"Select\"\n      },\n      \"status\": {\n        \"all\": \"All\",\n        \"completed\": \"Completed\",\n        \"preprocessed\": \"Preprocessed\",\n        \"processing\": \"Processing\",\n        \"pending\": \"Pending\",\n        \"failed\": \"Failed\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"Failed to load documents\\n{{error}}\",\n        \"scanFailed\": \"Failed to scan documents\\n{{error}}\",\n        \"scanProgressFailed\": \"Failed to get scan progress\\n{{error}}\"\n      },\n      \"fileNameLabel\": \"File Name\",\n      \"showButton\": \"Show\",\n      \"hideButton\": \"Hide\",\n      \"showFileNameTooltip\": \"Show file name\",\n      \"hideFileNameTooltip\": \"Hide file name\"\n    },\n    \"pipelineStatus\": {\n      \"title\": \"Pipeline Status\",\n      \"busy\": \"Pipeline Busy\",\n      \"requestPending\": \"Request Pending\",\n      \"cancellationRequested\": \"Cancellation Requested\",\n      \"jobName\": \"Job Name\",\n      \"startTime\": \"Start Time\",\n      \"progress\": \"Progress\",\n      \"unit\": \"Batch\",\n      \"pipelineMessages\": \"Pipeline Messages\",\n      \"cancelButton\": \"Cancel\",\n      \"cancelTooltip\": \"Cancel pipeline processing\",\n      \"cancelConfirmTitle\": \"Confirm Pipeline Cancellation\",\n      \"cancelConfirmDescription\": \"This will interrupt the ongoing pipeline processing. Are you sure you want to continue?\",\n      \"cancelConfirmButton\": \"Confirm Cancellation\",\n      \"cancelInProgress\": \"Cancellation in progress...\",\n      \"pipelineNotRunning\": \"Pipeline not running\",\n      \"cancelSuccess\": \"Pipeline cancellation requested\",\n      \"cancelFailed\": \"Failed to cancel pipeline\\n{{error}}\",\n      \"cancelNotBusy\": \"Pipeline is not running, no need to cancel\",\n      \"errors\": {\n        \"fetchFailed\": \"Failed to fetch pipeline status\\n{{error}}\"\n      }\n    }\n  },\n  \"graphPanel\": {\n    \"dataIsTruncated\": \"Graph data is truncated to Max Nodes\",\n    \"statusDialog\": {\n      \"title\": \"LightRAG Server Settings\",\n      \"description\": \"View current system status and connection information\"\n    },\n    \"legend\": \"Legend\",\n    \"nodeTypes\": {\n      \"person\": \"Person\",\n      \"category\": \"Category\",\n      \"geo\": \"Geographic\",\n      \"location\": \"Location\",\n      \"organization\": \"Organization\",\n      \"event\": \"Event\",\n      \"equipment\": \"Equipment\",\n      \"weapon\": \"Weapon\",\n      \"animal\": \"Animal\",\n      \"unknown\": \"Unknown\",\n      \"object\": \"Object\",\n      \"group\": \"Group\",\n      \"technology\": \"Technology\",\n      \"product\": \"Product\",\n      \"document\": \"Document\",\n      \"content\": \"Content\",\n      \"data\": \"Data\",\n      \"artifact\": \"Artifact\",\n      \"concept\": \"Concept\",\n      \"naturalobject\": \"Natural Object\",\n      \"method\": \"Method\",\n      \"creature\": \"Creature\",\n      \"plant\": \"Plant\",\n      \"disease\": \"Disease\",\n      \"drug\": \"Drug\",\n      \"food\": \"Food\",\n      \"other\": \"Other\"\n    },\n    \"sideBar\": {\n      \"settings\": {\n        \"settings\": \"Settings\",\n        \"healthCheck\": \"Health Check\",\n        \"showPropertyPanel\": \"Show Property Panel\",\n        \"showSearchBar\": \"Show Search Bar\",\n        \"showNodeLabel\": \"Show Node Label\",\n        \"nodeDraggable\": \"Node Draggable\",\n        \"showEdgeLabel\": \"Show Edge Label\",\n        \"hideUnselectedEdges\": \"Hide Unselected Edges\",\n        \"edgeEvents\": \"Edge Events\",\n        \"maxQueryDepth\": \"Max Query Depth\",\n        \"maxNodes\": \"Max Nodes\",\n        \"maxLayoutIterations\": \"Max Layout Iterations\",\n        \"resetToDefault\": \"Reset to default\",\n        \"edgeSizeRange\": \"Edge Size Range\",\n        \"depth\": \"D\",\n        \"max\": \"Max\",\n        \"degree\": \"Degree\",\n        \"apiKey\": \"API Key\",\n        \"enterYourAPIkey\": \"Enter your API key\",\n        \"save\": \"Save\",\n        \"refreshLayout\": \"Refresh Layout\"\n      },\n      \"zoomControl\": {\n        \"zoomIn\": \"Zoom In\",\n        \"zoomOut\": \"Zoom Out\",\n        \"resetZoom\": \"Reset Zoom\",\n        \"rotateCamera\": \"Clockwise Rotate\",\n        \"rotateCameraCounterClockwise\": \"Counter-Clockwise Rotate\"\n      },\n      \"layoutsControl\": {\n        \"startAnimation\": \"Continue layout animation\",\n        \"stopAnimation\": \"Stop layout animation\",\n        \"layoutGraph\": \"Layout Graph\",\n        \"layouts\": {\n          \"Circular\": \"Circular\",\n          \"Circlepack\": \"Circlepack\",\n          \"Random\": \"Random\",\n          \"Noverlaps\": \"Noverlaps\",\n          \"Force Directed\": \"Force Directed\",\n          \"Force Atlas\": \"Force Atlas\"\n        }\n      },\n      \"fullScreenControl\": {\n        \"fullScreen\": \"Full Screen\",\n        \"windowed\": \"Windowed\"\n      },\n      \"legendControl\": {\n        \"toggleLegend\": \"Toggle Legend\"\n      }\n    },\n    \"statusIndicator\": {\n      \"connected\": \"Connected\",\n      \"disconnected\": \"Disconnected\"\n    },\n    \"statusCard\": {\n      \"unavailable\": \"Status information unavailable\",\n      \"serverInfo\": \"Server Info\",\n      \"workingDirectory\": \"Working Directory\",\n      \"inputDirectory\": \"Input Directory\",\n      \"maxParallelInsert\": \"Concurrent Doc Processing\",\n      \"summarySettings\": \"Summary Settings\",\n      \"llmConfig\": \"LLM Configuration\",\n      \"llmBinding\": \"LLM Binding\",\n      \"llmBindingHost\": \"LLM Endpoint\",\n      \"llmModel\": \"LLM Model\",\n      \"embeddingConfig\": \"Embedding Configuration\",\n      \"embeddingBinding\": \"Embedding Binding\",\n      \"embeddingBindingHost\": \"Embedding Endpoint\",\n      \"embeddingModel\": \"Embedding Model\",\n      \"storageConfig\": \"Storage Configuration\",\n      \"kvStorage\": \"KV Storage\",\n      \"docStatusStorage\": \"Doc Status Storage\",\n      \"graphStorage\": \"Graph Storage\",\n      \"vectorStorage\": \"Vector Storage\",\n      \"workspace\": \"Workspace\",\n      \"maxGraphNodes\": \"Max Graph Nodes\",\n      \"rerankerConfig\": \"Reranker Configuration\",\n      \"rerankerBindingHost\": \"Reranker Endpoint\",\n      \"rerankerModel\": \"Reranker Model\",\n      \"lockStatus\": \"Lock Status\",\n      \"threshold\": \"Threshold\"\n    },\n    \"propertiesView\": {\n      \"editProperty\": \"Edit {{property}}\",\n      \"editPropertyDescription\": \"Edit the property value in the text area below.\",\n      \"errors\": {\n        \"duplicateName\": \"Node name already exists\",\n        \"updateFailed\": \"Failed to update node\",\n        \"tryAgainLater\": \"Please try again later\",\n        \"updateSuccessButMergeFailed\": \"Properties updated, but merge failed: {{error}}\",\n        \"mergeFailed\": \"Merge failed: {{error}}\"\n      },\n      \"success\": {\n        \"entityUpdated\": \"Node updated successfully\",\n        \"relationUpdated\": \"Relation updated successfully\",\n        \"entityMerged\": \"Nodes merged successfully\"\n      },\n      \"mergeOptionLabel\": \"Automatically merge when a duplicate name is found\",\n      \"mergeOptionDescription\": \"If enabled, renaming to an existing name will merge this node into the existing one instead of failing.\",\n      \"mergeDialog\": {\n        \"title\": \"Node merged\",\n        \"description\": \"\\\"{{source}}\\\" has been merged into \\\"{{target}}\\\".\",\n        \"refreshHint\": \"Refresh the graph to load the latest structure.\",\n        \"keepCurrentStart\": \"Refresh and keep current start node\",\n        \"useMergedStart\": \"Refresh and use merged node\",\n        \"refreshing\": \"Refreshing graph...\"\n      },\n      \"node\": {\n        \"title\": \"Node\",\n        \"id\": \"ID\",\n        \"labels\": \"Labels\",\n        \"degree\": \"Degree\",\n        \"properties\": \"Properties\",\n        \"relationships\": \"Relations(within subgraph)\",\n        \"expandNode\": \"Expand Node\",\n        \"pruneNode\": \"Prune Node\",\n        \"deleteAllNodesError\": \"Refuse to delete all nodes in the graph\",\n        \"nodesRemoved\": \"{{count}} nodes removed, including orphan nodes\",\n        \"noNewNodes\": \"No expandable nodes found\",\n        \"propertyNames\": {\n          \"description\": \"Description\",\n          \"entity_id\": \"Name\",\n          \"entity_type\": \"Type\",\n          \"source_id\": \"C-ID\",\n          \"Neighbour\": \"Neigh\",\n          \"file_path\": \"File\",\n          \"keywords\": \"Keys\",\n          \"weight\": \"Weight\"\n        }\n      },\n      \"edge\": {\n        \"title\": \"Relationship\",\n        \"id\": \"ID\",\n        \"type\": \"Type\",\n        \"source\": \"Source\",\n        \"target\": \"Target\",\n        \"properties\": \"Properties\"\n      }\n    },\n    \"search\": {\n      \"placeholder\": \"Search nodes in page...\",\n      \"message\": \"And {{count}} others\"\n    },\n    \"graphLabels\": {\n      \"selectTooltip\": \"Get subgraph of a node (label)\",\n      \"noLabels\": \"No matching nodes found\",\n      \"label\": \"Search node name\",\n      \"placeholder\": \"Search node name...\",\n      \"andOthers\": \"And {{count}} others\",\n      \"refreshGlobalTooltip\": \"Refresh global graph data and reset search history\",\n      \"refreshCurrentLabelTooltip\": \"Refresh current page graph data\",\n      \"refreshingTooltip\": \"Refreshing data...\"\n    },\n    \"emptyGraph\": \"Empty(Try Reload Again)\"\n  },\n  \"retrievePanel\": {\n    \"chatMessage\": {\n      \"copyTooltip\": \"Copy to clipboard\",\n      \"copyError\": \"Failed to copy text to clipboard\",\n      \"copyEmpty\": \"No content to copy\",\n      \"copySuccess\": \"Content copied to clipboard\",\n      \"copySuccessLegacy\": \"Content copied (legacy method)\",\n      \"copySuccessManual\": \"Content copied (manual method)\",\n      \"copyFailed\": \"Failed to copy content\",\n      \"copyManualInstruction\": \"Please select and copy the text manually\",\n      \"thinking\": \"Thinking...\",\n      \"thinkingTime\": \"Thinking time {{time}}s\",\n      \"thinkingInProgress\": \"Thinking in progress...\"\n    },\n    \"retrieval\": {\n      \"startPrompt\": \"Start a retrieval by typing your query below\",\n      \"clear\": \"Clear\",\n      \"send\": \"Send\",\n      \"placeholder\": \"Enter your query (Support prefix: /<Query Mode>)\",\n      \"error\": \"Error: Failed to get response\",\n      \"queryModeError\": \"Only supports the following query modes: {{modes}}\",\n      \"queryModePrefixInvalid\": \"Invalid query mode prefix. Use: /<mode> [space] your query\"\n    },\n    \"querySettings\": {\n      \"parametersTitle\": \"Parameters\",\n      \"parametersDescription\": \"Configure your query parameters\",\n      \"queryMode\": \"Query Mode\",\n      \"queryModeTooltip\": \"Select the retrieval strategy:\\n• Naive: Traditional text chunk vector retrieval\\n• Local: Focus on entity retrieval\\n• Global: Focus on relationship retrieval\\n• Hybrid: Local+Global\\n• Mix: Local+Global+Naive\\n• Bypass: Skip retrieval, send conversation history and current question to LLM\",\n      \"queryModeOptions\": {\n        \"naive\": \"Naive\",\n        \"local\": \"Local\",\n        \"global\": \"Global\",\n        \"hybrid\": \"Hybrid\",\n        \"mix\": \"Mix\",\n        \"bypass\": \"Bypass\"\n      },\n      \"responseFormat\": \"Response Format\",\n      \"responseFormatTooltip\": \"Defines the response format. Examples:\\n• Multiple Paragraphs\\n• Single Paragraph\\n• Bullet Points\",\n      \"responseFormatOptions\": {\n        \"multipleParagraphs\": \"Multiple Paragraphs\",\n        \"singleParagraph\": \"Single Paragraph\",\n        \"bulletPoints\": \"Bullet Points\"\n      },\n      \"topK\": \"KG Top K\",\n      \"topKTooltip\": \"Number of entities and relations to retrieve. Applicable for non-naive modes.\",\n      \"topKPlaceholder\": \"Enter top_k value\",\n      \"chunkTopK\": \"Chunk Top K\",\n      \"chunkTopKTooltip\": \"Number of text chunks to retrieve, applicable for all modes.\",\n      \"chunkTopKPlaceholder\": \"Enter chunk_top_k value\",\n      \"maxEntityTokens\": \"Max Entity Tokens\",\n      \"maxEntityTokensTooltip\": \"Maximum number of tokens allocated for entity context in unified token control system\",\n      \"maxRelationTokens\": \"Max Relation Tokens\",\n      \"maxRelationTokensTooltip\": \"Maximum number of tokens allocated for relationship context in unified token control system\",\n      \"maxTotalTokens\": \"Max Total Tokens\",\n      \"maxTotalTokensTooltip\": \"Maximum total tokens budget for the entire query context (entities + relations + chunks + system prompt)\",\n      \"historyTurns\": \"History Turns\",\n      \"historyTurnsTooltip\": \"Number of complete conversation turns (user-assistant pairs) to consider in the response context\",\n      \"historyTurnsPlaceholder\": \"Number of history turns\",\n      \"onlyNeedContext\": \"Only Need Context\",\n      \"onlyNeedContextTooltip\": \"If True, only returns the retrieved context without generating a response\",\n      \"onlyNeedPrompt\": \"Only Need Prompt\",\n      \"onlyNeedPromptTooltip\": \"If True, only returns the generated prompt without producing a response\",\n      \"streamResponse\": \"Stream Response\",\n      \"streamResponseTooltip\": \"If True, enables streaming output for real-time responses\",\n      \"userPrompt\": \"Additional Output Prompt\",\n      \"userPromptTooltip\": \"Provide additional response requirements to the LLM (unrelated to query content, only for output processing).\",\n      \"userPromptPlaceholder\": \"Enter custom prompt (optional)\",\n      \"enableRerank\": \"Enable Rerank\",\n      \"enableRerankTooltip\": \"Enable reranking for retrieved text chunks. If True but no rerank model is configured, a warning will be issued. Default is True.\"\n    }\n  },\n  \"apiSite\": {\n    \"loading\": \"Loading API Documentation...\"\n  },\n  \"apiKeyAlert\": {\n    \"title\": \"API Key is required\",\n    \"description\": \"Please enter your API key to access the service\",\n    \"placeholder\": \"Enter your API key\",\n    \"save\": \"Save\"\n  },\n  \"pagination\": {\n    \"showing\": \"Showing {{start}} to {{end}} of {{total}} entries\",\n    \"page\": \"Page\",\n    \"pageSize\": \"Page Size\",\n    \"firstPage\": \"First Page\",\n    \"prevPage\": \"Previous Page\",\n    \"nextPage\": \"Next Page\",\n    \"lastPage\": \"Last Page\"\n  }\n}\n"
  },
  {
    "path": "lightrag_webui/src/locales/fr.json",
    "content": "{\n  \"settings\": {\n    \"language\": \"Langue\",\n    \"theme\": \"Thème\",\n    \"light\": \"Clair\",\n    \"dark\": \"Sombre\",\n    \"system\": \"Système\"\n  },\n  \"header\": {\n    \"documents\": \"Documents\",\n    \"knowledgeGraph\": \"Graphe de connaissances\",\n    \"retrieval\": \"Récupération\",\n    \"api\": \"API\",\n    \"projectRepository\": \"Référentiel du projet\",\n    \"logout\": \"Déconnexion\",\n    \"frontendNeedsRebuild\": \"Le frontend nécessite une reconstruction\",\n    \"themeToggle\": {\n      \"switchToLight\": \"Passer au thème clair\",\n      \"switchToDark\": \"Passer au thème sombre\"\n    }\n  },\n  \"login\": {\n    \"description\": \"Veuillez entrer votre compte et mot de passe pour vous connecter au système\",\n    \"username\": \"Nom d'utilisateur\",\n    \"usernamePlaceholder\": \"Veuillez saisir un nom d'utilisateur\",\n    \"password\": \"Mot de passe\",\n    \"passwordPlaceholder\": \"Veuillez saisir un mot de passe\",\n    \"loginButton\": \"Connexion\",\n    \"loggingIn\": \"Connexion en cours...\",\n    \"successMessage\": \"Connexion réussie\",\n    \"errorEmptyFields\": \"Veuillez saisir votre nom d'utilisateur et mot de passe\",\n    \"errorInvalidCredentials\": \"Échec de la connexion, veuillez vérifier le nom d'utilisateur et le mot de passe\",\n    \"authDisabled\": \"L'authentification est désactivée. Utilisation du mode sans connexion.\",\n    \"guestMode\": \"Mode sans connexion\"\n  },\n  \"common\": {\n    \"cancel\": \"Annuler\",\n    \"save\": \"Sauvegarder\",\n    \"saving\": \"Sauvegarde en cours...\",\n    \"saveFailed\": \"Échec de la sauvegarde\"\n  },\n  \"documentPanel\": {\n    \"clearDocuments\": {\n      \"button\": \"Effacer\",\n      \"tooltip\": \"Effacer les documents\",\n      \"title\": \"Effacer les documents\",\n      \"description\": \"Cette action supprimera tous les documents du système\",\n      \"warning\": \"ATTENTION : Cette action supprimera définitivement tous les documents et ne peut pas être annulée !\",\n      \"confirm\": \"Voulez-vous vraiment effacer tous les documents ?\",\n      \"confirmPrompt\": \"Tapez 'yes' pour confirmer cette action\",\n      \"confirmPlaceholder\": \"Tapez yes pour confirmer\",\n      \"clearCache\": \"Effacer le cache LLM\",\n      \"confirmButton\": \"OUI\",\n      \"clearing\": \"Effacement en cours...\",\n      \"timeout\": \"L'opération d'effacement a expiré, veuillez réessayer\",\n      \"success\": \"Documents effacés avec succès\",\n      \"cacheCleared\": \"Cache effacé avec succès\",\n      \"cacheClearFailed\": \"Échec de l'effacement du cache :\\n{{error}}\",\n      \"failed\": \"Échec de l'effacement des documents :\\n{{message}}\",\n      \"error\": \"Échec de l'effacement des documents :\\n{{error}}\"\n    },\n    \"deleteDocuments\": {\n      \"button\": \"Supprimer\",\n      \"tooltip\": \"Supprimer les documents sélectionnés\",\n      \"title\": \"Supprimer les documents\",\n      \"description\": \"Cette action supprimera définitivement les documents sélectionnés du système\",\n      \"warning\": \"ATTENTION : Cette action supprimera définitivement les documents sélectionnés et ne peut pas être annulée !\",\n      \"confirm\": \"Voulez-vous vraiment supprimer {{count}} document(s) sélectionné(s) ?\",\n      \"confirmPrompt\": \"Tapez 'yes' pour confirmer cette action\",\n      \"confirmPlaceholder\": \"Tapez yes pour confirmer\",\n      \"confirmButton\": \"OUI\",\n      \"deleteFileOption\": \"Supprimer également les fichiers téléchargés\",\n      \"deleteFileTooltip\": \"Cochez cette option pour supprimer également les fichiers téléchargés correspondants sur le serveur\",\n      \"deleteLLMCacheOption\": \"Supprimer également le cache LLM d'extraction\",\n      \"success\": \"Pipeline de suppression de documents démarré avec succès\",\n      \"failed\": \"Échec de la suppression des documents :\\n{{message}}\",\n      \"error\": \"Échec de la suppression des documents :\\n{{error}}\",\n      \"busy\": \"Le pipeline est occupé, veuillez réessayer plus tard\",\n      \"notAllowed\": \"Aucune autorisation pour effectuer cette opération\"\n    },\n    \"selectDocuments\": {\n      \"selectCurrentPage\": \"Sélectionner la page actuelle ({{count}})\",\n      \"deselectAll\": \"Tout désélectionner ({{count}})\"\n    },\n    \"uploadDocuments\": {\n      \"button\": \"Télécharger\",\n      \"tooltip\": \"Télécharger des documents\",\n      \"title\": \"Télécharger des documents\",\n      \"description\": \"Glissez-déposez vos documents ici ou cliquez pour parcourir.\",\n      \"single\": {\n        \"uploading\": \"Téléchargement de {{name}} : {{percent}}%\",\n        \"success\": \"Succès du téléchargement :\\n{{name}} téléchargé avec succès\",\n        \"failed\": \"Échec du téléchargement :\\n{{name}}\\n{{message}}\",\n        \"error\": \"Échec du téléchargement :\\n{{name}}\\n{{error}}\"\n      },\n      \"batch\": {\n        \"uploading\": \"Téléchargement des fichiers...\",\n        \"success\": \"Fichiers téléchargés avec succès\",\n        \"error\": \"Certains fichiers n'ont pas pu être téléchargés\"\n      },\n      \"generalError\": \"Échec du téléchargement\\n{{error}}\",\n      \"fileTypes\": \"Types pris en charge : TXT, MD, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, H, CPP, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS\",\n      \"fileUploader\": {\n        \"singleFileLimit\": \"Impossible de télécharger plus d'un fichier à la fois\",\n        \"maxFilesLimit\": \"Impossible de télécharger plus de {{count}} fichiers\",\n        \"fileRejected\": \"Le fichier {{name}} a été rejeté\",\n        \"unsupportedType\": \"Type de fichier non pris en charge\",\n        \"fileTooLarge\": \"Fichier trop volumineux, taille maximale {{maxSize}}\",\n        \"dropHere\": \"Déposez les fichiers ici\",\n        \"dragAndDrop\": \"Glissez et déposez les fichiers ici, ou cliquez pour sélectionner\",\n        \"removeFile\": \"Supprimer le fichier\",\n        \"uploadDescription\": \"Vous pouvez télécharger {{isMultiple ? 'plusieurs' : count}} fichiers (jusqu'à {{maxSize}} chacun)\",\n        \"duplicateFile\": \"Le nom du fichier existe déjà dans le cache du serveur\"\n      }\n    },\n    \"documentManager\": {\n      \"title\": \"Gestion des documents\",\n      \"scanButton\": \"Scanner/Retraiter\",\n      \"scanTooltip\": \"Scanner et traiter les documents dans le dossier d'entrée, et retraiter également tous les documents échoués\",\n      \"refreshTooltip\": \"Réinitialiser la liste des documents\",\n      \"pipelineStatusButton\": \"Pipeline\",\n      \"pipelineStatusTooltip\": \"Voir l'état du pipeline de traitement des documents\",\n      \"uploadedTitle\": \"Documents téléchargés\",\n      \"uploadedDescription\": \"Liste des documents téléchargés et leurs statuts.\",\n      \"emptyTitle\": \"Aucun document\",\n      \"emptyDescription\": \"Il n'y a pas encore de documents téléchargés.\",\n      \"columns\": {\n        \"id\": \"ID\",\n        \"fileName\": \"Nom du fichier\",\n        \"summary\": \"Résumé\",\n        \"status\": \"Statut\",\n        \"length\": \"Longueur\",\n        \"chunks\": \"Fragments\",\n        \"created\": \"Créé\",\n        \"updated\": \"Mis à jour\",\n        \"metadata\": \"Métadonnées\",\n        \"select\": \"Sélectionner\"\n      },\n      \"status\": {\n        \"all\": \"Tous\",\n        \"completed\": \"Terminé\",\n        \"preprocessed\": \"Prétraité\",\n        \"processing\": \"En traitement\",\n        \"pending\": \"En attente\",\n        \"failed\": \"Échoué\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"Échec du chargement des documents\\n{{error}}\",\n        \"scanFailed\": \"Échec de la numérisation des documents\\n{{error}}\",\n        \"scanProgressFailed\": \"Échec de l'obtention de la progression de la numérisation\\n{{error}}\"\n      },\n      \"fileNameLabel\": \"Nom du fichier\",\n      \"showButton\": \"Afficher\",\n      \"hideButton\": \"Masquer\",\n      \"showFileNameTooltip\": \"Afficher le nom du fichier\",\n      \"hideFileNameTooltip\": \"Masquer le nom du fichier\"\n    },\n    \"pipelineStatus\": {\n      \"title\": \"État du Pipeline\",\n      \"busy\": \"Pipeline Occupé\",\n      \"requestPending\": \"Demande en Attente\",\n      \"cancellationRequested\": \"Annulation Demandée\",\n      \"jobName\": \"Nom du Travail\",\n      \"startTime\": \"Heure de Début\",\n      \"progress\": \"Progrès\",\n      \"unit\": \"Lot\",\n      \"pipelineMessages\": \"Messages de Pipeline\",\n      \"cancelButton\": \"Annuler\",\n      \"cancelTooltip\": \"Annuler le traitement du pipeline\",\n      \"cancelConfirmTitle\": \"Confirmer l'Annulation du Pipeline\",\n      \"cancelConfirmDescription\": \"Cette action interrompra le traitement du pipeline en cours. Êtes-vous sûr de vouloir continuer ?\",\n      \"cancelConfirmButton\": \"Confirmer l'Annulation\",\n      \"cancelInProgress\": \"Annulation en cours...\",\n      \"pipelineNotRunning\": \"Le pipeline n'est pas en cours d'exécution\",\n      \"cancelSuccess\": \"Annulation du pipeline demandée\",\n      \"cancelFailed\": \"Échec de l'annulation du pipeline\\n{{error}}\",\n      \"cancelNotBusy\": \"Le pipeline n'est pas en cours d'exécution, pas besoin d'annuler\",\n      \"errors\": {\n        \"fetchFailed\": \"Échec de la récupération de l'état du pipeline\\n{{error}}\"\n      }\n    }\n  },\n  \"graphPanel\": {\n    \"dataIsTruncated\": \"Les données du graphe sont tronquées au nombre maximum de nœuds\",\n    \"statusDialog\": {\n      \"title\": \"Paramètres du Serveur LightRAG\",\n      \"description\": \"Afficher l'état actuel du système et les informations de connexion\"\n    },\n    \"legend\": \"Légende\",\n    \"nodeTypes\": {\n      \"person\": \"Personne\",\n      \"category\": \"Catégorie\",\n      \"geo\": \"Géographique\",\n      \"location\": \"Emplacement\",\n      \"organization\": \"Organisation\",\n      \"event\": \"Événement\",\n      \"equipment\": \"Équipement\",\n      \"weapon\": \"Arme\",\n      \"animal\": \"Animal\",\n      \"unknown\": \"Inconnu\",\n      \"object\": \"Objet\",\n      \"group\": \"Groupe\",\n      \"technology\": \"Technologie\",\n      \"product\": \"Produit\",\n      \"document\": \"Document\",\n      \"content\": \"Contenu\",\n      \"data\": \"Données\",\n      \"artifact\": \"Artefact\",\n      \"concept\": \"Concept\",\n      \"naturalobject\": \"Objet naturel\",\n      \"method\": \"Méthode\",\n      \"creature\": \"Créature\",\n      \"plant\": \"Plante\",\n      \"disease\": \"Maladie\",\n      \"drug\": \"Médicament\",\n      \"food\": \"Nourriture\",\n      \"other\": \"Autre\"\n    },\n    \"sideBar\": {\n      \"settings\": {\n        \"settings\": \"Paramètres\",\n        \"healthCheck\": \"Vérification de l'état\",\n        \"showPropertyPanel\": \"Afficher le panneau des propriétés\",\n        \"showSearchBar\": \"Afficher la barre de recherche\",\n        \"showNodeLabel\": \"Afficher l'étiquette du nœud\",\n        \"nodeDraggable\": \"Nœud déplaçable\",\n        \"showEdgeLabel\": \"Afficher l'étiquette de l'arête\",\n        \"hideUnselectedEdges\": \"Masquer les arêtes non sélectionnées\",\n        \"edgeEvents\": \"Événements des arêtes\",\n        \"maxQueryDepth\": \"Profondeur maximale de la requête\",\n        \"maxNodes\": \"Nombre maximum de nœuds\",\n        \"maxLayoutIterations\": \"Itérations maximales de mise en page\",\n        \"resetToDefault\": \"Réinitialiser par défaut\",\n        \"edgeSizeRange\": \"Plage de taille des arêtes\",\n        \"depth\": \"D\",\n        \"max\": \"Max\",\n        \"degree\": \"Degré\",\n        \"apiKey\": \"Clé API\",\n        \"enterYourAPIkey\": \"Entrez votre clé API\",\n        \"save\": \"Sauvegarder\",\n        \"refreshLayout\": \"Actualiser la mise en page\"\n      },\n      \"zoomControl\": {\n        \"zoomIn\": \"Zoom avant\",\n        \"zoomOut\": \"Zoom arrière\",\n        \"resetZoom\": \"Réinitialiser le zoom\",\n        \"rotateCamera\": \"Rotation horaire\",\n        \"rotateCameraCounterClockwise\": \"Rotation antihoraire\"\n      },\n      \"layoutsControl\": {\n        \"startAnimation\": \"Démarrer l'animation de mise en page\",\n        \"stopAnimation\": \"Arrêter l'animation de mise en page\",\n        \"layoutGraph\": \"Mettre en page le graphe\",\n        \"layouts\": {\n          \"Circular\": \"Circulaire\",\n          \"Circlepack\": \"Paquet circulaire\",\n          \"Random\": \"Aléatoire\",\n          \"Noverlaps\": \"Sans chevauchement\",\n          \"Force Directed\": \"Dirigé par la force\",\n          \"Force Atlas\": \"Atlas de force\"\n        }\n      },\n      \"fullScreenControl\": {\n        \"fullScreen\": \"Plein écran\",\n        \"windowed\": \"Fenêtré\"\n      },\n      \"legendControl\": {\n        \"toggleLegend\": \"Basculer la légende\"\n      }\n    },\n    \"statusIndicator\": {\n      \"connected\": \"Connecté\",\n      \"disconnected\": \"Déconnecté\"\n    },\n    \"statusCard\": {\n      \"unavailable\": \"Informations sur l'état indisponibles\",\n      \"serverInfo\": \"Informations du serveur\",\n      \"workingDirectory\": \"Répertoire de travail\",\n      \"inputDirectory\": \"Répertoire d'entrée\",\n      \"maxParallelInsert\": \"Traitement simultané des documents\",\n      \"summarySettings\": \"Paramètres de résumé\",\n      \"llmConfig\": \"Configuration du modèle de langage\",\n      \"llmBinding\": \"Liaison du modèle de langage\",\n      \"llmBindingHost\": \"Point de terminaison LLM\",\n      \"llmModel\": \"Modèle de langage\",\n      \"embeddingConfig\": \"Configuration d'incorporation\",\n      \"embeddingBinding\": \"Liaison d'incorporation\",\n      \"embeddingBindingHost\": \"Point de terminaison d'incorporation\",\n      \"embeddingModel\": \"Modèle d'incorporation\",\n      \"storageConfig\": \"Configuration de stockage\",\n      \"kvStorage\": \"Stockage clé-valeur\",\n      \"docStatusStorage\": \"Stockage de l'état des documents\",\n      \"graphStorage\": \"Stockage du graphe\",\n      \"vectorStorage\": \"Stockage vectoriel\",\n      \"workspace\": \"Espace de travail\",\n      \"maxGraphNodes\": \"Nombre maximum de nœuds du graphe\",\n      \"rerankerConfig\": \"Configuration du reclassement\",\n      \"rerankerBindingHost\": \"Point de terminaison de reclassement\",\n      \"rerankerModel\": \"Modèle de reclassement\",\n      \"lockStatus\": \"État des verrous\",\n      \"threshold\": \"Seuil\"\n    },\n    \"propertiesView\": {\n      \"editProperty\": \"Modifier {{property}}\",\n      \"editPropertyDescription\": \"Modifiez la valeur de la propriété dans la zone de texte ci-dessous.\",\n      \"errors\": {\n        \"duplicateName\": \"Le nom du nœud existe déjà\",\n        \"updateFailed\": \"Échec de la mise à jour du nœud\",\n        \"tryAgainLater\": \"Veuillez réessayer plus tard\",\n        \"updateSuccessButMergeFailed\": \"Propriétés mises à jour, mais la fusion a échoué : {{error}}\",\n        \"mergeFailed\": \"Échec de la fusion : {{error}}\"\n      },\n      \"success\": {\n        \"entityUpdated\": \"Nœud mis à jour avec succès\",\n        \"relationUpdated\": \"Relation mise à jour avec succès\",\n        \"entityMerged\": \"Fusion des nœuds réussie\"\n      },\n      \"mergeOptionLabel\": \"Fusionner automatiquement en cas de nom dupliqué\",\n      \"mergeOptionDescription\": \"Si activé, renommer vers un nom existant fusionnera automatiquement ce nœud avec celui-ci au lieu d'échouer.\",\n      \"mergeDialog\": {\n        \"title\": \"Nœud fusionné\",\n        \"description\": \"\\\"{{source}}\\\" a été fusionné dans \\\"{{target}}\\\".\",\n        \"refreshHint\": \"Actualisez le graphe pour charger la structure la plus récente.\",\n        \"keepCurrentStart\": \"Actualiser en conservant le nœud de départ actuel\",\n        \"useMergedStart\": \"Actualiser en utilisant le nœud fusionné\",\n        \"refreshing\": \"Actualisation du graphe...\"\n      },\n      \"node\": {\n        \"title\": \"Nœud\",\n        \"id\": \"ID\",\n        \"labels\": \"Étiquettes\",\n        \"degree\": \"Degré\",\n        \"properties\": \"Propriétés\",\n        \"relationships\": \"Relations(dans le sous-graphe)\",\n        \"expandNode\": \"Développer le nœud\",\n        \"pruneNode\": \"Élaguer le nœud\",\n        \"deleteAllNodesError\": \"Refus de supprimer tous les nœuds du graphe\",\n        \"nodesRemoved\": \"{{count}} nœuds supprimés, y compris les nœuds orphelins\",\n        \"noNewNodes\": \"Aucun nœud développable trouvé\",\n        \"propertyNames\": {\n          \"description\": \"Description\",\n          \"entity_id\": \"Nom\",\n          \"entity_type\": \"Type\",\n          \"source_id\": \"C-ID\",\n          \"Neighbour\": \"Voisin\",\n          \"file_path\": \"File\",\n          \"keywords\": \"Keys\",\n          \"weight\": \"Poids\"\n        }\n      },\n      \"edge\": {\n        \"title\": \"Relation\",\n        \"id\": \"ID\",\n        \"type\": \"Type\",\n        \"source\": \"Source\",\n        \"target\": \"Cible\",\n        \"properties\": \"Propriétés\"\n      }\n    },\n    \"search\": {\n      \"placeholder\": \"Rechercher des nœuds dans la page...\",\n      \"message\": \"Et {{count}} autres\"\n    },\n    \"graphLabels\": {\n      \"selectTooltip\": \"Obtenir le sous-graphe d'un nœud (étiquette)\",\n      \"noLabels\": \"Aucun nœud correspondant trouvé\",\n      \"label\": \"Rechercher le nom du nœud\",\n      \"placeholder\": \"Rechercher le nom du nœud...\",\n      \"andOthers\": \"Et {{count}} autres\",\n      \"refreshGlobalTooltip\": \"Actualiser les données du graphe global et réinitialiser l'historique de recherche\",\n      \"refreshCurrentLabelTooltip\": \"Actualiser les données du graphe de la page actuelle\",\n      \"refreshingTooltip\": \"Actualisation des données en cours...\"\n    },\n    \"emptyGraph\": \"Vide (Essayez de recharger)\"\n  },\n  \"retrievePanel\": {\n    \"chatMessage\": {\n      \"copyTooltip\": \"Copier dans le presse-papiers\",\n      \"copyError\": \"Échec de la copie du texte dans le presse-papiers\",\n      \"copyEmpty\": \"Aucun contenu à copier\",\n      \"copySuccess\": \"Contenu copié dans le presse-papiers\",\n      \"copySuccessLegacy\": \"Contenu copié (méthode héritée)\",\n      \"copySuccessManual\": \"Contenu copié (méthode manuelle)\",\n      \"copyFailed\": \"Échec de la copie du contenu\",\n      \"copyManualInstruction\": \"Veuillez sélectionner et copier le texte manuellement\",\n      \"thinking\": \"Réflexion en cours...\",\n      \"thinkingTime\": \"Temps de réflexion {{time}}s\",\n      \"thinkingInProgress\": \"Réflexion en cours...\"\n    },\n    \"retrieval\": {\n      \"startPrompt\": \"Démarrez une récupération en tapant votre requête ci-dessous\",\n      \"clear\": \"Effacer\",\n      \"send\": \"Envoyer\",\n      \"placeholder\": \"Tapez votre requête (Préfixe de requête : /<Query Mode>)\",\n      \"error\": \"Erreur : Échec de l'obtention de la réponse\",\n      \"queryModeError\": \"Seuls les modes de requête suivants sont pris en charge : {{modes}}\",\n      \"queryModePrefixInvalid\": \"Préfixe de mode de requête invalide. Utilisez : /<mode> [espace] votre requête\"\n    },\n    \"querySettings\": {\n      \"parametersTitle\": \"Paramètres\",\n      \"parametersDescription\": \"Configurez vos paramètres de requête\",\n      \"queryMode\": \"Mode de requête\",\n      \"queryModeTooltip\": \"Sélectionnez la stratégie de récupération :\\n• Naïf : Récupération vectorielle traditionnelle par blocs de texte\\n• Local : Axé sur la récupération d'entités\\n• Global : Axé sur la récupération de relations\\n• Hybride : Local+Global\\n• Mixte : Local+Global+Naïf\\n• Bypass : Ignorer la récupération, envoyer l'historique de conversation et la question actuelle au LLM\",\n      \"queryModeOptions\": {\n        \"naive\": \"Naïf\",\n        \"local\": \"Local\",\n        \"global\": \"Global\",\n        \"hybrid\": \"Hybride\",\n        \"mix\": \"Mixte\",\n        \"bypass\": \"Bypass\"\n      },\n      \"responseFormat\": \"Format de réponse\",\n      \"responseFormatTooltip\": \"Définit le format de la réponse. Exemples :\\n• Plusieurs paragraphes\\n• Paragraphe unique\\n• Points à puces\",\n      \"responseFormatOptions\": {\n        \"multipleParagraphs\": \"Plusieurs paragraphes\",\n        \"singleParagraph\": \"Paragraphe unique\",\n        \"bulletPoints\": \"Points à puces\"\n      },\n      \"topK\": \"KG Top K\",\n      \"topKTooltip\": \"Nombre d'entités et de relations à récupérer. Applicable pour les modes non-naïfs.\",\n      \"topKPlaceholder\": \"Entrez la valeur top_k\",\n      \"chunkTopK\": \"Top K des Chunks\",\n      \"chunkTopKTooltip\": \"Nombre de morceaux de texte à récupérer, applicable à tous les modes.\",\n      \"chunkTopKPlaceholder\": \"Entrez la valeur chunk_top_k\",\n      \"maxEntityTokens\": \"Limite de jetons d'entité\",\n      \"maxEntityTokensTooltip\": \"Nombre maximum de jetons alloués au contexte d'entité dans le système de contrôle de jetons unifié\",\n      \"maxRelationTokens\": \"Limite de jetons de relation\",\n      \"maxRelationTokensTooltip\": \"Nombre maximum de jetons alloués au contexte de relation dans le système de contrôle de jetons unifié\",\n      \"maxTotalTokens\": \"Limite totale de jetons\",\n      \"maxTotalTokensTooltip\": \"Budget total maximum de jetons pour l'ensemble du contexte de requête (entités + relations + blocs + prompt système)\",\n      \"historyTurns\": \"Tours d'historique\",\n      \"historyTurnsTooltip\": \"Nombre de tours complets de conversation (paires utilisateur-assistant) à prendre en compte dans le contexte de la réponse\",\n      \"historyTurnsPlaceholder\": \"Nombre de tours d'historique\",\n      \"onlyNeedContext\": \"Besoin uniquement du contexte\",\n      \"onlyNeedContextTooltip\": \"Si vrai, ne renvoie que le contexte récupéré sans générer de réponse\",\n      \"onlyNeedPrompt\": \"Besoin uniquement de l'invite\",\n      \"onlyNeedPromptTooltip\": \"Si vrai, ne renvoie que l'invite générée sans produire de réponse\",\n      \"streamResponse\": \"Réponse en flux\",\n      \"streamResponseTooltip\": \"Si vrai, active la sortie en flux pour des réponses en temps réel\",\n      \"userPrompt\": \"Invite de sortie supplémentaire\",\n      \"userPromptTooltip\": \"Fournir des exigences de réponse supplémentaires au LLM (sans rapport avec le contenu de la requête, uniquement pour le traitement de sortie).\",\n      \"userPromptPlaceholder\": \"Entrez une invite personnalisée (facultatif)\",\n      \"enableRerank\": \"Activer le Reclassement\",\n      \"enableRerankTooltip\": \"Active le reclassement pour les fragments de texte récupérés. Si True mais qu'aucun modèle de reclassement n'est configuré, un avertissement sera émis. True par défaut.\"\n    }\n  },\n  \"apiSite\": {\n    \"loading\": \"Chargement de la documentation de l'API...\"\n  },\n  \"apiKeyAlert\": {\n    \"title\": \"Clé API requise\",\n    \"description\": \"Veuillez entrer votre clé API pour accéder au service\",\n    \"placeholder\": \"Entrez votre clé API\",\n    \"save\": \"Sauvegarder\"\n  },\n  \"pagination\": {\n    \"showing\": \"Affichage de {{start}} à {{end}} sur {{total}} entrées\",\n    \"page\": \"Page\",\n    \"pageSize\": \"Taille de la page\",\n    \"firstPage\": \"Première page\",\n    \"prevPage\": \"Page précédente\",\n    \"nextPage\": \"Page suivante\",\n    \"lastPage\": \"Dernière page\"\n  }\n}\n"
  },
  {
    "path": "lightrag_webui/src/locales/ja.json",
    "content": "{\n  \"settings\": {\n    \"language\": \"言語\",\n    \"theme\": \"テーマ\",\n    \"light\": \"ライト\",\n    \"dark\": \"ダーク\",\n    \"system\": \"システム\"\n  },\n  \"header\": {\n    \"documents\": \"ドキュメント\",\n    \"knowledgeGraph\": \"ナレッジグラフ\",\n    \"retrieval\": \"検索\",\n    \"api\": \"API\",\n    \"projectRepository\": \"プロジェクトリポジトリ\",\n    \"logout\": \"ログアウト\",\n    \"frontendNeedsRebuild\": \"フロントエンドの再ビルドが必要です\",\n    \"themeToggle\": {\n      \"switchToLight\": \"ライトテーマに切り替え\",\n      \"switchToDark\": \"ダークテーマに切り替え\"\n    }\n  },\n  \"login\": {\n    \"description\": \"システムにログインするには、アカウントとパスワードを入力してください\",\n    \"username\": \"ユーザー名\",\n    \"usernamePlaceholder\": \"ユーザー名を入力してください\",\n    \"password\": \"パスワード\",\n    \"passwordPlaceholder\": \"パスワードを入力してください\",\n    \"loginButton\": \"ログイン\",\n    \"loggingIn\": \"ログイン中...\",\n    \"successMessage\": \"ログインに成功しました\",\n    \"errorEmptyFields\": \"ユーザー名とパスワードを入力してください\",\n    \"errorInvalidCredentials\": \"ログインに失敗しました。ユーザー名とパスワードを確認してください\",\n    \"authDisabled\": \"認証が無効になっています。ログインフリーモードを使用しています。\",\n    \"guestMode\": \"ログインフリー\"\n  },\n  \"common\": {\n    \"cancel\": \"キャンセル\",\n    \"save\": \"保存\",\n    \"saving\": \"保存中...\",\n    \"saveFailed\": \"保存に失敗しました\"\n  },\n  \"documentPanel\": {\n    \"clearDocuments\": {\n      \"button\": \"クリア\",\n      \"tooltip\": \"ドキュメントをクリア\",\n      \"title\": \"ドキュメントをクリア\",\n      \"description\": \"システムからすべてのドキュメントを削除します\",\n      \"warning\": \"警告: この操作はすべてのドキュメントを永続的に削除し、元に戻すことはできません！\",\n      \"confirm\": \"本当にすべてのドキュメントをクリアしますか？\",\n      \"confirmPrompt\": \"この操作を確認するには「yes」と入力してください\",\n      \"confirmPlaceholder\": \"確認するには「yes」と入力\",\n      \"clearCache\": \"LLMキャッシュをクリア\",\n      \"confirmButton\": \"はい\",\n      \"clearing\": \"クリア中...\",\n      \"timeout\": \"クリア操作がタイムアウトしました。もう一度お試しください\",\n      \"success\": \"ドキュメントが正常にクリアされました\",\n      \"cacheCleared\": \"キャッシュが正常にクリアされました\",\n      \"cacheClearFailed\": \"キャッシュのクリアに失敗しました:\\n{{error}}\",\n      \"failed\": \"ドキュメントのクリアに失敗しました:\\n{{message}}\",\n      \"error\": \"ドキュメントのクリアに失敗しました:\\n{{error}}\"\n    },\n    \"deleteDocuments\": {\n      \"button\": \"削除\",\n      \"tooltip\": \"選択したドキュメントを削除\",\n      \"title\": \"ドキュメントを削除\",\n      \"description\": \"システムから選択したドキュメントを永続的に削除します\",\n      \"warning\": \"警告: この操作は選択したドキュメントを永続的に削除し、元に戻すことはできません！\",\n      \"confirm\": \"本当に{{count}}個の選択したドキュメントを削除しますか？\",\n      \"confirmPrompt\": \"この操作を確認するには「yes」と入力してください\",\n      \"confirmPlaceholder\": \"確認するには「yes」と入力\",\n      \"confirmButton\": \"はい\",\n      \"deleteFileOption\": \"アップロードされたファイルも削除\",\n      \"deleteFileTooltip\": \"このオプションを選択すると、サーバー上の対応するアップロードファイルも削除されます\",\n      \"deleteLLMCacheOption\": \"抽出されたLLMキャッシュも削除\",\n      \"success\": \"ドキュメント削除パイプラインが正常に開始されました\",\n      \"failed\": \"ドキュメントの削除に失敗しました:\\n{{message}}\",\n      \"error\": \"ドキュメントの削除に失敗しました:\\n{{error}}\",\n      \"busy\": \"パイプラインがビジーです。後でもう一度お試しください\",\n      \"notAllowed\": \"この操作を実行する権限がありません\"\n    },\n    \"selectDocuments\": {\n      \"selectCurrentPage\": \"現在のページを選択 ({{count}})\",\n      \"deselectAll\": \"すべての選択を解除 ({{count}})\"\n    },\n    \"uploadDocuments\": {\n      \"button\": \"アップロード\",\n      \"tooltip\": \"ドキュメントをアップロード\",\n      \"title\": \"ドキュメントをアップロード\",\n      \"description\": \"ドキュメントをここにドラッグ&ドロップするか、クリックして参照してください。\",\n      \"single\": {\n        \"uploading\": \"アップロード中 {{name}}: {{percent}}%\",\n        \"success\": \"アップロード成功:\\n{{name}}が正常にアップロードされました\",\n        \"failed\": \"アップロード失敗:\\n{{name}}\\n{{message}}\",\n        \"error\": \"アップロード失敗:\\n{{name}}\\n{{error}}\"\n      },\n      \"batch\": {\n        \"uploading\": \"ファイルをアップロード中...\",\n        \"success\": \"ファイルが正常にアップロードされました\",\n        \"error\": \"一部のファイルのアップロードに失敗しました\"\n      },\n      \"generalError\": \"アップロード失敗\\n{{error}}\",\n      \"fileTypes\": \"サポートされている形式: TXT, MD, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, H, CPP, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS\",\n      \"fileUploader\": {\n        \"singleFileLimit\": \"一度に1つ以上のファイルをアップロードできません\",\n        \"maxFilesLimit\": \"{{count}}個を超えるファイルをアップロードできません\",\n        \"fileRejected\": \"ファイル {{name}} は拒否されました\",\n        \"unsupportedType\": \"サポートされていないファイル形式\",\n        \"fileTooLarge\": \"ファイルが大きすぎます。最大サイズは{{maxSize}}です\",\n        \"dropHere\": \"ファイルをここにドロップ\",\n        \"dragAndDrop\": \"ファイルをここにドラッグ&ドロップするか、クリックしてファイルを選択\",\n        \"removeFile\": \"ファイルを削除\",\n        \"uploadDescription\": \"{{isMultiple ? '複数' : count}}個のファイルをアップロードできます（それぞれ最大{{maxSize}}まで）\",\n        \"duplicateFile\": \"ファイル名がサーバーキャッシュに既に存在します\"\n      }\n    },\n    \"documentManager\": {\n      \"title\": \"ドキュメント管理\",\n      \"scanButton\": \"スキャン/再試行\",\n      \"scanTooltip\": \"入力フォルダ内のドキュメントをスキャンして処理し、失敗したすべてのドキュメントも再処理します\",\n      \"refreshTooltip\": \"ドキュメントリストをリセット\",\n      \"pipelineStatusButton\": \"パイプライン\",\n      \"pipelineStatusTooltip\": \"ドキュメント処理パイプラインのステータスを表示\",\n      \"uploadedTitle\": \"アップロードされたドキュメント\",\n      \"uploadedDescription\": \"アップロードされたドキュメントとそのステータスのリスト。\",\n      \"emptyTitle\": \"ドキュメントなし\",\n      \"emptyDescription\": \"まだアップロードされたドキュメントがありません。\",\n      \"columns\": {\n        \"id\": \"ID\",\n        \"fileName\": \"ファイル名\",\n        \"summary\": \"概要\",\n        \"status\": \"ステータス\",\n        \"length\": \"長さ\",\n        \"chunks\": \"チャンク\",\n        \"created\": \"作成日時\",\n        \"updated\": \"更新日時\",\n        \"metadata\": \"メタデータ\",\n        \"select\": \"選択\"\n      },\n      \"status\": {\n        \"all\": \"すべて\",\n        \"completed\": \"完了\",\n        \"preprocessed\": \"前処理済み\",\n        \"processing\": \"処理中\",\n        \"pending\": \"保留中\",\n        \"failed\": \"失敗\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"ドキュメントの読み込みに失敗しました\\n{{error}}\",\n        \"scanFailed\": \"ドキュメントのスキャンに失敗しました\\n{{error}}\",\n        \"scanProgressFailed\": \"スキャン進捗の取得に失敗しました\\n{{error}}\"\n      },\n      \"fileNameLabel\": \"ファイル名\",\n      \"showButton\": \"表示\",\n      \"hideButton\": \"非表示\",\n      \"showFileNameTooltip\": \"ファイル名を表示\",\n      \"hideFileNameTooltip\": \"ファイル名を非表示\"\n    },\n    \"pipelineStatus\": {\n      \"title\": \"パイプラインステータス\",\n      \"busy\": \"パイプラインがビジー\",\n      \"requestPending\": \"リクエスト保留中\",\n      \"cancellationRequested\": \"キャンセル要求済み\",\n      \"jobName\": \"ジョブ名\",\n      \"startTime\": \"開始時刻\",\n      \"progress\": \"進捗\",\n      \"unit\": \"バッチ\",\n      \"pipelineMessages\": \"パイプラインメッセージ\",\n      \"cancelButton\": \"キャンセル\",\n      \"cancelTooltip\": \"パイプライン処理をキャンセル\",\n      \"cancelConfirmTitle\": \"パイプラインキャンセルの確認\",\n      \"cancelConfirmDescription\": \"これにより、進行中のパイプライン処理が中断されます。続行してもよろしいですか？\",\n      \"cancelConfirmButton\": \"キャンセルを確認\",\n      \"cancelInProgress\": \"キャンセル処理中...\",\n      \"pipelineNotRunning\": \"パイプラインは実行されていません\",\n      \"cancelSuccess\": \"パイプラインキャンセルが要求されました\",\n      \"cancelFailed\": \"パイプラインのキャンセルに失敗しました\\n{{error}}\",\n      \"cancelNotBusy\": \"パイプラインは実行されていないため、キャンセルする必要はありません\",\n      \"errors\": {\n        \"fetchFailed\": \"パイプラインステータスの取得に失敗しました\\n{{error}}\"\n      }\n    }\n  },\n  \"graphPanel\": {\n    \"dataIsTruncated\": \"グラフデータは最大ノード数に切り詰められました\",\n    \"statusDialog\": {\n      \"title\": \"LightRAGサーバー設定\",\n      \"description\": \"現在のシステムステータスと接続情報を表示\"\n    },\n    \"legend\": \"凡例\",\n    \"nodeTypes\": {\n      \"person\": \"人物\",\n      \"category\": \"カテゴリ\",\n      \"geo\": \"地理\",\n      \"location\": \"場所\",\n      \"organization\": \"組織\",\n      \"event\": \"イベント\",\n      \"equipment\": \"機器\",\n      \"weapon\": \"武器\",\n      \"animal\": \"動物\",\n      \"unknown\": \"不明\",\n      \"object\": \"オブジェクト\",\n      \"group\": \"グループ\",\n      \"technology\": \"技術\",\n      \"product\": \"製品\",\n      \"document\": \"ドキュメント\",\n      \"content\": \"コンテンツ\",\n      \"data\": \"データ\",\n      \"artifact\": \"アーティファクト\",\n      \"concept\": \"概念\",\n      \"naturalobject\": \"自然物\",\n      \"method\": \"方法\",\n      \"creature\": \"生物\",\n      \"plant\": \"植物\",\n      \"disease\": \"病気\",\n      \"drug\": \"薬\",\n      \"food\": \"食品\",\n      \"other\": \"その他\"\n    },\n    \"sideBar\": {\n      \"settings\": {\n        \"settings\": \"設定\",\n        \"healthCheck\": \"ヘルスチェック\",\n        \"showPropertyPanel\": \"プロパティパネルを表示\",\n        \"showSearchBar\": \"検索バーを表示\",\n        \"showNodeLabel\": \"ノードラベルを表示\",\n        \"nodeDraggable\": \"ノードをドラッグ可能\",\n        \"showEdgeLabel\": \"エッジラベルを表示\",\n        \"hideUnselectedEdges\": \"選択されていないエッジを非表示\",\n        \"edgeEvents\": \"エッジイベント\",\n        \"maxQueryDepth\": \"最大クエリ深度\",\n        \"maxNodes\": \"最大ノード数\",\n        \"maxLayoutIterations\": \"最大レイアウト反復回数\",\n        \"resetToDefault\": \"デフォルトにリセット\",\n        \"edgeSizeRange\": \"エッジサイズ範囲\",\n        \"depth\": \"D\",\n        \"max\": \"最大\",\n        \"degree\": \"次数\",\n        \"apiKey\": \"APIキー\",\n        \"enterYourAPIkey\": \"APIキーを入力してください\",\n        \"save\": \"保存\",\n        \"refreshLayout\": \"レイアウトを更新\"\n      },\n      \"zoomControl\": {\n        \"zoomIn\": \"ズームイン\",\n        \"zoomOut\": \"ズームアウト\",\n        \"resetZoom\": \"ズームをリセット\",\n        \"rotateCamera\": \"時計回りに回転\",\n        \"rotateCameraCounterClockwise\": \"反時計回りに回転\"\n      },\n      \"layoutsControl\": {\n        \"startAnimation\": \"レイアウトアニメーションを続行\",\n        \"stopAnimation\": \"レイアウトアニメーションを停止\",\n        \"layoutGraph\": \"グラフをレイアウト\",\n        \"layouts\": {\n          \"Circular\": \"円形\",\n          \"Circlepack\": \"サークルパック\",\n          \"Random\": \"ランダム\",\n          \"Noverlaps\": \"重複なし\",\n          \"Force Directed\": \"力指向\",\n          \"Force Atlas\": \"フォースアトラス\"\n        }\n      },\n      \"fullScreenControl\": {\n        \"fullScreen\": \"フルスクリーン\",\n        \"windowed\": \"ウィンドウ\"\n      },\n      \"legendControl\": {\n        \"toggleLegend\": \"凡例を切り替え\"\n      }\n    },\n    \"statusIndicator\": {\n      \"connected\": \"接続済み\",\n      \"disconnected\": \"切断済み\"\n    },\n    \"statusCard\": {\n      \"unavailable\": \"ステータス情報が利用できません\",\n      \"serverInfo\": \"サーバー情報\",\n      \"workingDirectory\": \"作業ディレクトリ\",\n      \"inputDirectory\": \"入力ディレクトリ\",\n      \"maxParallelInsert\": \"同時ドキュメント処理\",\n      \"summarySettings\": \"概要設定\",\n      \"llmConfig\": \"LLM設定\",\n      \"llmBinding\": \"LLMバインディング\",\n      \"llmBindingHost\": \"LLMエンドポイント\",\n      \"llmModel\": \"LLMモデル\",\n      \"embeddingConfig\": \"埋め込み設定\",\n      \"embeddingBinding\": \"埋め込みバインディング\",\n      \"embeddingBindingHost\": \"埋め込みエンドポイント\",\n      \"embeddingModel\": \"埋め込みモデル\",\n      \"storageConfig\": \"ストレージ設定\",\n      \"kvStorage\": \"KVストレージ\",\n      \"docStatusStorage\": \"ドキュメントステータスストレージ\",\n      \"graphStorage\": \"グラフストレージ\",\n      \"vectorStorage\": \"ベクトルストレージ\",\n      \"workspace\": \"ワークスペース\",\n      \"maxGraphNodes\": \"最大グラフノード数\",\n      \"rerankerConfig\": \"リランカー設定\",\n      \"rerankerBindingHost\": \"リランカーエンドポイント\",\n      \"rerankerModel\": \"リランカーモデル\",\n      \"lockStatus\": \"ロックステータス\",\n      \"threshold\": \"しきい値\"\n    },\n    \"propertiesView\": {\n      \"editProperty\": \"{{property}}を編集\",\n      \"editPropertyDescription\": \"下のテキストエリアでプロパティ値を編集してください。\",\n      \"errors\": {\n        \"duplicateName\": \"ノード名が既に存在します\",\n        \"updateFailed\": \"ノードの更新に失敗しました\",\n        \"tryAgainLater\": \"後でもう一度お試しください\",\n        \"updateSuccessButMergeFailed\": \"プロパティは更新されましたが、マージに失敗しました: {{error}}\",\n        \"mergeFailed\": \"マージに失敗しました: {{error}}\"\n      },\n      \"success\": {\n        \"entityUpdated\": \"ノードが正常に更新されました\",\n        \"relationUpdated\": \"関係が正常に更新されました\",\n        \"entityMerged\": \"ノードが正常にマージされました\"\n      },\n      \"mergeOptionLabel\": \"重複名が見つかった場合に自動的にマージ\",\n      \"mergeOptionDescription\": \"有効にすると、既存の名前に名前を変更すると、失敗する代わりにこのノードが既存のノードにマージされます。\",\n      \"mergeDialog\": {\n        \"title\": \"ノードがマージされました\",\n        \"description\": \"\\\"{{source}}\\\"が\\\"{{target}}\\\"にマージされました。\",\n        \"refreshHint\": \"グラフを更新して最新の構造を読み込みます。\",\n        \"keepCurrentStart\": \"更新して現在の開始ノードを保持\",\n        \"useMergedStart\": \"更新してマージされたノードを使用\",\n        \"refreshing\": \"グラフを更新中...\"\n      },\n      \"node\": {\n        \"title\": \"ノード\",\n        \"id\": \"ID\",\n        \"labels\": \"ラベル\",\n        \"degree\": \"次数\",\n        \"properties\": \"プロパティ\",\n        \"relationships\": \"関係（サブグラフ内）\",\n        \"expandNode\": \"ノードを展開\",\n        \"pruneNode\": \"ノードを剪定\",\n        \"deleteAllNodesError\": \"グラフ内のすべてのノードを削除することを拒否します\",\n        \"nodesRemoved\": \"{{count}}個のノードが削除されました（孤立ノードを含む）\",\n        \"noNewNodes\": \"展開可能なノードが見つかりませんでした\",\n        \"propertyNames\": {\n          \"description\": \"説明\",\n          \"entity_id\": \"名前\",\n          \"entity_type\": \"タイプ\",\n          \"source_id\": \"C-ID\",\n          \"Neighbour\": \"隣接\",\n          \"file_path\": \"ファイル\",\n          \"keywords\": \"キー\",\n          \"weight\": \"重み\"\n        }\n      },\n      \"edge\": {\n        \"title\": \"関係\",\n        \"id\": \"ID\",\n        \"type\": \"タイプ\",\n        \"source\": \"ソース\",\n        \"target\": \"ターゲット\",\n        \"properties\": \"プロパティ\"\n      }\n    },\n    \"search\": {\n      \"placeholder\": \"ページ内のノードを検索...\",\n      \"message\": \"その他{{count}}件\"\n    },\n    \"graphLabels\": {\n      \"selectTooltip\": \"ノード（ラベル）のサブグラフを取得\",\n      \"noLabels\": \"一致するノードが見つかりませんでした\",\n      \"label\": \"ノード名を検索\",\n      \"placeholder\": \"ノード名を検索...\",\n      \"andOthers\": \"その他{{count}}件\",\n      \"refreshGlobalTooltip\": \"グローバルグラフデータを更新し、検索履歴をリセット\",\n      \"refreshCurrentLabelTooltip\": \"現在のページのグラフデータを更新\",\n      \"refreshingTooltip\": \"データを更新中...\"\n    },\n    \"emptyGraph\": \"空（再読み込みをお試しください）\"\n  },\n  \"retrievePanel\": {\n    \"chatMessage\": {\n      \"copyTooltip\": \"クリップボードにコピー\",\n      \"copyError\": \"クリップボードへのテキストコピーに失敗しました\",\n      \"copyEmpty\": \"コピーするコンテンツがありません\",\n      \"copySuccess\": \"コンテンツがクリップボードにコピーされました\",\n      \"copySuccessLegacy\": \"コンテンツがコピーされました（レガシーメソッド）\",\n      \"copySuccessManual\": \"コンテンツがコピーされました（手動メソッド）\",\n      \"copyFailed\": \"コンテンツのコピーに失敗しました\",\n      \"copyManualInstruction\": \"テキストを手動で選択してコピーしてください\",\n      \"thinking\": \"思考中...\",\n      \"thinkingTime\": \"思考時間 {{time}}秒\",\n      \"thinkingInProgress\": \"思考中...\"\n    },\n    \"retrieval\": {\n      \"startPrompt\": \"下にクエリを入力して検索を開始\",\n      \"clear\": \"クリア\",\n      \"send\": \"送信\",\n      \"placeholder\": \"クエリを入力（プレフィックス対応: /<クエリモード>）\",\n      \"error\": \"エラー: 応答の取得に失敗しました\",\n      \"queryModeError\": \"次のクエリモードのみサポートされています: {{modes}}\",\n      \"queryModePrefixInvalid\": \"無効なクエリモードプレフィックス。使用: /<mode> [スペース] クエリ\"\n    },\n    \"querySettings\": {\n      \"parametersTitle\": \"パラメータ\",\n      \"parametersDescription\": \"クエリパラメータを設定\",\n      \"queryMode\": \"クエリモード\",\n      \"queryModeTooltip\": \"検索戦略を選択:\\n• Naive: 従来のテキストチャンクベクトル検索\\n• Local: エンティティ検索に焦点\\n• Global: 関係検索に焦点\\n• Hybrid: Local+Global\\n• Mix: Local+Global+Naive\\n• Bypass: 検索をスキップし、会話履歴と現在の質問をLLMに送信\",\n      \"queryModeOptions\": {\n        \"naive\": \"Naive\",\n        \"local\": \"Local\",\n        \"global\": \"Global\",\n        \"hybrid\": \"Hybrid\",\n        \"mix\": \"Mix\",\n        \"bypass\": \"Bypass\"\n      },\n      \"responseFormat\": \"応答形式\",\n      \"responseFormatTooltip\": \"応答形式を定義します。例:\\n• 複数段落\\n• 単一段落\\n• 箇条書き\",\n      \"responseFormatOptions\": {\n        \"multipleParagraphs\": \"複数段落\",\n        \"singleParagraph\": \"単一段落\",\n        \"bulletPoints\": \"箇条書き\"\n      },\n      \"topK\": \"KG Top K\",\n      \"topKTooltip\": \"取得するエンティティと関係の数。非naiveモードに適用されます。\",\n      \"topKPlaceholder\": \"top_k値を入力\",\n      \"chunkTopK\": \"チャンクTop K\",\n      \"chunkTopKTooltip\": \"取得するテキストチャンクの数。すべてのモードに適用されます。\",\n      \"chunkTopKPlaceholder\": \"chunk_top_k値を入力\",\n      \"maxEntityTokens\": \"最大エンティティトークン数\",\n      \"maxEntityTokensTooltip\": \"統一トークン制御システムでエンティティコンテキストに割り当てられる最大トークン数\",\n      \"maxRelationTokens\": \"最大関係トークン数\",\n      \"maxRelationTokensTooltip\": \"統一トークン制御システムで関係コンテキストに割り当てられる最大トークン数\",\n      \"maxTotalTokens\": \"最大合計トークン数\",\n      \"maxTotalTokensTooltip\": \"クエリコンテキスト全体（エンティティ+関係+チャンク+システムプロンプト）の最大合計トークン予算\",\n      \"historyTurns\": \"履歴ターン数\",\n      \"historyTurnsTooltip\": \"応答コンテキストで考慮する完全な会話ターン（ユーザー-アシスタントペア）の数\",\n      \"historyTurnsPlaceholder\": \"履歴ターン数\",\n      \"onlyNeedContext\": \"コンテキストのみ必要\",\n      \"onlyNeedContextTooltip\": \"Trueの場合、応答を生成せずに取得されたコンテキストのみを返します\",\n      \"onlyNeedPrompt\": \"プロンプトのみ必要\",\n      \"onlyNeedPromptTooltip\": \"Trueの場合、応答を生成せずに生成されたプロンプトのみを返します\",\n      \"streamResponse\": \"ストリーム応答\",\n      \"streamResponseTooltip\": \"Trueの場合、リアルタイム応答のストリーミング出力を有効にします\",\n      \"userPrompt\": \"追加出力プロンプト\",\n      \"userPromptTooltip\": \"LLMに追加の応答要件を提供します（クエリコンテンツとは無関係で、出力処理のみに使用）。\",\n      \"userPromptPlaceholder\": \"カスタムプロンプトを入力（オプション）\",\n      \"enableRerank\": \"リランクを有効化\",\n      \"enableRerankTooltip\": \"取得されたテキストチャンクのリランクを有効にします。Trueに設定してもリランクモデルが設定されていない場合、警告が発行されます。デフォルトはTrueです。\"\n    }\n  },\n  \"apiSite\": {\n    \"loading\": \"APIドキュメントを読み込み中...\"\n  },\n  \"apiKeyAlert\": {\n    \"title\": \"APIキーが必要です\",\n    \"description\": \"サービスにアクセスするには、APIキーを入力してください\",\n    \"placeholder\": \"APIキーを入力\",\n    \"save\": \"保存\"\n  },\n  \"pagination\": {\n    \"showing\": \"{{start}}から{{end}}まで、全{{total}}件を表示\",\n    \"page\": \"ページ\",\n    \"pageSize\": \"ページサイズ\",\n    \"firstPage\": \"最初のページ\",\n    \"prevPage\": \"前のページ\",\n    \"nextPage\": \"次のページ\",\n    \"lastPage\": \"最後のページ\"\n  }\n}\n"
  },
  {
    "path": "lightrag_webui/src/locales/ko.json",
    "content": "{\n  \"settings\": {\n    \"language\": \"언어\",\n    \"theme\": \"테마\",\n    \"light\": \"라이트\",\n    \"dark\": \"다크\",\n    \"system\": \"시스템\"\n  },\n  \"header\": {\n    \"documents\": \"문서\",\n    \"knowledgeGraph\": \"지식 그래프\",\n    \"retrieval\": \"검색\",\n    \"api\": \"API\",\n    \"projectRepository\": \"프로젝트 저장소\",\n    \"logout\": \"로그아웃\",\n    \"frontendNeedsRebuild\": \"프론트엔드 재빌드 필요\",\n    \"themeToggle\": {\n      \"switchToLight\": \"라이트 테마로 전환\",\n      \"switchToDark\": \"다크 테마로 전환\"\n    }\n  },\n  \"login\": {\n    \"description\": \"시스템에 로그인하려면 계정과 비밀번호를 입력하세요\",\n    \"username\": \"사용자 이름\",\n    \"usernamePlaceholder\": \"사용자 이름을 입력하세요\",\n    \"password\": \"비밀번호\",\n    \"passwordPlaceholder\": \"비밀번호를 입력하세요\",\n    \"loginButton\": \"로그인\",\n    \"loggingIn\": \"로그인 중...\",\n    \"successMessage\": \"로그인 성공\",\n    \"errorEmptyFields\": \"사용자 이름과 비밀번호를 입력하세요\",\n    \"errorInvalidCredentials\": \"로그인 실패, 사용자 이름과 비밀번호를 확인하세요\",\n    \"authDisabled\": \"인증이 비활성화되었습니다. 게스트 모드를 사용합니다.\",\n    \"guestMode\": \"게스트 모드\"\n  },\n  \"common\": {\n    \"cancel\": \"취소\",\n    \"save\": \"저장\",\n    \"saving\": \"저장 중...\",\n    \"saveFailed\": \"저장 실패\"\n  },\n  \"documentPanel\": {\n    \"clearDocuments\": {\n      \"button\": \"문서 전체 삭제\",\n      \"tooltip\": \"문서 전체 삭제\",\n      \"title\": \"문서 전체 삭제\",\n      \"description\": \"시스템에서 모든 문서를 제거합니다\",\n      \"warning\": \"경고: 이 작업은 모든 문서를 영구적으로 삭제하며 되돌릴 수 없습니다!\",\n      \"confirm\": \"모든 문서를 정말로 삭제하시겠습니까?\",\n      \"confirmPrompt\": \"확인하려면 'yes'를 입력하세요\",\n      \"confirmPlaceholder\": \"확인하려면 'yes' 입력\",\n      \"clearCache\": \"LLM 캐시 삭제\",\n      \"confirmButton\": \"예\",\n      \"clearing\": \"삭제하는 중...\",\n      \"timeout\": \"삭제 작업 시간 초과, 다시 시도해주세요\",\n      \"success\": \"문서가 성공적으로 삭제되었습니다\",\n      \"cacheCleared\": \"캐시가 성공적으로 삭제되었습니다\",\n      \"cacheClearFailed\": \"캐시 삭제 실패:\\n{{error}}\",\n      \"failed\": \"문서 삭제 실패:\\n{{message}}\",\n      \"error\": \"문서 삭제 실패:\\n{{error}}\"\n    },\n    \"deleteDocuments\": {\n      \"button\": \"삭제\",\n      \"tooltip\": \"선택한 문서 삭제\",\n      \"title\": \"문서 삭제\",\n      \"description\": \"선택한 문서를 시스템에서 영구적으로 삭제합니다\",\n      \"warning\": \"경고: 이 작업은 선택한 문서를 영구적으로 삭제하며 되돌릴 수 없습니다!\",\n      \"confirm\": \"선택한 {{count}}개의 문서를 정말로 삭제하시겠습니까?\",\n      \"confirmPrompt\": \"확인하려면 'yes'를 입력하세요\",\n      \"confirmPlaceholder\": \"확인하려면 'yes' 입력\",\n      \"confirmButton\": \"예\",\n      \"deleteFileOption\": \"업로드된 파일도 삭제\",\n      \"deleteFileTooltip\": \"서버에 있는 해당 업로드 파일도 삭제하려면 이 옵션을 선택하세요\",\n      \"deleteLLMCacheOption\": \"추출된 LLM 캐시도 삭제\",\n      \"success\": \"문서 삭제 파이프라인이 성공적으로 시작되었습니다\",\n      \"failed\": \"문서 삭제 실패:\\n{{message}}\",\n      \"error\": \"문서 삭제 실패:\\n{{error}}\",\n      \"busy\": \"파이프라인이 현재 작업 중입니다. 잠시 후 다시 시도해 주세요.\",\n      \"notAllowed\": \"이 작업을 수행할 권한이 없습니다\"\n    },\n    \"selectDocuments\": {\n      \"selectCurrentPage\": \"현재 페이지 선택 ({{count}})\",\n      \"deselectAll\": \"전체 선택 해제 ({{count}})\"\n    },\n    \"uploadDocuments\": {\n      \"button\": \"업로드\",\n      \"tooltip\": \"문서 업로드\",\n      \"title\": \"문서 업로드\",\n      \"description\": \"문서를 여기로 드래그 앤 드롭하거나 클릭하여 찾아보세요.\",\n      \"single\": {\n        \"uploading\": \"업로드 중 {{name}}: {{percent}}%\",\n        \"success\": \"업로드 성공:\\n{{name}} 업로드 완료\",\n        \"failed\": \"업로드 실패:\\n{{name}}\\n{{message}}\",\n        \"error\": \"업로드 실패:\\n{{name}}\\n{{error}}\"\n      },\n      \"batch\": {\n        \"uploading\": \"파일 업로드 중...\",\n        \"success\": \"파일이 성공적으로 업로드되었습니다\",\n        \"error\": \"일부 파일 업로드 실패\"\n      },\n      \"generalError\": \"업로드 실패\\n{{error}}\",\n      \"fileTypes\": \"지원되는 유형: TXT, MD, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, H, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS\",\n      \"fileUploader\": {\n        \"singleFileLimit\": \"한 번에 1개의 파일만 업로드할 수 있습니다\",\n        \"maxFilesLimit\": \"{{count}}개 이상의 파일은 업로드할 수 없습니다\",\n        \"fileRejected\": \"{{name}} 파일이 거부되었습니다\",\n        \"unsupportedType\": \"지원되지 않는 파일 형식\",\n        \"fileTooLarge\": \"파일이 너무 큽니다. 최대 크기는 {{maxSize}}입니다\",\n        \"dropHere\": \"여기에 파일을 놓으세요\",\n        \"dragAndDrop\": \"여기에 파일을 드래그 앤 드롭하거나 클릭하여 선택하세요\",\n        \"removeFile\": \"파일 제거\",\n        \"uploadDescription\": \"{{isMultiple ? '여러' : count}}개의 파일을 업로드할 수 있습니다 (각 최대 {{maxSize}})\",\n        \"duplicateFile\": \"파일 이름이 서버 캐시에 이미 존재합니다\"\n      }\n    },\n    \"documentManager\": {\n      \"title\": \"문서 관리\",\n      \"scanButton\": \"스캔/재시도\",\n      \"scanTooltip\": \"문서 관리의 목록을 스캔하고, 실패한 모든 문서를 재처리합니다\",\n      \"refreshTooltip\": \"문서 목록 초기화\",\n      \"pipelineStatusButton\": \"파이프라인\",\n      \"pipelineStatusTooltip\": \"문서 처리 파이프라인 상태 보기\",\n      \"uploadedTitle\": \"업로드된 문서\",\n      \"uploadedDescription\": \"업로드된 문서 목록 및 상태.\",\n      \"emptyTitle\": \"문서 없음\",\n      \"emptyDescription\": \"아직 업로드된 문서가 없습니다.\",\n      \"columns\": {\n        \"id\": \"ID\",\n        \"fileName\": \"파일명\",\n        \"summary\": \"요약\",\n        \"status\": \"상태\",\n        \"length\": \"길이\",\n        \"chunks\": \"청크\",\n        \"created\": \"생성일\",\n        \"updated\": \"수정일\",\n        \"metadata\": \"메타데이터\",\n        \"select\": \"선택\"\n      },\n      \"status\": {\n        \"all\": \"전체\",\n        \"completed\": \"완료\",\n        \"preprocessed\": \"전처리 완료\",\n        \"processing\": \"처리 중\",\n        \"pending\": \"대기 중\",\n        \"failed\": \"실패\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"문서 불러오기 실패\\n{{error}}\",\n        \"scanFailed\": \"문서 스캔 실패\\n{{error}}\",\n        \"scanProgressFailed\": \"스캔 진행 상황 가져오기 실패\\n{{error}}\"\n      },\n      \"fileNameLabel\": \"파일명\",\n      \"showButton\": \"표시\",\n      \"hideButton\": \"숨기기\",\n      \"showFileNameTooltip\": \"파일명 표시\",\n      \"hideFileNameTooltip\": \"파일명 숨기기\"\n    },\n    \"pipelineStatus\": {\n      \"title\": \"파이프라인 상태\",\n      \"busy\": \"파이프라인 작업 중\",\n      \"requestPending\": \"요청 대기 중\",\n      \"cancellationRequested\": \"취소 요청됨\",\n      \"jobName\": \"작업 이름\",\n      \"startTime\": \"시작 시간\",\n      \"progress\": \"진행 상황\",\n      \"unit\": \"배치\",\n      \"pipelineMessages\": \"파이프라인 메시지\",\n      \"cancelButton\": \"취소\",\n      \"cancelTooltip\": \"파이프라인 처리 취소\",\n      \"cancelConfirmTitle\": \"파이프라인 취소 확인\",\n      \"cancelConfirmDescription\": \"현재 진행 중인 파이프라인 처리가 중단됩니다. 계속하시겠습니까?\",\n      \"cancelConfirmButton\": \"취소 확인\",\n      \"cancelInProgress\": \"취소 중...\",\n      \"pipelineNotRunning\": \"파이프라인이 작업 중이 아닙니다\",\n      \"cancelSuccess\": \"파이프라인 취소가 요청되었습니다\",\n      \"cancelFailed\": \"파이프라인 취소 실패\\n{{error}}\",\n      \"cancelNotBusy\": \"파이프라인이 작업 중이 아니므로 취소할 필요가 없습니다\",\n      \"errors\": {\n        \"fetchFailed\": \"파이프라인 상태 가져오기 실패\\n{{error}}\"\n      }\n    }\n  },\n  \"graphPanel\": {\n    \"dataIsTruncated\": \"그래프 데이터가 최대 노드 수로 제한되었습니다\",\n    \"statusDialog\": {\n      \"title\": \"LightRAG 서버 설정\",\n      \"description\": \"현재 시스템 상태 및 연결 정보 보기\"\n    },\n    \"legend\": \"범례\",\n    \"nodeTypes\": {\n      \"person\": \"사람\",\n      \"category\": \"카테고리\",\n      \"geo\": \"지리\",\n      \"location\": \"장소\",\n      \"organization\": \"조직\",\n      \"event\": \"이벤트\",\n      \"equipment\": \"장비\",\n      \"weapon\": \"무기\",\n      \"animal\": \"동물\",\n      \"unknown\": \"알 수 없음\",\n      \"object\": \"사물\",\n      \"group\": \"그룹\",\n      \"technology\": \"기술\",\n      \"product\": \"제품\",\n      \"document\": \"문서\",\n      \"content\": \"내용\",\n      \"data\": \"데이터\",\n      \"artifact\": \"아티팩트\",\n      \"concept\": \"개념\",\n      \"naturalobject\": \"자연물\",\n      \"method\": \"방법\",\n      \"creature\": \"생물\",\n      \"plant\": \"식물\",\n      \"disease\": \"질병\",\n      \"drug\": \"의약품\",\n      \"food\": \"식품\",\n      \"other\": \"기타\"\n    },\n    \"sideBar\": {\n      \"settings\": {\n        \"settings\": \"설정\",\n        \"healthCheck\": \"헬스 체크\",\n        \"showPropertyPanel\": \"속성 패널 표시\",\n        \"showSearchBar\": \"검색 바 표시\",\n        \"showNodeLabel\": \"노드 레이블 표시\",\n        \"nodeDraggable\": \"노드 드래그 가능\",\n        \"showEdgeLabel\": \"엣지 레이블 표시\",\n        \"hideUnselectedEdges\": \"선택되지 않은 엣지 숨기기\",\n        \"edgeEvents\": \"엣지 이벤트\",\n        \"maxQueryDepth\": \"최대 쿼리 깊이\",\n        \"maxNodes\": \"최대 노드\",\n        \"maxLayoutIterations\": \"최대 레이아웃 반복\",\n        \"resetToDefault\": \"기본값으로 재설정\",\n        \"edgeSizeRange\": \"엣지 크기 범위\",\n        \"depth\": \"D\",\n        \"max\": \"최대\",\n        \"degree\": \"차수\",\n        \"apiKey\": \"API 키\",\n        \"enterYourAPIkey\": \"API 키를 입력하세요\",\n        \"save\": \"저장\",\n        \"refreshLayout\": \"레이아웃 새로고침\"\n      },\n      \"zoomControl\": {\n        \"zoomIn\": \"확대\",\n        \"zoomOut\": \"축소\",\n        \"resetZoom\": \"줌 초기화\",\n        \"rotateCamera\": \"시계 방향 회전\",\n        \"rotateCameraCounterClockwise\": \"반시계 방향 회전\"\n      },\n      \"layoutsControl\": {\n        \"startAnimation\": \"레이아웃 애니메이션 계속\",\n        \"stopAnimation\": \"레이아웃 애니메이션 중지\",\n        \"layoutGraph\": \"그래프 레이아웃\",\n        \"layouts\": {\n          \"Circular\": \"원형\",\n          \"Circlepack\": \"서클 패킹\",\n          \"Random\": \"무작위\",\n          \"Noverlaps\": \"겹침 없음\",\n          \"Force Directed\": \"역학 기반\",\n          \"Force Atlas\": \"포스 아틀라스\"\n        }\n      },\n      \"fullScreenControl\": {\n        \"fullScreen\": \"전체 화면\",\n        \"windowed\": \"창 모드\"\n      },\n      \"legendControl\": {\n        \"toggleLegend\": \"범례 토글\"\n      }\n    },\n    \"statusIndicator\": {\n      \"connected\": \"연결됨\",\n      \"disconnected\": \"연결 끊김\"\n    },\n    \"statusCard\": {\n      \"unavailable\": \"상태 정보를 사용할 수 없음\",\n      \"serverInfo\": \"서버 정보\",\n      \"workingDirectory\": \"작업 디렉토리\",\n      \"inputDirectory\": \"입력 디렉토리\",\n      \"maxParallelInsert\": \"동시 문서 처리\",\n      \"summarySettings\": \"요약 설정\",\n      \"llmConfig\": \"LLM 구성\",\n      \"llmBinding\": \"LLM 바인딩\",\n      \"llmBindingHost\": \"LLM 엔드포인트\",\n      \"llmModel\": \"LLM 모델\",\n      \"embeddingConfig\": \"임베딩 구성\",\n      \"embeddingBinding\": \"임베딩 바인딩\",\n      \"embeddingBindingHost\": \"임베딩 엔드포인트\",\n      \"embeddingModel\": \"임베딩 모델\",\n      \"storageConfig\": \"스토리지 구성\",\n      \"kvStorage\": \"KV 스토리지\",\n      \"docStatusStorage\": \"문서 상태 스토리지\",\n      \"graphStorage\": \"그래프 스토리지\",\n      \"vectorStorage\": \"벡터 스토리지\",\n      \"workspace\": \"작업 공간\",\n      \"maxGraphNodes\": \"최대 그래프 노드\",\n      \"rerankerConfig\": \"Reranker 구성\",\n      \"rerankerBindingHost\": \"Reranker 엔드포인트\",\n      \"rerankerModel\": \"Reranker 모델\",\n      \"lockStatus\": \"잠금 상태\",\n      \"threshold\": \"임계값\"\n    },\n    \"propertiesView\": {\n      \"editProperty\": \"{{property}} 수정\",\n      \"editPropertyDescription\": \"아래 텍스트 영역에서 속성 값을 수정하세요.\",\n      \"errors\": {\n        \"duplicateName\": \"노드 이름이 이미 존재합니다\",\n        \"updateFailed\": \"노드 업데이트 실패\",\n        \"tryAgainLater\": \"나중에 다시 시도해주세요\",\n        \"updateSuccessButMergeFailed\": \"속성이 업데이트되었으나 병합 실패: {{error}}\",\n        \"mergeFailed\": \"병합 실패: {{error}}\"\n      },\n      \"success\": {\n        \"entityUpdated\": \"노드가 성공적으로 업데이트되었습니다\",\n        \"relationUpdated\": \"관계가 성공적으로 업데이트되었습니다\",\n        \"entityMerged\": \"노드가 성공적으로 병합되었습니다\"\n      },\n      \"mergeOptionLabel\": \"중복 이름 발견 시 자동으로 병합\",\n      \"mergeOptionDescription\": \"이 옵션을 활성화하면 기존 이름으로 변경 시 실패하는 대신 해당 노드로 병합됩니다.\",\n      \"mergeDialog\": {\n        \"title\": \"노드 병합됨\",\n        \"description\": \"\\\"{{source}}\\\" 노드가 \\\"{{target}}\\\" 노드로 병합되었습니다.\",\n        \"refreshHint\": \"최신 구조를 로드하려면 그래프를 새로고침하세요.\",\n        \"keepCurrentStart\": \"새로고침 및 현재 시작 노드 유지\",\n        \"useMergedStart\": \"새로고침 및 병합된 노드 사용\",\n        \"refreshing\": \"그래프 새로고침 중...\"\n      },\n      \"node\": {\n        \"title\": \"노드\",\n        \"id\": \"ID\",\n        \"labels\": \"레이블\",\n        \"degree\": \"차수\",\n        \"properties\": \"속성\",\n        \"relationships\": \"관계(하위 그래프 내)\",\n        \"expandNode\": \"노드 확장\",\n        \"pruneNode\": \"노드 가지치기\",\n        \"deleteAllNodesError\": \"그래프의 모든 노드 삭제 실패\",\n        \"nodesRemoved\": \"고아 노드를 포함하여 {{count}}개의 노드가 제거되었습니다\",\n        \"noNewNodes\": \"확장 가능한 노드를 찾을 수 없습니다\",\n        \"propertyNames\": {\n          \"description\": \"설명\",\n          \"entity_id\": \"이름\",\n          \"entity_type\": \"유형\",\n          \"source_id\": \"C-ID\",\n          \"Neighbour\": \"인접 노드\",\n          \"file_path\": \"파일\",\n          \"keywords\": \"키워드\",\n          \"weight\": \"가중치\"\n        }\n      },\n      \"edge\": {\n        \"title\": \"관계\",\n        \"id\": \"ID\",\n        \"type\": \"유형\",\n        \"source\": \"소스\",\n        \"target\": \"대상\",\n        \"properties\": \"속성\"\n      }\n    },\n    \"search\": {\n      \"placeholder\": \"페이지 내 노드 검색...\",\n      \"message\": \"외 {{count}}개\"\n    },\n    \"graphLabels\": {\n      \"selectTooltip\": \"노드의 하위 그래프 가져오기 (레이블)\",\n      \"noLabels\": \"일치하는 노드를 찾을 수 없습니다\",\n      \"label\": \"노드 이름 검색\",\n      \"placeholder\": \"노드 이름 검색...\",\n      \"andOthers\": \"외 {{count}}개\",\n      \"refreshGlobalTooltip\": \"전역 그래프 데이터 새로고침 및 검색 기록 초기화\",\n      \"refreshCurrentLabelTooltip\": \"현재 페이지 그래프 데이터 새로고침\",\n      \"refreshingTooltip\": \"데이터 새로고침 중...\"\n    },\n    \"emptyGraph\": \"데이터 없음(새로고침을 시도해보세요)\"\n  },\n  \"retrievePanel\": {\n    \"chatMessage\": {\n      \"copyTooltip\": \"클립보드에 복사\",\n      \"copyError\": \"텍스트를 클립보드에 복사 실패\",\n      \"copyEmpty\": \"복사할 내용이 없습니다\",\n      \"copySuccess\": \"내용이 클립보드에 복사되었습니다\",\n      \"copySuccessLegacy\": \"내용이 복사되었습니다 (레거시 방식)\",\n      \"copySuccessManual\": \"내용이 복사되었습니다 (수동 방식)\",\n      \"copyFailed\": \"내용 복사 실패\",\n      \"copyManualInstruction\": \"텍스트를 직접 선택하여 복사하세요\",\n      \"thinking\": \"생각 중...\",\n      \"thinkingTime\": \"생각 소요 시간 {{time}}초\",\n      \"thinkingInProgress\": \"생각 진행 중...\"\n    },\n    \"retrieval\": {\n      \"startPrompt\": \"아래에 질문을 입력하여 검색을 시작하세요\",\n      \"clear\": \"지우기\",\n      \"send\": \"전송\",\n      \"placeholder\": \"질문을 입력하세요 (접두사 지원: /<쿼리 모드>)\",\n      \"error\": \"오류: 응답을 가져오지 못했습니다\",\n      \"queryModeError\": \"다음 쿼리 모드만 지원합니다: {{modes}}\",\n      \"queryModePrefixInvalid\": \"잘못된 쿼리 모드 접두사입니다. 사용법: /<모드> [공백] 질문\"\n    },\n    \"querySettings\": {\n      \"parametersTitle\": \"매개변수\",\n      \"parametersDescription\": \"쿼리 매개변수를 구성하세요\",\n      \"queryMode\": \"쿼리 모드\",\n      \"queryModeTooltip\": \"검색 전략을 선택하세요:\\n• Naive: 전통적인 텍스트 청크 벡터 검색\\n• Local: 엔티티 검색에 집중\\n• Global: 관계 검색에 집중\\n• Hybrid: Local+Global\\n• Mix: Local+Global+Naive\\n• Bypass: 검색 건너뛰기, 대화 기록과 현재 질문을 LLM에 전송\",\n      \"queryModeOptions\": {\n        \"naive\": \"Naive\",\n        \"local\": \"Local\",\n        \"global\": \"Global\",\n        \"hybrid\": \"Hybrid\",\n        \"mix\": \"Mix\",\n        \"bypass\": \"Bypass\"\n      },\n      \"responseFormat\": \"응답 형식\",\n      \"responseFormatTooltip\": \"응답 형식을 정의합니다. 예:\\n• 여러 단락\\n• 단일 단락\\n• 글머리 기호\",\n      \"responseFormatOptions\": {\n        \"multipleParagraphs\": \"여러 단락\",\n        \"singleParagraph\": \"단일 단락\",\n        \"bulletPoints\": \"글머리 기호\"\n      },\n      \"topK\": \"KG Top K\",\n      \"topKTooltip\": \"검색할 엔티티 및 관계 수. Naive가 아닌 모드에 적용됩니다.\",\n      \"topKPlaceholder\": \"top_k 값 입력\",\n      \"chunkTopK\": \"Chunk Top K\",\n      \"chunkTopKTooltip\": \"검색할 텍스트 청크 수, 모든 모드에 적용됩니다.\",\n      \"chunkTopKPlaceholder\": \"chunk_top_k 값 입력\",\n      \"maxEntityTokens\": \"최대 엔티티 토큰\",\n      \"maxEntityTokensTooltip\": \"통합 토큰 제어 시스템에서 엔티티 컨텍스트에 할당된 최대 토큰 수\",\n      \"maxRelationTokens\": \"최대 관계 토큰\",\n      \"maxRelationTokensTooltip\": \"통합 토큰 제어 시스템에서 관계 컨텍스트에 할당된 최대 토큰 수\",\n      \"maxTotalTokens\": \"최대 총 토큰\",\n      \"maxTotalTokensTooltip\": \"전체 쿼리 컨텍스트(엔티티 + 관계 + 청크 + 시스템 프롬프트)에 대한 최대 총 토큰 예산\",\n      \"historyTurns\": \"대화 턴 수\",\n      \"historyTurnsTooltip\": \"응답 컨텍스트에서 고려할 전체 대화 턴(사용자-어시스턴트 쌍) 수\",\n      \"historyTurnsPlaceholder\": \"대화 턴 수\",\n      \"onlyNeedContext\": \"컨텍스트만 필요\",\n      \"onlyNeedContextTooltip\": \"True일 경우, 응답을 생성하지 않고 검색된 컨텍스트만 반환합니다\",\n      \"onlyNeedPrompt\": \"프롬프트만 필요\",\n      \"onlyNeedPromptTooltip\": \"True일 경우, 응답을 생성하지 않고 생성된 프롬프트만 반환합니다\",\n      \"streamResponse\": \"스트림 응답\",\n      \"streamResponseTooltip\": \"True일 경우, 실시간 응답을 위한 스트리밍 출력을 활성화합니다\",\n      \"userPrompt\": \"추가 출력 프롬프트\",\n      \"userPromptTooltip\": \"LLM에 추가 응답 요구 사항을 제공합니다 (쿼리 내용과 무관, 출력 처리에만 사용).\",\n      \"userPromptPlaceholder\": \"사용자 지정 프롬프트 입력 (선택 사항)\",\n      \"enableRerank\": \"Rerank 활성화\",\n      \"enableRerankTooltip\": \"검색된 텍스트 청크에 대해 Rerank를 활성화합니다. True이지만 Rerank 모델이 구성되지 않은 경우 경고가 발생합니다. 기본값은 True입니다.\"\n    }\n  },\n  \"apiSite\": {\n    \"loading\": \"API 문서 로드 중...\"\n  },\n  \"apiKeyAlert\": {\n    \"title\": \"API 키가 필요합니다\",\n    \"description\": \"서비스에 액세스하려면 API 키를 입력하세요\",\n    \"placeholder\": \"API 키 입력\",\n    \"save\": \"저장\"\n  },\n  \"pagination\": {\n    \"showing\": \"전체 {{total}}개 중 {{start}}-{{end}}\",\n    \"page\": \"페이지\",\n    \"pageSize\": \"페이지 크기\",\n    \"firstPage\": \"첫 페이지\",\n    \"prevPage\": \"이전 페이지\",\n    \"nextPage\": \"다음 페이지\",\n    \"lastPage\": \"마지막 페이지\"\n  }\n}\n"
  },
  {
    "path": "lightrag_webui/src/locales/ru.json",
    "content": "{\n  \"settings\": {\n    \"language\": \"Язык\",\n    \"theme\": \"Тема\",\n    \"light\": \"Светлая\",\n    \"dark\": \"Тёмная\",\n    \"system\": \"Системная\"\n  },\n  \"header\": {\n    \"documents\": \"Документы\",\n    \"knowledgeGraph\": \"Граф знаний\",\n    \"retrieval\": \"Поиск\",\n    \"api\": \"API\",\n    \"projectRepository\": \"Репозиторий проекта\",\n    \"logout\": \"Выйти\",\n    \"frontendNeedsRebuild\": \"Требуется пересборка фронтенда\",\n    \"themeToggle\": {\n      \"switchToLight\": \"Переключить на светлую тему\",\n      \"switchToDark\": \"Переключить на тёмную тему\"\n    }\n  },\n  \"login\": {\n    \"description\": \"Пожалуйста, введите ваш аккаунт и пароль для входа в систему\",\n    \"username\": \"Имя пользователя\",\n    \"usernamePlaceholder\": \"Введите имя пользователя\",\n    \"password\": \"Пароль\",\n    \"passwordPlaceholder\": \"Введите пароль\",\n    \"loginButton\": \"Войти\",\n    \"loggingIn\": \"Вход в систему...\",\n    \"successMessage\": \"Вход выполнен успешно\",\n    \"errorEmptyFields\": \"Пожалуйста, введите имя пользователя и пароль\",\n    \"errorInvalidCredentials\": \"Ошибка входа, проверьте имя пользователя и пароль\",\n    \"authDisabled\": \"Аутентификация отключена. Используется режим без входа.\",\n    \"guestMode\": \"Без входа\"\n  },\n  \"common\": {\n    \"cancel\": \"Отмена\",\n    \"save\": \"Сохранить\",\n    \"saving\": \"Сохранение...\",\n    \"saveFailed\": \"Ошибка сохранения\"\n  },\n  \"documentPanel\": {\n    \"clearDocuments\": {\n      \"button\": \"Очистить\",\n      \"tooltip\": \"Очистить документы\",\n      \"title\": \"Очистить документы\",\n      \"description\": \"Это действие удалит все документы из системы\",\n      \"warning\": \"ВНИМАНИЕ: Это действие навсегда удалит все документы и не может быть отменено!\",\n      \"confirm\": \"Вы действительно хотите очистить все документы?\",\n      \"confirmPrompt\": \"Введите 'yes' для подтверждения действия\",\n      \"confirmPlaceholder\": \"Введите yes для подтверждения\",\n      \"clearCache\": \"Очистить кэш LLM\",\n      \"confirmButton\": \"ДА\",\n      \"clearing\": \"Очистка...\",\n      \"timeout\": \"Операция очистки превысила время ожидания, попробуйте снова\",\n      \"success\": \"Документы успешно очищены\",\n      \"cacheCleared\": \"Кэш успешно очищен\",\n      \"cacheClearFailed\": \"Не удалось очистить кэш:\\n{{error}}\",\n      \"failed\": \"Ошибка очистки документов:\\n{{message}}\",\n      \"error\": \"Ошибка очистки документов:\\n{{error}}\"\n    },\n    \"deleteDocuments\": {\n      \"button\": \"Удалить\",\n      \"tooltip\": \"Удалить выбранные документы\",\n      \"title\": \"Удалить документы\",\n      \"description\": \"Это действие навсегда удалит выбранные документы из системы\",\n      \"warning\": \"ВНИМАНИЕ: Это действие навсегда удалит выбранные документы и не может быть отменено!\",\n      \"confirm\": \"Вы действительно хотите удалить {{count}} выбранный(ых) документ(ов)?\",\n      \"confirmPrompt\": \"Введите 'yes' для подтверждения действия\",\n      \"confirmPlaceholder\": \"Введите yes для подтверждения\",\n      \"confirmButton\": \"ДА\",\n      \"deleteFileOption\": \"Также удалить загруженные файлы\",\n      \"deleteFileTooltip\": \"Отметьте эту опцию, чтобы также удалить соответствующие загруженные файлы на сервере\",\n      \"deleteLLMCacheOption\": \"Также удалить извлечённый кэш LLM\",\n      \"success\": \"Конвейер удаления документов успешно запущен\",\n      \"failed\": \"Ошибка удаления документов:\\n{{message}}\",\n      \"error\": \"Ошибка удаления документов:\\n{{error}}\",\n      \"busy\": \"Конвейер занят, попробуйте позже\",\n      \"notAllowed\": \"Нет разрешения на выполнение этой операции\"\n    },\n    \"selectDocuments\": {\n      \"selectCurrentPage\": \"Выбрать текущую страницу ({{count}})\",\n      \"deselectAll\": \"Снять все выделения ({{count}})\"\n    },\n    \"uploadDocuments\": {\n      \"button\": \"Загрузить\",\n      \"tooltip\": \"Загрузить документы\",\n      \"title\": \"Загрузить документы\",\n      \"description\": \"Перетащите ваши документы сюда или нажмите для просмотра.\",\n      \"single\": {\n        \"uploading\": \"Загрузка {{name}}: {{percent}}%\",\n        \"success\": \"Загрузка успешна:\\n{{name}} успешно загружен\",\n        \"failed\": \"Ошибка загрузки:\\n{{name}}\\n{{message}}\",\n        \"error\": \"Ошибка загрузки:\\n{{name}}\\n{{error}}\"\n      },\n      \"batch\": {\n        \"uploading\": \"Загрузка файлов...\",\n        \"success\": \"Файлы успешно загружены\",\n        \"error\": \"Некоторые файлы не удалось загрузить\"\n      },\n      \"generalError\": \"Ошибка загрузки\\n{{error}}\",\n      \"fileTypes\": \"Поддерживаемые типы: TXT, MD, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, H, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS\",\n      \"fileUploader\": {\n        \"singleFileLimit\": \"Нельзя загрузить более 1 файла за раз\",\n        \"maxFilesLimit\": \"Нельзя загрузить более {{count}} файлов\",\n        \"fileRejected\": \"Файл {{name}} был отклонён\",\n        \"unsupportedType\": \"Неподдерживаемый тип файла\",\n        \"fileTooLarge\": \"Файл слишком большой, максимальный размер {{maxSize}}\",\n        \"dropHere\": \"Перетащите файлы сюда\",\n        \"dragAndDrop\": \"Перетащите файлы сюда или нажмите для выбора файлов\",\n        \"removeFile\": \"Удалить файл\",\n        \"uploadDescription\": \"Вы можете загрузить {{isMultiple ? 'несколько' : count}} файлов (до {{maxSize}} каждый)\",\n        \"duplicateFile\": \"Имя файла уже существует в кэше сервера\"\n      }\n    },\n    \"documentManager\": {\n      \"title\": \"Управление документами\",\n      \"scanButton\": \"Сканировать/Повторить\",\n      \"scanTooltip\": \"Сканировать и обработать документы во входной папке, а также повторно обработать все неудачные документы\",\n      \"refreshTooltip\": \"Сбросить список документов\",\n      \"pipelineStatusButton\": \"Конвейер\",\n      \"pipelineStatusTooltip\": \"Просмотр статуса конвейера обработки документов\",\n      \"uploadedTitle\": \"Загруженные документы\",\n      \"uploadedDescription\": \"Список загруженных документов и их статусы.\",\n      \"emptyTitle\": \"Нет документов\",\n      \"emptyDescription\": \"Документы ещё не загружены.\",\n      \"columns\": {\n        \"id\": \"ID\",\n        \"fileName\": \"Имя файла\",\n        \"summary\": \"Краткое содержание\",\n        \"status\": \"Статус\",\n        \"length\": \"Длина\",\n        \"chunks\": \"Фрагменты\",\n        \"created\": \"Создан\",\n        \"updated\": \"Обновлён\",\n        \"metadata\": \"Метаданные\",\n        \"select\": \"Выбрать\"\n      },\n      \"status\": {\n        \"all\": \"Все\",\n        \"completed\": \"Завершено\",\n        \"preprocessed\": \"Предобработано\",\n        \"processing\": \"Обработка\",\n        \"pending\": \"Ожидание\",\n        \"failed\": \"Ошибка\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"Не удалось загрузить документы\\n{{error}}\",\n        \"scanFailed\": \"Не удалось просканировать документы\\n{{error}}\",\n        \"scanProgressFailed\": \"Не удалось получить прогресс сканирования\\n{{error}}\"\n      },\n      \"fileNameLabel\": \"Имя файла\",\n      \"showButton\": \"Показать\",\n      \"hideButton\": \"Скрыть\",\n      \"showFileNameTooltip\": \"Показать имя файла\",\n      \"hideFileNameTooltip\": \"Скрыть имя файла\"\n    },\n    \"pipelineStatus\": {\n      \"title\": \"Статус конвейера\",\n      \"busy\": \"Конвейер занят\",\n      \"requestPending\": \"Запрос ожидает\",\n      \"cancellationRequested\": \"Запрошена отмена\",\n      \"jobName\": \"Название задачи\",\n      \"startTime\": \"Время начала\",\n      \"progress\": \"Прогресс\",\n      \"unit\": \"Пакет\",\n      \"pipelineMessages\": \"Сообщения конвейера\",\n      \"cancelButton\": \"Отмена\",\n      \"cancelTooltip\": \"Отменить обработку конвейера\",\n      \"cancelConfirmTitle\": \"Подтверждение отмены конвейера\",\n      \"cancelConfirmDescription\": \"Это прервёт текущую обработку конвейера. Вы уверены, что хотите продолжить?\",\n      \"cancelConfirmButton\": \"Подтвердить отмену\",\n      \"cancelInProgress\": \"Отмена выполняется...\",\n      \"pipelineNotRunning\": \"Конвейер не запущен\",\n      \"cancelSuccess\": \"Запрошена отмена конвейера\",\n      \"cancelFailed\": \"Не удалось отменить конвейер\\n{{error}}\",\n      \"cancelNotBusy\": \"Конвейер не запущен, отмена не требуется\",\n      \"errors\": {\n        \"fetchFailed\": \"Не удалось получить статус конвейера\\n{{error}}\"\n      }\n    }\n  },\n  \"graphPanel\": {\n    \"dataIsTruncated\": \"Данные графа обрезаны до максимального количества узлов\",\n    \"statusDialog\": {\n      \"title\": \"Настройки сервера LightRAG\",\n      \"description\": \"Просмотр текущего статуса системы и информации о подключении\"\n    },\n    \"legend\": \"Легенда\",\n    \"nodeTypes\": {\n      \"person\": \"Персона\",\n      \"category\": \"Категория\",\n      \"geo\": \"Географическое\",\n      \"location\": \"Местоположение\",\n      \"organization\": \"Организация\",\n      \"event\": \"Событие\",\n      \"equipment\": \"Оборудование\",\n      \"weapon\": \"Оружие\",\n      \"animal\": \"Животное\",\n      \"unknown\": \"Неизвестно\",\n      \"object\": \"Объект\",\n      \"group\": \"Группа\",\n      \"technology\": \"Технология\",\n      \"product\": \"Продукт\",\n      \"document\": \"Документ\",\n      \"content\": \"Содержимое\",\n      \"data\": \"Данные\",\n      \"artifact\": \"Артефакт\",\n      \"concept\": \"Концепция\",\n      \"naturalobject\": \"Природный объект\",\n      \"method\": \"Метод\",\n      \"creature\": \"Существо\",\n      \"plant\": \"Растение\",\n      \"disease\": \"Болезнь\",\n      \"drug\": \"Лекарство\",\n      \"food\": \"Еда\",\n      \"other\": \"Другое\"\n    },\n    \"sideBar\": {\n      \"settings\": {\n        \"settings\": \"Настройки\",\n        \"healthCheck\": \"Проверка здоровья\",\n        \"showPropertyPanel\": \"Показать панель свойств\",\n        \"showSearchBar\": \"Показать панель поиска\",\n        \"showNodeLabel\": \"Показать метки узлов\",\n        \"nodeDraggable\": \"Узлы перетаскиваемые\",\n        \"showEdgeLabel\": \"Показать метки рёбер\",\n        \"hideUnselectedEdges\": \"Скрыть невыбранные рёбра\",\n        \"edgeEvents\": \"События рёбер\",\n        \"maxQueryDepth\": \"Максимальная глубина запроса\",\n        \"maxNodes\": \"Максимальное количество узлов\",\n        \"maxLayoutIterations\": \"Максимальное количество итераций раскладки\",\n        \"resetToDefault\": \"Сбросить к значениям по умолчанию\",\n        \"edgeSizeRange\": \"Диапазон размера рёбер\",\n        \"depth\": \"Г\",\n        \"max\": \"Макс\",\n        \"degree\": \"Степень\",\n        \"apiKey\": \"API ключ\",\n        \"enterYourAPIkey\": \"Введите ваш API ключ\",\n        \"save\": \"Сохранить\",\n        \"refreshLayout\": \"Обновить раскладку\"\n      },\n      \"zoomControl\": {\n        \"zoomIn\": \"Увеличить\",\n        \"zoomOut\": \"Уменьшить\",\n        \"resetZoom\": \"Сбросить масштаб\",\n        \"rotateCamera\": \"Поворот по часовой стрелке\",\n        \"rotateCameraCounterClockwise\": \"Поворот против часовой стрелки\"\n      },\n      \"layoutsControl\": {\n        \"startAnimation\": \"Продолжить анимацию раскладки\",\n        \"stopAnimation\": \"Остановить анимацию раскладки\",\n        \"layoutGraph\": \"Раскладка графа\",\n        \"layouts\": {\n          \"Circular\": \"Круговой\",\n          \"Circlepack\": \"Упаковка кругов\",\n          \"Random\": \"Случайный\",\n          \"Noverlaps\": \"Без перекрытий\",\n          \"Force Directed\": \"Силовой\",\n          \"Force Atlas\": \"Силовой Атлас\"\n        }\n      },\n      \"fullScreenControl\": {\n        \"fullScreen\": \"Полный экран\",\n        \"windowed\": \"Оконный режим\"\n      },\n      \"legendControl\": {\n        \"toggleLegend\": \"Переключить легенду\"\n      }\n    },\n    \"statusIndicator\": {\n      \"connected\": \"Подключено\",\n      \"disconnected\": \"Отключено\"\n    },\n    \"statusCard\": {\n      \"unavailable\": \"Информация о статусе недоступна\",\n      \"serverInfo\": \"Информация о сервере\",\n      \"workingDirectory\": \"Рабочая директория\",\n      \"inputDirectory\": \"Входная директория\",\n      \"maxParallelInsert\": \"Параллельная обработка документов\",\n      \"summarySettings\": \"Настройки краткого содержания\",\n      \"llmConfig\": \"Конфигурация LLM\",\n      \"llmBinding\": \"Привязка LLM\",\n      \"llmBindingHost\": \"Конечная точка LLM\",\n      \"llmModel\": \"Модель LLM\",\n      \"embeddingConfig\": \"Конфигурация встраивания\",\n      \"embeddingBinding\": \"Привязка встраивания\",\n      \"embeddingBindingHost\": \"Конечная точка встраивания\",\n      \"embeddingModel\": \"Модель встраивания\",\n      \"storageConfig\": \"Конфигурация хранилища\",\n      \"kvStorage\": \"KV хранилище\",\n      \"docStatusStorage\": \"Хранилище статуса документов\",\n      \"graphStorage\": \"Хранилище графа\",\n      \"vectorStorage\": \"Векторное хранилище\",\n      \"workspace\": \"Рабочее пространство\",\n      \"maxGraphNodes\": \"Максимальное количество узлов графа\",\n      \"rerankerConfig\": \"Конфигурация ранжирования\",\n      \"rerankerBindingHost\": \"Конечная точка ранжирования\",\n      \"rerankerModel\": \"Модель ранжирования\",\n      \"lockStatus\": \"Статус блокировки\",\n      \"threshold\": \"Порог\"\n    },\n    \"propertiesView\": {\n      \"editProperty\": \"Редактировать {{property}}\",\n      \"editPropertyDescription\": \"Отредактируйте значение свойства в текстовой области ниже.\",\n      \"errors\": {\n        \"duplicateName\": \"Имя узла уже существует\",\n        \"updateFailed\": \"Не удалось обновить узел\",\n        \"tryAgainLater\": \"Пожалуйста, попробуйте позже\",\n        \"updateSuccessButMergeFailed\": \"Свойства обновлены, но слияние не удалось: {{error}}\",\n        \"mergeFailed\": \"Слияние не удалось: {{error}}\"\n      },\n      \"success\": {\n        \"entityUpdated\": \"Узел успешно обновлён\",\n        \"relationUpdated\": \"Связь успешно обновлена\",\n        \"entityMerged\": \"Узлы успешно объединены\"\n      },\n      \"mergeOptionLabel\": \"Автоматически объединять при обнаружении дублирующегося имени\",\n      \"mergeOptionDescription\": \"Если включено, переименование в существующее имя объединит этот узел с существующим вместо ошибки.\",\n      \"mergeDialog\": {\n        \"title\": \"Узел объединён\",\n        \"description\": \"\\\"{{source}}\\\" был объединён в \\\"{{target}}\\\".\",\n        \"refreshHint\": \"Обновите граф, чтобы загрузить последнюю структуру.\",\n        \"keepCurrentStart\": \"Обновить и сохранить текущий начальный узел\",\n        \"useMergedStart\": \"Обновить и использовать объединённый узел\",\n        \"refreshing\": \"Обновление графа...\"\n      },\n      \"node\": {\n        \"title\": \"Узел\",\n        \"id\": \"ID\",\n        \"labels\": \"Метки\",\n        \"degree\": \"Степень\",\n        \"properties\": \"Свойства\",\n        \"relationships\": \"Связи (в подграфе)\",\n        \"expandNode\": \"Развернуть узел\",\n        \"pruneNode\": \"Обрезать узел\",\n        \"deleteAllNodesError\": \"Отказ в удалении всех узлов в графе\",\n        \"nodesRemoved\": \"{{count}} узлов удалено, включая изолированные узлы\",\n        \"noNewNodes\": \"Расширяемых узлов не найдено\",\n        \"propertyNames\": {\n          \"description\": \"Описание\",\n          \"entity_id\": \"Имя\",\n          \"entity_type\": \"Тип\",\n          \"source_id\": \"C-ID\",\n          \"Neighbour\": \"Сосед\",\n          \"file_path\": \"Файл\",\n          \"keywords\": \"Ключи\",\n          \"weight\": \"Вес\"\n        }\n      },\n      \"edge\": {\n        \"title\": \"Связь\",\n        \"id\": \"ID\",\n        \"type\": \"Тип\",\n        \"source\": \"Источник\",\n        \"target\": \"Цель\",\n        \"properties\": \"Свойства\"\n      }\n    },\n    \"search\": {\n      \"placeholder\": \"Поиск узлов на странице...\",\n      \"message\": \"И ещё {{count}} других\"\n    },\n    \"graphLabels\": {\n      \"selectTooltip\": \"Получить подграф узла (метка)\",\n      \"noLabels\": \"Соответствующих узлов не найдено\",\n      \"label\": \"Поиск имени узла\",\n      \"placeholder\": \"Поиск имени узла...\",\n      \"andOthers\": \"И ещё {{count}} других\",\n      \"refreshGlobalTooltip\": \"Обновить глобальные данные графа и сбросить историю поиска\",\n      \"refreshCurrentLabelTooltip\": \"Обновить данные графа текущей страницы\",\n      \"refreshingTooltip\": \"Обновление данных...\"\n    },\n    \"emptyGraph\": \"Пусто (попробуйте перезагрузить снова)\"\n  },\n  \"retrievePanel\": {\n    \"chatMessage\": {\n      \"copyTooltip\": \"Копировать в буфер обмена\",\n      \"copyError\": \"Не удалось скопировать текст в буфер обмена\",\n      \"copyEmpty\": \"Нет содержимого для копирования\",\n      \"copySuccess\": \"Содержимое скопировано в буфер обмена\",\n      \"copySuccessLegacy\": \"Содержимое скопировано (устаревший метод)\",\n      \"copySuccessManual\": \"Содержимое скопировано (ручной метод)\",\n      \"copyFailed\": \"Не удалось скопировать содержимое\",\n      \"copyManualInstruction\": \"Пожалуйста, выберите и скопируйте текст вручную\",\n      \"thinking\": \"Размышление...\",\n      \"thinkingTime\": \"Время размышления {{time}}с\",\n      \"thinkingInProgress\": \"Размышление в процессе...\"\n    },\n    \"retrieval\": {\n      \"startPrompt\": \"Начните поиск, введя ваш запрос ниже\",\n      \"clear\": \"Очистить\",\n      \"send\": \"Отправить\",\n      \"placeholder\": \"Введите ваш запрос (Поддержка префикса: /<Режим запроса>)\",\n      \"error\": \"Ошибка: Не удалось получить ответ\",\n      \"queryModeError\": \"Поддерживаются только следующие режимы запроса: {{modes}}\",\n      \"queryModePrefixInvalid\": \"Неверный префикс режима запроса. Используйте: /<режим> [пробел] ваш запрос\"\n    },\n    \"querySettings\": {\n      \"parametersTitle\": \"Параметры\",\n      \"parametersDescription\": \"Настройте параметры вашего запроса\",\n      \"queryMode\": \"Режим запроса\",\n      \"queryModeTooltip\": \"Выберите стратегию поиска:\\n• Naive: Традиционный поиск по вектору текстовых фрагментов\\n• Local: Фокус на поиске сущностей\\n• Global: Фокус на поиске связей\\n• Hybrid: Local+Global\\n• Mix: Local+Global+Naive\\n• Bypass: Пропустить поиск, отправить историю разговора и текущий вопрос в LLM\",\n      \"queryModeOptions\": {\n        \"naive\": \"Naive\",\n        \"local\": \"Local\",\n        \"global\": \"Global\",\n        \"hybrid\": \"Hybrid\",\n        \"mix\": \"Mix\",\n        \"bypass\": \"Bypass\"\n      },\n      \"responseFormat\": \"Формат ответа\",\n      \"responseFormatTooltip\": \"Определяет формат ответа. Примеры:\\n• Несколько абзацев\\n• Один абзац\\n• Маркированный список\",\n      \"responseFormatOptions\": {\n        \"multipleParagraphs\": \"Несколько абзацев\",\n        \"singleParagraph\": \"Один абзац\",\n        \"bulletPoints\": \"Маркированный список\"\n      },\n      \"topK\": \"KG Top K\",\n      \"topKTooltip\": \"Количество извлекаемых сущностей и связей. Применимо для режимов, отличных от naive.\",\n      \"topKPlaceholder\": \"Введите значение top_k\",\n      \"chunkTopK\": \"Chunk Top K\",\n      \"chunkTopKTooltip\": \"Количество извлекаемых текстовых фрагментов, применимо для всех режимов.\",\n      \"chunkTopKPlaceholder\": \"Введите значение chunk_top_k\",\n      \"maxEntityTokens\": \"Макс. токенов сущностей\",\n      \"maxEntityTokensTooltip\": \"Максимальное количество токенов, выделенных для контекста сущностей в системе единого управления токенами\",\n      \"maxRelationTokens\": \"Макс. токенов связей\",\n      \"maxRelationTokensTooltip\": \"Максимальное количество токенов, выделенных для контекста связей в системе единого управления токенами\",\n      \"maxTotalTokens\": \"Макс. общее количество токенов\",\n      \"maxTotalTokensTooltip\": \"Максимальный общий бюджет токенов для всего контекста запроса (сущности + связи + фрагменты + системный промпт)\",\n      \"historyTurns\": \"История ходов\",\n      \"historyTurnsTooltip\": \"Количество полных ходов разговора (пары пользователь-ассистент) для учёта в контексте ответа\",\n      \"historyTurnsPlaceholder\": \"Количество ходов истории\",\n      \"onlyNeedContext\": \"Только контекст\",\n      \"onlyNeedContextTooltip\": \"Если True, возвращает только извлечённый контекст без генерации ответа\",\n      \"onlyNeedPrompt\": \"Только промпт\",\n      \"onlyNeedPromptTooltip\": \"Если True, возвращает только сгенерированный промпт без создания ответа\",\n      \"streamResponse\": \"Потоковый ответ\",\n      \"streamResponseTooltip\": \"Если True, включает потоковый вывод для ответов в реальном времени\",\n      \"userPrompt\": \"Дополнительный промпт вывода\",\n      \"userPromptTooltip\": \"Предоставьте дополнительные требования к ответу для LLM (не связанные с содержимым запроса, только для обработки вывода).\",\n      \"userPromptPlaceholder\": \"Введите пользовательский промпт (необязательно)\",\n      \"enableRerank\": \"Включить ранжирование\",\n      \"enableRerankTooltip\": \"Включить ранжирование для извлечённых текстовых фрагментов. Если True, но модель ранжирования не настроена, будет выдано предупреждение. По умолчанию True.\"\n    }\n  },\n  \"apiSite\": {\n    \"loading\": \"Загрузка документации API...\"\n  },\n  \"apiKeyAlert\": {\n    \"title\": \"Требуется API ключ\",\n    \"description\": \"Пожалуйста, введите ваш API ключ для доступа к сервису\",\n    \"placeholder\": \"Введите ваш API ключ\",\n    \"save\": \"Сохранить\"\n  },\n  \"pagination\": {\n    \"showing\": \"Показано {{start}} - {{end}} из {{total}} записей\",\n    \"page\": \"Страница\",\n    \"pageSize\": \"Размер страницы\",\n    \"firstPage\": \"Первая страница\",\n    \"prevPage\": \"Предыдущая страница\",\n    \"nextPage\": \"Следующая страница\",\n    \"lastPage\": \"Последняя страница\"\n  }\n}\n"
  },
  {
    "path": "lightrag_webui/src/locales/uk.json",
    "content": "{\n  \"settings\": {\n    \"language\": \"Мова\",\n    \"theme\": \"Тема\",\n    \"light\": \"Світла\",\n    \"dark\": \"Темна\",\n    \"system\": \"Системна\"\n  },\n  \"header\": {\n    \"documents\": \"Документи\",\n    \"knowledgeGraph\": \"Граф знань\",\n    \"retrieval\": \"Пошук\",\n    \"api\": \"API\",\n    \"projectRepository\": \"Репозиторій проекту\",\n    \"logout\": \"Вихід\",\n    \"frontendNeedsRebuild\": \"Потрібна перебудова фронтенду\",\n    \"themeToggle\": {\n      \"switchToLight\": \"Перемкнути на світлу тему\",\n      \"switchToDark\": \"Перемкнути на темну тему\"\n    }\n  },\n  \"login\": {\n    \"description\": \"Будь ласка, введіть ваш обліковий запис та пароль для входу в систему\",\n    \"username\": \"Ім'я користувача\",\n    \"usernamePlaceholder\": \"Будь ласка, введіть ім'я користувача\",\n    \"password\": \"Пароль\",\n    \"passwordPlaceholder\": \"Будь ласка, введіть пароль\",\n    \"loginButton\": \"Увійти\",\n    \"loggingIn\": \"Вхід...\",\n    \"successMessage\": \"Вхід успішний\",\n    \"errorEmptyFields\": \"Будь ласка, введіть ім'я користувача та пароль\",\n    \"errorInvalidCredentials\": \"Вхід не вдався, будь ласка, перевірте ім'я користувача та пароль\",\n    \"authDisabled\": \"Аутентифікацію вимкнено. Використовується режим без входу.\",\n    \"guestMode\": \"Без входу\"\n  },\n  \"common\": {\n    \"cancel\": \"Скасувати\",\n    \"save\": \"Зберегти\",\n    \"saving\": \"Збереження...\",\n    \"saveFailed\": \"Збереження не вдалося\"\n  },\n  \"documentPanel\": {\n    \"clearDocuments\": {\n      \"button\": \"Очистити\",\n      \"tooltip\": \"Очистити документи\",\n      \"title\": \"Очистити документи\",\n      \"description\": \"Це видалить усі документи з системи\",\n      \"warning\": \"ПОПЕРЕДЖЕННЯ: Ця дія назавжди видалить усі документи і не може бути скасована!\",\n      \"confirm\": \"Ви дійсно хочете очистити всі документи?\",\n      \"confirmPrompt\": \"Введіть 'yes' для підтвердження цієї дії\",\n      \"confirmPlaceholder\": \"Введіть yes для підтвердження\",\n      \"clearCache\": \"Очистити кеш LLM\",\n      \"confirmButton\": \"ТАК\",\n      \"clearing\": \"Очищення...\",\n      \"timeout\": \"Операція очищення перевищила час очікування, будь ласка, спробуйте ще раз\",\n      \"success\": \"Документи успішно очищено\",\n      \"cacheCleared\": \"Кеш успішно очищено\",\n      \"cacheClearFailed\": \"Не вдалося очистити кеш:\\n{{error}}\",\n      \"failed\": \"Очищення документів не вдалося:\\n{{message}}\",\n      \"error\": \"Очищення документів не вдалося:\\n{{error}}\"\n    },\n    \"deleteDocuments\": {\n      \"button\": \"Видалити\",\n      \"tooltip\": \"Видалити вибрані документи\",\n      \"title\": \"Видалити документи\",\n      \"description\": \"Це назавжди видалить вибрані документи з системи\",\n      \"warning\": \"ПОПЕРЕДЖЕННЯ: Ця дія назавжди видалить вибрані документи і не може бути скасована!\",\n      \"confirm\": \"Ви дійсно хочете видалити {{count}} вибраний(их) документ(ів)?\",\n      \"confirmPrompt\": \"Введіть 'yes' для підтвердження цієї дії\",\n      \"confirmPlaceholder\": \"Введіть yes для підтвердження\",\n      \"confirmButton\": \"ТАК\",\n      \"deleteFileOption\": \"Також видалити завантажені файли\",\n      \"deleteFileTooltip\": \"Встановіть цю опцію, щоб також видалити відповідні завантажені файли на сервері\",\n      \"deleteLLMCacheOption\": \"Також видалити витягнутий кеш LLM\",\n      \"success\": \"Пайплайн видалення документів успішно запущено\",\n      \"failed\": \"Видалення документів не вдалося:\\n{{message}}\",\n      \"error\": \"Видалення документів не вдалося:\\n{{error}}\",\n      \"busy\": \"Пайплайн зайнятий, будь ласка, спробуйте пізніше\",\n      \"notAllowed\": \"Немає дозволу на виконання цієї операції\"\n    },\n    \"selectDocuments\": {\n      \"selectCurrentPage\": \"Вибрати поточну сторінку ({{count}})\",\n      \"deselectAll\": \"Зняти всі вибрані ({{count}})\"\n    },\n    \"uploadDocuments\": {\n      \"button\": \"Завантажити\",\n      \"tooltip\": \"Завантажити документи\",\n      \"title\": \"Завантажити документи\",\n      \"description\": \"Перетягніть ваші документи сюди або натисніть для перегляду.\",\n      \"single\": {\n        \"uploading\": \"Завантаження {{name}}: {{percent}}%\",\n        \"success\": \"Завантаження успішне:\\n{{name}} успішно завантажено\",\n        \"failed\": \"Завантаження не вдалося:\\n{{name}}\\n{{message}}\",\n        \"error\": \"Завантаження не вдалося:\\n{{name}}\\n{{error}}\"\n      },\n      \"batch\": {\n        \"uploading\": \"Завантаження файлів...\",\n        \"success\": \"Файли успішно завантажено\",\n        \"error\": \"Деякі файли не вдалося завантажити\"\n      },\n      \"generalError\": \"Завантаження не вдалося\\n{{error}}\",\n      \"fileTypes\": \"Підтримувані типи: TXT, MD, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, H, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS\",\n      \"fileUploader\": {\n        \"singleFileLimit\": \"Не можна завантажити більше 1 файлу одночасно\",\n        \"maxFilesLimit\": \"Не можна завантажити більше {{count}} файлів\",\n        \"fileRejected\": \"Файл {{name}} було відхилено\",\n        \"unsupportedType\": \"Непідтримуваний тип файлу\",\n        \"fileTooLarge\": \"Файл занадто великий, максимальний розмір {{maxSize}}\",\n        \"dropHere\": \"Перетягніть файли сюди\",\n        \"dragAndDrop\": \"Перетягніть файли сюди або натисніть для вибору файлів\",\n        \"removeFile\": \"Видалити файл\",\n        \"uploadDescription\": \"Ви можете завантажити {{isMultiple ? 'кілька' : count}} файлів (до {{maxSize}} кожен)\",\n        \"duplicateFile\": \"Ім'я файлу вже існує в кеші сервера\"\n      }\n    },\n    \"documentManager\": {\n      \"title\": \"Управління документами\",\n      \"scanButton\": \"Сканувати/Повторити\",\n      \"scanTooltip\": \"Сканувати та обробити документи в папці введення, а також повторно обробити всі невдалі документи\",\n      \"refreshTooltip\": \"Скинути список документів\",\n      \"pipelineStatusButton\": \"Пайплайн\",\n      \"pipelineStatusTooltip\": \"Переглянути статус пайплайну обробки документів\",\n      \"uploadedTitle\": \"Завантажені документи\",\n      \"uploadedDescription\": \"Список завантажених документів та їх статусів.\",\n      \"emptyTitle\": \"Немає документів\",\n      \"emptyDescription\": \"Ще немає завантажених документів.\",\n      \"columns\": {\n        \"id\": \"ID\",\n        \"fileName\": \"Ім'я файлу\",\n        \"summary\": \"Резюме\",\n        \"status\": \"Статус\",\n        \"length\": \"Довжина\",\n        \"chunks\": \"Чанки\",\n        \"created\": \"Створено\",\n        \"updated\": \"Оновлено\",\n        \"metadata\": \"Метадані\",\n        \"select\": \"Вибрати\"\n      },\n      \"status\": {\n        \"all\": \"Всі\",\n        \"completed\": \"Завершено\",\n        \"preprocessed\": \"Попередньо оброблено\",\n        \"processing\": \"Обробка\",\n        \"pending\": \"Очікування\",\n        \"failed\": \"Невдало\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"Не вдалося завантажити документи\\n{{error}}\",\n        \"scanFailed\": \"Не вдалося відсканувати документи\\n{{error}}\",\n        \"scanProgressFailed\": \"Не вдалося отримати прогрес сканування\\n{{error}}\"\n      },\n      \"fileNameLabel\": \"Ім'я файлу\",\n      \"showButton\": \"Показати\",\n      \"hideButton\": \"Приховати\",\n      \"showFileNameTooltip\": \"Показати ім'я файлу\",\n      \"hideFileNameTooltip\": \"Приховати ім'я файлу\"\n    },\n    \"pipelineStatus\": {\n      \"title\": \"Статус пайплайну\",\n      \"busy\": \"Пайплайн зайнятий\",\n      \"requestPending\": \"Запит очікує\",\n      \"cancellationRequested\": \"Запит на скасування\",\n      \"jobName\": \"Назва завдання\",\n      \"startTime\": \"Час початку\",\n      \"progress\": \"Прогрес\",\n      \"unit\": \"Пакет\",\n      \"pipelineMessages\": \"Повідомлення пайплайну\",\n      \"cancelButton\": \"Скасувати\",\n      \"cancelTooltip\": \"Скасувати обробку пайплайну\",\n      \"cancelConfirmTitle\": \"Підтвердити скасування пайплайну\",\n      \"cancelConfirmDescription\": \"Це перерве поточну обробку пайплайну. Ви впевнені, що хочете продовжити?\",\n      \"cancelConfirmButton\": \"Підтвердити скасування\",\n      \"cancelInProgress\": \"Скасування в процесі...\",\n      \"pipelineNotRunning\": \"Пайплайн не працює\",\n      \"cancelSuccess\": \"Запит на скасування пайплайну\",\n      \"cancelFailed\": \"Не вдалося скасувати пайплайн\\n{{error}}\",\n      \"cancelNotBusy\": \"Пайплайн не працює, немає потреби скасовувати\",\n      \"errors\": {\n        \"fetchFailed\": \"Не вдалося отримати статус пайплайну\\n{{error}}\"\n      }\n    }\n  },\n  \"graphPanel\": {\n    \"dataIsTruncated\": \"Дані графа обрізано до максимальної кількості вузлів\",\n    \"statusDialog\": {\n      \"title\": \"Налаштування сервера LightRAG\",\n      \"description\": \"Переглянути поточний статус системи та інформацію про підключення\"\n    },\n    \"legend\": \"Легенда\",\n    \"nodeTypes\": {\n      \"person\": \"Особа\",\n      \"category\": \"Категорія\",\n      \"geo\": \"Географічне\",\n      \"location\": \"Місце\",\n      \"organization\": \"Організація\",\n      \"event\": \"Подія\",\n      \"equipment\": \"Обладнання\",\n      \"weapon\": \"Зброя\",\n      \"animal\": \"Тварина\",\n      \"unknown\": \"Невідомо\",\n      \"object\": \"Об'єкт\",\n      \"group\": \"Група\",\n      \"technology\": \"Технологія\",\n      \"product\": \"Продукт\",\n      \"document\": \"Документ\",\n      \"content\": \"Контент\",\n      \"data\": \"Дані\",\n      \"artifact\": \"Артефакт\",\n      \"concept\": \"Концепція\",\n      \"naturalobject\": \"Природний об'єкт\",\n      \"method\": \"Метод\",\n      \"creature\": \"Істота\",\n      \"plant\": \"Рослина\",\n      \"disease\": \"Хвороба\",\n      \"drug\": \"Ліки\",\n      \"food\": \"Їжа\",\n      \"other\": \"Інше\"\n    },\n    \"sideBar\": {\n      \"settings\": {\n        \"settings\": \"Налаштування\",\n        \"healthCheck\": \"Перевірка здоров'я\",\n        \"showPropertyPanel\": \"Показати панель властивостей\",\n        \"showSearchBar\": \"Показати панель пошуку\",\n        \"showNodeLabel\": \"Показати мітку вузла\",\n        \"nodeDraggable\": \"Вузол перетягуваний\",\n        \"showEdgeLabel\": \"Показати мітку ребра\",\n        \"hideUnselectedEdges\": \"Приховати невибрані ребра\",\n        \"edgeEvents\": \"Події ребер\",\n        \"maxQueryDepth\": \"Максимальна глибина запиту\",\n        \"maxNodes\": \"Максимальна кількість вузлів\",\n        \"maxLayoutIterations\": \"Максимальна кількість ітерацій макета\",\n        \"resetToDefault\": \"Скинути до за замовчуванням\",\n        \"edgeSizeRange\": \"Діапазон розміру ребер\",\n        \"depth\": \"Г\",\n        \"max\": \"Макс\",\n        \"degree\": \"Ступінь\",\n        \"apiKey\": \"API ключ\",\n        \"enterYourAPIkey\": \"Введіть ваш API ключ\",\n        \"save\": \"Зберегти\",\n        \"refreshLayout\": \"Оновити макет\"\n      },\n      \"zoomControl\": {\n        \"zoomIn\": \"Збільшити\",\n        \"zoomOut\": \"Зменшити\",\n        \"resetZoom\": \"Скинути масштаб\",\n        \"rotateCamera\": \"Повернути за годинниковою стрілкою\",\n        \"rotateCameraCounterClockwise\": \"Повернути проти годинникової стрілки\"\n      },\n      \"layoutsControl\": {\n        \"startAnimation\": \"Продовжити анімацію макета\",\n        \"stopAnimation\": \"Зупинити анімацію макета\",\n        \"layoutGraph\": \"Макет графа\",\n        \"layouts\": {\n          \"Circular\": \"Круговий\",\n          \"Circlepack\": \"Кругове упакування\",\n          \"Random\": \"Випадковий\",\n          \"Noverlaps\": \"Без перекриттів\",\n          \"Force Directed\": \"Силово-направлений\",\n          \"Force Atlas\": \"Force Atlas\"\n        }\n      },\n      \"fullScreenControl\": {\n        \"fullScreen\": \"Повноекранний режим\",\n        \"windowed\": \"Віконний режим\"\n      },\n      \"legendControl\": {\n        \"toggleLegend\": \"Перемкнути легенду\"\n      }\n    },\n    \"statusIndicator\": {\n      \"connected\": \"Підключено\",\n      \"disconnected\": \"Відключено\"\n    },\n    \"statusCard\": {\n      \"unavailable\": \"Інформація про статус недоступна\",\n      \"serverInfo\": \"Інформація про сервер\",\n      \"workingDirectory\": \"Робоча директорія\",\n      \"inputDirectory\": \"Вхідна директорія\",\n      \"maxParallelInsert\": \"Паралельна обробка документів\",\n      \"summarySettings\": \"Налаштування резюме\",\n      \"llmConfig\": \"Конфігурація LLM\",\n      \"llmBinding\": \"Прив'язка LLM\",\n      \"llmBindingHost\": \"Кінцева точка LLM\",\n      \"llmModel\": \"Модель LLM\",\n      \"embeddingConfig\": \"Конфігурація вбудовування\",\n      \"embeddingBinding\": \"Прив'язка вбудовування\",\n      \"embeddingBindingHost\": \"Кінцева точка вбудовування\",\n      \"embeddingModel\": \"Модель вбудовування\",\n      \"storageConfig\": \"Конфігурація сховища\",\n      \"kvStorage\": \"KV сховище\",\n      \"docStatusStorage\": \"Сховище статусу документів\",\n      \"graphStorage\": \"Сховище графа\",\n      \"vectorStorage\": \"Векторне сховище\",\n      \"workspace\": \"Робочий простір\",\n      \"maxGraphNodes\": \"Максимальна кількість вузлів графа\",\n      \"rerankerConfig\": \"Конфігурація реранкера\",\n      \"rerankerBindingHost\": \"Кінцева точка реранкера\",\n      \"rerankerModel\": \"Модель реранкера\",\n      \"lockStatus\": \"Статус блокування\",\n      \"threshold\": \"Поріг\"\n    },\n    \"propertiesView\": {\n      \"editProperty\": \"Редагувати {{property}}\",\n      \"editPropertyDescription\": \"Редагуйте значення властивості в текстовій області нижче.\",\n      \"errors\": {\n        \"duplicateName\": \"Ім'я вузла вже існує\",\n        \"updateFailed\": \"Не вдалося оновити вузол\",\n        \"tryAgainLater\": \"Будь ласка, спробуйте пізніше\",\n        \"updateSuccessButMergeFailed\": \"Властивості оновлено, але об'єднання не вдалося: {{error}}\",\n        \"mergeFailed\": \"Об'єднання не вдалося: {{error}}\"\n      },\n      \"success\": {\n        \"entityUpdated\": \"Вузол успішно оновлено\",\n        \"relationUpdated\": \"Відношення успішно оновлено\",\n        \"entityMerged\": \"Вузли успішно об'єднано\"\n      },\n      \"mergeOptionLabel\": \"Автоматично об'єднувати, коли знайдено дублікат імені\",\n      \"mergeOptionDescription\": \"Якщо увімкнено, перейменування на існуюче ім'я об'єднає цей вузол з існуючим замість невдачі.\",\n      \"mergeDialog\": {\n        \"title\": \"Вузол об'єднано\",\n        \"description\": \"\\\"{{source}}\\\" було об'єднано з \\\"{{target}}\\\".\",\n        \"refreshHint\": \"Оновіть граф, щоб завантажити останню структуру.\",\n        \"keepCurrentStart\": \"Оновити та зберегти поточний початковий вузол\",\n        \"useMergedStart\": \"Оновити та використати об'єднаний вузол\",\n        \"refreshing\": \"Оновлення графа...\"\n      },\n      \"node\": {\n        \"title\": \"Вузол\",\n        \"id\": \"ID\",\n        \"labels\": \"Мітки\",\n        \"degree\": \"Ступінь\",\n        \"properties\": \"Властивості\",\n        \"relationships\": \"Відношення (в межах підграфа)\",\n        \"expandNode\": \"Розширити вузол\",\n        \"pruneNode\": \"Обрізати вузол\",\n        \"deleteAllNodesError\": \"Відмова видалити всі вузли в графі\",\n        \"nodesRemoved\": \"{{count}} вузлів видалено, включаючи сирітські вузли\",\n        \"noNewNodes\": \"Розширюваних вузлів не знайдено\",\n        \"propertyNames\": {\n          \"description\": \"Опис\",\n          \"entity_id\": \"Ім'я\",\n          \"entity_type\": \"Тип\",\n          \"source_id\": \"C-ID\",\n          \"Neighbour\": \"Сусід\",\n          \"file_path\": \"Файл\",\n          \"keywords\": \"Ключі\",\n          \"weight\": \"Вага\"\n        }\n      },\n      \"edge\": {\n        \"title\": \"Відношення\",\n        \"id\": \"ID\",\n        \"type\": \"Тип\",\n        \"source\": \"Джерело\",\n        \"target\": \"Ціль\",\n        \"properties\": \"Властивості\"\n      }\n    },\n    \"search\": {\n      \"placeholder\": \"Шукати вузли на сторінці...\",\n      \"message\": \"Та {{count}} інших\"\n    },\n    \"graphLabels\": {\n      \"selectTooltip\": \"Отримати підграф вузла (мітка)\",\n      \"noLabels\": \"Відповідних вузлів не знайдено\",\n      \"label\": \"Шукати ім'я вузла\",\n      \"placeholder\": \"Шукати ім'я вузла...\",\n      \"andOthers\": \"Та {{count}} інших\",\n      \"refreshGlobalTooltip\": \"Оновити глобальні дані графа та скинути історію пошуку\",\n      \"refreshCurrentLabelTooltip\": \"Оновити дані графа поточної сторінки\",\n      \"refreshingTooltip\": \"Оновлення даних...\"\n    },\n    \"emptyGraph\": \"Порожньо (Спробуйте перезавантажити знову)\"\n  },\n  \"retrievePanel\": {\n    \"chatMessage\": {\n      \"copyTooltip\": \"Копіювати в буфер обміну\",\n      \"copyError\": \"Не вдалося скопіювати текст в буфер обміну\",\n      \"copyEmpty\": \"Немає вмісту для копіювання\",\n      \"copySuccess\": \"Вміст скопійовано в буфер обміну\",\n      \"copySuccessLegacy\": \"Вміст скопійовано (застарілий метод)\",\n      \"copySuccessManual\": \"Вміст скопійовано (ручний метод)\",\n      \"copyFailed\": \"Не вдалося скопіювати вміст\",\n      \"copyManualInstruction\": \"Будь ласка, виберіть та скопіюйте текст вручну\",\n      \"thinking\": \"Мислення...\",\n      \"thinkingTime\": \"Час мислення {{time}}с\",\n      \"thinkingInProgress\": \"Мислення в процесі...\"\n    },\n    \"retrieval\": {\n      \"startPrompt\": \"Почніть пошук, ввівши ваш запит нижче\",\n      \"clear\": \"Очистити\",\n      \"send\": \"Відправити\",\n      \"placeholder\": \"Введіть ваш запит (Підтримка префіксу: /<Режим запиту>)\",\n      \"error\": \"Помилка: Не вдалося отримати відповідь\",\n      \"queryModeError\": \"Підтримуються лише наступні режими запиту: {{modes}}\",\n      \"queryModePrefixInvalid\": \"Недійсний префікс режиму запиту. Використовуйте: /<mode> [пробіл] ваш запит\"\n    },\n    \"querySettings\": {\n      \"parametersTitle\": \"Параметри\",\n      \"parametersDescription\": \"Налаштуйте параметри вашого запиту\",\n      \"queryMode\": \"Режим запиту\",\n      \"queryModeTooltip\": \"Виберіть стратегію пошуку:\\n• Naive: Традиційний пошук векторів текстових чанків\\n• Local: Фокус на пошуку сутностей\\n• Global: Фокус на пошуку відношень\\n• Hybrid: Local+Global\\n• Mix: Local+Global+Naive\\n• Bypass: Пропустити пошук, надіслати історію розмови та поточне питання в LLM\",\n      \"queryModeOptions\": {\n        \"naive\": \"Naive\",\n        \"local\": \"Local\",\n        \"global\": \"Global\",\n        \"hybrid\": \"Hybrid\",\n        \"mix\": \"Mix\",\n        \"bypass\": \"Bypass\"\n      },\n      \"responseFormat\": \"Формат відповіді\",\n      \"responseFormatTooltip\": \"Визначає формат відповіді. Приклади:\\n• Кілька абзаців\\n• Один абзац\\n• Маркований список\",\n      \"responseFormatOptions\": {\n        \"multipleParagraphs\": \"Кілька абзаців\",\n        \"singleParagraph\": \"Один абзац\",\n        \"bulletPoints\": \"Маркований список\"\n      },\n      \"topK\": \"KG Top K\",\n      \"topKTooltip\": \"Кількість сутностей та відношень для отримання. Застосовується для не-naive режимів.\",\n      \"topKPlaceholder\": \"Введіть значення top_k\",\n      \"chunkTopK\": \"Chunk Top K\",\n      \"chunkTopKTooltip\": \"Кількість текстових чанків для отримання, застосовується для всіх режимів.\",\n      \"chunkTopKPlaceholder\": \"Введіть значення chunk_top_k\",\n      \"maxEntityTokens\": \"Макс. токенів сутностей\",\n      \"maxEntityTokensTooltip\": \"Максимальна кількість токенів, виділених для контексту сутностей в уніфікованій системі контролю токенів\",\n      \"maxRelationTokens\": \"Макс. токенів відношень\",\n      \"maxRelationTokensTooltip\": \"Максимальна кількість токенів, виділених для контексту відношень в уніфікованій системі контролю токенів\",\n      \"maxTotalTokens\": \"Макс. загальна кількість токенів\",\n      \"maxTotalTokensTooltip\": \"Максимальний загальний бюджет токенів для всього контексту запиту (сутності + відношення + чанки + системний промпт)\",\n      \"historyTurns\": \"Хідів історії\",\n      \"historyTurnsTooltip\": \"Кількість повних ходів розмови (пари користувач-асистент) для врахування в контексті відповіді\",\n      \"historyTurnsPlaceholder\": \"Кількість ходів історії\",\n      \"onlyNeedContext\": \"Потрібен лише контекст\",\n      \"onlyNeedContextTooltip\": \"Якщо True, повертає лише отриманий контекст без генерації відповіді\",\n      \"onlyNeedPrompt\": \"Потрібен лише промпт\",\n      \"onlyNeedPromptTooltip\": \"Якщо True, повертає лише згенерований промпт без створення відповіді\",\n      \"streamResponse\": \"Потокова відповідь\",\n      \"streamResponseTooltip\": \"Якщо True, увімкнює потоковий вивід для відповідей у реальному часі\",\n      \"userPrompt\": \"Додатковий промпт виводу\",\n      \"userPromptTooltip\": \"Надайте додаткові вимоги до відповіді для LLM (не пов'язані з вмістом запиту, лише для обробки виводу).\",\n      \"userPromptPlaceholder\": \"Введіть користувацький промпт (необов'язково)\",\n      \"enableRerank\": \"Увімкнути реранк\",\n      \"enableRerankTooltip\": \"Увімкнути реранкінг для отриманих текстових чанків. Якщо True, але модель реранкера не налаштована, буде видано попередження. За замовчуванням True.\"\n    }\n  },\n  \"apiSite\": {\n    \"loading\": \"Завантаження документації API...\"\n  },\n  \"apiKeyAlert\": {\n    \"title\": \"Потрібен API ключ\",\n    \"description\": \"Будь ласка, введіть ваш API ключ для доступу до сервісу\",\n    \"placeholder\": \"Введіть ваш API ключ\",\n    \"save\": \"Зберегти\"\n  },\n  \"pagination\": {\n    \"showing\": \"Показано {{start}} до {{end}} з {{total}} записів\",\n    \"page\": \"Сторінка\",\n    \"pageSize\": \"Розмір сторінки\",\n    \"firstPage\": \"Перша сторінка\",\n    \"prevPage\": \"Попередня сторінка\",\n    \"nextPage\": \"Наступна сторінка\",\n    \"lastPage\": \"Остання сторінка\"\n  }\n}\n"
  },
  {
    "path": "lightrag_webui/src/locales/vi.json",
    "content": "{\n  \"settings\": {\n    \"language\": \"Ngôn ngữ\",\n    \"theme\": \"Giao diện\",\n    \"light\": \"Sáng\",\n    \"dark\": \"Tối\",\n    \"system\": \"Hệ thống\"\n  },\n  \"header\": {\n    \"documents\": \"Tài liệu\",\n    \"knowledgeGraph\": \"Đồ thị tri thức\",\n    \"retrieval\": \"Truy xuất\",\n    \"api\": \"API\",\n    \"projectRepository\": \"Kho dự án\",\n    \"logout\": \"Đăng xuất\",\n    \"frontendNeedsRebuild\": \"Giao diện cần được xây dựng lại\",\n    \"themeToggle\": {\n      \"switchToLight\": \"Chuyển sang giao diện sáng\",\n      \"switchToDark\": \"Chuyển sang giao diện tối\"\n    }\n  },\n  \"login\": {\n    \"description\": \"Vui lòng nhập tài khoản và mật khẩu để đăng nhập vào hệ thống\",\n    \"username\": \"Tên người dùng\",\n    \"usernamePlaceholder\": \"Vui lòng nhập tên người dùng\",\n    \"password\": \"Mật khẩu\",\n    \"passwordPlaceholder\": \"Vui lòng nhập mật khẩu\",\n    \"loginButton\": \"Đăng nhập\",\n    \"loggingIn\": \"Đang đăng nhập...\",\n    \"successMessage\": \"Đăng nhập thành công\",\n    \"errorEmptyFields\": \"Vui lòng nhập tên người dùng và mật khẩu\",\n    \"errorInvalidCredentials\": \"Đăng nhập thất bại, vui lòng kiểm tra tên người dùng và mật khẩu\",\n    \"authDisabled\": \"Xác thực bị tắt. Đang sử dụng chế độ không cần đăng nhập.\",\n    \"guestMode\": \"Không cần đăng nhập\"\n  },\n  \"common\": {\n    \"cancel\": \"Hủy\",\n    \"save\": \"Lưu\",\n    \"saving\": \"Đang lưu...\",\n    \"saveFailed\": \"Lưu thất bại\"\n  },\n  \"documentPanel\": {\n    \"clearDocuments\": {\n      \"button\": \"Xóa tất cả\",\n      \"tooltip\": \"Xóa tài liệu\",\n      \"title\": \"Xóa Tài Liệu\",\n      \"description\": \"Thao tác này sẽ xóa tất cả tài liệu khỏi hệ thống\",\n      \"warning\": \"CẢNH BÁO: Hành động này sẽ xóa vĩnh viễn tất cả tài liệu và không thể hoàn tác!\",\n      \"confirm\": \"Bạn có thực sự muốn xóa tất cả tài liệu không?\",\n      \"confirmPrompt\": \"Nhập 'yes' để xác nhận hành động này\",\n      \"confirmPlaceholder\": \"Nhập yes để xác nhận\",\n      \"clearCache\": \"Xóa bộ nhớ cache LLM\",\n      \"confirmButton\": \"CÓ\",\n      \"clearing\": \"Đang xóa...\",\n      \"timeout\": \"Thao tác xóa hết thời gian, vui lòng thử lại\",\n      \"success\": \"Đã xóa tài liệu thành công\",\n      \"cacheCleared\": \"Đã xóa bộ nhớ cache thành công\",\n      \"cacheClearFailed\": \"Xóa bộ nhớ cache thất bại:\\n{{error}}\",\n      \"failed\": \"Xóa Tài Liệu Thất Bại:\\n{{message}}\",\n      \"error\": \"Xóa Tài Liệu Thất Bại:\\n{{error}}\"\n    },\n    \"deleteDocuments\": {\n      \"button\": \"Xóa\",\n      \"tooltip\": \"Xóa tài liệu đã chọn\",\n      \"title\": \"Xóa Tài Liệu\",\n      \"description\": \"Thao tác này sẽ xóa vĩnh viễn các tài liệu đã chọn khỏi hệ thống\",\n      \"warning\": \"CẢNH BÁO: Hành động này sẽ xóa vĩnh viễn các tài liệu đã chọn và không thể hoàn tác!\",\n      \"confirm\": \"Bạn có thực sự muốn xóa {{count}} tài liệu đã chọn không?\",\n      \"confirmPrompt\": \"Nhập 'yes' để xác nhận hành động này\",\n      \"confirmPlaceholder\": \"Nhập yes để xác nhận\",\n      \"confirmButton\": \"CÓ\",\n      \"deleteFileOption\": \"Cũng xóa các tệp đã tải lên\",\n      \"deleteFileTooltip\": \"Chọn tùy chọn này để cũng xóa các tệp đã tải lên tương ứng trên máy chủ\",\n      \"deleteLLMCacheOption\": \"Cũng xóa cache LLM đã trích xuất\",\n      \"success\": \"Đã bắt đầu quy trình xóa tài liệu thành công\",\n      \"failed\": \"Xóa Tài Liệu Thất Bại:\\n{{message}}\",\n      \"error\": \"Xóa Tài Liệu Thất Bại:\\n{{error}}\",\n      \"busy\": \"Quy trình đang bận, vui lòng thử lại sau\",\n      \"notAllowed\": \"Không có quyền thực hiện thao tác này\"\n    },\n    \"selectDocuments\": {\n      \"selectCurrentPage\": \"Chọn Trang Hiện Tại ({{count}})\",\n      \"deselectAll\": \"Bỏ Chọn Tất Cả ({{count}})\"\n    },\n    \"uploadDocuments\": {\n      \"button\": \"Tải lên\",\n      \"tooltip\": \"Tải lên tài liệu\",\n      \"title\": \"Tải Lên Tài Liệu\",\n      \"description\": \"Kéo và thả tài liệu vào đây hoặc nhấp để duyệt.\",\n      \"single\": {\n        \"uploading\": \"Đang tải lên {{name}}: {{percent}}%\",\n        \"success\": \"Tải Lên Thành Công:\\n{{name}} đã được tải lên thành công\",\n        \"failed\": \"Tải Lên Thất Bại:\\n{{name}}\\n{{message}}\",\n        \"error\": \"Tải Lên Thất Bại:\\n{{name}}\\n{{error}}\"\n      },\n      \"batch\": {\n        \"uploading\": \"Đang tải lên các tệp...\",\n        \"success\": \"Các tệp đã được tải lên thành công\",\n        \"error\": \"Một số tệp tải lên thất bại\"\n      },\n      \"generalError\": \"Tải Lên Thất Bại\\n{{error}}\",\n      \"fileTypes\": \"Các loại được hỗ trợ: TXT, MD, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, H, CPP, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS\",\n      \"fileUploader\": {\n        \"singleFileLimit\": \"Không thể tải lên nhiều hơn 1 tệp một lần\",\n        \"maxFilesLimit\": \"Không thể tải lên nhiều hơn {{count}} tệp\",\n        \"fileRejected\": \"Tệp {{name}} đã bị từ chối\",\n        \"unsupportedType\": \"Loại tệp không được hỗ trợ\",\n        \"fileTooLarge\": \"Tệp quá lớn, kích thước tối đa là {{maxSize}}\",\n        \"dropHere\": \"Thả tệp vào đây\",\n        \"dragAndDrop\": \"Kéo và thả tệp vào đây, hoặc nhấp để chọn tệp\",\n        \"removeFile\": \"Xóa tệp\",\n        \"uploadDescription\": \"Bạn có thể tải lên {{isMultiple ? 'nhiều' : count}} tệp (tối đa {{maxSize}} mỗi tệp)\",\n        \"duplicateFile\": \"Tên tệp đã tồn tại trong bộ nhớ cache của máy chủ\"\n      }\n    },\n    \"documentManager\": {\n      \"title\": \"Quản Lý Tài Liệu\",\n      \"scanButton\": \"Quét/Thử lại\",\n      \"scanTooltip\": \"Quét và xử lý tài liệu trong thư mục đầu vào, đồng thời xử lý lại tất cả tài liệu thất bại\",\n      \"refreshTooltip\": \"Đặt lại danh sách tài liệu\",\n      \"pipelineStatusButton\": \"Quy trình\",\n      \"pipelineStatusTooltip\": \"Xem trạng thái quy trình xử lý tài liệu\",\n      \"uploadedTitle\": \"Tài Liệu Đã Tải Lên\",\n      \"uploadedDescription\": \"Danh sách các tài liệu đã tải lên và trạng thái của chúng.\",\n      \"emptyTitle\": \"Không Có Tài Liệu\",\n      \"emptyDescription\": \"Chưa có tài liệu nào được tải lên.\",\n      \"columns\": {\n        \"id\": \"ID\",\n        \"fileName\": \"Tên Tệp\",\n        \"summary\": \"Tóm Tắt\",\n        \"status\": \"Trạng Thái\",\n        \"length\": \"Độ Dài\",\n        \"chunks\": \"Đoạn\",\n        \"created\": \"Ngày Tạo\",\n        \"updated\": \"Ngày Cập Nhật\",\n        \"metadata\": \"Siêu Dữ Liệu\",\n        \"select\": \"Chọn\"\n      },\n      \"status\": {\n        \"all\": \"Tất cả\",\n        \"completed\": \"Hoàn thành\",\n        \"preprocessed\": \"Đã tiền xử lý\",\n        \"processing\": \"Đang xử lý\",\n        \"pending\": \"Đang chờ\",\n        \"failed\": \"Thất bại\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"Tải tài liệu thất bại\\n{{error}}\",\n        \"scanFailed\": \"Quét tài liệu thất bại\\n{{error}}\",\n        \"scanProgressFailed\": \"Lấy tiến trình quét thất bại\\n{{error}}\"\n      },\n      \"fileNameLabel\": \"Tên Tệp\",\n      \"showButton\": \"Hiện\",\n      \"hideButton\": \"Ẩn\",\n      \"showFileNameTooltip\": \"Hiện tên tệp\",\n      \"hideFileNameTooltip\": \"Ẩn tên tệp\"\n    },\n    \"pipelineStatus\": {\n      \"title\": \"Trạng Thái Quy Trình\",\n      \"busy\": \"Quy Trình Đang Bận\",\n      \"requestPending\": \"Yêu Cầu Đang Chờ\",\n      \"cancellationRequested\": \"Đã Yêu Cầu Hủy\",\n      \"jobName\": \"Tên Công Việc\",\n      \"startTime\": \"Thời Gian Bắt Đầu\",\n      \"progress\": \"Tiến Trình\",\n      \"unit\": \"Lô\",\n      \"pipelineMessages\": \"Thông Báo Quy Trình\",\n      \"cancelButton\": \"Hủy\",\n      \"cancelTooltip\": \"Hủy xử lý quy trình\",\n      \"cancelConfirmTitle\": \"Xác Nhận Hủy Quy Trình\",\n      \"cancelConfirmDescription\": \"Thao tác này sẽ ngắt quá trình xử lý đang diễn ra. Bạn có chắc chắn muốn tiếp tục không?\",\n      \"cancelConfirmButton\": \"Xác Nhận Hủy\",\n      \"cancelInProgress\": \"Đang hủy...\",\n      \"pipelineNotRunning\": \"Quy trình không đang chạy\",\n      \"cancelSuccess\": \"Đã yêu cầu hủy quy trình\",\n      \"cancelFailed\": \"Hủy quy trình thất bại\\n{{error}}\",\n      \"cancelNotBusy\": \"Quy trình không đang chạy, không cần hủy\",\n      \"errors\": {\n        \"fetchFailed\": \"Lấy trạng thái quy trình thất bại\\n{{error}}\"\n      }\n    }\n  },\n  \"graphPanel\": {\n    \"dataIsTruncated\": \"Dữ liệu đồ thị bị cắt bớt đến số Nút tối Đa\",\n    \"statusDialog\": {\n      \"title\": \"Cài Đặt Máy Chủ LightRAG\",\n      \"description\": \"Xem trạng thái hệ thống hiện tại và thông tin kết nối\"\n    },\n    \"legend\": \"Chú thích\",\n    \"nodeTypes\": {\n      \"person\": \"Người\",\n      \"category\": \"Danh Mục\",\n      \"geo\": \"Địa Lý\",\n      \"location\": \"Địa Điểm\",\n      \"organization\": \"Tổ Chức\",\n      \"event\": \"Sự Kiện\",\n      \"equipment\": \"Thiết Bị\",\n      \"weapon\": \"Vũ Khí\",\n      \"animal\": \"Động Vật\",\n      \"unknown\": \"Không Xác Định\",\n      \"object\": \"Đối Tượng\",\n      \"group\": \"Nhóm\",\n      \"technology\": \"Công Nghệ\",\n      \"product\": \"Sản Phẩm\",\n      \"document\": \"Tài Liệu\",\n      \"content\": \"Nội Dung\",\n      \"data\": \"Dữ Liệu\",\n      \"artifact\": \"Vật Phẩm\",\n      \"concept\": \"Khái Niệm\",\n      \"naturalobject\": \"Vật Thể Tự Nhiên\",\n      \"method\": \"Phương Pháp\",\n      \"creature\": \"Sinh Vật\",\n      \"plant\": \"Thực Vật\",\n      \"disease\": \"Bệnh\",\n      \"drug\": \"Thuốc\",\n      \"food\": \"Thực Phẩm\",\n      \"other\": \"Khác\"\n    },\n    \"sideBar\": {\n      \"settings\": {\n        \"settings\": \"Cài Đặt\",\n        \"healthCheck\": \"Kiểm Tra Kết Nối\",\n        \"showPropertyPanel\": \"Hiển Thị Bảng Thuộc Tính\",\n        \"showSearchBar\": \"Hiển Thị Thanh Tìm Kiếm\",\n        \"showNodeLabel\": \"Hiển Thị Nhãn Nút\",\n        \"nodeDraggable\": \"Cho Phép Kéo Nút\",\n        \"showEdgeLabel\": \"Hiển Thị Nhãn Cạnh\",\n        \"hideUnselectedEdges\": \"Ẩn Cạnh Không Được Chọn\",\n        \"edgeEvents\": \"Sự Kiện Cạnh\",\n        \"maxQueryDepth\": \"Độ Sâu Truy Vấn Tối Đa\",\n        \"maxNodes\": \"Số Nút Tối Đa\",\n        \"maxLayoutIterations\": \"Số Vòng Lặp Bố Cục Tối Đa\",\n        \"resetToDefault\": \"Đặt lại về mặc định\",\n        \"edgeSizeRange\": \"Phạm Vi Kích Thước Cạnh\",\n        \"depth\": \"Sâu\",\n        \"max\": \"Tối Đa\",\n        \"degree\": \"Bậc\",\n        \"apiKey\": \"Khóa API\",\n        \"enterYourAPIkey\": \"Nhập khóa API của bạn\",\n        \"save\": \"Lưu\",\n        \"refreshLayout\": \"Làm Mới Bố Cục\"\n      },\n      \"zoomControl\": {\n        \"zoomIn\": \"Phóng To\",\n        \"zoomOut\": \"Thu Nhỏ\",\n        \"resetZoom\": \"Đặt Lại Thu Phóng\",\n        \"rotateCamera\": \"Xoay Theo Chiều Kim Đồng Hồ\",\n        \"rotateCameraCounterClockwise\": \"Xoay Ngược Chiều Kim Đồng Hồ\"\n      },\n      \"layoutsControl\": {\n        \"startAnimation\": \"Tiếp tục hoạt ảnh bố cục\",\n        \"stopAnimation\": \"Dừng hoạt ảnh bố cục\",\n        \"layoutGraph\": \"Bố Cục Đồ Thị\",\n        \"layouts\": {\n          \"Circular\": \"Circular\",\n          \"Circlepack\": \"Circlepack\",\n          \"Random\": \"Random\",\n          \"Noverlaps\": \"Noverlaps\",\n          \"Force Directed\": \"Force Directed\",\n          \"Force Atlas\": \"Force Atlas\"\n        }\n      },\n      \"fullScreenControl\": {\n        \"fullScreen\": \"Toàn Màn Hình\",\n        \"windowed\": \"Chế Độ Cửa Sổ\"\n      },\n      \"legendControl\": {\n        \"toggleLegend\": \"Bật/Tắt Chú Thích\"\n      }\n    },\n    \"statusIndicator\": {\n      \"connected\": \"Đã kết nối\",\n      \"disconnected\": \"Mất kết nối\"\n    },\n    \"statusCard\": {\n      \"unavailable\": \"Thông tin trạng thái không khả dụng\",\n      \"serverInfo\": \"Thông Tin Máy Chủ\",\n      \"workingDirectory\": \"Thư Mục Làm Việc\",\n      \"inputDirectory\": \"Thư Mục Đầu Vào\",\n      \"maxParallelInsert\": \"Xử Lý Tài Liệu Đồng Thời\",\n      \"summarySettings\": \"Cài Đặt Tóm Tắt\",\n      \"llmConfig\": \"Cấu Hình LLM\",\n      \"llmBinding\": \"LLM Binding\",\n      \"llmBindingHost\": \"Điểm Cuối LLM\",\n      \"llmModel\": \"Mô Hình LLM\",\n      \"embeddingConfig\": \"Cấu Hình Embedding\",\n      \"embeddingBinding\": \"Embedding Binding\",\n      \"embeddingBindingHost\": \"Điểm Cuối Embedding\",\n      \"embeddingModel\": \"Mô Hình Embedding\",\n      \"storageConfig\": \"Cấu Hình Lưu Trữ\",\n      \"kvStorage\": \"KV Storage\",\n      \"docStatusStorage\": \"Lưu Trữ Trạng Thái Tài Liệu\",\n      \"graphStorage\": \"Graph Storage\",\n      \"vectorStorage\": \"Vector Storage\",\n      \"workspace\": \"Không Gian Làm Việc\",\n      \"maxGraphNodes\": \"Số Nút Đồ Thị Tối Đa\",\n      \"rerankerConfig\": \"Cấu Hình Reranker\",\n      \"rerankerBindingHost\": \"Điểm Cuối Reranker\",\n      \"rerankerModel\": \"Mô Hình Reranker\",\n      \"lockStatus\": \"Trạng Thái Khóa\",\n      \"threshold\": \"Ngưỡng\"\n    },\n    \"propertiesView\": {\n      \"editProperty\": \"Chỉnh Sửa {{property}}\",\n      \"editPropertyDescription\": \"Chỉnh sửa giá trị thuộc tính trong vùng văn bản bên dưới.\",\n      \"errors\": {\n        \"duplicateName\": \"Tên nút đã tồn tại\",\n        \"updateFailed\": \"Cập nhật nút thất bại\",\n        \"tryAgainLater\": \"Vui lòng thử lại sau\",\n        \"updateSuccessButMergeFailed\": \"Đã cập nhật thuộc tính, nhưng hợp nhất thất bại: {{error}}\",\n        \"mergeFailed\": \"Hợp nhất thất bại: {{error}}\"\n      },\n      \"success\": {\n        \"entityUpdated\": \"Nút đã được cập nhật thành công\",\n        \"relationUpdated\": \"Quan hệ đã được cập nhật thành công\",\n        \"entityMerged\": \"Các nút đã được hợp nhất thành công\"\n      },\n      \"mergeOptionLabel\": \"Tự động hợp nhất khi tìm thấy tên trùng lặp\",\n      \"mergeOptionDescription\": \"Nếu được bật, đổi tên thành một tên đã tồn tại sẽ hợp nhất nút này vào nút hiện có thay vì thất bại.\",\n      \"mergeDialog\": {\n        \"title\": \"Nút đã được hợp nhất\",\n        \"description\": \"\\\"{{source}}\\\" đã được hợp nhất vào \\\"{{target}}\\\".\",\n        \"refreshHint\": \"Làm mới đồ thị để tải cấu trúc mới nhất.\",\n        \"keepCurrentStart\": \"Làm mới và giữ nút bắt đầu hiện tại\",\n        \"useMergedStart\": \"Làm mới và sử dụng nút đã hợp nhất\",\n        \"refreshing\": \"Đang làm mới đồ thị...\"\n      },\n      \"node\": {\n        \"title\": \"Nút\",\n        \"id\": \"ID\",\n        \"labels\": \"Nhãn\",\n        \"degree\": \"Bậc\",\n        \"properties\": \"Thuộc Tính\",\n        \"relationships\": \"Quan Hệ (trong đồ thị con)\",\n        \"expandNode\": \"Mở Rộng Nút\",\n        \"pruneNode\": \"Thu Gọn Nút\",\n        \"deleteAllNodesError\": \"Từ chối xóa tất cả các nút trong đồ thị\",\n        \"nodesRemoved\": \"Đã xóa {{count}} nút, bao gồm các nút cô lập\",\n        \"noNewNodes\": \"Không tìm thấy nút có thể mở rộng\",\n        \"propertyNames\": {\n          \"description\": \"Mô Tả\",\n          \"entity_id\": \"Tên\",\n          \"entity_type\": \"Loại\",\n          \"source_id\": \"C-ID\",\n          \"Neighbour\": \"Láng Giềng\",\n          \"file_path\": \"Tệp\",\n          \"keywords\": \"Từ Khóa\",\n          \"weight\": \"Trọng Số\"\n        }\n      },\n      \"edge\": {\n        \"title\": \"Quan Hệ\",\n        \"id\": \"ID\",\n        \"type\": \"Loại\",\n        \"source\": \"Nguồn\",\n        \"target\": \"Đích\",\n        \"properties\": \"Thuộc Tính\"\n      }\n    },\n    \"search\": {\n      \"placeholder\": \"Tìm kiếm nút trong trang...\",\n      \"message\": \"Và {{count}} khác\"\n    },\n    \"graphLabels\": {\n      \"selectTooltip\": \"Lấy đồ thị con của một nút (nhãn)\",\n      \"noLabels\": \"Không tìm thấy nút phù hợp\",\n      \"label\": \"Tìm kiếm tên nút\",\n      \"placeholder\": \"Tìm kiếm tên nút...\",\n      \"andOthers\": \"Và {{count}} khác\",\n      \"refreshGlobalTooltip\": \"Làm mới dữ liệu đồ thị toàn cục và đặt lại lịch sử tìm kiếm\",\n      \"refreshCurrentLabelTooltip\": \"Làm mới dữ liệu đồ thị trang hiện tại\",\n      \"refreshingTooltip\": \"Đang làm mới dữ liệu...\"\n    },\n    \"emptyGraph\": \"Trống (Thử Tải Lại)\"\n  },\n  \"retrievePanel\": {\n    \"chatMessage\": {\n      \"copyTooltip\": \"Sao chép vào bộ nhớ tạm\",\n      \"copyError\": \"Sao chép văn bản vào bộ nhớ tạm thất bại\",\n      \"copyEmpty\": \"Không có nội dung để sao chép\",\n      \"copySuccess\": \"Đã sao chép nội dung vào bộ nhớ tạm\",\n      \"copySuccessLegacy\": \"Đã sao chép nội dung (phương pháp cũ)\",\n      \"copySuccessManual\": \"Đã sao chép nội dung (phương pháp thủ công)\",\n      \"copyFailed\": \"Sao chép nội dung thất bại\",\n      \"copyManualInstruction\": \"Vui lòng chọn và sao chép văn bản thủ công\",\n      \"thinking\": \"Đang suy nghĩ...\",\n      \"thinkingTime\": \"Thời gian suy nghĩ {{time}}s\",\n      \"thinkingInProgress\": \"Đang suy nghĩ...\"\n    },\n    \"retrieval\": {\n      \"startPrompt\": \"Bắt đầu truy xuất bằng cách nhập truy vấn của bạn bên dưới\",\n      \"clear\": \"Xóa\",\n      \"send\": \"Gửi\",\n      \"placeholder\": \"Nhập truy vấn của bạn (Hỗ trợ tiền tố: /<Chế Độ Truy Vấn>)\",\n      \"error\": \"Lỗi: Không thể nhận phản hồi\",\n      \"queryModeError\": \"Chỉ hỗ trợ các chế độ truy vấn sau: {{modes}}\",\n      \"queryModePrefixInvalid\": \"Tiền tố chế độ truy vấn không hợp lệ. Dùng: /<mode> [khoảng trắng] truy vấn của bạn\"\n    },\n    \"querySettings\": {\n      \"parametersTitle\": \"Tham Số\",\n      \"parametersDescription\": \"Cấu hình tham số truy vấn của bạn\",\n      \"queryMode\": \"Chế Độ Truy Vấn\",\n      \"queryModeTooltip\": \"Chọn chiến lược truy xuất:\\n• Naive: Truy xuất vector đoạn văn bản truyền thống\\n• Local: Tập trung vào truy xuất thực thể\\n• Global: Tập trung vào truy xuất quan hệ\\n• Hybrid: Local+Global\\n• Mix: Local+Global+Naive\\n• Bypass: Bỏ qua truy xuất, gửi lịch sử hội thoại và câu hỏi hiện tại đến LLM\",\n      \"queryModeOptions\": {\n        \"naive\": \"Naive\",\n        \"local\": \"Local\",\n        \"global\": \"Global\",\n        \"hybrid\": \"Hybrid\",\n        \"mix\": \"Mix\",\n        \"bypass\": \"Bypass\"\n      },\n      \"responseFormat\": \"Định Dạng Phản Hồi\",\n      \"responseFormatTooltip\": \"Xác định định dạng phản hồi. Ví dụ:\\n• Nhiều Đoạn Văn\\n• Một Đoạn Văn\\n• Danh Sách Điểm\",\n      \"responseFormatOptions\": {\n        \"multipleParagraphs\": \"Nhiều Đoạn Văn\",\n        \"singleParagraph\": \"Một Đoạn Văn\",\n        \"bulletPoints\": \"Danh Sách Điểm\"\n      },\n      \"topK\": \"KG Top K\",\n      \"topKTooltip\": \"Số lượng thực thể và quan hệ cần truy xuất. Áp dụng cho các chế độ không phải naive.\",\n      \"topKPlaceholder\": \"Nhập giá trị top_k\",\n      \"chunkTopK\": \"Chunk Top K\",\n      \"chunkTopKTooltip\": \"Số lượng đoạn văn bản cần truy xuất, áp dụng cho tất cả các chế độ.\",\n      \"chunkTopKPlaceholder\": \"Nhập giá trị chunk_top_k\",\n      \"maxEntityTokens\": \"Token Thực Thể Tối Đa\",\n      \"maxEntityTokensTooltip\": \"Số lượng token tối đa được phân bổ cho ngữ cảnh thực thể trong hệ thống kiểm soát token thống nhất\",\n      \"maxRelationTokens\": \"Token Quan Hệ Tối Đa\",\n      \"maxRelationTokensTooltip\": \"Số lượng token tối đa được phân bổ cho ngữ cảnh quan hệ trong hệ thống kiểm soát token thống nhất\",\n      \"maxTotalTokens\": \"Tổng Token Tối Đa\",\n      \"maxTotalTokensTooltip\": \"Ngân sách token tổng tối đa cho toàn bộ ngữ cảnh truy vấn (thực thể + quan hệ + đoạn + lời nhắc hệ thống)\",\n      \"historyTurns\": \"Lượt Lịch Sử\",\n      \"historyTurnsTooltip\": \"Số lượt hội thoại hoàn chỉnh (cặp người dùng-trợ lý) cần xem xét trong ngữ cảnh phản hồi\",\n      \"historyTurnsPlaceholder\": \"Số lượt lịch sử\",\n      \"onlyNeedContext\": \"Chỉ Cần Ngữ Cảnh\",\n      \"onlyNeedContextTooltip\": \"Nếu True, chỉ trả về ngữ cảnh đã truy xuất mà không tạo phản hồi\",\n      \"onlyNeedPrompt\": \"Chỉ Cần Lời Nhắc\",\n      \"onlyNeedPromptTooltip\": \"Nếu True, chỉ trả về lời nhắc đã tạo mà không tạo phản hồi\",\n      \"streamResponse\": \"Phản Hồi Theo Luồng\",\n      \"streamResponseTooltip\": \"Nếu True, bật đầu ra theo luồng cho phản hồi thời gian thực\",\n      \"userPrompt\": \"Lời Nhắc Đầu Ra Bổ Sung\",\n      \"userPromptTooltip\": \"Cung cấp yêu cầu phản hồi bổ sung cho LLM (không liên quan đến nội dung truy vấn, chỉ để xử lý đầu ra).\",\n      \"userPromptPlaceholder\": \"Nhập lời nhắc tùy chỉnh (tùy chọn)\",\n      \"enableRerank\": \"Bật Xếp Hạng Lại\",\n      \"enableRerankTooltip\": \"Bật xếp hạng lại cho các đoạn văn bản đã truy xuất. Nếu True nhưng không có mô hình rerank nào được cấu hình, một cảnh báo sẽ được đưa ra. Mặc định là True.\"\n    }\n  },\n  \"apiSite\": {\n    \"loading\": \"Đang tải tài liệu API...\"\n  },\n  \"apiKeyAlert\": {\n    \"title\": \"Cần Có Khóa API\",\n    \"description\": \"Vui lòng nhập khóa API để truy cập dịch vụ\",\n    \"placeholder\": \"Nhập khóa API của bạn\",\n    \"save\": \"Lưu\"\n  },\n  \"pagination\": {\n    \"showing\": \"Hiển thị {{start}} đến {{end}} của {{total}} mục\",\n    \"page\": \"Trang\",\n    \"pageSize\": \"Kích Thước Trang\",\n    \"firstPage\": \"Trang Đầu\",\n    \"prevPage\": \"Trang Trước\",\n    \"nextPage\": \"Trang Tiếp\",\n    \"lastPage\": \"Trang Cuối\"\n  }\n}\n"
  },
  {
    "path": "lightrag_webui/src/locales/zh.json",
    "content": "{\n  \"settings\": {\n    \"language\": \"语言\",\n    \"theme\": \"主题\",\n    \"light\": \"浅色\",\n    \"dark\": \"深色\",\n    \"system\": \"系统\"\n  },\n  \"header\": {\n    \"documents\": \"文档\",\n    \"knowledgeGraph\": \"知识图谱\",\n    \"retrieval\": \"检索\",\n    \"api\": \"API\",\n    \"projectRepository\": \"项目仓库\",\n    \"logout\": \"退出登录\",\n    \"frontendNeedsRebuild\": \"前端代码需重新构建\",\n    \"themeToggle\": {\n      \"switchToLight\": \"切换到浅色主题\",\n      \"switchToDark\": \"切换到深色主题\"\n    }\n  },\n  \"login\": {\n    \"description\": \"请输入您的账号和密码登录系统\",\n    \"username\": \"用户名\",\n    \"usernamePlaceholder\": \"请输入用户名\",\n    \"password\": \"密码\",\n    \"passwordPlaceholder\": \"请输入密码\",\n    \"loginButton\": \"登录\",\n    \"loggingIn\": \"登录中...\",\n    \"successMessage\": \"登录成功\",\n    \"errorEmptyFields\": \"请输入您的用户名和密码\",\n    \"errorInvalidCredentials\": \"登录失败，请检查用户名和密码\",\n    \"authDisabled\": \"认证已禁用，使用无需登陆模式。\",\n    \"guestMode\": \"无需登陆\"\n  },\n  \"common\": {\n    \"cancel\": \"取消\",\n    \"save\": \"保存\",\n    \"saving\": \"保存中...\",\n    \"saveFailed\": \"保存失败\"\n  },\n  \"documentPanel\": {\n    \"clearDocuments\": {\n      \"button\": \"清空\",\n      \"tooltip\": \"清空文档\",\n      \"title\": \"清空文档\",\n      \"description\": \"此操作将从系统中移除所有文档\",\n      \"warning\": \"警告：此操作将永久删除所有文档，无法恢复！\",\n      \"confirm\": \"确定要清空所有文档吗？\",\n      \"confirmPrompt\": \"请输入 yes 确认操作\",\n      \"confirmPlaceholder\": \"输入 yes 确认\",\n      \"clearCache\": \"清空LLM缓存\",\n      \"confirmButton\": \"确定\",\n      \"clearing\": \"正在清除...\",\n      \"timeout\": \"清除操作超时，请重试\",\n      \"success\": \"文档清空成功\",\n      \"cacheCleared\": \"缓存清空成功\",\n      \"cacheClearFailed\": \"清空缓存失败：\\n{{error}}\",\n      \"failed\": \"清空文档失败：\\n{{message}}\",\n      \"error\": \"清空文档失败：\\n{{error}}\"\n    },\n    \"deleteDocuments\": {\n      \"button\": \"删除\",\n      \"tooltip\": \"删除选中的文档\",\n      \"title\": \"删除文档\",\n      \"description\": \"此操作将永久删除选中的文档\",\n      \"warning\": \"警告：此操作将永久删除选中的文档，无法恢复！\",\n      \"confirm\": \"确定要删除 {{count}} 个选中的文档吗？\",\n      \"confirmPrompt\": \"请输入 yes 确认操作\",\n      \"confirmPlaceholder\": \"输入 yes 确认\",\n      \"confirmButton\": \"确定\",\n      \"deleteFileOption\": \"同时删除上传文件\",\n      \"deleteFileTooltip\": \"选中此选项将同时删除服务器上对应的上传文件\",\n      \"deleteLLMCacheOption\": \"同时删除实体关系抽取 LLM 缓存\",\n      \"success\": \"文档删除流水线启动成功\",\n      \"failed\": \"删除文档失败：\\n{{message}}\",\n      \"error\": \"删除文档失败：\\n{{error}}\",\n      \"busy\": \"流水线被占用，请稍后再试\",\n      \"notAllowed\": \"没有操作权限\"\n    },\n    \"selectDocuments\": {\n      \"selectCurrentPage\": \"全选当前页 ({{count}})\",\n      \"deselectAll\": \"取消全选 ({{count}})\"\n    },\n    \"uploadDocuments\": {\n      \"button\": \"上传\",\n      \"tooltip\": \"上传文档\",\n      \"title\": \"上传文档\",\n      \"description\": \"拖拽文件到此处或点击浏览\",\n      \"single\": {\n        \"uploading\": \"正在上传 {{name}}：{{percent}}%\",\n        \"success\": \"上传成功：\\n{{name}} 上传完成\",\n        \"failed\": \"上传失败：\\n{{name}}\\n{{message}}\",\n        \"error\": \"上传失败：\\n{{name}}\\n{{error}}\"\n      },\n      \"batch\": {\n        \"uploading\": \"正在上传文件...\",\n        \"success\": \"文件上传完成\",\n        \"error\": \"部分文件上传失败\"\n      },\n      \"generalError\": \"上传失败\\n{{error}}\",\n      \"fileTypes\": \"支持的文件类型：TXT, MD, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, H, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS\",\n      \"fileUploader\": {\n        \"singleFileLimit\": \"一次只能上传一个文件\",\n        \"maxFilesLimit\": \"最多只能上传 {{count}} 个文件\",\n        \"fileRejected\": \"文件 {{name}} 被拒绝\",\n        \"unsupportedType\": \"不支持的文件类型\",\n        \"fileTooLarge\": \"文件过大，最大允许 {{maxSize}}\",\n        \"dropHere\": \"将文件拖放到此处\",\n        \"dragAndDrop\": \"拖放文件到此处，或点击选择文件\",\n        \"removeFile\": \"移除文件\",\n        \"uploadDescription\": \"您可以上传{{isMultiple ? '多个' : count}}个文件（每个文件最大{{maxSize}}）\",\n        \"duplicateFile\": \"文件名与服务器上的缓存重复\"\n      }\n    },\n    \"documentManager\": {\n      \"title\": \"文档管理\",\n      \"scanButton\": \"扫描/重试\",\n      \"scanTooltip\": \"扫描处理输入目录中的文档，同时重新处理所有失败的文档\",\n      \"refreshTooltip\": \"复位文档清单\",\n      \"pipelineStatusButton\": \"流水线\",\n      \"pipelineStatusTooltip\": \"查看文档处理流水线状态\",\n      \"uploadedTitle\": \"已上传文档\",\n      \"uploadedDescription\": \"已上传文档列表及其状态\",\n      \"emptyTitle\": \"无文档\",\n      \"emptyDescription\": \"还没有上传任何文档\",\n      \"columns\": {\n        \"id\": \"ID\",\n        \"fileName\": \"文件名\",\n        \"summary\": \"摘要\",\n        \"status\": \"状态\",\n        \"length\": \"长度\",\n        \"chunks\": \"分块\",\n        \"created\": \"创建时间\",\n        \"updated\": \"更新时间\",\n        \"metadata\": \"元数据\",\n        \"select\": \"选择\"\n      },\n      \"status\": {\n        \"all\": \"全部\",\n        \"completed\": \"已完成\",\n        \"preprocessed\": \"预处理\",\n        \"processing\": \"处理中\",\n        \"pending\": \"等待中\",\n        \"failed\": \"失败\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"加载文档失败\\n{{error}}\",\n        \"scanFailed\": \"扫描文档失败\\n{{error}}\",\n        \"scanProgressFailed\": \"获取扫描进度失败\\n{{error}}\"\n      },\n      \"fileNameLabel\": \"文件名\",\n      \"showButton\": \"显示\",\n      \"hideButton\": \"隐藏\",\n      \"showFileNameTooltip\": \"显示文件名\",\n      \"hideFileNameTooltip\": \"隐藏文件名\"\n    },\n    \"pipelineStatus\": {\n      \"title\": \"流水线状态\",\n      \"busy\": \"流水线忙碌\",\n      \"requestPending\": \"待处理请求\",\n      \"cancellationRequested\": \"取消请求\",\n      \"jobName\": \"作业名称\",\n      \"startTime\": \"开始时间\",\n      \"progress\": \"进度\",\n      \"unit\": \"批\",\n      \"pipelineMessages\": \"流水线消息\",\n      \"cancelButton\": \"中断\",\n      \"cancelTooltip\": \"中断流水线处理\",\n      \"cancelConfirmTitle\": \"确认中断流水线\",\n      \"cancelConfirmDescription\": \"此操作将中断正在进行的流水线处理。确定要继续吗？\",\n      \"cancelConfirmButton\": \"确认中断\",\n      \"cancelInProgress\": \"取消请求进行中...\",\n      \"pipelineNotRunning\": \"流水线未运行\",\n      \"cancelSuccess\": \"流水线中断请求已发送\",\n      \"cancelFailed\": \"中断流水线失败\\n{{error}}\",\n      \"cancelNotBusy\": \"流水线未运行，无需中断\",\n      \"errors\": {\n        \"fetchFailed\": \"获取流水线状态失败\\n{{error}}\"\n      }\n    }\n  },\n  \"graphPanel\": {\n    \"dataIsTruncated\": \"图数据已截断至最大返回节点数\",\n    \"statusDialog\": {\n      \"title\": \"LightRAG 服务器设置\",\n      \"description\": \"查看当前系统状态和连接信息\"\n    },\n    \"legend\": \"图例\",\n    \"nodeTypes\": {\n      \"person\": \"人物角色\",\n      \"category\": \"分类\",\n      \"geo\": \"地理名称\",\n      \"location\": \"位置\",\n      \"organization\": \"组织机构\",\n      \"event\": \"事件\",\n      \"equipment\": \"装备\",\n      \"weapon\": \"武器\",\n      \"animal\": \"动物\",\n      \"unknown\": \"未知\",\n      \"object\": \"物品\",\n      \"group\": \"群组\",\n      \"technology\": \"技术\",\n      \"product\": \"产品\",\n      \"document\": \"文档\",\n      \"content\": \"内容\",\n      \"data\": \"数据\",\n      \"artifact\": \"人工制品\",\n      \"concept\": \"概念\",\n      \"naturalobject\": \"自然物品\",\n      \"method\": \"方法\",\n      \"creature\": \"生物神怪\",\n      \"plant\": \"植物\",\n      \"disease\": \"疾病\",\n      \"drug\": \"药物\",\n      \"food\": \"食物\",\n      \"other\": \"其他\"\n    },\n    \"sideBar\": {\n      \"settings\": {\n        \"settings\": \"设置\",\n        \"healthCheck\": \"健康检查\",\n        \"showPropertyPanel\": \"显示属性面板\",\n        \"showSearchBar\": \"显示搜索栏\",\n        \"showNodeLabel\": \"显示节点标签\",\n        \"nodeDraggable\": \"节点可拖动\",\n        \"showEdgeLabel\": \"显示边标签\",\n        \"hideUnselectedEdges\": \"隐藏未选中的边\",\n        \"edgeEvents\": \"边事件\",\n        \"maxQueryDepth\": \"最大查询深度\",\n        \"maxNodes\": \"最大返回节点数\",\n        \"maxLayoutIterations\": \"最大布局迭代次数\",\n        \"resetToDefault\": \"重置为默认值\",\n        \"edgeSizeRange\": \"边粗细范围\",\n        \"depth\": \"深\",\n        \"max\": \"Max\",\n        \"degree\": \"邻边\",\n        \"apiKey\": \"API密钥\",\n        \"enterYourAPIkey\": \"输入您的API密钥\",\n        \"save\": \"保存\",\n        \"refreshLayout\": \"刷新布局\"\n      },\n      \"zoomControl\": {\n        \"zoomIn\": \"放大\",\n        \"zoomOut\": \"缩小\",\n        \"resetZoom\": \"重置缩放\",\n        \"rotateCamera\": \"顺时针旋转图形\",\n        \"rotateCameraCounterClockwise\": \"逆时针旋转图形\"\n      },\n      \"layoutsControl\": {\n        \"startAnimation\": \"继续布局动画\",\n        \"stopAnimation\": \"停止布局动画\",\n        \"layoutGraph\": \"图布局\",\n        \"layouts\": {\n          \"Circular\": \"环形\",\n          \"Circlepack\": \"圆形打包\",\n          \"Random\": \"随机\",\n          \"Noverlaps\": \"无重叠\",\n          \"Force Directed\": \"力导向\",\n          \"Force Atlas\": \"力地图\"\n        }\n      },\n      \"fullScreenControl\": {\n        \"fullScreen\": \"全屏\",\n        \"windowed\": \"窗口\"\n      },\n      \"legendControl\": {\n        \"toggleLegend\": \"切换图例显示\"\n      }\n    },\n    \"statusIndicator\": {\n      \"connected\": \"已连接\",\n      \"disconnected\": \"未连接\"\n    },\n    \"statusCard\": {\n      \"unavailable\": \"状态信息不可用\",\n      \"serverInfo\": \"服务器信息\",\n      \"workingDirectory\": \"工作目录\",\n      \"inputDirectory\": \"输入目录\",\n      \"maxParallelInsert\": \"并行处理文档\",\n      \"summarySettings\": \"摘要设置\",\n      \"llmConfig\": \"LLM配置\",\n      \"llmBinding\": \"LLM绑定\",\n      \"llmBindingHost\": \"LLM端点\",\n      \"llmModel\": \"LLM模型\",\n      \"embeddingConfig\": \"嵌入配置\",\n      \"embeddingBinding\": \"嵌入绑定\",\n      \"embeddingBindingHost\": \"嵌入端点\",\n      \"embeddingModel\": \"嵌入模型\",\n      \"storageConfig\": \"存储配置\",\n      \"kvStorage\": \"KV存储\",\n      \"docStatusStorage\": \"文档状态存储\",\n      \"graphStorage\": \"图存储\",\n      \"vectorStorage\": \"向量存储\",\n      \"workspace\": \"工作空间\",\n      \"maxGraphNodes\": \"最大图节点数\",\n      \"rerankerConfig\": \"重排序配置\",\n      \"rerankerBindingHost\": \"重排序端点\",\n      \"rerankerModel\": \"重排序模型\",\n      \"lockStatus\": \"锁状态\",\n      \"threshold\": \"阈值\"\n    },\n    \"propertiesView\": {\n      \"editProperty\": \"编辑{{property}}\",\n      \"editPropertyDescription\": \"在下方文本区域编辑属性值。\",\n      \"errors\": {\n        \"duplicateName\": \"节点名称已存在\",\n        \"updateFailed\": \"更新节点失败\",\n        \"tryAgainLater\": \"请稍后重试\",\n        \"updateSuccessButMergeFailed\": \"属性已更新，但合并失败：{{error}}\",\n        \"mergeFailed\": \"合并失败：{{error}}\"\n      },\n      \"success\": {\n        \"entityUpdated\": \"节点更新成功\",\n        \"relationUpdated\": \"关系更新成功\",\n        \"entityMerged\": \"节点合并成功\"\n      },\n      \"mergeOptionLabel\": \"重名时自动合并\",\n      \"mergeOptionDescription\": \"勾选后，重命名为已存在的名称会将当前节点自动合并过去，而不会报错。\",\n      \"mergeDialog\": {\n        \"title\": \"节点已合并\",\n        \"description\": \"\\\"{{source}}\\\" 已合并到 \\\"{{target}}\\\"。\",\n        \"refreshHint\": \"请刷新图谱以获取最新结构。\",\n        \"keepCurrentStart\": \"刷新并保持当前起始节点\",\n        \"useMergedStart\": \"刷新并以合并后的节点为起始节点\",\n        \"refreshing\": \"正在刷新图谱...\"\n      },\n      \"node\": {\n        \"title\": \"节点\",\n        \"id\": \"ID\",\n        \"labels\": \"标签\",\n        \"degree\": \"度数\",\n        \"properties\": \"属性\",\n        \"relationships\": \"关系(子图内)\",\n        \"expandNode\": \"扩展节点\",\n        \"pruneNode\": \"修剪节点\",\n        \"deleteAllNodesError\": \"拒绝删除图中的所有节点\",\n        \"nodesRemoved\": \"已删除 {{count}} 个节点，包括孤立节点\",\n        \"noNewNodes\": \"没有发现可以扩展的节点\",\n        \"propertyNames\": {\n          \"description\": \"描述\",\n          \"entity_id\": \"名称\",\n          \"entity_type\": \"类型\",\n          \"source_id\": \"C-ID\",\n          \"Neighbour\": \"邻接\",\n          \"file_path\": \"文件\",\n          \"keywords\": \"Keys\",\n          \"weight\": \"权重\"\n        }\n      },\n      \"edge\": {\n        \"title\": \"关系\",\n        \"id\": \"ID\",\n        \"type\": \"类型\",\n        \"source\": \"源节点\",\n        \"target\": \"目标节点\",\n        \"properties\": \"属性\"\n      }\n    },\n    \"search\": {\n      \"placeholder\": \"页面内搜索节点...\",\n      \"message\": \"还有 {{count}} 个\"\n    },\n    \"graphLabels\": {\n      \"selectTooltip\": \"获取节点(标签)子图\",\n      \"noLabels\": \"未找到匹配的节点\",\n      \"label\": \"搜索节点名称\",\n      \"placeholder\": \"搜索节点名称...\",\n      \"andOthers\": \"还有 {{count}} 个\",\n      \"refreshGlobalTooltip\": \"刷新全图数据和重置搜索历史\",\n      \"refreshCurrentLabelTooltip\": \"刷新当前页面图数据\",\n      \"refreshingTooltip\": \"正在刷新数据...\"\n    },\n    \"emptyGraph\": \"无数据(请重载图形数据)\"\n  },\n  \"retrievePanel\": {\n    \"chatMessage\": {\n      \"copyTooltip\": \"复制到剪贴板\",\n      \"copyError\": \"复制文本到剪贴板失败\",\n      \"copyEmpty\": \"没有内容可复制\",\n      \"copySuccess\": \"内容已复制到剪贴板\",\n      \"copySuccessLegacy\": \"内容已复制（传统方法）\",\n      \"copySuccessManual\": \"内容已复制（手动方法）\",\n      \"copyFailed\": \"复制内容失败\",\n      \"copyManualInstruction\": \"请手动选择并复制文本\",\n      \"thinking\": \"正在思考...\",\n      \"thinkingTime\": \"思考用时 {{time}} 秒\",\n      \"thinkingInProgress\": \"思考进行中...\"\n    },\n    \"retrieval\": {\n      \"startPrompt\": \"输入查询开始检索\",\n      \"clear\": \"清空\",\n      \"send\": \"发送\",\n      \"placeholder\": \"输入查询内容 (支持模式前缀: /<Query Mode>)\",\n      \"error\": \"错误：获取响应失败\",\n      \"queryModeError\": \"仅支持以下查询模式：{{modes}}\",\n      \"queryModePrefixInvalid\": \"无效的查询模式前缀。请使用：/<模式> [空格] 查询内容\"\n    },\n    \"querySettings\": {\n      \"parametersTitle\": \"参数\",\n      \"parametersDescription\": \"配置查询参数\",\n      \"queryMode\": \"查询模式\",\n      \"queryModeTooltip\": \"选择检索策略：\\n• Naive：传统文本块向量检索\\n• Local：侧重实体检索\\n• Global：侧重关系检索\\n• Hybrid：Local+Global\\n• Mix：Local+Global+Naive\\n• Bypass：跳过检索,把历史会话与当前问题送LLM\",\n      \"queryModeOptions\": {\n        \"naive\": \"Naive\",\n        \"local\": \"Local\",\n        \"global\": \"Global\",\n        \"hybrid\": \"Hybrid\",\n        \"mix\": \"Mix\",\n        \"bypass\": \"Bypass\"\n      },\n      \"responseFormat\": \"响应格式\",\n      \"responseFormatTooltip\": \"定义响应格式。例如：\\n• 多段落\\n• 单段落\\n• 要点\",\n      \"responseFormatOptions\": {\n        \"multipleParagraphs\": \"多段落\",\n        \"singleParagraph\": \"单段落\",\n        \"bulletPoints\": \"要点\"\n      },\n      \"topK\": \"KG Top K\",\n      \"topKTooltip\": \"实体关系检索数量, 适用于非naive模式\",\n      \"topKPlaceholder\": \"输入top_k值\",\n      \"chunkTopK\": \"文本块 Top K\",\n      \"chunkTopKTooltip\": \"文本块检索数量, 适用于所有模式\",\n      \"chunkTopKPlaceholder\": \"输入文本块chunk_top_k值\",\n      \"maxEntityTokens\": \"实体令牌数上限\",\n      \"maxEntityTokensTooltip\": \"统一令牌控制系统中分配给实体上下文的最大令牌数\",\n      \"maxRelationTokens\": \"关系令牌数上限\",\n      \"maxRelationTokensTooltip\": \"统一令牌控制系统中分配给关系上下文的最大令牌数\",\n      \"maxTotalTokens\": \"总令牌数上限\",\n      \"maxTotalTokensTooltip\": \"整个查询上下文的最大总令牌预算（实体+关系+文档块+系统提示）\",\n      \"historyTurns\": \"历史轮次\",\n      \"historyTurnsTooltip\": \"响应上下文中考虑的完整对话轮次（用户-助手对）数量\",\n      \"historyTurnsPlaceholder\": \"历史轮次数\",\n      \"onlyNeedContext\": \"仅需上下文\",\n      \"onlyNeedContextTooltip\": \"如果为True，仅返回检索到的上下文而不生成响应\",\n      \"onlyNeedPrompt\": \"仅需提示\",\n      \"onlyNeedPromptTooltip\": \"如果为True，仅返回生成的提示而不产生响应\",\n      \"streamResponse\": \"流式响应\",\n      \"streamResponseTooltip\": \"如果为True，启用实时流式输出响应\",\n      \"userPrompt\": \"附加输出提示词\",\n      \"userPromptTooltip\": \"向LLM提供额外的响应要求（与查询内容无关，仅用于处理输出）。\",\n      \"userPromptPlaceholder\": \"输入自定义提示词（可选）\",\n      \"enableRerank\": \"启用重排\",\n      \"enableRerankTooltip\": \"为检索到的文本块启用重排。如果为True但未配置重排模型，将发出警告。默认为True。\"\n    }\n  },\n  \"apiSite\": {\n    \"loading\": \"正在加载 API 文档...\"\n  },\n  \"apiKeyAlert\": {\n    \"title\": \"需要 API Key\",\n    \"description\": \"请输入您的 API Key 以访问服务\",\n    \"placeholder\": \"请输入 API Key\",\n    \"save\": \"保存\"\n  },\n  \"pagination\": {\n    \"showing\": \"显示第 {{start}} 到 {{end}} 条，共 {{total}} 条记录\",\n    \"page\": \"页\",\n    \"pageSize\": \"每页显示\",\n    \"firstPage\": \"首页\",\n    \"prevPage\": \"上一页\",\n    \"nextPage\": \"下一页\",\n    \"lastPage\": \"末页\"\n  }\n}\n"
  },
  {
    "path": "lightrag_webui/src/locales/zh_TW.json",
    "content": "{\n  \"settings\": {\n    \"language\": \"語言\",\n    \"theme\": \"主題\",\n    \"light\": \"淺色\",\n    \"dark\": \"深色\",\n    \"system\": \"系統\"\n  },\n  \"header\": {\n    \"documents\": \"文件\",\n    \"knowledgeGraph\": \"知識圖譜\",\n    \"retrieval\": \"檢索\",\n    \"api\": \"API\",\n    \"projectRepository\": \"專案庫\",\n    \"logout\": \"登出\",\n    \"frontendNeedsRebuild\": \"前端程式碼需重新建置\",\n    \"themeToggle\": {\n      \"switchToLight\": \"切換至淺色主題\",\n      \"switchToDark\": \"切換至深色主題\"\n    }\n  },\n  \"login\": {\n    \"description\": \"請輸入您的帳號和密碼登入系統\",\n    \"username\": \"帳號\",\n    \"usernamePlaceholder\": \"請輸入帳號\",\n    \"password\": \"密碼\",\n    \"passwordPlaceholder\": \"請輸入密碼\",\n    \"loginButton\": \"登入\",\n    \"loggingIn\": \"登入中...\",\n    \"successMessage\": \"登入成功\",\n    \"errorEmptyFields\": \"請輸入您的帳號和密碼\",\n    \"errorInvalidCredentials\": \"登入失敗，請檢查帳號和密碼\",\n    \"authDisabled\": \"認證已停用，使用免登入模式\",\n    \"guestMode\": \"免登入\"\n  },\n  \"common\": {\n    \"cancel\": \"取消\",\n    \"save\": \"儲存\",\n    \"saving\": \"儲存中...\",\n    \"saveFailed\": \"儲存失敗\"\n  },\n  \"documentPanel\": {\n    \"clearDocuments\": {\n      \"button\": \"清空\",\n      \"tooltip\": \"清空文件\",\n      \"title\": \"清空文件\",\n      \"description\": \"此操作將從系統中移除所有文件\",\n      \"warning\": \"警告：此操作將永久刪除所有文件，無法復原！\",\n      \"confirm\": \"確定要清空所有文件嗎？\",\n      \"confirmPrompt\": \"請輸入 yes 確認操作\",\n      \"confirmPlaceholder\": \"輸入 yes 以確認\",\n      \"clearCache\": \"清空 LLM 快取\",\n      \"confirmButton\": \"確定\",\n      \"clearing\": \"正在清除...\",\n      \"timeout\": \"清除操作逾時，請重試\",\n      \"success\": \"文件清空成功\",\n      \"cacheCleared\": \"快取清空成功\",\n      \"cacheClearFailed\": \"清空快取失敗：\\n{{error}}\",\n      \"failed\": \"清空文件失敗：\\n{{message}}\",\n      \"error\": \"清空文件失敗：\\n{{error}}\"\n    },\n    \"deleteDocuments\": {\n      \"button\": \"刪除\",\n      \"tooltip\": \"刪除選取的文件\",\n      \"title\": \"刪除文件\",\n      \"description\": \"此操作將永久刪除選取的文件\",\n      \"warning\": \"警告：此操作將永久刪除選取的文件，無法復原！\",\n      \"confirm\": \"確定要刪除 {{count}} 個選取的文件嗎？\",\n      \"confirmPrompt\": \"請輸入 yes 確認操作\",\n      \"confirmPlaceholder\": \"輸入 yes 以確認\",\n      \"confirmButton\": \"確定\",\n      \"deleteFileOption\": \"同時刪除上傳檔案\",\n      \"deleteFileTooltip\": \"選取此選項將同時刪除伺服器上對應的上傳檔案\",\n      \"deleteLLMCacheOption\": \"同時刪除實體關係擷取 LLM 快取\",\n      \"success\": \"文件刪除流水線啟動成功\",\n      \"failed\": \"刪除文件失敗：\\n{{message}}\",\n      \"error\": \"刪除文件失敗：\\n{{error}}\",\n      \"busy\": \"pipeline 被佔用，請稍後再試\",\n      \"notAllowed\": \"沒有操作權限\"\n    },\n    \"selectDocuments\": {\n      \"selectCurrentPage\": \"全選當前頁 ({{count}})\",\n      \"deselectAll\": \"取消全選 ({{count}})\"\n    },\n    \"uploadDocuments\": {\n      \"button\": \"上傳\",\n      \"tooltip\": \"上傳文件\",\n      \"title\": \"上傳文件\",\n      \"description\": \"拖曳檔案至此處或點擊瀏覽\",\n      \"single\": {\n        \"uploading\": \"正在上傳 {{name}}：{{percent}}%\",\n        \"success\": \"上傳成功：\\n{{name}} 上傳完成\",\n        \"failed\": \"上傳失敗：\\n{{name}}\\n{{message}}\",\n        \"error\": \"上傳失敗：\\n{{name}}\\n{{error}}\"\n      },\n      \"batch\": {\n        \"uploading\": \"正在上傳檔案...\",\n        \"success\": \"檔案上傳完成\",\n        \"error\": \"部分檔案上傳失敗\"\n      },\n      \"generalError\": \"上傳失敗\\n{{error}}\",\n      \"fileTypes\": \"支援的檔案類型：TXT, MD, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, H, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS\",\n      \"fileUploader\": {\n        \"singleFileLimit\": \"一次只能上傳一個檔案\",\n        \"maxFilesLimit\": \"最多只能上傳 {{count}} 個檔案\",\n        \"fileRejected\": \"檔案 {{name}} 被拒絕\",\n        \"unsupportedType\": \"不支援的檔案類型\",\n        \"fileTooLarge\": \"檔案過大，最大允許 {{maxSize}}\",\n        \"dropHere\": \"將檔案拖放至此處\",\n        \"dragAndDrop\": \"拖放檔案至此處，或點擊選擇檔案\",\n        \"removeFile\": \"移除檔案\",\n        \"uploadDescription\": \"您可以上傳{{isMultiple ? '多個' : count}}個檔案（每個檔案最大{{maxSize}}）\",\n        \"duplicateFile\": \"檔案名稱與伺服器上的快取重複\"\n      }\n    },\n    \"documentManager\": {\n      \"title\": \"文件管理\",\n      \"scanButton\": \"掃描/重試\",\n      \"scanTooltip\": \"掃描處理輸入目錄中的文件，同時重新處理所有失敗的文件\",\n      \"refreshTooltip\": \"重設文件清單\",\n      \"pipelineStatusButton\": \"管線狀態\",\n      \"pipelineStatusTooltip\": \"查看文件處理管線狀態\",\n      \"uploadedTitle\": \"已上傳文件\",\n      \"uploadedDescription\": \"已上傳文件清單及其狀態\",\n      \"emptyTitle\": \"無文件\",\n      \"emptyDescription\": \"尚未上傳任何文件\",\n      \"columns\": {\n        \"id\": \"ID\",\n        \"fileName\": \"檔案名稱\",\n        \"summary\": \"摘要\",\n        \"status\": \"狀態\",\n        \"length\": \"長度\",\n        \"chunks\": \"分塊\",\n        \"created\": \"建立時間\",\n        \"updated\": \"更新時間\",\n        \"metadata\": \"元資料\",\n        \"select\": \"選擇\"\n      },\n      \"status\": {\n        \"all\": \"全部\",\n        \"completed\": \"已完成\",\n        \"preprocessed\": \"預處理\",\n        \"processing\": \"處理中\",\n        \"pending\": \"等待中\",\n        \"failed\": \"失敗\"\n      },\n      \"errors\": {\n        \"loadFailed\": \"載入文件失敗\\n{{error}}\",\n        \"scanFailed\": \"掃描文件失敗\\n{{error}}\",\n        \"scanProgressFailed\": \"取得掃描進度失敗\\n{{error}}\"\n      },\n      \"fileNameLabel\": \"檔案名稱\",\n      \"showButton\": \"顯示\",\n      \"hideButton\": \"隱藏\",\n      \"showFileNameTooltip\": \"顯示檔案名稱\",\n      \"hideFileNameTooltip\": \"隱藏檔案名稱\"\n    },\n    \"pipelineStatus\": {\n      \"title\": \"流水線狀態\",\n      \"busy\": \"流水線忙碌\",\n      \"requestPending\": \"待處理請求\",\n      \"cancellationRequested\": \"取消請求\",\n      \"jobName\": \"作業名稱\",\n      \"startTime\": \"開始時間\",\n      \"progress\": \"進度\",\n      \"unit\": \"批\",\n      \"pipelineMessages\": \"流水線消息\",\n      \"cancelButton\": \"中斷\",\n      \"cancelTooltip\": \"中斷流水線處理\",\n      \"cancelConfirmTitle\": \"確認中斷流水線\",\n      \"cancelConfirmDescription\": \"此操作將中斷正在進行的流水線處理。確定要繼續嗎？\",\n      \"cancelConfirmButton\": \"確認中斷\",\n      \"cancelInProgress\": \"取消請求進行中...\",\n      \"pipelineNotRunning\": \"流水線未運行\",\n      \"cancelSuccess\": \"流水線中斷請求已發送\",\n      \"cancelFailed\": \"中斷流水線失敗\\n{{error}}\",\n      \"cancelNotBusy\": \"流水線未運行，無需中斷\",\n      \"errors\": {\n        \"fetchFailed\": \"獲取流水線狀態失敗\\n{{error}}\"\n      }\n    }\n  },\n  \"graphPanel\": {\n    \"dataIsTruncated\": \"圖資料已截斷至最大回傳節點數\",\n    \"statusDialog\": {\n      \"title\": \"LightRAG 伺服器設定\",\n      \"description\": \"查看目前系統狀態和連線資訊\"\n    },\n    \"legend\": \"圖例\",\n    \"nodeTypes\": {\n      \"person\": \"人物角色\",\n      \"category\": \"分類\",\n      \"geo\": \"地理名稱\",\n      \"location\": \"位置\",\n      \"organization\": \"組織機構\",\n      \"event\": \"事件\",\n      \"equipment\": \"設備\",\n      \"weapon\": \"武器\",\n      \"animal\": \"動物\",\n      \"unknown\": \"未知\",\n      \"object\": \"物品\",\n      \"group\": \"群組\",\n      \"technology\": \"技術\",\n      \"product\": \"產品\",\n      \"document\": \"文檔\",\n      \"content\": \"內容\",\n      \"data\": \"資料\",\n      \"artifact\": \"人工製品\",\n      \"concept\": \"概念\",\n      \"naturalobject\": \"自然物品\",\n      \"method\": \"方法\",\n      \"creature\": \"生物神怪\",\n      \"plant\": \"植物\",\n      \"disease\": \"疾病\",\n      \"drug\": \"藥物\",\n      \"food\": \"食物\",\n      \"other\": \"其他\"\n    },\n    \"sideBar\": {\n      \"settings\": {\n        \"settings\": \"設定\",\n        \"healthCheck\": \"健康檢查\",\n        \"showPropertyPanel\": \"顯示屬性面板\",\n        \"showSearchBar\": \"顯示搜尋列\",\n        \"showNodeLabel\": \"顯示節點標籤\",\n        \"nodeDraggable\": \"節點可拖曳\",\n        \"showEdgeLabel\": \"顯示 Edge 標籤\",\n        \"hideUnselectedEdges\": \"隱藏未選取的 Edge\",\n        \"edgeEvents\": \"Edge 事件\",\n        \"maxQueryDepth\": \"最大查詢深度\",\n        \"maxNodes\": \"最大回傳節點數\",\n        \"maxLayoutIterations\": \"最大版面配置迭代次數\",\n        \"resetToDefault\": \"重設為預設值\",\n        \"edgeSizeRange\": \"Edge 粗細範圍\",\n        \"depth\": \"深度\",\n        \"max\": \"最大值\",\n        \"degree\": \"鄰邊\",\n        \"apiKey\": \"API key\",\n        \"enterYourAPIkey\": \"輸入您的 API key\",\n        \"save\": \"儲存\",\n        \"refreshLayout\": \"重新整理版面配置\"\n      },\n      \"zoomControl\": {\n        \"zoomIn\": \"放大\",\n        \"zoomOut\": \"縮小\",\n        \"resetZoom\": \"重設縮放\",\n        \"rotateCamera\": \"順時針旋轉圖形\",\n        \"rotateCameraCounterClockwise\": \"逆時針旋轉圖形\"\n      },\n      \"layoutsControl\": {\n        \"startAnimation\": \"繼續版面配置動畫\",\n        \"stopAnimation\": \"停止版面配置動畫\",\n        \"layoutGraph\": \"圖形版面配置\",\n        \"layouts\": {\n          \"Circular\": \"環形\",\n          \"Circlepack\": \"圓形打包\",\n          \"Random\": \"隨機\",\n          \"Noverlaps\": \"無重疊\",\n          \"Force Directed\": \"力導向\",\n          \"Force Atlas\": \"力圖\"\n        }\n      },\n      \"fullScreenControl\": {\n        \"fullScreen\": \"全螢幕\",\n        \"windowed\": \"視窗\"\n      },\n      \"legendControl\": {\n        \"toggleLegend\": \"切換圖例顯示\"\n      }\n    },\n    \"statusIndicator\": {\n      \"connected\": \"已連線\",\n      \"disconnected\": \"未連線\"\n    },\n    \"statusCard\": {\n      \"unavailable\": \"狀態資訊不可用\",\n      \"serverInfo\": \"伺服器資訊\",\n      \"workingDirectory\": \"工作目錄\",\n      \"inputDirectory\": \"輸入目錄\",\n      \"maxParallelInsert\": \"並行處理文档\",\n      \"summarySettings\": \"摘要設定\",\n      \"llmConfig\": \"LLM 設定\",\n      \"llmBinding\": \"LLM 綁定\",\n      \"llmBindingHost\": \"LLM 端點\",\n      \"llmModel\": \"LLM 模型\",\n      \"embeddingConfig\": \"嵌入設定\",\n      \"embeddingBinding\": \"嵌入綁定\",\n      \"embeddingBindingHost\": \"嵌入端點\",\n      \"embeddingModel\": \"嵌入模型\",\n      \"storageConfig\": \"儲存設定\",\n      \"kvStorage\": \"KV 儲存\",\n      \"docStatusStorage\": \"文件狀態儲存\",\n      \"graphStorage\": \"圖形儲存\",\n      \"vectorStorage\": \"向量儲存\",\n      \"workspace\": \"工作空間\",\n      \"maxGraphNodes\": \"最大圖形節點數\",\n      \"rerankerConfig\": \"重排序設定\",\n      \"rerankerBindingHost\": \"重排序端點\",\n      \"rerankerModel\": \"重排序模型\",\n      \"lockStatus\": \"鎖定狀態\",\n      \"threshold\": \"閾值\"\n    },\n    \"propertiesView\": {\n      \"editProperty\": \"編輯{{property}}\",\n      \"editPropertyDescription\": \"在下方文字區域編輯屬性值。\",\n      \"errors\": {\n        \"duplicateName\": \"節點名稱已存在\",\n        \"updateFailed\": \"更新節點失敗\",\n        \"tryAgainLater\": \"請稍後重試\",\n        \"updateSuccessButMergeFailed\": \"屬性已更新，但合併失敗：{{error}}\",\n        \"mergeFailed\": \"合併失敗：{{error}}\"\n      },\n      \"success\": {\n        \"entityUpdated\": \"節點更新成功\",\n        \"relationUpdated\": \"關係更新成功\",\n        \"entityMerged\": \"節點合併成功\"\n      },\n      \"mergeOptionLabel\": \"遇到重名時自動合併\",\n      \"mergeOptionDescription\": \"勾選後，重新命名為既有名稱時會自動將當前節點合併過去，不再報錯。\",\n      \"mergeDialog\": {\n        \"title\": \"節點已合併\",\n        \"description\": \"\\\"{{source}}\\\" 已合併到 \\\"{{target}}\\\"。\",\n        \"refreshHint\": \"請重新整理圖譜以取得最新結構。\",\n        \"keepCurrentStart\": \"重新整理並保留目前的起始節點\",\n        \"useMergedStart\": \"重新整理並以合併後的節點為起始節點\",\n        \"refreshing\": \"正在重新整理圖譜...\"\n      },\n      \"node\": {\n        \"title\": \"節點\",\n        \"id\": \"ID\",\n        \"labels\": \"標籤\",\n        \"degree\": \"度數\",\n        \"properties\": \"屬性\",\n        \"relationships\": \"關係(子圖內)\",\n        \"expandNode\": \"展開節點\",\n        \"pruneNode\": \"修剪節點\",\n        \"deleteAllNodesError\": \"拒絕刪除圖中的所有節點\",\n        \"nodesRemoved\": \"已刪除 {{count}} 個節點，包括孤立節點\",\n        \"noNewNodes\": \"沒有發現可以展開的節點\",\n        \"propertyNames\": {\n          \"description\": \"描述\",\n          \"entity_id\": \"名稱\",\n          \"entity_type\": \"類型\",\n          \"source_id\": \"C-ID\",\n          \"Neighbour\": \"鄰接\",\n          \"file_path\": \"檔案\",\n          \"keywords\": \"Keys\",\n          \"weight\": \"權重\"\n        }\n      },\n      \"edge\": {\n        \"title\": \"關係\",\n        \"id\": \"ID\",\n        \"type\": \"類型\",\n        \"source\": \"來源節點\",\n        \"target\": \"目標節點\",\n        \"properties\": \"屬性\"\n      }\n    },\n    \"search\": {\n      \"placeholder\": \"頁面內搜尋節點...\",\n      \"message\": \"還有 {{count}} 個\"\n    },\n    \"graphLabels\": {\n      \"selectTooltip\": \"獲取節點(標籤)子圖\",\n      \"noLabels\": \"未找到匹配的節點\",\n      \"label\": \"搜尋節點名稱\",\n      \"placeholder\": \"搜尋節點名稱...\",\n      \"andOthers\": \"還有 {{count}} 個\",\n      \"refreshGlobalTooltip\": \"重新整理全圖資料和重置搜尋歷史\",\n      \"refreshCurrentLabelTooltip\": \"重新整理目前頁面圖形資料\",\n      \"refreshingTooltip\": \"正在重新整理資料...\"\n    },\n    \"emptyGraph\": \"無數據(請重載圖形數據)\"\n  },\n  \"retrievePanel\": {\n    \"chatMessage\": {\n      \"copyTooltip\": \"複製到剪貼簿\",\n      \"copyError\": \"複製文字到剪貼簿失敗\",\n      \"copyEmpty\": \"沒有內容可複製\",\n      \"copySuccess\": \"內容已複製到剪貼簿\",\n      \"copySuccessLegacy\": \"內容已複製（傳統方法）\",\n      \"copySuccessManual\": \"內容已複製（手動方法）\",\n      \"copyFailed\": \"複製內容失敗\",\n      \"copyManualInstruction\": \"請手動選取並複製文字\",\n      \"thinking\": \"正在思考...\",\n      \"thinkingTime\": \"思考用時 {{time}} 秒\",\n      \"thinkingInProgress\": \"思考進行中...\"\n    },\n    \"retrieval\": {\n      \"startPrompt\": \"輸入查詢開始檢索\",\n      \"clear\": \"清空\",\n      \"send\": \"送出\",\n      \"placeholder\": \"輸入查詢內容 (支援模式前綴：/<Query Mode>)\",\n      \"error\": \"錯誤：取得回應失敗\",\n      \"queryModeError\": \"僅支援以下查詢模式：{{modes}}\",\n      \"queryModePrefixInvalid\": \"無效的查詢模式前綴。請使用：/<模式> [空格] 查詢內容\"\n    },\n    \"querySettings\": {\n      \"parametersTitle\": \"參數\",\n      \"parametersDescription\": \"設定查詢參數\",\n      \"queryMode\": \"查詢模式\",\n      \"queryModeTooltip\": \"選擇檢索策略：\\n• Naive：傳統文字塊向量檢索\\n• Local：側重實體檢索\\n• Global：側重關係檢索\\n• Hybrid：Local+Global\\n• Mix：Local+Global+Naive\\n• Bypass：跳過檢索，把歷史會話與當前問題送LLM\",\n      \"queryModeOptions\": {\n        \"naive\": \"Naive\",\n        \"local\": \"Local\",\n        \"global\": \"Global\",\n        \"hybrid\": \"Hybrid\",\n        \"mix\": \"Mix\",\n        \"bypass\": \"Bypass\"\n      },\n      \"responseFormat\": \"回應格式\",\n      \"responseFormatTooltip\": \"定義回應格式。例如：\\n• 多段落\\n• 單段落\\n• 重點\",\n      \"responseFormatOptions\": {\n        \"multipleParagraphs\": \"多段落\",\n        \"singleParagraph\": \"單段落\",\n        \"bulletPoints\": \"重點\"\n      },\n      \"topK\": \"知識圖譜 Top K\",\n      \"topKTooltip\": \"實體關係檢索數量，適用於非 naive 模式。\",\n      \"topKPlaceholder\": \"輸入 top_k 值\",\n      \"chunkTopK\": \"文本區塊 Top K\",\n      \"chunkTopKTooltip\": \"文本區塊檢索數量，適用於所有模式。\",\n      \"chunkTopKPlaceholder\": \"輸入文本區塊 chunk_top_k 值\",\n      \"historyTurns\": \"歷史輪次\",\n      \"historyTurnsTooltip\": \"回應上下文中考慮的完整對話輪次（使用者-助手對）數量\",\n      \"historyTurnsPlaceholder\": \"歷史輪次數\",\n      \"onlyNeedContext\": \"僅需上下文\",\n      \"onlyNeedContextTooltip\": \"如果為True，僅回傳檢索到的上下文而不產生回應\",\n      \"onlyNeedPrompt\": \"僅需提示\",\n      \"onlyNeedPromptTooltip\": \"如果為True，僅回傳產生的提示而不產生回應\",\n      \"streamResponse\": \"串流回應\",\n      \"streamResponseTooltip\": \"如果為True，啟用即時串流輸出回應\",\n      \"userPrompt\": \"附加輸出提示詞\",\n      \"userPromptTooltip\": \"向LLM提供額外的響應要求（與查詢內容無關，僅用於處理輸出）。\",\n      \"userPromptPlaceholder\": \"輸入自定義提示詞（可選）\",\n      \"enableRerank\": \"啟用重排\",\n      \"enableRerankTooltip\": \"為檢索到的文本塊啟用重排。如果為True但未配置重排模型，將發出警告。默認為True。\",\n      \"maxEntityTokens\": \"實體令牌數上限\",\n      \"maxEntityTokensTooltip\": \"統一令牌控制系統中分配給實體上下文的最大令牌數\",\n      \"maxRelationTokens\": \"關係令牌數上限\",\n      \"maxRelationTokensTooltip\": \"統一令牌控制系統中分配給關係上下文的最大令牌數\",\n      \"maxTotalTokens\": \"總令牌數上限\",\n      \"maxTotalTokensTooltip\": \"整個查詢上下文的最大總令牌預算（實體+關係+文檔塊+系統提示）\"\n    }\n  },\n  \"apiSite\": {\n    \"loading\": \"正在載入 API 文件...\"\n  },\n  \"apiKeyAlert\": {\n    \"title\": \"需要 API key\",\n    \"description\": \"請輸入您的 API key 以存取服務\",\n    \"placeholder\": \"請輸入 API key\",\n    \"save\": \"儲存\"\n  },\n  \"pagination\": {\n    \"showing\": \"顯示第 {{start}} 到 {{end}} 筆，共 {{total}} 筆記錄\",\n    \"page\": \"頁\",\n    \"pageSize\": \"每頁顯示\",\n    \"firstPage\": \"第一頁\",\n    \"prevPage\": \"上一頁\",\n    \"nextPage\": \"下一頁\",\n    \"lastPage\": \"最後一頁\"\n  }\n}\n"
  },
  {
    "path": "lightrag_webui/src/main.tsx",
    "content": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport './index.css'\nimport AppRouter from './AppRouter'\nimport './i18n.ts';\nimport 'katex/dist/katex.min.css';\n// Import KaTeX extensions at app startup to ensure they are registered before any rendering\nimport 'katex/contrib/mhchem'; // Chemistry formulas: \\ce{} and \\pu{}\nimport 'katex/contrib/copy-tex'; // Allow copying rendered formulas as LaTeX source\n\n\n\ncreateRoot(document.getElementById('root')!).render(\n  <StrictMode>\n    <AppRouter />\n  </StrictMode>\n)\n"
  },
  {
    "path": "lightrag_webui/src/services/navigation.ts",
    "content": "import { NavigateFunction } from 'react-router-dom';\nimport { useAuthStore, useBackendState } from '@/stores/state';\nimport { useGraphStore } from '@/stores/graph';\nimport { useSettingsStore } from '@/stores/settings';\n\nclass NavigationService {\n  private navigate: NavigateFunction | null = null;\n\n  setNavigate(navigate: NavigateFunction) {\n    this.navigate = navigate;\n  }\n\n  /**\n   * Reset all application state to ensure a clean environment.\n   * This function should be called when:\n   * 1. User logs out\n   * 2. Authentication token expires\n   * 3. Direct access to login page\n   *\n   * @param preserveHistory If true, chat history will be preserved. Default is false.\n   */\n  resetAllApplicationState(preserveHistory = false) {\n    console.log('Resetting all application state...');\n\n    // Reset graph state\n    const graphStore = useGraphStore.getState();\n    const sigma = graphStore.sigmaInstance;\n    graphStore.reset();\n    graphStore.setGraphDataFetchAttempted(false);\n    graphStore.setLabelsFetchAttempted(false);\n    graphStore.setSigmaInstance(null);\n    graphStore.setIsFetching(false); // Reset isFetching state to prevent data loading issues\n\n    // Reset backend state\n    useBackendState.getState().clear();\n\n    // Reset retrieval history message only if preserveHistory is false\n    if (!preserveHistory) {\n      useSettingsStore.getState().setRetrievalHistory([]);\n    }\n\n    // Clear authentication state\n    sessionStorage.clear();\n\n    if (sigma) {\n      sigma.getGraph().clear();\n      sigma.kill();\n      useGraphStore.getState().setSigmaInstance(null);\n    }\n  }\n\n  /**\n   * Navigate to login page and reset application state\n   */\n  navigateToLogin() {\n    if (!this.navigate) {\n      console.error('Navigation function not set');\n      return;\n    }\n\n    // Store current username before logout for comparison during next login\n    const currentUsername = useAuthStore.getState().username;\n    if (currentUsername) {\n      localStorage.setItem('LIGHTRAG-PREVIOUS-USER', currentUsername);\n    }\n\n    // Reset application state but preserve history\n    // History will be cleared on next login if the user changes\n    this.resetAllApplicationState(true);\n    useAuthStore.getState().logout();\n\n    this.navigate('/login');\n  }\n\n  navigateToHome() {\n    if (!this.navigate) {\n      console.error('Navigation function not set');\n      return;\n    }\n\n    this.navigate('/');\n  }\n}\n\nexport const navigationService = new NavigationService();\n"
  },
  {
    "path": "lightrag_webui/src/stores/graph.ts",
    "content": "import { create } from 'zustand'\nimport { createSelectors } from '@/lib/utils'\nimport { DirectedGraph } from 'graphology'\nimport MiniSearch from 'minisearch'\nimport { resolveNodeColor, DEFAULT_NODE_COLOR } from '@/utils/graphColor'\n\nexport type RawNodeType = {\n  // for NetworkX: id is identical to properties['entity_id']\n  // for Neo4j: id is unique identifier for each node\n  id: string\n  labels: string[]\n  properties: Record<string, any>\n\n  size: number\n  x: number\n  y: number\n  color: string\n\n  degree: number\n}\n\nexport type RawEdgeType = {\n  // for NetworkX: id is \"source-target\"\n  // for Neo4j: id is unique identifier for each edge\n  id: string\n  source: string\n  target: string\n  type?: string\n  properties: Record<string, any>\n  // dynamicId: key for sigmaGraph\n  dynamicId: string\n}\n\n/**\n * Interface for tracking edges that need updating when a node ID changes\n */\ninterface EdgeToUpdate {\n  originalDynamicId: string\n  newEdgeId: string\n  edgeIndex: number\n}\n\nexport class RawGraph {\n  nodes: RawNodeType[] = []\n  edges: RawEdgeType[] = []\n  // nodeIDMap: map node id to index in nodes array (SigmaGraph has nodeId as key)\n  nodeIdMap: Record<string, number> = {}\n  // edgeIDMap: map edge id to index in edges array (SigmaGraph not use id as key)\n  edgeIdMap: Record<string, number> = {}\n  // edgeDynamicIdMap: map edge dynamic id to index in edges array (SigmaGraph has DynamicId as key)\n  edgeDynamicIdMap: Record<string, number> = {}\n\n  getNode = (nodeId: string) => {\n    const nodeIndex = this.nodeIdMap[nodeId]\n    if (nodeIndex !== undefined) {\n      return this.nodes[nodeIndex]\n    }\n    return undefined\n  }\n\n  getEdge = (edgeId: string, dynamicId: boolean = true) => {\n    const edgeIndex = dynamicId ? this.edgeDynamicIdMap[edgeId] : this.edgeIdMap[edgeId]\n    if (edgeIndex !== undefined) {\n      return this.edges[edgeIndex]\n    }\n    return undefined\n  }\n\n  buildDynamicMap = () => {\n    this.edgeDynamicIdMap = {}\n    for (let i = 0; i < this.edges.length; i++) {\n      const edge = this.edges[i]\n      this.edgeDynamicIdMap[edge.dynamicId] = i\n    }\n  }\n}\n\ninterface GraphState {\n  selectedNode: string | null\n  focusedNode: string | null\n  selectedEdge: string | null\n  focusedEdge: string | null\n\n  rawGraph: RawGraph | null\n  sigmaGraph: DirectedGraph | null\n  sigmaInstance: any | null\n\n  searchEngine: MiniSearch | null\n\n  moveToSelectedNode: boolean\n  isFetching: boolean\n  graphIsEmpty: boolean\n  lastSuccessfulQueryLabel: string\n\n  typeColorMap: Map<string, string>\n\n  // Global flags to track data fetching attempts\n  graphDataFetchAttempted: boolean\n  labelsFetchAttempted: boolean\n\n  setSigmaInstance: (instance: any) => void\n  setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) => void\n  setFocusedNode: (nodeId: string | null) => void\n  setSelectedEdge: (edgeId: string | null) => void\n  setFocusedEdge: (edgeId: string | null) => void\n  clearSelection: () => void\n  reset: () => void\n\n  setMoveToSelectedNode: (moveToSelectedNode: boolean) => void\n  setGraphIsEmpty: (isEmpty: boolean) => void\n  setLastSuccessfulQueryLabel: (label: string) => void\n\n  setRawGraph: (rawGraph: RawGraph | null) => void\n  setSigmaGraph: (sigmaGraph: DirectedGraph | null) => void\n  setIsFetching: (isFetching: boolean) => void\n\n  // Legend color mapping methods\n  setTypeColorMap: (typeColorMap: Map<string, string>) => void\n\n  // Search engine methods\n  setSearchEngine: (engine: MiniSearch | null) => void\n  resetSearchEngine: () => void\n\n  // Methods to set global flags\n  setGraphDataFetchAttempted: (attempted: boolean) => void\n  setLabelsFetchAttempted: (attempted: boolean) => void\n\n  // Event trigger methods for node operations\n  triggerNodeExpand: (nodeId: string | null) => void\n  triggerNodePrune: (nodeId: string | null) => void\n\n  // Node operation state\n  nodeToExpand: string | null\n  nodeToPrune: string | null\n\n  // Version counter to trigger data refresh\n  graphDataVersion: number\n  incrementGraphDataVersion: () => void\n\n  // Methods for updating graph elements and UI state together\n  updateNodeAndSelect: (nodeId: string, entityId: string, propertyName: string, newValue: string) => Promise<void>\n  updateEdgeAndSelect: (edgeId: string, dynamicId: string, sourceId: string, targetId: string, propertyName: string, newValue: string) => Promise<void>\n}\n\nconst useGraphStoreBase = create<GraphState>()((set, get) => ({\n  selectedNode: null,\n  focusedNode: null,\n  selectedEdge: null,\n  focusedEdge: null,\n\n  moveToSelectedNode: false,\n  isFetching: false,\n  graphIsEmpty: false,\n  lastSuccessfulQueryLabel: '', // Initialize as empty to ensure fetchAllDatabaseLabels runs on first query\n\n  // Initialize global flags\n  graphDataFetchAttempted: false,\n  labelsFetchAttempted: false,\n\n  rawGraph: null,\n  sigmaGraph: null,\n  sigmaInstance: null,\n\n  typeColorMap: new Map<string, string>(),\n\n  searchEngine: null,\n\n  setGraphIsEmpty: (isEmpty: boolean) => set({ graphIsEmpty: isEmpty }),\n  setLastSuccessfulQueryLabel: (label: string) => set({ lastSuccessfulQueryLabel: label }),\n\n\n  setIsFetching: (isFetching: boolean) => set({ isFetching }),\n  setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) =>\n    set({ selectedNode: nodeId, moveToSelectedNode }),\n  setFocusedNode: (nodeId: string | null) => set({ focusedNode: nodeId }),\n  setSelectedEdge: (edgeId: string | null) => set({ selectedEdge: edgeId }),\n  setFocusedEdge: (edgeId: string | null) => set({ focusedEdge: edgeId }),\n  clearSelection: () =>\n    set({\n      selectedNode: null,\n      focusedNode: null,\n      selectedEdge: null,\n      focusedEdge: null\n    }),\n  reset: () => {\n    set({\n      selectedNode: null,\n      focusedNode: null,\n      selectedEdge: null,\n      focusedEdge: null,\n      rawGraph: null,\n      sigmaGraph: null,  // to avoid other components from acccessing graph objects\n      searchEngine: null,\n      moveToSelectedNode: false,\n      graphIsEmpty: false\n    });\n  },\n\n  setRawGraph: (rawGraph: RawGraph | null) =>\n    set({\n      rawGraph\n    }),\n\n  setSigmaGraph: (sigmaGraph: DirectedGraph | null) => {\n    // Replace graph instance, no need to keep WebGL context\n    set({ sigmaGraph });\n  },\n\n  setMoveToSelectedNode: (moveToSelectedNode?: boolean) => set({ moveToSelectedNode }),\n\n  setSigmaInstance: (instance: any) => set({ sigmaInstance: instance }),\n\n  setTypeColorMap: (typeColorMap: Map<string, string>) => set({ typeColorMap }),\n\n  setSearchEngine: (engine: MiniSearch | null) => set({ searchEngine: engine }),\n  resetSearchEngine: () => set({ searchEngine: null }),\n\n  // Methods to set global flags\n  setGraphDataFetchAttempted: (attempted: boolean) => set({ graphDataFetchAttempted: attempted }),\n  setLabelsFetchAttempted: (attempted: boolean) => set({ labelsFetchAttempted: attempted }),\n\n  // Node operation state\n  nodeToExpand: null,\n  nodeToPrune: null,\n\n  // Event trigger methods for node operations\n  triggerNodeExpand: (nodeId: string | null) => set({ nodeToExpand: nodeId }),\n  triggerNodePrune: (nodeId: string | null) => set({ nodeToPrune: nodeId }),\n\n  // Version counter implementation\n  graphDataVersion: 0,\n  incrementGraphDataVersion: () => set((state) => ({ graphDataVersion: state.graphDataVersion + 1 })),\n\n  // Methods for updating graph elements and UI state together\n  updateNodeAndSelect: async (nodeId: string, entityId: string, propertyName: string, newValue: string) => {\n    // Get current state\n    const state = get()\n    const { sigmaGraph, rawGraph } = state\n\n    // Validate graph state\n    if (!sigmaGraph || !rawGraph || !sigmaGraph.hasNode(nodeId)) {\n      return\n    }\n\n    try {\n      const nodeAttributes = sigmaGraph.getNodeAttributes(nodeId)\n\n      console.log('updateNodeAndSelect', nodeId, entityId, propertyName, newValue)\n\n      // For entity_id changes (node renaming) with raw graph storage\n      if ((nodeId === entityId) && (propertyName === 'entity_id')) {\n        // Create new node with updated ID but same attributes\n        sigmaGraph.addNode(newValue, { ...nodeAttributes, label: newValue })\n\n        const edgesToUpdate: EdgeToUpdate[] = []\n\n        // Process all edges connected to this node\n        sigmaGraph.forEachEdge(nodeId, (edge, attributes, source, target) => {\n          const otherNode = source === nodeId ? target : source\n          const isOutgoing = source === nodeId\n\n          // Get original edge dynamic ID for later reference\n          const originalEdgeDynamicId = edge\n          const edgeIndexInRawGraph = rawGraph.edgeDynamicIdMap[originalEdgeDynamicId]\n\n          // Create new edge with updated node reference\n          const newEdgeId = sigmaGraph.addEdge(\n            isOutgoing ? newValue : otherNode,\n            isOutgoing ? otherNode : newValue,\n            attributes\n          )\n\n          // Track edges that need updating in the raw graph\n          if (edgeIndexInRawGraph !== undefined) {\n            edgesToUpdate.push({\n              originalDynamicId: originalEdgeDynamicId,\n              newEdgeId: newEdgeId,\n              edgeIndex: edgeIndexInRawGraph\n            })\n          }\n\n          // Remove the old edge\n          sigmaGraph.dropEdge(edge)\n        })\n\n        // Remove the old node after all edges are processed\n        sigmaGraph.dropNode(nodeId)\n\n        // Update node reference in raw graph data\n        const nodeIndex = rawGraph.nodeIdMap[nodeId]\n        if (nodeIndex !== undefined) {\n          rawGraph.nodes[nodeIndex].id = newValue\n          rawGraph.nodes[nodeIndex].labels = [newValue]\n          rawGraph.nodes[nodeIndex].properties.entity_id = newValue\n          delete rawGraph.nodeIdMap[nodeId]\n          rawGraph.nodeIdMap[newValue] = nodeIndex\n        }\n\n        // Update all edge references in raw graph data\n        edgesToUpdate.forEach(({ originalDynamicId, newEdgeId, edgeIndex }) => {\n          if (rawGraph.edges[edgeIndex]) {\n            // Update source/target references\n            if (rawGraph.edges[edgeIndex].source === nodeId) {\n              rawGraph.edges[edgeIndex].source = newValue\n            }\n            if (rawGraph.edges[edgeIndex].target === nodeId) {\n              rawGraph.edges[edgeIndex].target = newValue\n            }\n\n            // Update dynamic ID mappings\n            rawGraph.edges[edgeIndex].dynamicId = newEdgeId\n            delete rawGraph.edgeDynamicIdMap[originalDynamicId]\n            rawGraph.edgeDynamicIdMap[newEdgeId] = edgeIndex\n          }\n        })\n\n        // Update selected node in store\n        set({ selectedNode: newValue, moveToSelectedNode: true })\n      } else {\n        // For non-NetworkX nodes or non-entity_id changes\n        const nodeIndex = rawGraph.nodeIdMap[String(nodeId)]\n        if (nodeIndex !== undefined) {\n          const nodeRef = rawGraph.nodes[nodeIndex]\n          nodeRef.properties[propertyName] = newValue\n          if (propertyName === 'entity_id') {\n            nodeRef.labels = [newValue]\n            sigmaGraph.setNodeAttribute(String(nodeId), 'label', newValue)\n          }\n          if (propertyName === 'entity_type') {\n            const { color, map, updated } = resolveNodeColor(newValue, state.typeColorMap)\n            const resolvedColor = color || DEFAULT_NODE_COLOR\n            nodeRef.color = resolvedColor\n            sigmaGraph.setNodeAttribute(String(nodeId), 'color', resolvedColor)\n            if (updated) {\n              set({ typeColorMap: map })\n            }\n          }\n        }\n\n        // Trigger a re-render by incrementing the version counter\n        set((state) => ({ graphDataVersion: state.graphDataVersion + 1 }))\n      }\n    } catch (error) {\n      console.error('Error updating node in graph:', error)\n      throw new Error('Failed to update node in graph')\n    }\n  },\n\n  updateEdgeAndSelect: async (edgeId: string, dynamicId: string, sourceId: string, targetId: string, propertyName: string, newValue: string) => {\n    // Get current state\n    const state = get()\n    const { sigmaGraph, rawGraph } = state\n\n    // Validate graph state\n    if (!sigmaGraph || !rawGraph) {\n      return\n    }\n\n    try {\n      const edgeIndex = rawGraph.edgeIdMap[String(edgeId)]\n      if (edgeIndex !== undefined && rawGraph.edges[edgeIndex]) {\n        rawGraph.edges[edgeIndex].properties[propertyName] = newValue\n        if(dynamicId !== undefined && propertyName === 'keywords') {\n          sigmaGraph.setEdgeAttribute(dynamicId, 'label', newValue)\n        }\n      }\n\n      // Trigger a re-render by incrementing the version counter\n      set((state) => ({ graphDataVersion: state.graphDataVersion + 1 }))\n\n      // Update selected edge in store to ensure UI reflects changes\n      set({ selectedEdge: dynamicId })\n    } catch (error) {\n      console.error(`Error updating edge ${sourceId}->${targetId} in graph:`, error)\n      throw new Error('Failed to update edge in graph')\n    }\n  }\n}))\n\nconst useGraphStore = createSelectors(useGraphStoreBase)\n\nexport { useGraphStore }\n"
  },
  {
    "path": "lightrag_webui/src/stores/settings.ts",
    "content": "import { create } from 'zustand'\nimport { persist, createJSONStorage } from 'zustand/middleware'\nimport { createSelectors } from '@/lib/utils'\nimport { defaultQueryLabel } from '@/lib/constants'\nimport { Message, QueryRequest } from '@/api/lightrag'\n\ntype Theme = 'dark' | 'light' | 'system'\ntype Language = 'en' | 'zh' | 'fr' | 'ar' | 'zh_TW' | 'ru' | 'ja' | 'de' | 'uk' | 'ko' | 'vi'\ntype Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api'\n\ninterface SettingsState {\n  // Document manager settings\n  showFileName: boolean\n  setShowFileName: (show: boolean) => void\n\n  documentsPageSize: number\n  setDocumentsPageSize: (size: number) => void\n\n  // User prompt history\n  userPromptHistory: string[]\n  addUserPromptToHistory: (prompt: string) => void\n  setUserPromptHistory: (history: string[]) => void\n\n  // Graph viewer settings\n  showPropertyPanel: boolean\n  showNodeSearchBar: boolean\n  showLegend: boolean\n  setShowLegend: (show: boolean) => void\n\n  showNodeLabel: boolean\n  enableNodeDrag: boolean\n\n  showEdgeLabel: boolean\n  enableHideUnselectedEdges: boolean\n  enableEdgeEvents: boolean\n\n  minEdgeSize: number\n  setMinEdgeSize: (size: number) => void\n\n  maxEdgeSize: number\n  setMaxEdgeSize: (size: number) => void\n\n  graphQueryMaxDepth: number\n  setGraphQueryMaxDepth: (depth: number) => void\n\n  graphMaxNodes: number\n  setGraphMaxNodes: (nodes: number, triggerRefresh?: boolean) => void\n\n  backendMaxGraphNodes: number | null\n  setBackendMaxGraphNodes: (maxNodes: number | null) => void\n\n  graphLayoutMaxIterations: number\n  setGraphLayoutMaxIterations: (iterations: number) => void\n\n  // Retrieval settings\n  queryLabel: string\n  setQueryLabel: (queryLabel: string) => void\n\n  retrievalHistory: Message[]\n  setRetrievalHistory: (history: Message[]) => void\n\n  querySettings: Omit<QueryRequest, 'query'>\n  updateQuerySettings: (settings: Partial<QueryRequest>) => void\n\n  // Auth settings\n  apiKey: string | null\n  setApiKey: (key: string | null) => void\n\n  // App settings\n  theme: Theme\n  setTheme: (theme: Theme) => void\n\n  language: Language\n  setLanguage: (lang: Language) => void\n\n  enableHealthCheck: boolean\n  setEnableHealthCheck: (enable: boolean) => void\n\n  currentTab: Tab\n  setCurrentTab: (tab: Tab) => void\n\n  // Search label dropdown refresh trigger (non-persistent, runtime only)\n  searchLabelDropdownRefreshTrigger: number\n  triggerSearchLabelDropdownRefresh: () => void\n}\n\nconst useSettingsStoreBase = create<SettingsState>()(\n  persist(\n    (set) => ({\n      theme: 'system',\n      language: 'en',\n      showPropertyPanel: true,\n      showNodeSearchBar: true,\n      showLegend: false,\n\n      showNodeLabel: true,\n      enableNodeDrag: true,\n\n      showEdgeLabel: false,\n      enableHideUnselectedEdges: true,\n      enableEdgeEvents: false,\n\n      minEdgeSize: 1,\n      maxEdgeSize: 1,\n\n      graphQueryMaxDepth: 3,\n      graphMaxNodes: 1000,\n      backendMaxGraphNodes: null,\n      graphLayoutMaxIterations: 15,\n\n      queryLabel: defaultQueryLabel,\n\n      enableHealthCheck: true,\n\n      apiKey: null,\n\n      currentTab: 'documents',\n      showFileName: false,\n      documentsPageSize: 10,\n\n      retrievalHistory: [],\n      userPromptHistory: [],\n\n      querySettings: {\n        mode: 'global',\n        top_k: 40,\n        chunk_top_k: 20,\n        max_entity_tokens: 6000,\n        max_relation_tokens: 8000,\n        max_total_tokens: 30000,\n        only_need_context: false,\n        only_need_prompt: false,\n        stream: true,\n        history_turns: 0,\n        user_prompt: '',\n        enable_rerank: true\n      },\n\n      setTheme: (theme: Theme) => set({ theme }),\n\n      setLanguage: (language: Language) => {\n        set({ language })\n      },\n\n      setGraphLayoutMaxIterations: (iterations: number) =>\n        set({\n          graphLayoutMaxIterations: iterations\n        }),\n\n      setQueryLabel: (queryLabel: string) =>\n        set({\n          queryLabel\n        }),\n\n      setGraphQueryMaxDepth: (depth: number) => set({ graphQueryMaxDepth: depth }),\n\n      setGraphMaxNodes: (nodes: number, triggerRefresh: boolean = false) => {\n        const state = useSettingsStore.getState();\n        if (state.graphMaxNodes === nodes) {\n          return;\n        }\n\n        if (triggerRefresh) {\n          const currentLabel = state.queryLabel;\n          // Atomically update both the node count and the query label to trigger a refresh.\n          set({ graphMaxNodes: nodes, queryLabel: '' });\n\n          // Restore the label after a short delay.\n          setTimeout(() => {\n            set({ queryLabel: currentLabel });\n          }, 300);\n        } else {\n          set({ graphMaxNodes: nodes });\n        }\n      },\n\n      setBackendMaxGraphNodes: (maxNodes: number | null) => set({ backendMaxGraphNodes: maxNodes }),\n\n      setMinEdgeSize: (size: number) => set({ minEdgeSize: size }),\n\n      setMaxEdgeSize: (size: number) => set({ maxEdgeSize: size }),\n\n      setEnableHealthCheck: (enable: boolean) => set({ enableHealthCheck: enable }),\n\n      setApiKey: (apiKey: string | null) => set({ apiKey }),\n\n      setCurrentTab: (tab: Tab) => set({ currentTab: tab }),\n\n      setRetrievalHistory: (history: Message[]) => set({ retrievalHistory: history }),\n\n      updateQuerySettings: (settings: Partial<QueryRequest>) => {\n        // Filter out history_turns to prevent changes, always keep it as 0\n        const filteredSettings = { ...settings }\n        delete filteredSettings.history_turns\n        set((state) => ({\n          querySettings: { ...state.querySettings, ...filteredSettings, history_turns: 0 }\n        }))\n      },\n\n      setShowFileName: (show: boolean) => set({ showFileName: show }),\n      setShowLegend: (show: boolean) => set({ showLegend: show }),\n      setDocumentsPageSize: (size: number) => set({ documentsPageSize: size }),\n\n      // User prompt history methods\n      addUserPromptToHistory: (prompt: string) => {\n        if (!prompt.trim()) return\n\n        set((state) => {\n          const newHistory = [...state.userPromptHistory]\n\n          // Remove existing occurrence if found\n          const existingIndex = newHistory.indexOf(prompt)\n          if (existingIndex !== -1) {\n            newHistory.splice(existingIndex, 1)\n          }\n\n          // Add to beginning\n          newHistory.unshift(prompt)\n\n          // Keep only last 12 items\n          if (newHistory.length > 12) {\n            newHistory.splice(12)\n          }\n\n          return { userPromptHistory: newHistory }\n        })\n      },\n\n      setUserPromptHistory: (history: string[]) => set({ userPromptHistory: history }),\n\n      // Search label dropdown refresh trigger (not persisted)\n      searchLabelDropdownRefreshTrigger: 0,\n      triggerSearchLabelDropdownRefresh: () =>\n        set((state) => ({\n          searchLabelDropdownRefreshTrigger: state.searchLabelDropdownRefreshTrigger + 1\n        }))\n    }),\n    {\n      name: 'settings-storage',\n      storage: createJSONStorage(() => localStorage),\n      version: 19,\n      migrate: (state: any, version: number) => {\n        if (version < 2) {\n          state.showEdgeLabel = false\n        }\n        if (version < 3) {\n          state.queryLabel = defaultQueryLabel\n        }\n        if (version < 4) {\n          state.showPropertyPanel = true\n          state.showNodeSearchBar = true\n          state.showNodeLabel = true\n          state.enableHealthCheck = true\n          state.apiKey = null\n        }\n        if (version < 5) {\n          state.currentTab = 'documents'\n        }\n        if (version < 6) {\n          state.querySettings = {\n            mode: 'global',\n            response_type: 'Multiple Paragraphs',\n            top_k: 10,\n            max_token_for_text_unit: 4000,\n            max_token_for_global_context: 4000,\n            max_token_for_local_context: 4000,\n            only_need_context: false,\n            only_need_prompt: false,\n            stream: true,\n            history_turns: 0,\n            hl_keywords: [],\n            ll_keywords: []\n          }\n          state.retrievalHistory = []\n        }\n        if (version < 7) {\n          state.graphQueryMaxDepth = 3\n          state.graphLayoutMaxIterations = 15\n        }\n        if (version < 8) {\n          state.graphMinDegree = 0\n          state.language = 'en'\n        }\n        if (version < 9) {\n          state.showFileName = false\n        }\n        if (version < 10) {\n          delete state.graphMinDegree // 删除废弃参数\n          state.graphMaxNodes = 1000  // 添加新参数\n        }\n        if (version < 11) {\n          state.minEdgeSize = 1\n          state.maxEdgeSize = 1\n        }\n        if (version < 12) {\n          // Clear retrieval history to avoid compatibility issues with MessageWithError type\n          state.retrievalHistory = []\n        }\n        if (version < 13) {\n          // Add user_prompt field for older versions\n          if (state.querySettings) {\n            state.querySettings.user_prompt = ''\n          }\n        }\n        if (version < 14) {\n          // Add backendMaxGraphNodes field for older versions\n          state.backendMaxGraphNodes = null\n        }\n        if (version < 15) {\n          // Add new querySettings\n          state.querySettings = {\n            ...state.querySettings,\n            mode: 'mix',\n            response_type: 'Multiple Paragraphs',\n            top_k: 40,\n            chunk_top_k: 10,\n            max_entity_tokens: 10000,\n            max_relation_tokens: 10000,\n            max_total_tokens: 32000,\n            enable_rerank: true,\n            history_turns: 0,\n          }\n        }\n        if (version < 16) {\n          // Add documentsPageSize field for older versions\n          state.documentsPageSize = 10\n        }\n        if (version < 17) {\n          // Force history_turns to 0 for all users\n          if (state.querySettings) {\n            state.querySettings.history_turns = 0\n          }\n        }\n        if (version < 18) {\n          // Add userPromptHistory field for older versions\n          state.userPromptHistory = []\n        }\n        if (version < 19) {\n          // Remove deprecated response_type parameter\n          if (state.querySettings) {\n            delete state.querySettings.response_type\n          }\n        }\n        return state\n      }\n    }\n  )\n)\n\nconst useSettingsStore = createSelectors(useSettingsStoreBase)\n\nexport { useSettingsStore, type Theme }\n"
  },
  {
    "path": "lightrag_webui/src/stores/state.ts",
    "content": "import { create } from 'zustand'\nimport { createSelectors } from '@/lib/utils'\nimport { checkHealth, LightragStatus } from '@/api/lightrag'\nimport { useSettingsStore } from './settings'\nimport { healthCheckInterval } from '@/lib/constants'\n\ninterface BackendState {\n  health: boolean\n  message: string | null\n  messageTitle: string | null\n  status: LightragStatus | null\n  lastCheckTime: number\n  pipelineBusy: boolean\n  healthCheckIntervalId: ReturnType<typeof setInterval> | null\n  healthCheckFunction: (() => void) | null\n  healthCheckIntervalValue: number\n\n  check: () => Promise<boolean>\n  clear: () => void\n  setErrorMessage: (message: string, messageTitle: string) => void\n  setPipelineBusy: (busy: boolean) => void\n  setHealthCheckFunction: (fn: () => void) => void\n  resetHealthCheckTimer: () => void\n  resetHealthCheckTimerDelayed: (delayMs: number) => void\n  clearHealthCheckTimer: () => void\n}\n\ninterface AuthState {\n  isAuthenticated: boolean;\n  isGuestMode: boolean;  // Add guest mode flag\n  coreVersion: string | null;\n  apiVersion: string | null;\n  username: string | null; // login username\n  webuiTitle: string | null; // Custom title\n  webuiDescription: string | null; // Title description\n  lastTokenRenewal: string | null; // Human-readable local time of last token renewal (for debugging and monitoring)\n  tokenExpiresAt: number | null; // Token expiration timestamp (extracted from JWT)\n\n  login: (token: string, isGuest?: boolean, coreVersion?: string | null, apiVersion?: string | null, webuiTitle?: string | null, webuiDescription?: string | null) => void;\n  logout: () => void;\n  setVersion: (coreVersion: string | null, apiVersion: string | null) => void;\n  setCustomTitle: (webuiTitle: string | null, webuiDescription: string | null) => void;\n  setTokenRenewal: (renewalTime: number, expiresAt: number) => void; // Track token renewal\n}\n\nconst useBackendStateStoreBase = create<BackendState>()((set, get) => ({\n  health: true,\n  message: null,\n  messageTitle: null,\n  lastCheckTime: Date.now(),\n  status: null,\n  pipelineBusy: false,\n  healthCheckIntervalId: null,\n  healthCheckFunction: null,\n  healthCheckIntervalValue: healthCheckInterval * 1000, // Use constant from lib/constants\n\n  check: async () => {\n    const health = await checkHealth()\n    if (health.status === 'healthy') {\n      // Update version information if health check returns it\n      if (health.core_version || health.api_version) {\n        useAuthStore.getState().setVersion(\n          health.core_version || null,\n          health.api_version || null\n        );\n      }\n\n      // Update custom title information if health check returns it\n      if ('webui_title' in health || 'webui_description' in health) {\n        useAuthStore.getState().setCustomTitle(\n          'webui_title' in health ? (health.webui_title ?? null) : null,\n          'webui_description' in health ? (health.webui_description ?? null) : null\n        );\n      }\n\n      // Extract and store backend max graph nodes limit\n      if (health.configuration?.max_graph_nodes) {\n        const maxNodes = parseInt(health.configuration.max_graph_nodes, 10)\n        if (!isNaN(maxNodes) && maxNodes > 0) {\n          const currentBackendMaxNodes = useSettingsStore.getState().backendMaxGraphNodes\n\n          // Only update if the backend limit has actually changed\n          if (currentBackendMaxNodes !== maxNodes) {\n            useSettingsStore.getState().setBackendMaxGraphNodes(maxNodes)\n\n            // Auto-adjust current graphMaxNodes if it exceeds the new backend limit\n            const currentMaxNodes = useSettingsStore.getState().graphMaxNodes\n            if (currentMaxNodes > maxNodes) {\n              useSettingsStore.getState().setGraphMaxNodes(maxNodes, true)\n            }\n          }\n        }\n      }\n\n      set({\n        health: true,\n        message: null,\n        messageTitle: null,\n        lastCheckTime: Date.now(),\n        status: health,\n        pipelineBusy: health.pipeline_busy\n      })\n      return true\n    }\n    set({\n      health: false,\n      message: health.message,\n      messageTitle: 'Backend Health Check Error!',\n      lastCheckTime: Date.now(),\n      status: null\n    })\n    return false\n  },\n\n  clear: () => {\n    set({ health: true, message: null, messageTitle: null })\n  },\n\n  setErrorMessage: (message: string, messageTitle: string) => {\n    set({ health: false, message, messageTitle })\n  },\n\n  setPipelineBusy: (busy: boolean) => {\n    set({ pipelineBusy: busy })\n  },\n\n  setHealthCheckFunction: (fn: () => void) => {\n    set({ healthCheckFunction: fn })\n  },\n\n  resetHealthCheckTimer: () => {\n    const { healthCheckIntervalId, healthCheckFunction, healthCheckIntervalValue } = get()\n    if (healthCheckIntervalId) {\n      clearInterval(healthCheckIntervalId)\n    }\n    if (healthCheckFunction) {\n      healthCheckFunction() // run health check immediately\n      const newIntervalId = setInterval(healthCheckFunction, healthCheckIntervalValue)\n      set({ healthCheckIntervalId: newIntervalId })\n    }\n  },\n\n  resetHealthCheckTimerDelayed: (delayMs: number) => {\n    setTimeout(() => {\n      get().resetHealthCheckTimer()\n    }, delayMs)\n  },\n\n  clearHealthCheckTimer: () => {\n    const { healthCheckIntervalId } = get()\n    if (healthCheckIntervalId) {\n      clearInterval(healthCheckIntervalId)\n      set({ healthCheckIntervalId: null })\n    }\n  }\n}))\n\nconst useBackendState = createSelectors(useBackendStateStoreBase)\n\nexport { useBackendState }\n\n// Format timestamp to human-readable local time with timezone\nconst formatTimestampToLocalString = (timestamp: number): string => {\n  const date = new Date(timestamp);\n  // Use Swedish locale 'sv-SE' to get YYYY-MM-DD HH:mm:ss format\n  const localTime = date.toLocaleString('sv-SE', { hour12: false });\n  // Get timezone offset\n  const offsetMinutes = -date.getTimezoneOffset();\n  const offsetHours = Math.floor(Math.abs(offsetMinutes) / 60);\n  const offsetSign = offsetMinutes >= 0 ? '+' : '-';\n  return `${localTime} (UTC${offsetSign}${offsetHours})`;\n};\n\nconst parseTokenPayload = (token: string): { sub?: string; role?: string; exp?: number } => {\n  try {\n    // JWT tokens are in the format: header.payload.signature\n    const parts = token.split('.');\n    if (parts.length !== 3) return {};\n    const payload = JSON.parse(atob(parts[1]));\n    return payload;\n  } catch (e) {\n    console.error('Error parsing token payload:', e);\n    return {};\n  }\n};\n\nconst getUsernameFromToken = (token: string): string | null => {\n  const payload = parseTokenPayload(token);\n  return payload.sub || null;\n};\n\nconst isGuestToken = (token: string): boolean => {\n  const payload = parseTokenPayload(token);\n  return payload.role === 'guest';\n};\n\nconst getTokenExpiresAt = (token: string): number | null => {\n  const payload = parseTokenPayload(token);\n  return payload.exp ? payload.exp * 1000 : null; // Convert to milliseconds\n};\n\nconst initAuthState = (): { isAuthenticated: boolean; isGuestMode: boolean; coreVersion: string | null; apiVersion: string | null; username: string | null; webuiTitle: string | null; webuiDescription: string | null; lastTokenRenewal: string | null; tokenExpiresAt: number | null } => {\n  const token = localStorage.getItem('LIGHTRAG-API-TOKEN');\n  const coreVersion = localStorage.getItem('LIGHTRAG-CORE-VERSION');\n  const apiVersion = localStorage.getItem('LIGHTRAG-API-VERSION');\n  const webuiTitle = localStorage.getItem('LIGHTRAG-WEBUI-TITLE');\n  const webuiDescription = localStorage.getItem('LIGHTRAG-WEBUI-DESCRIPTION');\n  const lastTokenRenewal = localStorage.getItem('LIGHTRAG-LAST-TOKEN-RENEWAL');\n  const username = token ? getUsernameFromToken(token) : null;\n  const tokenExpiresAt = token ? getTokenExpiresAt(token) : null;\n\n  if (!token) {\n    return {\n      isAuthenticated: false,\n      isGuestMode: false,\n      coreVersion: coreVersion,\n      apiVersion: apiVersion,\n      username: null,\n      webuiTitle: webuiTitle,\n      webuiDescription: webuiDescription,\n      lastTokenRenewal: null,\n      tokenExpiresAt: null,\n    };\n  }\n\n  return {\n    isAuthenticated: true,\n    isGuestMode: isGuestToken(token),\n    coreVersion: coreVersion,\n    apiVersion: apiVersion,\n    username: username,\n    webuiTitle: webuiTitle,\n    webuiDescription: webuiDescription,\n    lastTokenRenewal: lastTokenRenewal,\n    tokenExpiresAt: tokenExpiresAt,\n  };\n};\n\nexport const useAuthStore = create<AuthState>(set => {\n  // Get initial state from localStorage\n  const initialState = initAuthState();\n\n  return {\n    isAuthenticated: initialState.isAuthenticated,\n    isGuestMode: initialState.isGuestMode,\n    coreVersion: initialState.coreVersion,\n    apiVersion: initialState.apiVersion,\n    username: initialState.username,\n    webuiTitle: initialState.webuiTitle,\n    webuiDescription: initialState.webuiDescription,\n    lastTokenRenewal: initialState.lastTokenRenewal,\n    tokenExpiresAt: initialState.tokenExpiresAt,\n\n    login: (token, isGuest = false, coreVersion = null, apiVersion = null, webuiTitle = null, webuiDescription = null) => {\n      localStorage.setItem('LIGHTRAG-API-TOKEN', token);\n\n      if (coreVersion) {\n        localStorage.setItem('LIGHTRAG-CORE-VERSION', coreVersion);\n      }\n      if (apiVersion) {\n        localStorage.setItem('LIGHTRAG-API-VERSION', apiVersion);\n      }\n\n      if (webuiTitle) {\n        localStorage.setItem('LIGHTRAG-WEBUI-TITLE', webuiTitle);\n      } else {\n        localStorage.removeItem('LIGHTRAG-WEBUI-TITLE');\n      }\n\n      if (webuiDescription) {\n        localStorage.setItem('LIGHTRAG-WEBUI-DESCRIPTION', webuiDescription);\n      } else {\n        localStorage.removeItem('LIGHTRAG-WEBUI-DESCRIPTION');\n      }\n\n      const username = getUsernameFromToken(token);\n      const tokenExpiresAt = getTokenExpiresAt(token);\n      const now = Date.now();\n      const formattedTime = formatTimestampToLocalString(now);\n\n      // Initialize token issuance time with human-readable format\n      localStorage.setItem('LIGHTRAG-LAST-TOKEN-RENEWAL', formattedTime);\n\n      set({\n        isAuthenticated: true,\n        isGuestMode: isGuest,\n        username: username,\n        coreVersion: coreVersion,\n        apiVersion: apiVersion,\n        webuiTitle: webuiTitle,\n        webuiDescription: webuiDescription,\n        tokenExpiresAt: tokenExpiresAt,\n        lastTokenRenewal: formattedTime,\n      });\n    },\n\n    logout: () => {\n      localStorage.removeItem('LIGHTRAG-API-TOKEN');\n      localStorage.removeItem('LIGHTRAG-LAST-TOKEN-RENEWAL');\n\n      const coreVersion = localStorage.getItem('LIGHTRAG-CORE-VERSION');\n      const apiVersion = localStorage.getItem('LIGHTRAG-API-VERSION');\n      const webuiTitle = localStorage.getItem('LIGHTRAG-WEBUI-TITLE');\n      const webuiDescription = localStorage.getItem('LIGHTRAG-WEBUI-DESCRIPTION');\n\n      set({\n        isAuthenticated: false,\n        isGuestMode: false,\n        username: null,\n        coreVersion: coreVersion,\n        apiVersion: apiVersion,\n        webuiTitle: webuiTitle,\n        webuiDescription: webuiDescription,\n        lastTokenRenewal: null,\n        tokenExpiresAt: null,\n      });\n    },\n\n    setVersion: (coreVersion, apiVersion) => {\n      // Update localStorage\n      if (coreVersion) {\n        localStorage.setItem('LIGHTRAG-CORE-VERSION', coreVersion);\n      }\n      if (apiVersion) {\n        localStorage.setItem('LIGHTRAG-API-VERSION', apiVersion);\n      }\n\n      // Update state\n      set({\n        coreVersion: coreVersion,\n        apiVersion: apiVersion\n      });\n    },\n\n    setCustomTitle: (webuiTitle, webuiDescription) => {\n      // Update localStorage\n      if (webuiTitle) {\n        localStorage.setItem('LIGHTRAG-WEBUI-TITLE', webuiTitle);\n      } else {\n        localStorage.removeItem('LIGHTRAG-WEBUI-TITLE');\n      }\n\n      if (webuiDescription) {\n        localStorage.setItem('LIGHTRAG-WEBUI-DESCRIPTION', webuiDescription);\n      } else {\n        localStorage.removeItem('LIGHTRAG-WEBUI-DESCRIPTION');\n      }\n\n      // Update state\n      set({\n        webuiTitle: webuiTitle,\n        webuiDescription: webuiDescription\n      });\n    },\n\n    setTokenRenewal: (renewalTime, expiresAt) => {\n      const formattedTime = formatTimestampToLocalString(renewalTime);\n\n      // Update localStorage with human-readable format\n      localStorage.setItem('LIGHTRAG-LAST-TOKEN-RENEWAL', formattedTime);\n\n      // Update state\n      set({\n        lastTokenRenewal: formattedTime,\n        tokenExpiresAt: expiresAt\n      });\n    }\n  };\n});\n"
  },
  {
    "path": "lightrag_webui/src/types/katex.d.ts",
    "content": "declare module 'katex/contrib/mhchem';\ndeclare module 'katex/contrib/copy-tex';\n"
  },
  {
    "path": "lightrag_webui/src/utils/SearchHistoryManager.ts",
    "content": "import { searchHistoryMaxItems, searchHistoryVersion } from '@/lib/constants'\n\n/**\n * SearchHistoryManager - Manages search history persistence in localStorage\n *\n * This utility class handles:\n * - Storing and retrieving search history from localStorage\n * - Managing history size limits\n * - Sorting by access time and frequency\n * - Version compatibility\n */\n\nexport interface SearchHistoryItem {\n  label: string           // Label name\n  lastAccessed: number   // Last access timestamp\n  accessCount: number    // Access count for sorting optimization\n}\n\nexport interface SearchHistoryData {\n  items: SearchHistoryItem[]\n  version: string        // Data version for compatibility\n  workspace?: string     // Workspace isolation (if needed)\n}\n\nexport class SearchHistoryManager {\n  private static readonly STORAGE_KEY = 'lightrag_search_history'\n  private static readonly MAX_HISTORY = searchHistoryMaxItems\n  private static readonly VERSION = searchHistoryVersion\n\n  /**\n   * Get search history from localStorage\n   * @returns Array of search history items sorted by last accessed time (descending)\n   */\n  static getHistory(): SearchHistoryItem[] {\n    try {\n      const data = localStorage.getItem(this.STORAGE_KEY)\n      if (!data) return []\n\n      const parsed: SearchHistoryData = JSON.parse(data)\n\n      // Version compatibility check\n      if (parsed.version !== this.VERSION) {\n        console.warn(`Search history version mismatch. Expected ${this.VERSION}, got ${parsed.version}. Clearing history.`)\n        this.clearHistory()\n        return []\n      }\n\n      // Ensure items is an array\n      if (!Array.isArray(parsed.items)) {\n        console.warn('Invalid search history format. Clearing history.')\n        this.clearHistory()\n        return []\n      }\n\n      // Sort by last accessed time (descending) then by access count (descending)\n      return parsed.items.sort((a, b) => {\n        if (b.lastAccessed !== a.lastAccessed) {\n          return b.lastAccessed - a.lastAccessed\n        }\n        return (b.accessCount || 0) - (a.accessCount || 0)\n      })\n    } catch (error) {\n      console.error('Error reading search history:', error)\n      this.clearHistory()\n      return []\n    }\n  }\n\n  /**\n   * Add a label to search history (or update if exists)\n   * @param label Label to add to history\n   */\n  static addToHistory(label: string): void {\n    if (!label || typeof label !== 'string' || label.trim() === '') {\n      return\n    }\n\n    try {\n      const history = this.getHistory()\n      const now = Date.now()\n      const trimmedLabel = label.trim()\n\n      // Find existing item\n      const existingIndex = history.findIndex(item => item.label === trimmedLabel)\n\n      if (existingIndex >= 0) {\n        // Update existing item\n        const existingItem = history[existingIndex]\n        existingItem.lastAccessed = now\n        existingItem.accessCount = (existingItem.accessCount || 0) + 1\n\n        // Move to front (will be sorted properly when saved)\n        history.splice(existingIndex, 1)\n        history.unshift(existingItem)\n      } else {\n        // Add new item to the beginning\n        history.unshift({\n          label: trimmedLabel,\n          lastAccessed: now,\n          accessCount: 1\n        })\n      }\n\n      // Limit history size\n      if (history.length > this.MAX_HISTORY) {\n        history.splice(this.MAX_HISTORY)\n      }\n\n      // Save to localStorage\n      const data: SearchHistoryData = {\n        items: history,\n        version: this.VERSION\n      }\n\n      localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))\n    } catch (error) {\n      console.error('Error saving search history:', error)\n    }\n  }\n\n  /**\n   * Clear all search history\n   */\n  static clearHistory(): void {\n    try {\n      localStorage.removeItem(this.STORAGE_KEY)\n    } catch (error) {\n      console.error('Error clearing search history:', error)\n    }\n  }\n\n  /**\n   * Initialize history with default popular labels if empty\n   * @param popularLabels Array of popular labels to use as defaults\n   */\n  static async initializeWithDefaults(popularLabels: string[]): Promise<void> {\n    const history = this.getHistory()\n\n    if (history.length === 0 && popularLabels.length > 0) {\n      try {\n        const now = Date.now()\n        const defaultItems: SearchHistoryItem[] = popularLabels.map((label, index) => ({\n          label: label.trim(),\n          lastAccessed: now - index, // Ensure proper ordering\n          accessCount: 0 // Mark as default/popular items\n        }))\n\n        const data: SearchHistoryData = {\n          items: defaultItems,\n          version: this.VERSION\n        }\n\n        localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))\n      } catch (error) {\n        console.error('Error initializing search history with defaults:', error)\n      }\n    }\n  }\n\n  /**\n   * Get recent searches (items with accessCount > 0)\n   * @param limit Maximum number of recent searches to return\n   * @returns Array of recent search items\n   */\n  static getRecentSearches(limit: number = 10): SearchHistoryItem[] {\n    const history = this.getHistory()\n    return history\n      .filter(item => item.accessCount > 0)\n      .slice(0, limit)\n  }\n\n  /**\n   * Get popular recommendations (items with accessCount = 0, i.e., defaults)\n   * @param limit Maximum number of recommendations to return\n   * @returns Array of popular recommendation items\n   */\n  static getPopularRecommendations(limit?: number): SearchHistoryItem[] {\n    const history = this.getHistory()\n    const recommendations = history.filter(item => item.accessCount === 0)\n    return limit ? recommendations.slice(0, limit) : recommendations\n  }\n\n  /**\n   * Get all history items as simple string array\n   * @param limit Maximum number of items to return\n   * @returns Array of label strings\n   */\n  static getHistoryLabels(limit?: number): string[] {\n    const history = this.getHistory()\n    const labels = history.map(item => item.label)\n    return limit ? labels.slice(0, limit) : labels\n  }\n\n  /**\n   * Check if a label exists in history\n   * @param label Label to check\n   * @returns True if label exists in history\n   */\n  static hasLabel(label: string): boolean {\n    if (!label || typeof label !== 'string') return false\n    const history = this.getHistory()\n    return history.some(item => item.label === label.trim())\n  }\n\n  /**\n   * Remove a specific label from history\n   * @param label Label to remove\n   */\n  static removeLabel(label: string): void {\n    if (!label || typeof label !== 'string') return\n\n    try {\n      const history = this.getHistory()\n      const trimmedLabel = label.trim()\n      const filteredHistory = history.filter(item => item.label !== trimmedLabel)\n\n      if (filteredHistory.length !== history.length) {\n        const data: SearchHistoryData = {\n          items: filteredHistory,\n          version: this.VERSION\n        }\n\n        localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))\n      }\n    } catch (error) {\n      console.error('Error removing label from search history:', error)\n    }\n  }\n\n  /**\n   * Get storage statistics\n   * @returns Object with history statistics\n   */\n  static getStats(): {\n    totalItems: number\n    recentSearches: number\n    popularRecommendations: number\n    storageSize: number\n  } {\n    const history = this.getHistory()\n    const recentCount = history.filter(item => item.accessCount > 0).length\n    const popularCount = history.filter(item => item.accessCount === 0).length\n\n    let storageSize = 0\n    try {\n      const data = localStorage.getItem(this.STORAGE_KEY)\n      storageSize = data ? data.length : 0\n    } catch {\n      // Ignore error\n    }\n\n    return {\n      totalItems: history.length,\n      recentSearches: recentCount,\n      popularRecommendations: popularCount,\n      storageSize\n    }\n  }\n}\n"
  },
  {
    "path": "lightrag_webui/src/utils/clipboard.ts",
    "content": "/**\n * Robust clipboard utility with multiple fallback strategies\n * Handles various browser environments and security contexts\n */\n\nexport interface CopyResult {\n  success: boolean;\n  method: 'clipboard-api' | 'execCommand' | 'manual-select' | 'fallback';\n  error?: string;\n}\n\n/**\n * Copy text to clipboard with multiple fallback strategies\n * @param text - Text to copy to clipboard\n * @returns Promise<CopyResult> - Result object with success status and method used\n */\nexport async function copyToClipboard(text: string): Promise<CopyResult> {\n  if (!text || text.trim() === '') {\n    return {\n      success: false,\n      method: 'fallback',\n      error: 'No text provided'\n    };\n  }\n\n  // Strategy 1: Modern Clipboard API (preferred)\n  if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {\n    try {\n      await navigator.clipboard.writeText(text);\n      return {\n        success: true,\n        method: 'clipboard-api'\n      };\n    } catch (error) {\n      console.warn('Clipboard API failed:', error);\n      // Continue to fallback methods\n    }\n  }\n\n  // Strategy 2: Legacy execCommand (for older browsers)\n  try {\n    const result = await copyWithExecCommand(text);\n    if (result.success) {\n      return result;\n    }\n  } catch (error) {\n    console.warn('execCommand failed:', error);\n    // Continue to fallback methods\n  }\n\n  // Strategy 3: Manual text selection (most compatible)\n  try {\n    const result = await copyWithManualSelection(text);\n    if (result.success) {\n      return result;\n    }\n  } catch (error) {\n    console.warn('Manual selection failed:', error);\n  }\n\n  // Strategy 4: Complete fallback - return error\n  return {\n    success: false,\n    method: 'fallback',\n    error: 'All copy methods failed. Please copy the text manually.'\n  };\n}\n\n/**\n * Copy using legacy execCommand method\n */\nasync function copyWithExecCommand(text: string): Promise<CopyResult> {\n  return new Promise((resolve) => {\n    const textarea = document.createElement('textarea');\n    textarea.value = text;\n    textarea.style.position = 'fixed';\n    textarea.style.left = '-9999px';\n    textarea.style.top = '-9999px';\n    textarea.style.opacity = '0';\n    textarea.setAttribute('readonly', '');\n\n    document.body.appendChild(textarea);\n\n    try {\n      textarea.select();\n      textarea.setSelectionRange(0, text.length);\n\n      const successful = document.execCommand('copy');\n\n      if (successful) {\n        resolve({\n          success: true,\n          method: 'execCommand'\n        });\n      } else {\n        resolve({\n          success: false,\n          method: 'execCommand',\n          error: 'execCommand returned false'\n        });\n      }\n    } catch (error) {\n      resolve({\n        success: false,\n        method: 'execCommand',\n        error: error instanceof Error ? error.message : 'execCommand failed'\n      });\n    } finally {\n      document.body.removeChild(textarea);\n    }\n  });\n}\n\n/**\n * Copy using manual text selection method\n */\nasync function copyWithManualSelection(text: string): Promise<CopyResult> {\n  return new Promise((resolve) => {\n    const textarea = document.createElement('textarea');\n    textarea.value = text;\n    textarea.style.position = 'absolute';\n    textarea.style.left = '-9999px';\n    textarea.style.top = '-9999px';\n    textarea.style.opacity = '0';\n    textarea.style.pointerEvents = 'none';\n    textarea.setAttribute('readonly', '');\n    textarea.setAttribute('tabindex', '-1');\n\n    document.body.appendChild(textarea);\n\n    try {\n      // Focus and select the text\n      textarea.focus();\n      textarea.select();\n      textarea.setSelectionRange(0, text.length);\n\n      // Try to trigger copy event\n      const copyEvent = new ClipboardEvent('copy', {\n        clipboardData: new DataTransfer()\n      });\n\n      if (copyEvent.clipboardData) {\n        copyEvent.clipboardData.setData('text/plain', text);\n        document.dispatchEvent(copyEvent);\n\n        resolve({\n          success: true,\n          method: 'manual-select'\n        });\n      } else {\n        // Fallback: keep text selected for manual copy\n        resolve({\n          success: false,\n          method: 'manual-select',\n          error: 'Manual selection prepared, but automatic copy failed'\n        });\n      }\n    } catch (error) {\n      resolve({\n        success: false,\n        method: 'manual-select',\n        error: error instanceof Error ? error.message : 'Manual selection failed'\n      });\n    } finally {\n      // Clean up after a short delay to allow copy operation\n      setTimeout(() => {\n        if (document.body.contains(textarea)) {\n          document.body.removeChild(textarea);\n        }\n      }, 100);\n    }\n  });\n}\n\n/**\n * Check if clipboard functionality is available\n */\nexport function isClipboardSupported(): boolean {\n  return !!(\n    (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') ||\n    typeof document !== 'undefined'\n  );\n}\n\n/**\n * Get the best available clipboard method\n */\nexport function getBestClipboardMethod(): 'clipboard-api' | 'execCommand' | 'manual-select' | 'none' {\n  if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {\n    return 'clipboard-api';\n  }\n\n  if (typeof document !== 'undefined') {\n    return 'execCommand';\n  }\n\n  return 'none';\n}\n"
  },
  {
    "path": "lightrag_webui/src/utils/graphColor.ts",
    "content": "const DEFAULT_NODE_COLOR = '#5D6D7E'\n\nconst TYPE_SYNONYMS: Record<string, string> = {\n  unknown: 'unknown',\n  未知: 'unknown',\n\n  other: 'other',\n  其它: 'other',\n\n  concept: 'concept',\n  object: 'concept',\n  type: 'concept',\n  category: 'concept',\n  model: 'concept',\n  project: 'concept',\n  condition: 'concept',\n  rule: 'concept',\n  regulation: 'concept',\n  article: 'concept',\n  law: 'concept',\n  legalclause: 'concept',\n  policy: 'concept',\n  disease: 'concept',\n  概念: 'concept',\n  对象: 'concept',\n  类别: 'concept',\n  分类: 'concept',\n  模型: 'concept',\n  项目: 'concept',\n  条件: 'concept',\n  规则: 'concept',\n  法律: 'concept',\n  法律条款: 'concept',\n  条文: 'concept',\n  政策: 'policy',\n  疾病: 'concept',\n\n  method: 'method',\n  process: 'method',\n  方法: 'method',\n  过程: 'method',\n\n  artifact: 'artifact',\n  technology: 'artifact',\n  tech: 'artifact',\n  product: 'artifact',\n  equipment: 'artifact',\n  device: 'artifact',\n  stuff: 'artifact',\n  component: 'artifact',\n  material: 'artifact',\n  chemical: 'artifact',\n  drug: 'artifact',\n  medicine: 'artifact',\n  food: 'artifact',\n  weapon: 'artifact',\n  arms: 'artifact',\n  人工制品: 'artifact',\n  人造物品: 'artifact',\n  技术: 'technology',\n  科技: 'technology',\n  产品: 'artifact',\n  设备: 'artifact',\n  装备: 'artifact',\n  物品: 'artifact',\n  材料: 'artifact',\n  化学: 'artifact',\n  药物: 'artifact',\n  食品: 'artifact',\n  武器: 'artifact',\n  军火: 'artifact',\n\n  naturalobject: 'naturalobject',\n  natural: 'naturalobject',\n  phenomena: 'naturalobject',\n  substance: 'naturalobject',\n  plant: 'naturalobject',\n  自然对象: 'naturalobject',\n  自然物体: 'naturalobject',\n  自然现象: 'naturalobject',\n  物质: 'naturalobject',\n  物体: 'naturalobject',\n\n  data: 'data',\n  figure: 'data',\n  value: 'data',\n  数据: 'data',\n  数字: 'data',\n  数值: 'data',\n\n  content: 'content',\n  book: 'content',\n  video: 'content',\n  内容: 'content',\n  作品: 'content',\n  书籍: 'content',\n  视频: 'content',\n\n  organization: 'organization',\n  org: 'organization',\n  company: 'organization',\n  组织: 'organization',\n  公司: 'organization',\n  机构: 'organization',\n  组织机构: 'organization',\n\n  event: 'event',\n  事件: 'event',\n  activity: 'event',\n  活动: 'event',\n\n  person: 'person',\n  people: 'person',\n  human: 'person',\n  role: 'person',\n  人物: 'person',\n  人类: 'person',\n  人: 'person',\n  角色: 'person',\n\n  creature: 'creature',\n  animal: 'creature',\n  beings: 'creature',\n  being: 'creature',\n  alien: 'creature',\n  ghost: 'creature',\n  动物: 'creature',\n  生物: 'creature',\n  神仙: 'creature',\n  鬼怪: 'creature',\n  妖怪: 'creature',\n\n  location: 'location',\n  geography: 'location',\n  geo: 'location',\n  place: 'location',\n  address: 'location',\n  地点: 'location',\n  位置: 'location',\n  地址: 'location',\n  地理: 'location',\n  地域: 'location'\n}\n\nconst NODE_TYPE_COLORS: Record<string, string> = {\n  person: '#4169E1',\n  creature: '#bd7ebe',\n  organization: '#00cc00',\n  location: '#cf6d17',\n  event: '#00bfa0',\n  concept: '#e3493b',\n  method: '#b71c1c',\n  content: '#0f558a',\n  data: '#0000ff',\n  artifact: '#4421af',\n  naturalobject: '#b2e061',\n  other: '#f4d371',\n  unknown: '#b0b0b0'\n}\n\nconst EXTENDED_COLORS = [\n  '#84a3e1',\n  '#5a2c6d',\n  '#2F4F4F',\n  '#003366',\n  '#9b3a31',\n  '#00CED1',\n  '#b300b3',\n  '#0f705d',\n  '#ff99cc',\n  '#6ef7b3',\n  '#cd071e'\n]\n\nconst PREDEFINED_COLOR_SET = new Set(Object.values(NODE_TYPE_COLORS))\n\ninterface ResolveNodeColorResult {\n  color: string\n  map: Map<string, string>\n  updated: boolean\n}\n\nexport const resolveNodeColor = (\n  nodeType: string | undefined,\n  currentMap: Map<string, string> | undefined\n): ResolveNodeColorResult => {\n  const typeColorMap = currentMap ?? new Map<string, string>()\n  const normalizedType = nodeType ? nodeType.toLowerCase() : 'unknown'\n  const standardType = TYPE_SYNONYMS[normalizedType]\n  const cacheKey = standardType || normalizedType\n\n  if (typeColorMap.has(cacheKey)) {\n    return {\n      color: typeColorMap.get(cacheKey) || DEFAULT_NODE_COLOR,\n      map: typeColorMap,\n      updated: false\n    }\n  }\n\n  if (standardType) {\n    const color = NODE_TYPE_COLORS[standardType] || DEFAULT_NODE_COLOR\n    const newMap = new Map(typeColorMap)\n    newMap.set(standardType, color)\n    return {\n      color,\n      map: newMap,\n      updated: true\n    }\n  }\n\n  const usedExtendedColors = new Set(\n    Array.from(typeColorMap.values()).filter((color) => !PREDEFINED_COLOR_SET.has(color))\n  )\n\n  const unusedColor = EXTENDED_COLORS.find((color) => !usedExtendedColors.has(color))\n  const color = unusedColor || DEFAULT_NODE_COLOR\n\n  const newMap = new Map(typeColorMap)\n  newMap.set(normalizedType, color)\n\n  return {\n    color,\n    map: newMap,\n    updated: true\n  }\n}\n\nexport { DEFAULT_NODE_COLOR }\n"
  },
  {
    "path": "lightrag_webui/src/utils/remarkFootnotes.ts",
    "content": "import { visit } from 'unist-util-visit'\nimport type { Plugin } from 'unified'\nimport type { Root, Text } from 'mdast'\n\n// Simple footnote plugin for remark - only renders inline citations\nexport const remarkFootnotes: Plugin<[], Root> = () => {\n  return (tree: Root) => {\n    // Find footnote references and replace them with inline citations\n    visit(tree, 'text', (node: Text, index, parent) => {\n      if (!parent || typeof index !== 'number') return\n\n      const text = node.value\n      const footnoteRegex = /\\[\\^([^\\]]+)\\]/g\n      let match\n      const replacements: any[] = []\n      let lastIndex = 0\n\n      while ((match = footnoteRegex.exec(text)) !== null) {\n        const [fullMatch, id] = match\n        const startIndex = match.index!\n\n        // Add text before footnote\n        if (startIndex > lastIndex) {\n          replacements.push({\n            type: 'text',\n            value: text.slice(lastIndex, startIndex)\n          })\n        }\n\n        // Check if there's another footnote immediately following this one\n        const nextIndex = startIndex + fullMatch.length\n        const remainingText = text.slice(nextIndex)\n        const hasConsecutiveFootnote = /^\\[\\^[^\\]]+\\]/.test(remainingText)\n\n        // Add footnote reference as HTML with placeholder link\n        const footnoteHtml = `<sup><a href=\"#footnote-${id}\" class=\"footnote-ref\">${id}</a></sup>`\n\n        // Add spacing if there's a consecutive footnote\n        const htmlWithSpacing = hasConsecutiveFootnote\n          ? footnoteHtml + '&nbsp;'\n          : footnoteHtml\n\n        replacements.push({\n          type: 'html',\n          value: htmlWithSpacing\n        })\n\n        lastIndex = startIndex + fullMatch.length\n      }\n\n      // Add remaining text\n      if (lastIndex < text.length) {\n        replacements.push({\n          type: 'text',\n          value: text.slice(lastIndex)\n        })\n      }\n\n      // Replace the text node if we found footnotes\n      if (replacements.length > 1) {\n        parent.children.splice(index, 1, ...replacements)\n      }\n    })\n  }\n}\n"
  },
  {
    "path": "lightrag_webui/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\ninterface ImportMetaEnv {\n  readonly VITE_API_PROXY: string\n  readonly VITE_API_ENDPOINTS: string\n  readonly VITE_BACKEND_URL: string\n}\n\ninterface ImportMeta {\n  readonly env: ImportMetaEnv\n}\n"
  },
  {
    "path": "lightrag_webui/tailwind.config.js",
    "content": "import tailwindcssAnimate from 'tailwindcss-animate'\nimport typography from '@tailwindcss/typography'\n\n/** @type {import('tailwindcss').Config} */\nexport default {\n  darkMode: ['class'],\n  content: [\n    './pages/**/*.{ts,tsx}',\n    './components/**/*.{ts,tsx}',\n    './app/**/*.{ts,tsx}',\n    './src/**/*.{ts,tsx}',\n  ],\n  theme: {\n    container: {\n      center: true,\n      padding: '2rem',\n      screens: {\n        '2xl': '1400px',\n      },\n    },\n    extend: {\n      colors: {\n        border: 'hsl(var(--border))',\n        input: 'hsl(var(--input))',\n        ring: 'hsl(var(--ring))',\n        background: 'hsl(var(--background))',\n        foreground: 'hsl(var(--foreground))',\n        primary: {\n          DEFAULT: 'hsl(var(--primary))',\n          foreground: 'hsl(var(--primary-foreground))',\n        },\n        secondary: {\n          DEFAULT: 'hsl(var(--secondary))',\n          foreground: 'hsl(var(--secondary-foreground))',\n        },\n        destructive: {\n          DEFAULT: 'hsl(var(--destructive))',\n          foreground: 'hsl(var(--destructive-foreground))',\n        },\n        muted: {\n          DEFAULT: 'hsl(var(--muted))',\n          foreground: 'hsl(var(--muted-foreground))',\n        },\n        accent: {\n          DEFAULT: 'hsl(var(--accent))',\n          foreground: 'hsl(var(--accent-foreground))',\n        },\n        popover: {\n          DEFAULT: 'hsl(var(--popover))',\n          foreground: 'hsl(var(--popover-foreground))',\n        },\n        card: {\n          DEFAULT: 'hsl(var(--card))',\n          foreground: 'hsl(var(--card-foreground))',\n        },\n      },\n      borderRadius: {\n        lg: 'var(--radius)',\n        md: 'calc(var(--radius) - 2px)',\n        sm: 'calc(var(--radius) - 4px)',\n      },\n      keyframes: {\n        'accordion-down': {\n          from: { height: '0' },\n          to: { height: 'var(--radix-accordion-content-height, auto)' },\n        },\n        'accordion-up': {\n          from: { height: 'var(--radix-accordion-content-height, auto)' },\n          to: { height: '0' },\n        },\n      },\n      animation: {\n        'accordion-down': 'accordion-down 0.2s ease-out',\n        'accordion-up': 'accordion-up 0.2s ease-out',\n      },\n      typography: {\n        DEFAULT: {\n          css: {\n            maxWidth: '100%',\n            color: 'var(--tw-prose-body)',\n            '[class~=\"lead\"]': {\n              color: 'var(--tw-prose-lead)',\n            },\n            a: {\n              color: 'var(--tw-prose-links)',\n              textDecoration: 'underline',\n              fontWeight: '500',\n            },\n            strong: {\n              color: 'var(--tw-prose-bold)',\n              fontWeight: '600',\n            },\n            'ol[type=\"A\"]': {\n              listStyleType: 'upper-alpha',\n            },\n            'ol[type=\"a\"]': {\n              listStyleType: 'lower-alpha',\n            },\n            'ol[type=\"A\" s]': {\n              listStyleType: 'upper-alpha',\n            },\n            'ol[type=\"a\" s]': {\n              listStyleType: 'lower-alpha',\n            },\n            'ol[type=\"I\"]': {\n              listStyleType: 'upper-roman',\n            },\n            'ol[type=\"i\"]': {\n              listStyleType: 'lower-roman',\n            },\n            'ol[type=\"I\" s]': {\n              listStyleType: 'upper-roman',\n            },\n            'ol[type=\"i\" s]': {\n              listStyleType: 'lower-roman',\n            },\n            'ol[type=\"1\"]': {\n              listStyleType: 'decimal',\n            },\n            'ol > li': {\n              position: 'relative',\n              paddingLeft: '1.75em',\n            },\n            'ol > li::before': {\n              content: 'counter(list-item, var(--list-counter-style, decimal)) \".\"',\n              position: 'absolute',\n              fontWeight: '400',\n              color: 'var(--tw-prose-counters)',\n              left: '0',\n            },\n            'ul > li': {\n              position: 'relative',\n              paddingLeft: '1.75em',\n            },\n            'ul > li::before': {\n              content: '\"\"',\n              position: 'absolute',\n              backgroundColor: 'var(--tw-prose-bullets)',\n              borderRadius: '50%',\n              width: '0.375em',\n              height: '0.375em',\n              top: 'calc(0.875em - 0.1875em)',\n              left: '0.25em',\n            },\n            hr: {\n              borderColor: 'var(--tw-prose-hr)',\n              borderTopWidth: 1,\n              marginTop: '3em',\n              marginBottom: '3em',\n            },\n            blockquote: {\n              fontWeight: '500',\n              fontStyle: 'italic',\n              color: 'var(--tw-prose-quotes)',\n              borderLeftWidth: '0.25rem',\n              borderLeftColor: 'var(--tw-prose-quote-borders)',\n              quotes: '\"\\\\201C\"\"\\\\201D\"\"\\\\2018\"\"\\\\2019\"',\n              marginTop: '1.6em',\n              marginBottom: '1.6em',\n              paddingLeft: '1em',\n            },\n            h1: {\n              color: 'var(--tw-prose-headings)',\n              fontWeight: '800',\n              fontSize: '2.25em',\n              marginTop: '0',\n              marginBottom: '0.8888889em',\n              lineHeight: '1.1111111',\n            },\n            h2: {\n              color: 'var(--tw-prose-headings)',\n              fontWeight: '700',\n              fontSize: '1.5em',\n              marginTop: '2em',\n              marginBottom: '1em',\n              lineHeight: '1.3333333',\n            },\n            h3: {\n              color: 'var(--tw-prose-headings)',\n              fontWeight: '600',\n              fontSize: '1.25em',\n              marginTop: '1.6em',\n              marginBottom: '0.6em',\n              lineHeight: '1.6',\n            },\n            h4: {\n              color: 'var(--tw-prose-headings)',\n              fontWeight: '600',\n              marginTop: '1.5em',\n              marginBottom: '0.5em',\n              lineHeight: '1.5',\n            },\n            'figure > *': {\n              margin: '0',\n            },\n            figcaption: {\n              color: 'var(--tw-prose-captions)',\n              fontSize: '0.875em',\n              lineHeight: '1.4285714',\n              marginTop: '0.8571429em',\n            },\n            code: {\n              color: 'var(--tw-prose-code)',\n              fontWeight: '600',\n              fontSize: '0.875em',\n            },\n            'code::before': {\n              content: '\"\"',\n            },\n            'code::after': {\n              content: '\"\"',\n            },\n            'a code': {\n              color: 'var(--tw-prose-links)',\n            },\n            'h1 code': {\n              color: 'inherit',\n            },\n            'h2 code': {\n              color: 'inherit',\n              fontSize: '0.875em',\n            },\n            'h3 code': {\n              color: 'inherit',\n              fontSize: '0.9em',\n            },\n            'h4 code': {\n              color: 'inherit',\n            },\n            'blockquote code': {\n              color: 'inherit',\n            },\n            'thead': {\n              color: 'var(--tw-prose-headings)',\n              fontWeight: '600',\n              borderBottomWidth: '1px',\n              borderBottomColor: 'var(--tw-prose-th-borders)',\n            },\n            'thead th': {\n              verticalAlign: 'bottom',\n              paddingRight: '0.5714286em',\n              paddingBottom: '0.5714286em',\n              paddingLeft: '0.5714286em',\n            },\n            'tbody tr': {\n              borderBottomWidth: '1px',\n              borderBottomColor: 'var(--tw-prose-td-borders)',\n            },\n            'tbody tr:last-child': {\n              borderBottomWidth: '0',\n            },\n            'tbody td': {\n              verticalAlign: 'baseline',\n              paddingTop: '0.5714286em',\n              paddingRight: '0.5714286em',\n              paddingBottom: '0.5714286em',\n              paddingLeft: '0.5714286em',\n            },\n            p: {\n              marginTop: '1.25em',\n              marginBottom: '1.25em',\n            },\n          },\n        },\n      },\n    },\n  },\n  plugins: [tailwindcssAnimate, typography],\n}\n"
  },
  {
    "path": "lightrag_webui/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"isolatedModules\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n\n    /* Paths */\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\"src\", \"src/types\", \"vite.config.ts\", \"src/vite-env.d.ts\"]\n}\n"
  },
  {
    "path": "lightrag_webui/vite.config.ts",
    "content": "import { defineConfig, loadEnv } from 'vite'\nimport path from 'path'\nimport react from '@vitejs/plugin-react-swc'\nimport tailwindcss from '@tailwindcss/vite'\n\n// Use relative import instead of '@/lib/constants' path alias.\n// The '@' alias is configured in this file's resolve.alias and only takes effect\n// during bundling — Node.js cannot resolve it when loading vite.config.ts itself.\n// Bun resolves tsconfig paths natively, masking the issue, but Node.js does not.\nimport { webuiPrefix } from './src/lib/constants'\n\n// https://vite.dev/config/\n// Use functional config form so we can call loadEnv(). import.meta.env is only\n// available inside Bun's runtime; Node.js leaves it undefined, crashing the build.\nexport default defineConfig(({ mode }) => {\n  const env = loadEnv(mode, process.cwd(), '')\n\n  return {\n    plugins: [react(), tailwindcss()],\n    resolve: {\n      alias: {\n        '@': path.resolve(__dirname, './src')\n      },\n      // Force all modules to use the same katex instance\n      // This ensures mhchem extension registered in main.tsx is available to rehype-katex\n      dedupe: ['katex']\n    },\n    // base: env.VITE_BASE_URL || '/webui/',\n    base: webuiPrefix,\n    build: {\n      outDir: path.resolve(__dirname, '../lightrag/api/webui'),\n      emptyOutDir: true,\n      chunkSizeWarningLimit: 3800,\n      rollupOptions: {\n        // Let Vite handle chunking automatically to avoid circular dependency issues\n        output: {\n          // Ensure consistent chunk naming format\n          chunkFileNames: 'assets/[name]-[hash].js',\n          // Entry file naming format\n          entryFileNames: 'assets/[name]-[hash].js',\n          // Asset file naming format\n          assetFileNames: 'assets/[name]-[hash].[ext]'\n        }\n      }\n    },\n    server: {\n      proxy: env.VITE_API_PROXY === 'true' && env.VITE_API_ENDPOINTS ?\n        Object.fromEntries(\n          env.VITE_API_ENDPOINTS.split(',').map(endpoint => [\n            endpoint,\n            {\n              target: env.VITE_BACKEND_URL || 'http://localhost:9621',\n              changeOrigin: true,\n              rewrite: endpoint === '/api' ?\n                (p: string) => p.replace(/^\\/api/, '') :\n                endpoint === '/docs' || endpoint === '/redoc' || endpoint === '/openapi.json' || endpoint === '/static' ?\n                  (p: string) => p : undefined\n            }\n          ])\n        ) : {}\n    }\n  }\n})\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"setuptools>=64\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"lightrag-hku\"\ndynamic = [\"version\"]\nauthors = [\n    {name = \"Zirui Guo\"}\n]\ndescription = \"LightRAG: Simple and Fast Retrieval-Augmented Generation\"\nreadme = \"README.md\"\nlicense = {text = \"MIT\"}\nrequires-python = \">=3.10\"\nclassifiers = [\n    \"Development Status :: 4 - Beta\",\n    \"Programming Language :: Python :: 3\",\n    \"License :: OSI Approved :: MIT License\",\n    \"Operating System :: OS Independent\",\n    \"Intended Audience :: Developers\",\n    \"Topic :: Software Development :: Libraries :: Python Modules\",\n]\ndependencies = [\n    \"aiohttp\",\n    \"configparser\",\n    \"google-api-core>=2.0.0,<3.0.0\",\n    \"google-genai>=1.0.0,<2.0.0\",\n    \"json_repair\",\n    \"nano-vectordb\",\n    \"networkx\",\n    \"numpy>=1.24.0,<3.0.0\",\n    \"packaging\",\n    \"pandas>=2.0.0,<2.4.0\",\n    \"pipmaster\",\n    \"pydantic\",\n    \"pypinyin\",\n    \"python-dotenv\",\n    \"setuptools\",\n    \"tenacity\",\n    \"tiktoken\",\n    \"xlsxwriter>=3.1.0\",\n]\n\n[project.optional-dependencies]\n# Test framework dependencies (for CI/CD and testing)\npytest = [\n    \"pytest>=8.4.2\",\n    \"pytest-asyncio>=1.2.0\",\n    \"pre-commit\",\n    \"ruff\",\n]\n\napi = [\n    # Core dependencies\n    \"aiohttp\",\n    \"configparser\",\n    \"json_repair\",\n    \"nano-vectordb\",\n    \"networkx\",\n    \"numpy>=1.24.0,<3.0.0\",\n    \"openai>=2.0.0,<3.0.0\",\n    \"pandas>=2.0.0,<2.4.0\",\n    \"pipmaster\",\n    \"pydantic\",\n    \"pypinyin\",\n    \"python-dotenv\",\n    \"setuptools\",\n    \"tenacity\",\n    \"tiktoken\",\n    \"xlsxwriter>=3.1.0\",\n    \"google-api-core>=2.0.0,<3.0.0\",\n    \"google-genai>=1.0.0,<2.0.0\",\n    # API-specific dependencies\n    \"aiofiles\",\n    \"ascii_colors\",\n    \"distro\",\n    \"fastapi\",\n    \"httpcore\",\n    \"httpx>=0.28.1\",\n    \"jiter\",\n    \"bcrypt>=4.0.0\",\n    \"psutil\",\n    \"PyJWT>=2.8.0,<3.0.0\",\n    \"python-jose[cryptography]\",\n    \"python-multipart\",\n    \"pytz\",\n    \"uvicorn\",\n    \"gunicorn\",\n    # Document processing dependencies (required for API document upload functionality)\n    \"openpyxl>=3.0.0,<4.0.0\",      # XLSX processing\n    \"pycryptodome>=3.0.0,<4.0.0\",  # PDF encryption support\n    \"pypdf>=6.1.0\",                 # PDF processing\n    \"python-docx>=0.8.11,<2.0.0\",  # DOCX processing\n    \"python-pptx>=0.6.21,<2.0.0\",  # PPTX processing\n]\n\n# Advanced document processing engine (optional)\ndocling = [\n    # On macOS, pytorch and frameworks use Objective-C are not fork-safe,\n    # and not compatible to gunicorn multi-worker mode\n    \"docling>=2.0.0,<3.0.0; sys_platform != 'darwin'\",\n]\n\n# Offline deployment dependencies (layered design for flexibility)\noffline-storage = [\n    # Storage backend dependencies\n    \"redis>=5.0.0,<8.0.0\",\n    \"neo4j>=5.0.0,<7.0.0\",\n    \"pymilvus>=2.6.2,<3.0.0\",\n    \"pymongo>=4.0.0,<5.0.0\",\n    \"asyncpg>=0.31.0,<1.0.0\",\n    \"pgvector>=0.4.2,<1.0.0\",\n    \"qdrant-client>=1.11.0,<2.0.0\",\n    \"opensearch-py>=3.0.0,<4.0.0\",\n]\n\noffline-llm = [\n    # LLM provider dependencies\n    \"openai>=2.0.0,<3.0.0\",\n    \"anthropic>=0.18.0,<1.0.0\",\n    \"ollama>=0.1.0,<1.0.0\",\n    \"zhipuai>=2.0.0,<3.0.0\",\n    \"aioboto3>=12.0.0,<16.0.0\",\n    \"voyageai>=0.2.0,<1.0.0\",\n    \"llama-index>=0.14.0,<1.0.0\",  # Updated to ensure compatibility with openai 2.x\n    \"llama-index-llms-openai>=0.6.12\",  # Explicitly require version that supports openai 2.x\n    \"google-api-core>=2.0.0,<3.0.0\",\n    \"google-genai>=1.0.0,<2.0.0\",\n]\n\noffline = [\n    # Complete offline package (includes api for document processing, plus storage and LLM)\n    \"lightrag-hku[api,offline-storage,offline-llm]\",\n]\n\ntest = [\n    \"lightrag-hku[api]\",\n    \"pytest>=8.4.2\",\n    \"pytest-asyncio>=1.2.0\",\n    \"pre-commit\",\n    \"ruff\",\n]\n\nevaluation = [\n    \"lightrag-hku[api]\",\n    \"ragas>=0.3.7\",\n    \"datasets>=4.3.0\",\n]\n\nobservability = [\n    # LLM observability and tracing dependencies\n    \"langfuse>=3.8.1\",\n]\n\n[project.scripts]\nlightrag-server = \"lightrag.api.lightrag_server:main\"\nlightrag-gunicorn = \"lightrag.api.run_with_gunicorn:main\"\nlightrag-download-cache = \"lightrag.tools.download_cache:main\"\nlightrag-clean-llmqc = \"lightrag.tools.clean_llm_query_cache:main\"\n\n[project.urls]\nHomepage = \"https://github.com/HKUDS/LightRAG\"\nDocumentation = \"https://github.com/HKUDS/LightRAG\"\nRepository = \"https://github.com/HKUDS/LightRAG\"\n\"Bug Tracker\" = \"https://github.com/HKUDS/LightRAG/issues\"\n\n[tool.setuptools.packages.find]\ninclude = [\"lightrag*\"]\nexclude = [\"data*\", \"tests*\", \"scripts*\", \"examples*\", \"dickens*\", \"reproduce*\", \"output_complete*\", \"rag_storage*\", \"inputs*\"]\n\n[tool.setuptools]\ninclude-package-data = true\n\n[tool.setuptools.dynamic]\nversion = {attr = \"lightrag.__version__\"}\n\n[tool.setuptools.package-data]\nlightrag = [\"api/webui/**/*\", \"api/static/**/*\"]\n\n[tool.pytest.ini_options]\nasyncio_mode = \"auto\"\nasyncio_default_fixture_loop_scope = \"function\"\ntestpaths = [\"tests\"]\npython_files = [\"test_*.py\"]\npython_classes = [\"Test*\"]\npython_functions = [\"test_*\"]\n\n[tool.ruff]\ntarget-version = \"py310\"\n"
  },
  {
    "path": "reproduce/Step_0.py",
    "content": "import os\nimport json\nimport glob\nimport argparse\n\n\ndef extract_unique_contexts(input_directory, output_directory):\n    os.makedirs(output_directory, exist_ok=True)\n\n    jsonl_files = glob.glob(os.path.join(input_directory, \"*.jsonl\"))\n    print(f\"Found {len(jsonl_files)} JSONL files.\")\n\n    for file_path in jsonl_files:\n        filename = os.path.basename(file_path)\n        name, ext = os.path.splitext(filename)\n        output_filename = f\"{name}_unique_contexts.json\"\n        output_path = os.path.join(output_directory, output_filename)\n\n        unique_contexts_dict = {}\n\n        print(f\"Processing file: {filename}\")\n\n        try:\n            with open(file_path, \"r\", encoding=\"utf-8\") as infile:\n                for line_number, line in enumerate(infile, start=1):\n                    line = line.strip()\n                    if not line:\n                        continue\n                    try:\n                        json_obj = json.loads(line)\n                        context = json_obj.get(\"context\")\n                        if context and context not in unique_contexts_dict:\n                            unique_contexts_dict[context] = None\n                    except json.JSONDecodeError as e:\n                        print(\n                            f\"JSON decoding error in file {filename} at line {line_number}: {e}\"\n                        )\n        except FileNotFoundError:\n            print(f\"File not found: {filename}\")\n            continue\n        except Exception as e:\n            print(f\"An error occurred while processing file {filename}: {e}\")\n            continue\n\n        unique_contexts_list = list(unique_contexts_dict.keys())\n        print(\n            f\"There are {len(unique_contexts_list)} unique `context` entries in the file {filename}.\"\n        )\n\n        try:\n            with open(output_path, \"w\", encoding=\"utf-8\") as outfile:\n                json.dump(unique_contexts_list, outfile, ensure_ascii=False, indent=4)\n            print(f\"Unique `context` entries have been saved to: {output_filename}\")\n        except Exception as e:\n            print(f\"An error occurred while saving to the file {output_filename}: {e}\")\n\n    print(\"All files have been processed.\")\n\n\nif __name__ == \"__main__\":\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\"-i\", \"--input_dir\", type=str, default=\"../datasets\")\n    parser.add_argument(\n        \"-o\", \"--output_dir\", type=str, default=\"../datasets/unique_contexts\"\n    )\n\n    args = parser.parse_args()\n\n    extract_unique_contexts(args.input_dir, args.output_dir)\n"
  },
  {
    "path": "reproduce/Step_1.py",
    "content": "import os\nimport json\nimport time\nimport asyncio\n\nfrom lightrag import LightRAG\n\n\ndef insert_text(rag, file_path):\n    with open(file_path, mode=\"r\") as f:\n        unique_contexts = json.load(f)\n\n    retries = 0\n    max_retries = 3\n    while retries < max_retries:\n        try:\n            rag.insert(unique_contexts)\n            break\n        except Exception as e:\n            retries += 1\n            print(f\"Insertion failed, retrying ({retries}/{max_retries}), error: {e}\")\n            time.sleep(10)\n    if retries == max_retries:\n        print(\"Insertion failed after exceeding the maximum number of retries\")\n\n\ncls = \"agriculture\"\nWORKING_DIR = f\"../{cls}\"\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n\nasync def initialize_rag():\n    rag = LightRAG(working_dir=WORKING_DIR)\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\ndef main():\n    # Initialize RAG instance\n    rag = asyncio.run(initialize_rag())\n    insert_text(rag, f\"../datasets/unique_contexts/{cls}_unique_contexts.json\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "reproduce/Step_1_openai_compatible.py",
    "content": "import os\nimport json\nimport time\nimport asyncio\nimport numpy as np\n\nfrom lightrag import LightRAG\nfrom lightrag.utils import EmbeddingFunc\nfrom lightrag.llm.openai import openai_complete_if_cache, openai_embed\n\n\n## For Upstage API\n# please check if embedding_dim=4096 in lightrag.py and llm.py in lightrag direcotry\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], **kwargs\n) -> str:\n    return await openai_complete_if_cache(\n        \"solar-mini\",\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=os.getenv(\"UPSTAGE_API_KEY\"),\n        base_url=\"https://api.upstage.ai/v1/solar\",\n        **kwargs,\n    )\n\n\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await openai_embed(\n        texts,\n        model=\"solar-embedding-1-large-query\",\n        api_key=os.getenv(\"UPSTAGE_API_KEY\"),\n        base_url=\"https://api.upstage.ai/v1/solar\",\n    )\n\n\n## /For Upstage API\n\n\ndef insert_text(rag, file_path):\n    with open(file_path, mode=\"r\") as f:\n        unique_contexts = json.load(f)\n\n    retries = 0\n    max_retries = 3\n    while retries < max_retries:\n        try:\n            rag.insert(unique_contexts)\n            break\n        except Exception as e:\n            retries += 1\n            print(f\"Insertion failed, retrying ({retries}/{max_retries}), error: {e}\")\n            time.sleep(10)\n    if retries == max_retries:\n        print(\"Insertion failed after exceeding the maximum number of retries\")\n\n\ncls = \"mix\"\nWORKING_DIR = f\"../{cls}\"\n\nif not os.path.exists(WORKING_DIR):\n    os.mkdir(WORKING_DIR)\n\n\nasync def initialize_rag():\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=llm_model_func,\n        embedding_func=EmbeddingFunc(embedding_dim=4096, func=embedding_func),\n    )\n\n    await rag.initialize_storages()  # Auto-initializes pipeline_status\n    return rag\n\n\ndef main():\n    # Initialize RAG instance\n    rag = asyncio.run(initialize_rag())\n    insert_text(rag, f\"../datasets/unique_contexts/{cls}_unique_contexts.json\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "reproduce/Step_2.py",
    "content": "import json\nfrom openai import OpenAI\nfrom transformers import GPT2Tokenizer\n\n\ndef openai_complete_if_cache(\n    model=\"gpt-4o\", prompt=None, system_prompt=None, history_messages=[], **kwargs\n) -> str:\n    openai_client = OpenAI()\n\n    messages = []\n    if system_prompt:\n        messages.append({\"role\": \"system\", \"content\": system_prompt})\n    messages.extend(history_messages)\n    messages.append({\"role\": \"user\", \"content\": prompt})\n\n    response = openai_client.chat.completions.create(\n        model=model, messages=messages, **kwargs\n    )\n    return response.choices[0].message.content\n\n\ntokenizer = GPT2Tokenizer.from_pretrained(\"gpt2\")\n\n\ndef get_summary(context, tot_tokens=2000):\n    tokens = tokenizer.tokenize(context)\n    half_tokens = tot_tokens // 2\n\n    start_tokens = tokens[1000 : 1000 + half_tokens]\n    end_tokens = tokens[-(1000 + half_tokens) : 1000]\n\n    summary_tokens = start_tokens + end_tokens\n    summary = tokenizer.convert_tokens_to_string(summary_tokens)\n\n    return summary\n\n\nclses = [\"agriculture\"]\nfor cls in clses:\n    with open(f\"../datasets/unique_contexts/{cls}_unique_contexts.json\", mode=\"r\") as f:\n        unique_contexts = json.load(f)\n\n    summaries = [get_summary(context) for context in unique_contexts]\n\n    total_description = \"\\n\\n\".join(summaries)\n\n    prompt = f\"\"\"\n    Given the following description of a dataset:\n\n    {total_description}\n\n    Please identify 5 potential users who would engage with this dataset. For each user, list 5 tasks they would perform with this dataset. Then, for each (user, task) combination, generate 5 questions that require a high-level understanding of the entire dataset.\n\n    Output the results in the following structure:\n    - User 1: [user description]\n        - Task 1: [task description]\n            - Question 1:\n            - Question 2:\n            - Question 3:\n            - Question 4:\n            - Question 5:\n        - Task 2: [task description]\n            ...\n        - Task 5: [task description]\n    - User 2: [user description]\n        ...\n    - User 5: [user description]\n        ...\n    \"\"\"\n\n    result = openai_complete_if_cache(model=\"gpt-4o\", prompt=prompt)\n\n    file_path = f\"../datasets/questions/{cls}_questions.txt\"\n    with open(file_path, \"w\") as file:\n        file.write(result)\n\n    print(f\"{cls}_questions written to {file_path}\")\n"
  },
  {
    "path": "reproduce/Step_3.py",
    "content": "import re\nimport json\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.utils import always_get_an_event_loop\n\n\ndef extract_queries(file_path):\n    with open(file_path, \"r\") as f:\n        data = f.read()\n\n    data = data.replace(\"**\", \"\")\n\n    queries = re.findall(r\"- Question \\d+: (.+)\", data)\n\n    return queries\n\n\nasync def process_query(query_text, rag_instance, query_param):\n    try:\n        result = await rag_instance.aquery(query_text, param=query_param)\n        return {\"query\": query_text, \"result\": result}, None\n    except Exception as e:\n        return None, {\"query\": query_text, \"error\": str(e)}\n\n\ndef run_queries_and_save_to_json(\n    queries, rag_instance, query_param, output_file, error_file\n):\n    loop = always_get_an_event_loop()\n\n    with (\n        open(output_file, \"a\", encoding=\"utf-8\") as result_file,\n        open(error_file, \"a\", encoding=\"utf-8\") as err_file,\n    ):\n        result_file.write(\"[\\n\")\n        first_entry = True\n\n        for query_text in queries:\n            result, error = loop.run_until_complete(\n                process_query(query_text, rag_instance, query_param)\n            )\n\n            if result:\n                if not first_entry:\n                    result_file.write(\",\\n\")\n                json.dump(result, result_file, ensure_ascii=False, indent=4)\n                first_entry = False\n            elif error:\n                json.dump(error, err_file, ensure_ascii=False, indent=4)\n                err_file.write(\"\\n\")\n\n        result_file.write(\"\\n]\")\n\n\nif __name__ == \"__main__\":\n    cls = \"agriculture\"\n    mode = \"hybrid\"\n    WORKING_DIR = f\"../{cls}\"\n\n    rag = LightRAG(working_dir=WORKING_DIR)\n    query_param = QueryParam(mode=mode)\n\n    queries = extract_queries(f\"../datasets/questions/{cls}_questions.txt\")\n    run_queries_and_save_to_json(\n        queries, rag, query_param, f\"{cls}_result.json\", f\"{cls}_errors.json\"\n    )\n"
  },
  {
    "path": "reproduce/Step_3_openai_compatible.py",
    "content": "import os\nimport re\nimport json\nfrom lightrag import LightRAG, QueryParam\nfrom lightrag.llm.openai import openai_complete_if_cache, openai_embed\nfrom lightrag.utils import EmbeddingFunc, always_get_an_event_loop\nimport numpy as np\n\n\n## For Upstage API\n# please check if embedding_dim=4096 in lightrag.py and llm.py in lightrag direcotry\nasync def llm_model_func(\n    prompt, system_prompt=None, history_messages=[], **kwargs\n) -> str:\n    return await openai_complete_if_cache(\n        \"solar-mini\",\n        prompt,\n        system_prompt=system_prompt,\n        history_messages=history_messages,\n        api_key=os.getenv(\"UPSTAGE_API_KEY\"),\n        base_url=\"https://api.upstage.ai/v1/solar\",\n        **kwargs,\n    )\n\n\nasync def embedding_func(texts: list[str]) -> np.ndarray:\n    return await openai_embed(\n        texts,\n        model=\"solar-embedding-1-large-query\",\n        api_key=os.getenv(\"UPSTAGE_API_KEY\"),\n        base_url=\"https://api.upstage.ai/v1/solar\",\n    )\n\n\n## /For Upstage API\n\n\ndef extract_queries(file_path):\n    with open(file_path, \"r\") as f:\n        data = f.read()\n\n    data = data.replace(\"**\", \"\")\n\n    queries = re.findall(r\"- Question \\d+: (.+)\", data)\n\n    return queries\n\n\nasync def process_query(query_text, rag_instance, query_param):\n    try:\n        result = await rag_instance.aquery(query_text, param=query_param)\n        return {\"query\": query_text, \"result\": result}, None\n    except Exception as e:\n        return None, {\"query\": query_text, \"error\": str(e)}\n\n\ndef run_queries_and_save_to_json(\n    queries, rag_instance, query_param, output_file, error_file\n):\n    loop = always_get_an_event_loop()\n\n    with (\n        open(output_file, \"a\", encoding=\"utf-8\") as result_file,\n        open(error_file, \"a\", encoding=\"utf-8\") as err_file,\n    ):\n        result_file.write(\"[\\n\")\n        first_entry = True\n\n        for query_text in queries:\n            result, error = loop.run_until_complete(\n                process_query(query_text, rag_instance, query_param)\n            )\n\n            if result:\n                if not first_entry:\n                    result_file.write(\",\\n\")\n                json.dump(result, result_file, ensure_ascii=False, indent=4)\n                first_entry = False\n            elif error:\n                json.dump(error, err_file, ensure_ascii=False, indent=4)\n                err_file.write(\"\\n\")\n\n        result_file.write(\"\\n]\")\n\n\nif __name__ == \"__main__\":\n    cls = \"mix\"\n    mode = \"hybrid\"\n    WORKING_DIR = f\"../{cls}\"\n\n    rag = LightRAG(working_dir=WORKING_DIR)\n    rag = LightRAG(\n        working_dir=WORKING_DIR,\n        llm_model_func=llm_model_func,\n        embedding_func=EmbeddingFunc(embedding_dim=4096, func=embedding_func),\n    )\n    query_param = QueryParam(mode=mode)\n\n    base_dir = \"../datasets/questions\"\n    queries = extract_queries(f\"{base_dir}/{cls}_questions.txt\")\n    run_queries_and_save_to_json(\n        queries, rag, query_param, f\"{base_dir}/result.json\", f\"{base_dir}/errors.json\"\n    )\n"
  },
  {
    "path": "reproduce/batch_eval.py",
    "content": "import re\nimport json\nimport jsonlines\n\nfrom openai import OpenAI\n\n\ndef batch_eval(query_file, result1_file, result2_file, output_file_path):\n    client = OpenAI()\n\n    with open(query_file, \"r\") as f:\n        data = f.read()\n\n    queries = re.findall(r\"- Question \\d+: (.+)\", data)\n\n    with open(result1_file, \"r\") as f:\n        answers1 = json.load(f)\n    answers1 = [i[\"result\"] for i in answers1]\n\n    with open(result2_file, \"r\") as f:\n        answers2 = json.load(f)\n    answers2 = [i[\"result\"] for i in answers2]\n\n    requests = []\n    for i, (query, answer1, answer2) in enumerate(zip(queries, answers1, answers2)):\n        sys_prompt = \"\"\"\n        ---Role---\n        You are an expert tasked with evaluating two answers to the same question based on three criteria: **Comprehensiveness**, **Diversity**, and **Empowerment**.\n        \"\"\"\n\n        prompt = f\"\"\"\n        You will evaluate two answers to the same question based on three criteria: **Comprehensiveness**, **Diversity**, and **Empowerment**.\n\n        - **Comprehensiveness**: How much detail does the answer provide to cover all aspects and details of the question?\n        - **Diversity**: How varied and rich is the answer in providing different perspectives and insights on the question?\n        - **Empowerment**: How well does the answer help the reader understand and make informed judgments about the topic?\n\n        For each criterion, choose the better answer (either Answer 1 or Answer 2) and explain why. Then, select an overall winner based on these three categories.\n\n        Here is the question:\n        {query}\n\n        Here are the two answers:\n\n        **Answer 1:**\n        {answer1}\n\n        **Answer 2:**\n        {answer2}\n\n        Evaluate both answers using the three criteria listed above and provide detailed explanations for each criterion.\n\n        Output your evaluation in the following JSON format:\n\n        {{\n            \"Comprehensiveness\": {{\n                \"Winner\": \"[Answer 1 or Answer 2]\",\n                \"Explanation\": \"[Provide explanation here]\"\n            }},\n            \"Diversity\": {{\n                \"Winner\": \"[Answer 1 or Answer 2]\",\n                \"Explanation\": \"[Provide explanation here]\"\n            }},\n            \"Empowerment\": {{\n                \"Winner\": \"[Answer 1 or Answer 2]\",\n                \"Explanation\": \"[Provide explanation here]\"\n            }},\n            \"Overall Winner\": {{\n                \"Winner\": \"[Answer 1 or Answer 2]\",\n                \"Explanation\": \"[Summarize why this answer is the overall winner based on the three criteria]\"\n            }}\n        }}\n        \"\"\"\n\n        request_data = {\n            \"custom_id\": f\"request-{i + 1}\",\n            \"method\": \"POST\",\n            \"url\": \"/v1/chat/completions\",\n            \"body\": {\n                \"model\": \"gpt-4o-mini\",\n                \"messages\": [\n                    {\"role\": \"system\", \"content\": sys_prompt},\n                    {\"role\": \"user\", \"content\": prompt},\n                ],\n            },\n        }\n\n        requests.append(request_data)\n\n    with jsonlines.open(output_file_path, mode=\"w\") as writer:\n        for request in requests:\n            writer.write(request)\n\n    print(f\"Batch API requests written to {output_file_path}\")\n\n    batch_input_file = client.files.create(\n        file=open(output_file_path, \"rb\"), purpose=\"batch\"\n    )\n    batch_input_file_id = batch_input_file.id\n\n    batch = client.batches.create(\n        input_file_id=batch_input_file_id,\n        endpoint=\"/v1/chat/completions\",\n        completion_window=\"24h\",\n        metadata={\"description\": \"nightly eval job\"},\n    )\n\n    print(f\"Batch {batch.id} has been created.\")\n\n\nif __name__ == \"__main__\":\n    batch_eval()\n"
  },
  {
    "path": "requirements-offline-llm.txt",
    "content": "# LightRAG Offline Dependencies - LLM Providers\n# Install with: pip install -r requirements-offline-llm.txt\n# For offline installation:\n#   pip download -r requirements-offline-llm.txt -d ./packages\n#   pip install --no-index --find-links=./packages -r requirements-offline-llm.txt\n#\n# Recommended: Use pip install lightrag-hku[offline-llm] for the same effect\n# Or use constraints: pip install --constraint constraints-offline.txt -r requirements-offline-llm.txt\n\n# LLM provider dependencies (with version constraints matching pyproject.toml)\naioboto3>=12.0.0,<16.0.0\nanthropic>=0.18.0,<1.0.0\ngoogle-api-core>=2.0.0,<3.0.0\ngoogle-genai>=1.0.0,<2.0.0\nllama-index>=0.14.0,<1.0.0\nllama-index-llms-openai>=0.6.12\nollama>=0.1.0,<1.0.0\nopenai>=2.0.0,<3.0.0\nvoyageai>=0.2.0,<1.0.0\nzhipuai>=2.0.0,<3.0.0\n"
  },
  {
    "path": "requirements-offline-storage.txt",
    "content": "# LightRAG Offline Dependencies - Storage Backends\n# Install with: pip install -r requirements-offline-storage.txt\n# For offline installation:\n#   pip download -r requirements-offline-storage.txt -d ./packages\n#   pip install --no-index --find-links=./packages -r requirements-offline-storage.txt\n#\n# Recommended: Use pip install lightrag-hku[offline-storage] for the same effect\n# Or use constraints: pip install --constraint constraints-offline.txt -r requirements-offline-storage.txt\n\n# Storage backend dependencies (with version constraints matching pyproject.toml)\nasyncpg>=0.31.0,<1.0.0\nneo4j>=5.0.0,<7.0.0\npgvector>=0.4.2,<1.0.0\npymilvus>=2.6.2,<3.0.0\npymongo>=4.0.0,<5.0.0\nqdrant-client>=1.11.0,<2.0.0\nredis>=5.0.0,<8.0.0\n"
  },
  {
    "path": "requirements-offline.txt",
    "content": "# LightRAG Complete Offline Dependencies\n# Install with: pip install -r requirements-offline.txt\n# For offline installation:\n#   pip download -r requirements-offline.txt -d ./packages\n#   pip install --no-index --find-links=./packages -r requirements-offline.txt\n#\n# Recommended: Use pip install lightrag-hku[offline] for the same effect\n# Or use constraints: pip install --constraint constraints-offline.txt -r requirements-offline.txt\n\naioboto3>=12.0.0,<16.0.0\nanthropic>=0.18.0,<1.0.0\nasyncpg>=0.31.0,<1.0.0\ngoogle-api-core>=2.0.0,<3.0.0\ngoogle-genai>=1.0.0,<2.0.0\nllama-index>=0.14.0,<1.0.0\nllama-index-llms-openai>=0.6.12\nneo4j>=5.0.0,<7.0.0\nollama>=0.1.0,<1.0.0\nopenai>=2.0.0,<3.0.0\nopenpyxl>=3.0.0,<4.0.0\npgvector>=0.4.2,<1.0.0\npycryptodome>=3.0.0,<4.0.0\npymilvus>=2.6.2,<3.0.0\npymongo>=4.0.0,<5.0.0\npypdf>=6.1.0\npython-docx>=0.8.11,<2.0.0\npython-pptx>=0.6.21,<2.0.0\nqdrant-client>=1.11.0,<2.0.0\nredis>=5.0.0,<8.0.0\nvoyageai>=0.2.0,<1.0.0\nzhipuai>=2.0.0,<3.0.0\n"
  },
  {
    "path": "scripts/setup/lib/file_ops.sh",
    "content": "# File operations for interactive setup.\n\n# Registry of temp files created during this session; cleaned up on exit.\n_FILE_OPS_CLEANUP_TMP=()\ndeclare -A _FILE_OPS_VOLUME_BLOCKS=()\ndeclare -a _FILE_OPS_VOLUME_ORDER=()\n_WIZARD_MANAGED_SERVICES_MARKER=\"# __WIZARD_MANAGED_SERVICES__\"\n_file_ops_cleanup() {\n  local f\n  for f in \"${_FILE_OPS_CLEANUP_TMP[@]:-}\"; do\n    rm -f \"$f\" 2>/dev/null || true\n  done\n}\ntrap '_file_ops_cleanup' EXIT INT TERM\n\n# Keys whose values are always written with double quotes (e.g. may contain spaces).\n_ALWAYS_QUOTED_KEYS=\"|WEBUI_TITLE|WEBUI_DESCRIPTION|\"\n\nformat_env_value() {\n  local value=\"$1\"\n  local key=\"${2:-}\"\n  local escaped\n\n  if [[ -z \"$value\" ]]; then\n    printf ''\n    return\n  fi\n\n  if [[ -n \"$key\" && \"$_ALWAYS_QUOTED_KEYS\" == *\"|${key}|\"* ]] || \\\n     [[ \"$value\" =~ [[:space:]] || \"$value\" == *\"\\\"\"* || \"$value\" == *\"$\"* || \"$value\" == *\"#\"* ]]; then\n    # Double-quoted .env values only need escaping for backslash and double quote.\n    # Do not escape '$': python-dotenv preserves plain '$' literally, while '\\$'\n    # changes the loaded value.\n    # '#' in unquoted values is treated as a comment by python-dotenv, so any\n    # value containing '#' must be quoted.\n    escaped=\"${value//\\\\/\\\\\\\\}\"\n    escaped=\"${escaped//\\\"/\\\\\\\"}\"\n    printf '\"%s\"' \"$escaped\"\n    return\n  fi\n\n  printf '%s' \"$value\"\n}\n\nbackup_env_file() {\n  local env_file=\"${1:-${REPO_ROOT:-.}/.env}\"\n  local backup_file=\"\"\n\n  if [[ -f \"$env_file\" ]]; then\n    backup_file=\"${env_file}.backup.$(date +%Y%m%d_%H%M%S)\"\n    if ! cp \"$env_file\" \"$backup_file\"; then\n      format_error \"Failed to back up ${env_file} to ${backup_file}.\" \"Check disk space and file permissions.\"\n      return 1\n    fi\n    printf '%s' \"$backup_file\"\n  fi\n}\n\nbackup_compose_file() {\n  local compose_file=\"${1:-}\"\n  local repo_root=\"${REPO_ROOT:-.}\"\n  local backup_file=\"\"\n\n  if [[ -z \"$compose_file\" ]]; then\n    compose_file=\"$(find_generated_compose_file)\"\n  fi\n\n  if [[ -z \"$compose_file\" || ! -f \"$compose_file\" ]]; then\n    return 0\n  fi\n\n  backup_file=\"${repo_root}/docker-compose.backup$(date +%Y%m%d_%H%M%S).yml\"\n  if ! cp \"$compose_file\" \"$backup_file\"; then\n    format_error \"Failed to back up ${compose_file} to ${backup_file}.\" \\\n      \"Check disk space and file permissions.\"\n    return 1\n  fi\n\n  printf '%s' \"$backup_file\"\n}\n\nstage_ssl_assets() {\n  local cert_source=\"$1\"\n  local key_source=\"$2\"\n  local certs_dir=\"${REPO_ROOT:-.}/data/certs\"\n  local cert_target=\"\"\n  local key_target=\"\"\n\n  mkdir -p \"$certs_dir\"\n\n  if [[ -n \"$cert_source\" ]]; then\n    cert_target=\"${certs_dir}/$(resolve_staged_ssl_basename \"cert\" \"$cert_source\" \"$key_source\")\"\n    if [[ ! -e \"$cert_target\" || ! \"$cert_source\" -ef \"$cert_target\" ]]; then\n      cp \"$cert_source\" \"$cert_target\"\n    fi\n  fi\n\n  if [[ -n \"$key_source\" ]]; then\n    key_target=\"${certs_dir}/$(resolve_staged_ssl_basename \"key\" \"$key_source\" \"$cert_source\")\"\n    if [[ ! -e \"$key_target\" || ! \"$key_source\" -ef \"$key_target\" ]]; then\n      cp \"$key_source\" \"$key_target\"\n    fi\n  fi\n}\n\nstage_redis_config_asset() {\n  local template_path=\"${TEMPLATES_DIR:-}/redis.conf.template\"\n  local config_dir=\"${REPO_ROOT:-.}/data/config\"\n  local config_target=\"${config_dir}/redis.conf\"\n\n  if [[ -z \"$template_path\" || ! -f \"$template_path\" ]]; then\n    format_error \"Missing Redis config template: ${template_path}\" \\\n      \"Restore scripts/setup/templates/redis.conf.template before rerunning setup.\"\n    return 1\n  fi\n\n  mkdir -p \"$config_dir\"\n\n  if [[ -e \"$config_target\" ]]; then\n    log_info \"Preserving existing Redis config at ${config_target}\"\n    return 0\n  fi\n\n  if ! cp \"$template_path\" \"$config_target\"; then\n    format_error \"Failed to stage Redis config at ${config_target}\" \\\n      \"Check file permissions and available disk space, then rerun setup.\"\n    return 1\n  fi\n\n  log_success \"Staged Redis config at ${config_target}\"\n}\n\nresolve_staged_ssl_basename() {\n  local asset_type=\"$1\"\n  local source_path=\"$2\"\n  local peer_path=\"${3:-}\"\n  local basename_value=\"\"\n  local peer_basename=\"\"\n\n  basename_value=\"$(basename \"$source_path\")\"\n  if [[ -n \"$peer_path\" ]]; then\n    peer_basename=\"$(basename \"$peer_path\")\"\n    if [[ \"$basename_value\" == \"$peer_basename\" ]]; then\n      printf '%s-%s' \"$asset_type\" \"$basename_value\"\n      return 0\n    fi\n  fi\n\n  printf '%s' \"$basename_value\"\n}\n\ngenerate_env_file() {\n  local template_file=\"${1:-${REPO_ROOT:-.}/env.example}\"\n  local output_file=\"${2:-${REPO_ROOT:-.}/.env}\"\n  local tmp_file=\"${output_file}.tmp\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  local line key value\n  local -A written_keys=()\n  local -A match_write_keys=()\n\n  if [[ ! -f \"$template_file\" ]]; then\n    echo \"env.example not found at $template_file\" >&2\n    return 1\n  fi\n\n  # Pre-scan: identify commented keys whose value exactly matches the ENV_VALUE.\n  # When a match exists, the active value is written only at that matching line,\n  # leaving all other commented examples intact.\n  local _prescan_key _prescan_val _prescan_env_val _prescan_fmt\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$line\" =~ ^#[[:space:]]*([A-Z0-9_]+)=(.*)$ ]]; then\n      _prescan_key=\"${BASH_REMATCH[1]}\"\n      _prescan_val=\"${BASH_REMATCH[2]}\"\n      if [[ -z \"${match_write_keys[$_prescan_key]+set}\" && -n \"${ENV_VALUES[$_prescan_key]+set}\" ]]; then\n        _prescan_env_val=\"${ENV_VALUES[$_prescan_key]}\"\n        _prescan_fmt=\"$(format_env_value \"$_prescan_env_val\" \"$_prescan_key\")\"\n        if [[ \"$_prescan_val\" == \"$_prescan_env_val\" || \"$_prescan_val\" == \"$_prescan_fmt\" ]]; then\n          match_write_keys[\"$_prescan_key\"]=1\n        fi\n      fi\n    fi\n  done < \"$template_file\"\n\n  : > \"$tmp_file\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$line\" =~ ^[A-Z0-9_]+= ]]; then\n      key=\"${line%%=*}\"\n      if [[ -z \"${written_keys[$key]+set}\" ]]; then\n        if [[ -n \"${ENV_VALUES[$key]+set}\" ]]; then\n          value=\"${ENV_VALUES[$key]}\"\n          local _fmt_active_val\n          _fmt_active_val=\"$(format_env_value \"$value\" \"$key\")\"\n          printf '%s=%s\\n' \"$key\" \"$_fmt_active_val\" >> \"$tmp_file\"\n          local _orig_tmpl_val=\"${line#*=}\"\n          if [[ \"$_orig_tmpl_val\" != \"$value\" && \"$_orig_tmpl_val\" != \"$_fmt_active_val\" ]]; then\n            printf '# %s\\n' \"$line\" >> \"$tmp_file\"\n          fi\n        else\n          printf '%s\\n' \"$line\" >> \"$tmp_file\"\n        fi\n        written_keys[\"$key\"]=1\n      else\n        if [[ -n \"${ENV_VALUES[$key]+set}\" ]]; then\n          printf '# %s\\n' \"$line\" >> \"$tmp_file\"\n        else\n          printf '%s\\n' \"$line\" >> \"$tmp_file\"\n        fi\n      fi\n    elif [[ \"$line\" =~ ^#[[:space:]]*([A-Z0-9_]+)=(.*)$ ]]; then\n      key=\"${BASH_REMATCH[1]}\"\n      local _commented_val=\"${BASH_REMATCH[2]}\"\n      if [[ -z \"${written_keys[$key]+set}\" && -n \"${ENV_VALUES[$key]+set}\" ]]; then\n        value=\"${ENV_VALUES[$key]}\"\n        if [[ -n \"${match_write_keys[$key]+set}\" ]]; then\n          # A commented line matching the ENV value exists; only activate at that line.\n          local _fmt_val\n          _fmt_val=\"$(format_env_value \"$value\" \"$key\")\"\n          if [[ \"$_commented_val\" == \"$value\" || \"$_commented_val\" == \"$_fmt_val\" ]]; then\n            printf '%s=%s\\n' \"$key\" \"$_fmt_val\" >> \"$tmp_file\"\n            written_keys[\"$key\"]=1\n          else\n            printf '%s\\n' \"$line\" >> \"$tmp_file\"\n          fi\n        else\n          # No matching commented line; fall back to activating at first occurrence.\n          printf '%s=%s\\n' \"$key\" \"$(format_env_value \"$value\" \"$key\")\" >> \"$tmp_file\"\n          written_keys[\"$key\"]=1\n        fi\n      else\n        printf '%s\\n' \"$line\" >> \"$tmp_file\"\n      fi\n    else\n      printf '%s\\n' \"$line\" >> \"$tmp_file\"\n    fi\n  done < \"$template_file\"\n\n  mv \"$tmp_file\" \"$output_file\"\n}\n\n# All environment keys the wizard may inject into the lightrag service via\n# COMPOSE_ENV_OVERRIDES.  Used to remove stale entries before re-injection so\n# keys no longer needed are not left behind in the compose file.\n_WIZARD_COMPOSE_LIGHTRAG_KEYS=(\n  \"EMBEDDING_BINDING_HOST\" \"RERANK_BINDING_HOST\" \"LLM_BINDING_HOST\"\n  \"REDIS_URI\" \"MONGO_URI\" \"NEO4J_URI\" \"MILVUS_URI\" \"QDRANT_URL\" \"MEMGRAPH_URI\" \"OPENSEARCH_HOSTS\"\n  \"POSTGRES_HOST\" \"POSTGRES_PORT\" \"PORT\" \"HOST\" \"SSL_CERTFILE\" \"SSL_KEYFILE\"\n  \"WORKING_DIR\" \"INPUT_DIR\"\n)\n\n_managed_service_root_name() {\n  local service_name=\"$1\"\n\n  case \"$service_name\" in\n    postgres|neo4j|mongodb|redis|qdrant|memgraph|opensearch|vllm-embed|vllm-rerank)\n      printf '%s' \"$service_name\"\n      ;;\n    milvus|milvus-etcd|milvus-minio)\n      printf 'milvus'\n      ;;\n    *)\n      printf ''\n      ;;\n  esac\n}\n\n_managed_volume_root_name() {\n  local volume_name=\"$1\"\n\n  case \"$volume_name\" in\n    postgres_data)\n      printf 'postgres'\n      ;;\n    neo4j_data)\n      printf 'neo4j'\n      ;;\n    mongo_data)\n      printf 'mongodb'\n      ;;\n    redis_data)\n      printf 'redis'\n      ;;\n    milvus_data|milvus-etcd_data|milvus-minio_data)\n      printf 'milvus'\n      ;;\n    qdrant_data)\n      printf 'qdrant'\n      ;;\n    memgraph_data)\n      printf 'memgraph'\n      ;;\n    opensearch_data)\n      printf 'opensearch'\n      ;;\n    vllm_rerank_cache)\n      printf 'vllm-rerank'\n      ;;\n    vllm_embed_cache)\n      printf 'vllm-embed'\n      ;;\n    *)\n      printf ''\n      ;;\n  esac\n}\n\n_should_rewrite_wizard_managed_root_service() {\n  local root_service=\"$1\"\n\n  if [[ -z \"$root_service\" ]]; then\n    return 1\n  fi\n\n  if [[ \"${FORCE_REWRITE_COMPOSE:-no}\" == \"yes\" ]]; then\n    return 0\n  fi\n\n  if [[ -z \"${DOCKER_SERVICE_SET[$root_service]+set}\" ]]; then\n    return 0\n  fi\n\n  if [[ -n \"${COMPOSE_REWRITE_SERVICE_SET[$root_service]+set}\" ]]; then\n    return 0\n  fi\n\n  return 1\n}\n\n_should_preserve_wizard_managed_root_service() {\n  local root_service=\"$1\"\n\n  if [[ -z \"$root_service\" || \"${FORCE_REWRITE_COMPOSE:-no}\" == \"yes\" ]]; then\n    return 1\n  fi\n\n  if [[ -z \"${DOCKER_SERVICE_SET[$root_service]+set}\" ]]; then\n    return 1\n  fi\n\n  if [[ -n \"${COMPOSE_REWRITE_SERVICE_SET[$root_service]+set}\" ]]; then\n    return 1\n  fi\n\n  return 0\n}\n\n_existing_managed_root_service_present() {\n  local root_service=\"$1\"\n\n  [[ -n \"$root_service\" && -n \"${EXISTING_MANAGED_ROOT_SERVICE_SET[$root_service]+set}\" ]]\n}\n\n_refresh_existing_managed_root_service_set_from_compose() {\n  local compose_file=\"$1\"\n  local service_name\n\n  EXISTING_MANAGED_ROOT_SERVICE_SET=()\n\n  if [[ -z \"$compose_file\" || ! -f \"$compose_file\" ]]; then\n    return 0\n  fi\n\n  while IFS= read -r service_name; do\n    EXISTING_MANAGED_ROOT_SERVICE_SET[\"$service_name\"]=1\n  done < <(detect_managed_root_services \"$compose_file\")\n}\n\n_is_wizard_managed_root_service_name() {\n  local service_name=\"$1\"\n\n  [[ -n \"$(_managed_service_root_name \"$service_name\")\" ]]\n}\n\n_is_wizard_managed_service_name() {\n  local service_name=\"$1\"\n\n  [[ -n \"$(_managed_service_root_name \"$service_name\")\" ]]\n}\n\n_is_wizard_managed_volume_name() {\n  local volume_name=\"$1\"\n\n  [[ -n \"$(_managed_volume_root_name \"$volume_name\")\" ]]\n}\n\n# Remove wizard-managed keys from the lightrag service's environment block,\n# leaving any user-added keys intact.\n_strip_lightrag_wizard_environment_keys() {\n  local compose_file=\"$1\"\n  local tmp_file=\"${compose_file}.strip-wizard-keys\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  local line key wk list_entry\n  local in_lightrag=\"no\"\n  local in_environment=\"no\"\n\n  : > \"$tmp_file\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$line\" == \"  lightrag:\" ]]; then\n      in_lightrag=\"yes\"\n      in_environment=\"no\"\n    elif [[ \"$in_lightrag\" == \"yes\" && \"$line\" =~ ^[[:space:]]{2}[^[:space:]] && \"$line\" != \"  lightrag:\" ]]; then\n      in_lightrag=\"no\"\n      in_environment=\"no\"\n    fi\n\n    if [[ \"$in_lightrag\" == \"yes\" && \"$line\" == \"    environment:\" ]]; then\n      in_environment=\"yes\"\n      printf '%s\\n' \"$line\" >> \"$tmp_file\"\n      continue\n    fi\n\n    if [[ \"$in_lightrag\" == \"yes\" && \"$in_environment\" == \"yes\" ]]; then\n      if [[ \"$line\" =~ ^[[:space:]]{6}([A-Z0-9_]+): ]]; then\n        key=\"${BASH_REMATCH[1]}\"\n        for wk in \"${_WIZARD_COMPOSE_LIGHTRAG_KEYS[@]}\"; do\n          if [[ \"$key\" == \"$wk\" ]]; then\n            continue 2  # skip this wizard-managed key\n          fi\n        done\n      elif [[ \"$line\" =~ ^[[:space:]]{6}-[[:space:]](.+)$ ]]; then\n        list_entry=\"$(_strip_wrapping_quotes \"${BASH_REMATCH[1]}\")\"\n        key=\"${list_entry%%=*}\"\n        if [[ \"$key\" =~ ^[A-Z0-9_]+$ ]]; then\n          for wk in \"${_WIZARD_COMPOSE_LIGHTRAG_KEYS[@]}\"; do\n            if [[ \"$key\" == \"$wk\" ]]; then\n              continue 2  # skip this wizard-managed key\n            fi\n          done\n        fi\n      elif [[ -z \"$line\" ]]; then\n        continue  # skip blank lines inside the environment block\n      elif [[ ! \"$line\" =~ ^[[:space:]]{6} ]]; then\n        in_environment=\"no\"\n      fi\n    fi\n\n    printf '%s\\n' \"$line\" >> \"$tmp_file\"\n  done < \"$compose_file\"\n\n  mv \"$tmp_file\" \"$compose_file\"\n}\n\n_write_service_environment_entries() {\n  local tmp_file=\"$1\"\n  local style=\"$2\"\n  shift 2\n  local entries=(\"$@\")\n  local key value\n\n  for entry in \"${entries[@]}\"; do\n    key=\"${entry%%=*}\"\n    value=\"${entry#*=}\"\n    if [[ \"$style\" == \"list\" ]]; then\n      if [[ \"$value\" == \"${_COMPOSE_RAW_VALUE_PREFIX}\"* ]]; then\n        printf '      - %s=%s\\n' \"$key\" \"${value#${_COMPOSE_RAW_VALUE_PREFIX}}\" >> \"$tmp_file\"\n      else\n        printf '      - %s\\n' \"$(format_yaml_value \"${key}=${value}\")\" >> \"$tmp_file\"\n      fi\n    else\n      printf '      %s: %s\\n' \"$key\" \"$(format_compose_environment_value \"$value\")\" >> \"$tmp_file\"\n    fi\n  done\n}\n\n# Capture top-level named volume blocks so user-managed definitions can be\n# re-emitted when still referenced by preserved services.\n_collect_top_level_volume_blocks() {\n  local compose_file=\"$1\"\n  local line\n  local in_top_volumes=\"no\"\n  local current_volume=\"\"\n  local current_block=\"\"\n\n  _FILE_OPS_VOLUME_BLOCKS=()\n  _FILE_OPS_VOLUME_ORDER=()\n\n  if [[ ! -f \"$compose_file\" ]]; then\n    return 0\n  fi\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$line\" =~ ^[A-Za-z] ]]; then\n      if [[ \"$in_top_volumes\" == \"yes\" && -n \"$current_volume\" ]]; then\n        _FILE_OPS_VOLUME_BLOCKS[\"$current_volume\"]=\"$current_block\"\n        _FILE_OPS_VOLUME_ORDER+=(\"$current_volume\")\n      fi\n\n      current_volume=\"\"\n      current_block=\"\"\n      if [[ \"$line\" == \"volumes:\" ]]; then\n        in_top_volumes=\"yes\"\n      else\n        in_top_volumes=\"no\"\n      fi\n      continue\n    fi\n\n    if [[ \"$in_top_volumes\" != \"yes\" ]]; then\n      continue\n    fi\n\n    if [[ \"$line\" =~ ^[[:space:]]{2}([A-Za-z0-9_.-]+):[[:space:]]*$ ]]; then\n      if [[ -n \"$current_volume\" ]]; then\n        _FILE_OPS_VOLUME_BLOCKS[\"$current_volume\"]=\"$current_block\"\n        _FILE_OPS_VOLUME_ORDER+=(\"$current_volume\")\n      fi\n\n      current_volume=\"${BASH_REMATCH[1]}\"\n      current_block=\"${line}\"$'\\n'\n      continue\n    fi\n\n    if [[ -n \"$current_volume\" ]]; then\n      current_block+=\"${line}\"$'\\n'\n    fi\n  done < \"$compose_file\"\n\n  if [[ \"$in_top_volumes\" == \"yes\" && -n \"$current_volume\" ]]; then\n    _FILE_OPS_VOLUME_BLOCKS[\"$current_volume\"]=\"$current_block\"\n    _FILE_OPS_VOLUME_ORDER+=(\"$current_volume\")\n  fi\n}\n\n# Remove wizard-managed services and the top-level volumes block from a compose\n# file. Non-managed services are preserved verbatim.\n_strip_wizard_managed_services_and_top_level_volumes() {\n  local compose_file=\"$1\"\n  local tmp_file=\"${compose_file}.strip-svc\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  local line current_service=\"\" current_root_service=\"\"\n  local in_services=\"no\"\n  local in_top_volumes=\"no\"\n  local inserted_marker=\"no\"\n\n  : > \"$tmp_file\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$line\" == \"$_WIZARD_MANAGED_SERVICES_MARKER\" ]]; then\n      continue\n    fi\n\n    # Detect top-level (non-indented) keys.\n    if [[ \"$line\" =~ ^[A-Za-z] ]]; then\n      if [[ \"$in_services\" == \"yes\" && \"$line\" != \"services:\" && \"$inserted_marker\" != \"yes\" ]]; then\n        printf '%s\\n' \"$_WIZARD_MANAGED_SERVICES_MARKER\" >> \"$tmp_file\"\n        inserted_marker=\"yes\"\n      fi\n      in_top_volumes=\"no\"\n      if [[ \"$line\" == \"services:\" ]]; then\n        in_services=\"yes\"\n        current_service=\"\"\n      elif [[ \"$line\" =~ ^volumes:[[:space:]]*$ ]]; then\n        in_top_volumes=\"yes\"\n        in_services=\"no\"\n        current_service=\"\"\n        continue  # skip volumes: header; regenerated at end of generate_docker_compose\n      else\n        in_services=\"no\"\n        current_service=\"\"\n      fi\n      printf '%s\\n' \"$line\" >> \"$tmp_file\"\n      continue\n    fi\n\n    # Skip top-level volumes block content.\n    if [[ \"$in_top_volumes\" == \"yes\" ]]; then\n      continue\n    fi\n\n    # Track current service inside the services: block.\n    if [[ \"$in_services\" == \"yes\" && \"$line\" =~ ^[[:space:]]{2}([A-Za-z0-9_-]+):[[:space:]]*$ ]]; then\n      current_service=\"${BASH_REMATCH[1]}\"\n      current_root_service=\"$(_managed_service_root_name \"$current_service\")\"\n    fi\n\n    # Skip managed services that are being removed or regenerated. Preserve\n    # lightrag, user-added services, and unchanged managed service groups.\n    if [[ \"$in_services\" == \"yes\" && -n \"$current_service\" ]] && \\\n      [[ \"$current_service\" != \"lightrag\" ]] && \\\n      [[ -n \"$current_root_service\" ]] && \\\n      _should_rewrite_wizard_managed_root_service \"$current_root_service\"; then\n      continue\n    fi\n\n    printf '%s\\n' \"$line\" >> \"$tmp_file\"\n  done < \"$compose_file\"\n\n  if [[ \"$in_services\" == \"yes\" && \"$inserted_marker\" != \"yes\" ]]; then\n    printf '%s\\n' \"$_WIZARD_MANAGED_SERVICES_MARKER\" >> \"$tmp_file\"\n  fi\n\n  mv \"$tmp_file\" \"$compose_file\"\n}\n\n_merge_managed_service_blocks() {\n  local compose_file=\"$1\"\n  local service_blocks_file=\"$2\"\n  local tmp_file=\"${compose_file}.merge-services\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  local line\n  local inserted=\"no\"\n\n  : > \"$tmp_file\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$line\" == \"$_WIZARD_MANAGED_SERVICES_MARKER\" ]]; then\n      if [[ -s \"$service_blocks_file\" ]]; then\n        cat \"$service_blocks_file\" >> \"$tmp_file\"\n        inserted=\"yes\"\n      fi\n      continue\n    fi\n\n    printf '%s\\n' \"$line\" >> \"$tmp_file\"\n  done < \"$compose_file\"\n\n  if [[ -s \"$service_blocks_file\" && \"$inserted\" != \"yes\" ]]; then\n    cat \"$service_blocks_file\" >> \"$tmp_file\"\n  fi\n\n  mv \"$tmp_file\" \"$compose_file\"\n}\n\n_normalize_services_section_spacing() {\n  local compose_file=\"$1\"\n  local tmp_file=\"${compose_file}.normalize-services\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  local line\n  local in_services=\"no\"\n  local pending_blank=\"no\"\n  local saw_service_content=\"no\"\n\n  : > \"$tmp_file\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$in_services\" == \"yes\" ]]; then\n      if [[ \"$line\" == \"$_WIZARD_MANAGED_SERVICES_MARKER\" ]]; then\n        continue\n      fi\n\n      if [[ -z \"$line\" ]]; then\n        pending_blank=\"yes\"\n        continue\n      fi\n\n      if [[ ! \"$line\" =~ ^[[:space:]] ]]; then\n        pending_blank=\"no\"\n        if [[ \"$saw_service_content\" == \"yes\" ]]; then\n          printf '\\n' >> \"$tmp_file\"\n        fi\n        printf '%s\\n' \"$line\" >> \"$tmp_file\"\n        in_services=\"no\"\n        continue\n      fi\n\n      if [[ \"$pending_blank\" == \"yes\" && \"$saw_service_content\" == \"yes\" ]]; then\n        printf '\\n' >> \"$tmp_file\"\n      fi\n\n      printf '%s\\n' \"$line\" >> \"$tmp_file\"\n      pending_blank=\"no\"\n      saw_service_content=\"yes\"\n      continue\n    fi\n\n    printf '%s\\n' \"$line\" >> \"$tmp_file\"\n\n    if [[ \"$line\" == \"services:\" ]]; then\n      in_services=\"yes\"\n      pending_blank=\"no\"\n      saw_service_content=\"no\"\n    fi\n  done < \"$compose_file\"\n\n  mv \"$tmp_file\" \"$compose_file\"\n}\n\n_strip_wrapping_quotes() {\n  local value=\"$1\"\n\n  if [[ \"$value\" == \\\"*\\\" && \"$value\" == *\\\" ]]; then\n    value=\"${value#\\\"}\"\n    value=\"${value%\\\"}\"\n  elif [[ \"$value\" == \\'*\\' ]]; then\n    value=\"${value#\\'}\"\n    value=\"${value%\\'}\"\n  fi\n\n  printf '%s' \"$value\"\n}\n\nread_service_environment_value() {\n  local compose_file=\"$1\"\n  local service_name=\"$2\"\n  local wanted_key=\"$3\"\n  local line\n  local entry_key=\"\"\n  local entry_value=\"\"\n  local service_header=\"  ${service_name}:\"\n  local in_service=\"no\"\n  local in_environment=\"no\"\n\n  if [[ ! -f \"$compose_file\" ]]; then\n    return 1\n  fi\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$in_service\" == \"yes\" && \"$in_environment\" == \"yes\" ]]; then\n      if [[ \"$line\" =~ ^[[:space:]]{6}([A-Z0-9_]+):[[:space:]]*(.+)$ ]]; then\n        entry_key=\"${BASH_REMATCH[1]}\"\n        if [[ \"$entry_key\" == \"$wanted_key\" ]]; then\n          printf '%s' \"$(_strip_wrapping_quotes \"${BASH_REMATCH[2]}\")\"\n          return 0\n        fi\n      elif [[ \"$line\" =~ ^[[:space:]]{6}-[[:space:]](.+)$ ]]; then\n        entry_value=\"$(_strip_wrapping_quotes \"${BASH_REMATCH[1]}\")\"\n        entry_key=\"${entry_value%%=*}\"\n        if [[ \"$entry_key\" == \"$wanted_key\" && \"$entry_value\" == *=* ]]; then\n          printf '%s' \"${entry_value#*=}\"\n          return 0\n        fi\n      elif [[ ! \"$line\" =~ ^[[:space:]]{6} ]]; then\n        in_environment=\"no\"\n      fi\n    elif [[ \"$in_service\" == \"yes\" && \"$line\" =~ ^[[:space:]]{2}[^[:space:]] && \"$line\" != \"$service_header\" ]]; then\n      in_service=\"no\"\n    fi\n\n    if [[ \"$line\" == \"$service_header\" ]]; then\n      in_service=\"yes\"\n      in_environment=\"no\"\n    elif [[ \"$in_service\" == \"yes\" && \"$line\" == \"    environment:\" ]]; then\n      in_environment=\"yes\"\n    fi\n  done < \"$compose_file\"\n\n  return 1\n}\n\n_extract_named_volume_name() {\n  local mount_spec=\"$1\"\n  local source=\"\"\n\n  mount_spec=\"$(_strip_wrapping_quotes \"$mount_spec\")\"\n  source=\"${mount_spec%%:*}\"\n\n  if [[ \"$source\" == \"$mount_spec\" || -z \"$source\" ]]; then\n    return 1\n  fi\n\n  if [[ \"$source\" == .* || \"$source\" == /* || \"$source\" == \"~\"* ]]; then\n    return 1\n  fi\n\n  if [[ \"$source\" == *\"/\"* || \"$source\" == *'$'* ]]; then\n    return 1\n  fi\n\n  printf '%s' \"$source\"\n}\n\n_collect_referenced_named_volumes() {\n  local compose_file=\"$1\"\n  local line\n  local in_services=\"no\"\n  local current_service=\"\"\n  local in_volumes=\"no\"\n  local in_long_volume_entry=\"no\"\n  local long_volume_type=\"\"\n  local long_volume_source=\"\"\n  local volume_name=\"\"\n  local long_entry_key=\"\"\n  local long_entry_value=\"\"\n  local -A seen=()\n\n  if [[ ! -f \"$compose_file\" ]]; then\n    return 0\n  fi\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$line\" == \"services:\" ]]; then\n      in_services=\"yes\"\n      current_service=\"\"\n      in_volumes=\"no\"\n      in_long_volume_entry=\"no\"\n      long_volume_type=\"\"\n      continue\n    fi\n\n    if [[ \"$in_services\" == \"yes\" && \"$line\" =~ ^[A-Za-z] && \"$line\" != \"services:\" ]]; then\n      in_services=\"no\"\n      in_volumes=\"no\"\n      in_long_volume_entry=\"no\"\n      continue\n    fi\n\n    if [[ \"$in_services\" != \"yes\" ]]; then\n      continue\n    fi\n\n    if [[ \"$line\" =~ ^[[:space:]]{2}([A-Za-z0-9_-]+):[[:space:]]*$ ]]; then\n      current_service=\"${BASH_REMATCH[1]}\"\n      in_volumes=\"no\"\n      in_long_volume_entry=\"no\"\n      long_volume_type=\"\"\n      long_volume_source=\"\"\n      continue\n    fi\n\n    if [[ -z \"$current_service\" ]]; then\n      continue\n    fi\n\n    if [[ \"$line\" == \"    volumes:\" ]]; then\n      in_volumes=\"yes\"\n      in_long_volume_entry=\"no\"\n      long_volume_type=\"\"\n      long_volume_source=\"\"\n      continue\n    fi\n\n    if [[ \"$in_volumes\" != \"yes\" ]]; then\n      continue\n    fi\n\n    if [[ \"$line\" =~ ^[[:space:]]{4}[^[:space:]-] || \"$line\" =~ ^[[:space:]]{2}[^[:space:]] ]]; then\n      in_volumes=\"no\"\n      in_long_volume_entry=\"no\"\n      long_volume_type=\"\"\n      long_volume_source=\"\"\n      continue\n    fi\n\n    if [[ \"$line\" =~ ^[[:space:]]{6}-[[:space:]](.+)$ ]]; then\n      local volume_entry=\"${BASH_REMATCH[1]}\"\n      volume_entry=\"$(_strip_wrapping_quotes \"$volume_entry\")\"\n      in_long_volume_entry=\"no\"\n      long_volume_type=\"\"\n      long_volume_source=\"\"\n\n      if [[ \"$volume_entry\" =~ ^([A-Za-z_][A-Za-z0-9_-]*):[[:space:]]*(.*)$ ]]; then\n        long_entry_key=\"${BASH_REMATCH[1]}\"\n        long_entry_value=\"$(_strip_wrapping_quotes \"${BASH_REMATCH[2]}\")\"\n        case \"$long_entry_key\" in\n          type|source|target|read_only|bind|volume|tmpfs|consistency|nocopy|subpath)\n            in_long_volume_entry=\"yes\"\n            case \"$long_entry_key\" in\n              type)\n                if [[ \"$long_entry_value\" == \"volume\" ]]; then\n                  long_volume_type=\"volume\"\n                else\n                  long_volume_type=\"other\"\n                fi\n                ;;\n              source)\n                long_volume_source=\"$long_entry_value\"\n                ;;\n            esac\n            if [[ \"$long_volume_type\" == \"volume\" && -n \"$long_volume_source\" && -z \"${seen[$long_volume_source]+set}\" ]]; then\n              seen[\"$long_volume_source\"]=1\n              printf '%s\\n' \"$long_volume_source\"\n            fi\n            continue\n            ;;\n        esac\n      fi\n\n      volume_name=\"$(_extract_named_volume_name \"$volume_entry\")\" || continue\n      if [[ -z \"${seen[$volume_name]+set}\" ]]; then\n        seen[\"$volume_name\"]=1\n        printf '%s\\n' \"$volume_name\"\n      fi\n      continue\n    fi\n\n    if [[ \"$in_long_volume_entry\" == \"yes\" ]] && \\\n      [[ \"$line\" =~ ^[[:space:]]{8}([A-Za-z_][A-Za-z0-9_-]*):[[:space:]]*(.+)$ ]]; then\n      long_entry_key=\"${BASH_REMATCH[1]}\"\n      long_entry_value=\"$(_strip_wrapping_quotes \"${BASH_REMATCH[2]}\")\"\n      case \"$long_entry_key\" in\n        type)\n          if [[ \"$long_entry_value\" == \"volume\" ]]; then\n            long_volume_type=\"volume\"\n          else\n            long_volume_type=\"other\"\n          fi\n          ;;\n        source)\n          long_volume_source=\"$long_entry_value\"\n          ;;\n      esac\n\n      if [[ \"$long_volume_type\" == \"volume\" && -n \"$long_volume_source\" && -z \"${seen[$long_volume_source]+set}\" ]]; then\n        seen[\"$long_volume_source\"]=1\n        printf '%s\\n' \"$long_volume_source\"\n      fi\n    fi\n  done < \"$compose_file\"\n}\n\n_trim_trailing_blank_lines_in_file() {\n  local file=\"$1\"\n  local trim_file=\"${file}.trim-tail\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$trim_file\")\n\n  awk '\n    { lines[NR] = $0 }\n    END {\n      last = NR\n      while (last > 0 && lines[last] == \"\") {\n        last--\n      }\n      for (i = 1; i <= last; i++) {\n        print lines[i]\n      }\n    }\n  ' \"$file\" > \"$trim_file\"\n\n  mv \"$trim_file\" \"$file\"\n}\n\n_append_referenced_volume_blocks() {\n  local compose_file=\"$1\"\n  local -a referenced_volumes=()\n  local volume_name\n  local root_service\n\n  while IFS= read -r volume_name; do\n    if [[ -n \"$volume_name\" ]]; then\n      referenced_volumes+=(\"$volume_name\")\n    fi\n  done < <(_collect_referenced_named_volumes \"$compose_file\")\n\n  if ((${#referenced_volumes[@]} == 0)); then\n    return 0\n  fi\n\n  _trim_trailing_blank_lines_in_file \"$compose_file\"\n  printf '\\nvolumes:\\n' >> \"$compose_file\"\n  for volume_name in \"${referenced_volumes[@]}\"; do\n    if _is_wizard_managed_volume_name \"$volume_name\"; then\n      root_service=\"$(_managed_volume_root_name \"$volume_name\")\"\n      if [[ -n \"${_FILE_OPS_VOLUME_BLOCKS[$volume_name]+set}\" ]] && \\\n        _should_preserve_wizard_managed_root_service \"$root_service\"; then\n        printf '%s' \"${_FILE_OPS_VOLUME_BLOCKS[$volume_name]}\" >> \"$compose_file\"\n      else\n        printf '  %s:\\n' \"$volume_name\" >> \"$compose_file\"\n      fi\n    elif [[ -n \"${_FILE_OPS_VOLUME_BLOCKS[$volume_name]+set}\" ]]; then\n      printf '%s' \"${_FILE_OPS_VOLUME_BLOCKS[$volume_name]}\" >> \"$compose_file\"\n    else\n      printf '  %s:\\n' \"$volume_name\" >> \"$compose_file\"\n    fi\n  done\n}\n\ngenerate_docker_compose() {\n  local output_file=\"${1:-${REPO_ROOT:-.}/docker-compose.yml}\"\n  local base_file=\"${REPO_ROOT:-.}/docker-compose.yml\"\n  local tmp_file=\"${output_file}.tmp\"\n  local service_blocks_file=\"${output_file}.services\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  _FILE_OPS_CLEANUP_TMP+=(\"$service_blocks_file\")\n  local template_file\n  local lightrag_mounts=()\n  local lightrag_env_entries=()\n  local key\n  local root_service\n\n  # Prefer the existing generated compose as the starting point to preserve\n  # any user customisations to the lightrag service.  Fall back to the base\n  # docker-compose.yml when the output file doesn't exist yet.\n  if [[ -f \"$output_file\" && \"$output_file\" != \"$base_file\" ]]; then\n    _refresh_existing_managed_root_service_set_from_compose \"$output_file\"\n    _collect_top_level_volume_blocks \"$output_file\"\n    cp \"$output_file\" \"$tmp_file\"\n    # Strip wizard-managed services and top-level volumes. User-managed\n    # services are preserved, while volumes are rebuilt from final service\n    # references after managed templates are appended.\n    _strip_wizard_managed_services_and_top_level_volumes \"$tmp_file\"\n  elif [[ -f \"$base_file\" ]]; then\n    _refresh_existing_managed_root_service_set_from_compose \"$base_file\"\n    _collect_top_level_volume_blocks \"$base_file\"\n    cp \"$base_file\" \"$tmp_file\"\n    _strip_wizard_managed_services_and_top_level_volumes \"$tmp_file\"\n  else\n    EXISTING_MANAGED_ROOT_SERVICE_SET=()\n    _FILE_OPS_VOLUME_BLOCKS=()\n    _FILE_OPS_VOLUME_ORDER=()\n    printf 'services:\\n' > \"$tmp_file\"\n  fi\n\n  prepare_lightrag_service_for_generated_compose \"$tmp_file\"\n  normalize_lightrag_restart_policy \"$tmp_file\"\n  # Remove stale wizard-managed keys from lightrag's environment so that\n  # keys no longer in COMPOSE_ENV_OVERRIDES are not left behind.\n  _strip_lightrag_wizard_environment_keys \"$tmp_file\"\n\n  # Remove stale wizard-managed bind mounts from lightrag's volumes so that\n  # mounts no longer needed (e.g. after SSL removal) are not left behind.\n  _strip_lightrag_wizard_bind_mounts \"$tmp_file\"\n\n  if [[ -n \"${LIGHTRAG_COMPOSE_SERVER_PORT_MAPPING:-}\" ]]; then\n    _strip_lightrag_wizard_ports \"$tmp_file\"\n    inject_lightrag_port_mapping \"$tmp_file\" \"$LIGHTRAG_COMPOSE_SERVER_PORT_MAPPING\"\n  fi\n\n  append_lightrag_ssl_mount lightrag_mounts \"${COMPOSE_ENV_OVERRIDES[SSL_CERTFILE]:-}\" || return 1\n  append_lightrag_ssl_mount lightrag_mounts \"${COMPOSE_ENV_OVERRIDES[SSL_KEYFILE]:-}\" || return 1\n  if ((${#lightrag_mounts[@]} > 0)); then\n    inject_lightrag_bind_mounts \"$tmp_file\" \"${lightrag_mounts[@]}\"\n  fi\n\n  for key in \"${!COMPOSE_ENV_OVERRIDES[@]}\"; do\n    lightrag_env_entries+=(\"${key}=${COMPOSE_ENV_OVERRIDES[$key]}\")\n  done\n  if ((${#lightrag_env_entries[@]} > 0)); then\n    inject_lightrag_environment_overrides \"$tmp_file\" \"${lightrag_env_entries[@]}\"\n  fi\n\n  repair_misplaced_lightrag_depends_on \"$tmp_file\"\n  inject_lightrag_depends_on \"$tmp_file\" \"${DOCKER_SERVICES[@]}\"\n\n  : > \"$service_blocks_file\"\n  for service in \"${DOCKER_SERVICES[@]}\"; do\n    root_service=\"$(_managed_service_root_name \"$service\")\"\n    if _should_preserve_wizard_managed_root_service \"$root_service\" && \\\n      _existing_managed_root_service_present \"$root_service\"; then\n      continue\n    fi\n\n    template_file=\"$TEMPLATES_DIR/${service}.yml\"\n    if [[ \"$service\" == \"milvus\" ]]; then\n      if [[ \"${ENV_VALUES[MILVUS_DEVICE]:-cpu}\" == \"cuda\" ]]; then\n        if [[ -f \"$TEMPLATES_DIR/${service}-gpu.yml\" ]]; then\n          template_file=\"$TEMPLATES_DIR/${service}-gpu.yml\"\n        fi\n      fi\n    fi\n    if [[ \"$service\" == \"qdrant\" ]]; then\n      if [[ \"${ENV_VALUES[QDRANT_DEVICE]:-cpu}\" == \"cuda\" ]]; then\n        if [[ -f \"$TEMPLATES_DIR/${service}-gpu.yml\" ]]; then\n          template_file=\"$TEMPLATES_DIR/${service}-gpu.yml\"\n        fi\n      fi\n    fi\n    if [[ \"$service\" == \"vllm-rerank\" ]]; then\n      if [[ \"${ENV_VALUES[VLLM_RERANK_DEVICE]:-cpu}\" == \"cuda\" ]]; then\n        if [[ -f \"$TEMPLATES_DIR/${service}-gpu.yml\" ]]; then\n          template_file=\"$TEMPLATES_DIR/${service}-gpu.yml\"\n        fi\n      fi\n    fi\n    if [[ \"$service\" == \"vllm-embed\" ]]; then\n      if [[ \"${ENV_VALUES[VLLM_EMBED_DEVICE]:-cpu}\" == \"cuda\" ]]; then\n        if [[ -f \"$TEMPLATES_DIR/${service}-gpu.yml\" ]]; then\n          template_file=\"$TEMPLATES_DIR/${service}-gpu.yml\"\n        fi\n      fi\n    fi\n    if [[ ! -f \"$template_file\" ]]; then\n      format_error \"Missing docker template: $template_file\" \"Reinstall the setup scripts.\"\n      return 1\n    fi\n\n    printf '\\n' >> \"$service_blocks_file\"\n    cat \"$template_file\" >> \"$service_blocks_file\"\n\n    case \"$service\" in\n      postgres)\n        inject_service_environment_overrides \"$service_blocks_file\" \"postgres\" \\\n          \"POSTGRES_USER=${ENV_VALUES[POSTGRES_USER]:-}\" \\\n          \"POSTGRES_PASSWORD=${ENV_VALUES[POSTGRES_PASSWORD]:-}\" \\\n          \"POSTGRES_DB=${ENV_VALUES[POSTGRES_DATABASE]:-}\"\n        ;;\n      neo4j)\n        inject_service_environment_overrides \"$service_blocks_file\" \"neo4j\" \\\n          \"NEO4J_AUTH=${_COMPOSE_RAW_VALUE_PREFIX}\\${NEO4J_USERNAME:?missing}/\\${NEO4J_PASSWORD:?missing}\" \\\n          \"NEO4J_dbms_default__database=${ENV_VALUES[NEO4J_DATABASE]:-neo4j}\"\n        ;;\n      mongodb)\n        ;;\n      redis)\n        ;;\n      milvus)\n        ;;\n      qdrant)\n        ;;\n      memgraph)\n        ;;\n      opensearch)\n        ;;\n      vllm-rerank)\n        ;;\n      vllm-embed)\n        ;;\n    esac\n  done\n\n  _merge_managed_service_blocks \"$tmp_file\" \"$service_blocks_file\"\n  _normalize_services_section_spacing \"$tmp_file\"\n  _append_referenced_volume_blocks \"$tmp_file\"\n\n  mv \"$tmp_file\" \"$output_file\"\n}\n\nprepare_lightrag_service_for_generated_compose() {\n  # Let the containerized app read the mounted .env itself. Keeping env_file\n  # here would make Docker Compose re-parse the same secrets and expand '$'.\n  local compose_file=\"$1\"\n  local tmp_file=\"${compose_file}.strip-env-file\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  local line\n  local in_lightrag=\"no\"\n  local in_env_file=\"no\"\n\n  : > \"$tmp_file\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$in_env_file\" == \"yes\" ]]; then\n      if [[ \"$line\" =~ ^[[:space:]]{6}-[[:space:]] ]]; then\n        continue\n      fi\n      in_env_file=\"no\"\n    fi\n\n    if [[ \"$in_lightrag\" == \"yes\" && \"$line\" =~ ^[[:space:]]{2}[^[:space:]] && \"$line\" != \"  lightrag:\" ]]; then\n      in_lightrag=\"no\"\n    fi\n\n    if [[ \"$in_lightrag\" == \"yes\" && \"$line\" == \"    env_file:\" ]]; then\n      in_env_file=\"yes\"\n      continue\n    fi\n\n    if [[ \"$in_lightrag\" == \"yes\" && \"$line\" =~ ^[[:space:]]{4}container_name: ]]; then\n      continue\n    fi\n\n    printf '%s\\n' \"$line\" >> \"$tmp_file\"\n\n    if [[ \"$line\" == \"  lightrag:\" ]]; then\n      in_lightrag=\"yes\"\n      in_env_file=\"no\"\n    fi\n  done < \"$compose_file\"\n\n  mv \"$tmp_file\" \"$compose_file\"\n}\n\nnormalize_lightrag_restart_policy() {\n  local compose_file=\"$1\"\n  local tmp_file=\"${compose_file}.normalize-lightrag-restart\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  local line\n  local in_lightrag=\"no\"\n  local in_deploy=\"no\"\n  local deploy_seen=\"no\"\n  local insert_blank_after_deploy=\"no\"\n  local skip_blank_after_removed_restart=\"no\"\n  local -a deploy_lines=()\n\n  _trim_trailing_blank_lines() {\n    local file=\"$1\"\n    local trim_file=\"${file}.trim\"\n    _FILE_OPS_CLEANUP_TMP+=(\"$trim_file\")\n\n    awk '\n      { lines[NR] = $0 }\n      END {\n        last = NR\n        while (last > 0 && lines[last] == \"\") {\n          last--\n        }\n        for (i = 1; i <= last; i++) {\n          print lines[i]\n        }\n      }\n    ' \"$file\" > \"$trim_file\"\n\n    mv \"$trim_file\" \"$file\"\n  }\n\n  _write_normalized_lightrag_deploy_block() {\n    local deploy_line\n    local skipping_restart_policy=\"no\"\n\n    printf '    deploy:\\n' >> \"$tmp_file\"\n    for deploy_line in \"${deploy_lines[@]}\"; do\n      if [[ -z \"$deploy_line\" ]]; then\n        continue\n      fi\n\n      if [[ \"$skipping_restart_policy\" == \"yes\" ]]; then\n        if [[ \"$deploy_line\" =~ ^[[:space:]]{8} ]]; then\n          continue\n        fi\n        skipping_restart_policy=\"no\"\n      fi\n\n      if [[ \"$deploy_line\" == \"      restart_policy:\" ]]; then\n        skipping_restart_policy=\"yes\"\n        continue\n      fi\n\n      printf '%s\\n' \"$deploy_line\" >> \"$tmp_file\"\n    done\n\n    printf '      restart_policy:\\n' >> \"$tmp_file\"\n    printf '        condition: on-failure\\n' >> \"$tmp_file\"\n    printf '        max_attempts: 10\\n' >> \"$tmp_file\"\n  }\n\n  : > \"$tmp_file\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$in_deploy\" == \"yes\" ]]; then\n      if [[ \"$line\" =~ ^[[:space:]]{6} || -z \"$line\" ]]; then\n        deploy_lines+=(\"$line\")\n        continue\n      fi\n\n      _trim_trailing_blank_lines \"$tmp_file\"\n      _write_normalized_lightrag_deploy_block\n      deploy_lines=()\n      in_deploy=\"no\"\n      if [[ \"$line\" =~ ^[[:space:]]{2}[^[:space:]] || \"$line\" =~ ^[^[:space:]] ]]; then\n        insert_blank_after_deploy=\"yes\"\n      fi\n    fi\n\n    if [[ \"$in_lightrag\" == \"yes\" && \"$line\" =~ ^[[:space:]]{2}[^[:space:]] && \"$line\" != \"  lightrag:\" ]] || \\\n      [[ \"$in_lightrag\" == \"yes\" && \"$line\" =~ ^[^[:space:]] ]]; then\n      if [[ \"$deploy_seen\" != \"yes\" ]]; then\n        _trim_trailing_blank_lines \"$tmp_file\"\n        _write_normalized_lightrag_deploy_block\n        insert_blank_after_deploy=\"yes\"\n      fi\n      in_lightrag=\"no\"\n      deploy_seen=\"no\"\n      skip_blank_after_removed_restart=\"no\"\n    fi\n\n    if [[ \"$in_lightrag\" == \"yes\" && \"$line\" == \"    deploy:\" ]]; then\n      in_deploy=\"yes\"\n      deploy_seen=\"yes\"\n      deploy_lines=()\n      continue\n    fi\n\n    if [[ \"$in_lightrag\" == \"yes\" && \"$line\" =~ ^[[:space:]]{4}restart: ]]; then\n      skip_blank_after_removed_restart=\"yes\"\n      continue\n    fi\n\n    if [[ \"$skip_blank_after_removed_restart\" == \"yes\" && \"$in_lightrag\" == \"yes\" ]]; then\n      if [[ -z \"$line\" ]]; then\n        continue\n      fi\n      skip_blank_after_removed_restart=\"no\"\n    fi\n\n    if [[ \"$insert_blank_after_deploy\" == \"yes\" ]]; then\n      printf '\\n' >> \"$tmp_file\"\n      insert_blank_after_deploy=\"no\"\n    fi\n\n    printf '%s\\n' \"$line\" >> \"$tmp_file\"\n\n    if [[ \"$line\" == \"  lightrag:\" ]]; then\n      in_lightrag=\"yes\"\n      in_deploy=\"no\"\n      deploy_seen=\"no\"\n      insert_blank_after_deploy=\"no\"\n      skip_blank_after_removed_restart=\"no\"\n      deploy_lines=()\n    fi\n  done < \"$compose_file\"\n\n  if [[ \"$in_deploy\" == \"yes\" ]]; then\n    _trim_trailing_blank_lines \"$tmp_file\"\n    _write_normalized_lightrag_deploy_block\n    deploy_seen=\"yes\"\n  fi\n\n  if [[ \"$in_lightrag\" == \"yes\" && \"$deploy_seen\" != \"yes\" ]]; then\n    _trim_trailing_blank_lines \"$tmp_file\"\n    _write_normalized_lightrag_deploy_block\n  fi\n\n  mv \"$tmp_file\" \"$compose_file\"\n}\n\nappend_lightrag_ssl_mount() {\n  local array_name=\"$1\"\n  local container_path=\"$2\"\n  local relative_host_path=\"\"\n  local mount_entry=\"\"\n\n  if [[ -z \"$container_path\" ]]; then\n    return 0\n  fi\n\n  if [[ \"$container_path\" != /app/data/* ]]; then\n    format_error \"Unsupported SSL path: ${container_path}\" \"Use paths staged under /app/data.\"\n    return 1\n  fi\n\n  relative_host_path=\"./data/${container_path#/app/data/}\"\n  mount_entry=\"${relative_host_path}:${container_path}:ro\"\n  local -n _arr_ref=\"$array_name\"\n  _arr_ref+=(\"$mount_entry\")\n}\n\nformat_yaml_value() {\n  local value=\"$1\"\n  local escaped=\"${value//\\\\/\\\\\\\\}\"\n\n  escaped=\"${escaped//\\\"/\\\\\\\"}\"\n  escaped=\"${escaped//\\$/\\$\\$}\"\n  printf '\"%s\"' \"$escaped\"\n}\n\n_COMPOSE_RAW_VALUE_PREFIX=\"__LIGHTRAG_RAW_COMPOSE__:\"\n\nformat_compose_environment_value() {\n  local value=\"$1\"\n\n  if [[ \"$value\" == \"${_COMPOSE_RAW_VALUE_PREFIX}\"* ]]; then\n    printf '%s' \"${value#${_COMPOSE_RAW_VALUE_PREFIX}}\"\n    return 0\n  fi\n\n  format_yaml_value \"$value\"\n}\n\ninject_service_environment_overrides() {\n  local compose_file=\"$1\"\n  local service_name=\"$2\"\n  shift 2\n  local entries=(\"$@\")\n  local tmp_file=\"${compose_file}.${service_name}.env\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  local line key value\n  local in_service=\"no\"\n  local in_environment=\"no\"\n  local environment_style=\"mapping\"\n  local inserted=\"no\"\n  local service_header=\"  ${service_name}:\"\n\n  if ((${#entries[@]} == 0)); then\n    return 0\n  fi\n\n  : > \"$tmp_file\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$in_service\" == \"yes\" && \"$in_environment\" == \"yes\" ]]; then\n      if [[ \"$line\" =~ ^[[:space:]]{6}-[[:space:]] ]]; then\n        environment_style=\"list\"\n      elif [[ \"$line\" =~ ^[[:space:]]{6}[A-Z0-9_]+: ]]; then\n        environment_style=\"mapping\"\n      fi\n\n      if [[ \"$line\" =~ ^[[:space:]]{4}[^[:space:]] || \"$line\" =~ ^[[:space:]]{2}[^[:space:]] || \"$line\" =~ ^[^[:space:]] ]]; then\n        if [[ \"$inserted\" == \"no\" ]]; then\n          _write_service_environment_entries \"$tmp_file\" \"$environment_style\" \"${entries[@]}\"\n          inserted=\"yes\"\n        fi\n        in_environment=\"no\"\n      fi\n    elif [[ \"$in_service\" == \"yes\" && \\\n            ( \"$line\" =~ ^[[:space:]]{2}[^[:space:]] || \"$line\" =~ ^[^[:space:]] ) && \\\n            \"$line\" != \"$service_header\" ]]; then\n      if [[ \"$inserted\" == \"no\" ]]; then\n        printf '    environment:\\n' >> \"$tmp_file\"\n        _write_service_environment_entries \"$tmp_file\" \"mapping\" \"${entries[@]}\"\n        inserted=\"yes\"\n      fi\n      in_service=\"no\"\n    fi\n\n    printf '%s\\n' \"$line\" >> \"$tmp_file\"\n\n    if [[ \"$line\" == \"$service_header\" ]]; then\n      in_service=\"yes\"\n      in_environment=\"no\"\n      environment_style=\"mapping\"\n    elif [[ \"$in_service\" == \"yes\" && \"$line\" == \"    environment:\" ]]; then\n      in_environment=\"yes\"\n      environment_style=\"mapping\"\n    fi\n  done < \"$compose_file\"\n\n  if [[ \"$in_service\" == \"yes\" && \"$inserted\" == \"no\" ]]; then\n    if [[ \"$in_environment\" != \"yes\" ]]; then\n      printf '    environment:\\n' >> \"$tmp_file\"\n    fi\n    _write_service_environment_entries \"$tmp_file\" \"$environment_style\" \"${entries[@]}\"\n  fi\n\n  mv \"$tmp_file\" \"$compose_file\"\n}\n\n# Return success when a volume mount entry is a wizard-managed SSL cert/key\n# bind mount (./data/certs/* -> /app/data/certs/*, optional :ro suffix).\n_is_wizard_ssl_bind_mount() {\n  local mount_spec=\"$1\"\n  local host_path=\"\"\n  local remainder=\"\"\n  local container_path=\"\"\n  local mode=\"\"\n  local host_suffix=\"\"\n  local container_suffix=\"\"\n\n  host_path=\"${mount_spec%%:*}\"\n  remainder=\"${mount_spec#*:}\"\n  if [[ \"$remainder\" == \"$mount_spec\" ]]; then\n    return 1\n  fi\n\n  container_path=\"${remainder%%:*}\"\n  if [[ \"$container_path\" == \"$remainder\" ]]; then\n    mode=\"\"\n  else\n    mode=\"${remainder#${container_path}:}\"\n  fi\n\n  if [[ \"$host_path\" != ./data/certs/* ]]; then\n    return 1\n  fi\n\n  if [[ \"$container_path\" != /app/data/certs/* ]]; then\n    return 1\n  fi\n\n  # Wizard-generated SSL mounts are read-only. Keep non-read-only mounts so\n  # user-defined overrides under /app/data/certs are not stripped.\n  if [[ -n \"$mode\" && \"$mode\" != \"ro\" ]]; then\n    return 1\n  fi\n\n  host_suffix=\"${host_path#./data/certs/}\"\n  container_suffix=\"${container_path#/app/data/certs/}\"\n  [[ \"$host_suffix\" == \"$container_suffix\" ]]\n}\n\n# Remove wizard-managed SSL bind mounts from the lightrag service's volumes\n# block, leaving persistent and user-added /app/data/* mounts intact.\n_strip_lightrag_wizard_bind_mounts() {\n  local compose_file=\"$1\"\n  local tmp_file=\"${compose_file}.strip-mounts\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  local line\n  local mount_spec\n  local in_lightrag=\"no\"\n  local in_volumes=\"no\"\n\n  : > \"$tmp_file\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$in_lightrag\" == \"yes\" ]]; then\n      if [[ \"$line\" =~ ^[[:space:]]{2}[^[:space:]] && \"$line\" != \"  lightrag:\" ]]; then\n        in_lightrag=\"no\"\n        in_volumes=\"no\"\n      elif [[ \"$line\" == \"    volumes:\" ]]; then\n        in_volumes=\"yes\"\n      elif [[ \"$in_volumes\" == \"yes\" ]]; then\n        if [[ \"$line\" =~ ^[[:space:]]{4}[^[:space:]] ]]; then\n          in_volumes=\"no\"\n        elif [[ \"$line\" =~ ^[[:space:]]{6}-[[:space:]] ]]; then\n          mount_spec=\"${line#      - }\"\n          mount_spec=\"${mount_spec%\\\"}\"\n          mount_spec=\"${mount_spec#\\\"}\"\n          mount_spec=\"${mount_spec%\\'}\"\n          mount_spec=\"${mount_spec#\\'}\"\n          if _is_wizard_ssl_bind_mount \"$mount_spec\"; then\n            continue\n          fi\n        fi\n      fi\n    fi\n\n    printf '%s\\n' \"$line\" >> \"$tmp_file\"\n\n    if [[ \"$line\" == \"  lightrag:\" ]]; then\n      in_lightrag=\"yes\"\n      in_volumes=\"no\"\n    fi\n  done < \"$compose_file\"\n\n  mv \"$tmp_file\" \"$compose_file\"\n}\n\n_is_wizard_lightrag_port_mapping() {\n  local port_spec=\"$(_strip_wrapping_quotes \"$1\")\"\n\n  if [[ \"$port_spec\" == '${HOST:-0.0.0.0}:${PORT:-9621}:9621' || \\\n        \"$port_spec\" == '${PORT:-9621}:9621' ]]; then\n    return 0\n  fi\n\n  case \"$port_spec\" in\n    9621|9621/tcp|*:9621|*:9621/tcp)\n      return 0\n      ;;\n  esac\n\n  return 1\n}\n\n_strip_lightrag_wizard_ports() {\n  local compose_file=\"$1\"\n  local tmp_file=\"${compose_file}.strip-lightrag-ports\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  local line\n  local port_spec=\"\"\n  local in_lightrag=\"no\"\n  local in_ports=\"no\"\n\n  : > \"$tmp_file\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$in_lightrag\" == \"yes\" && \"$in_ports\" == \"yes\" ]]; then\n      if [[ \"$line\" =~ ^[[:space:]]{6}-[[:space:]](.+)$ ]]; then\n        port_spec=\"${BASH_REMATCH[1]}\"\n        if _is_wizard_lightrag_port_mapping \"$port_spec\"; then\n          continue\n        fi\n      elif [[ ! \"$line\" =~ ^[[:space:]]{6} ]]; then\n        in_ports=\"no\"\n      fi\n    elif [[ \"$in_lightrag\" == \"yes\" && \"$line\" =~ ^[[:space:]]{2}[^[:space:]] && \"$line\" != \"  lightrag:\" ]]; then\n      in_lightrag=\"no\"\n    fi\n\n    printf '%s\\n' \"$line\" >> \"$tmp_file\"\n\n    if [[ \"$line\" == \"  lightrag:\" ]]; then\n      in_lightrag=\"yes\"\n      in_ports=\"no\"\n    elif [[ \"$in_lightrag\" == \"yes\" && \"$line\" == \"    ports:\" ]]; then\n      in_ports=\"yes\"\n    fi\n  done < \"$compose_file\"\n\n  mv \"$tmp_file\" \"$compose_file\"\n}\n\ninject_lightrag_bind_mounts() {\n  local compose_file=\"$1\"\n  shift\n  local mounts=(\"$@\")\n  local tmp_file=\"${compose_file}.mounts\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  local line\n  local in_lightrag=\"no\"\n  local in_volumes=\"no\"\n  local inserted=\"no\"\n\n  if ((${#mounts[@]} == 0)); then\n    return 0\n  fi\n\n  : > \"$tmp_file\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$in_lightrag\" == \"yes\" && \"$in_volumes\" == \"yes\" ]]; then\n      if [[ \"$line\" =~ ^[[:space:]]{4}[^[:space:]-] || \"$line\" =~ ^[[:space:]]{2}[^[:space:]] || \"$line\" =~ ^(volumes|networks): ]]; then\n        if [[ \"$inserted\" == \"no\" ]]; then\n          for mount in \"${mounts[@]}\"; do\n            printf '      - \"%s\"\\n' \"$mount\" >> \"$tmp_file\"\n          done\n          inserted=\"yes\"\n        fi\n        in_volumes=\"no\"\n      fi\n    elif [[ \"$in_lightrag\" == \"yes\" && \"$line\" =~ ^[[:space:]]{2}[^[:space:]] && \"$line\" != \"  lightrag:\" ]]; then\n      if [[ \"$inserted\" == \"no\" ]]; then\n        printf '    volumes:\\n' >> \"$tmp_file\"\n        for mount in \"${mounts[@]}\"; do\n          printf '      - \"%s\"\\n' \"$mount\" >> \"$tmp_file\"\n        done\n        inserted=\"yes\"\n      fi\n      in_lightrag=\"no\"\n    fi\n\n    printf '%s\\n' \"$line\" >> \"$tmp_file\"\n\n    if [[ \"$line\" == \"  lightrag:\" ]]; then\n      in_lightrag=\"yes\"\n      in_volumes=\"no\"\n    elif [[ \"$in_lightrag\" == \"yes\" && \"$line\" == \"    volumes:\" ]]; then\n      in_volumes=\"yes\"\n    fi\n  done < \"$compose_file\"\n\n  if [[ \"$in_lightrag\" == \"yes\" && \"$inserted\" == \"no\" ]]; then\n    if [[ \"$in_volumes\" != \"yes\" ]]; then\n      printf '    volumes:\\n' >> \"$tmp_file\"\n    fi\n    for mount in \"${mounts[@]}\"; do\n      printf '      - \"%s\"\\n' \"$mount\" >> \"$tmp_file\"\n    done\n  fi\n\n  mv \"$tmp_file\" \"$compose_file\"\n}\n\ninject_lightrag_port_mapping() {\n  local compose_file=\"$1\"\n  local port_mapping=\"$2\"\n  local tmp_file=\"${compose_file}.ports\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  local line\n  local in_lightrag=\"no\"\n  local in_ports=\"no\"\n  local inserted=\"no\"\n\n  if [[ -z \"$port_mapping\" ]]; then\n    return 0\n  fi\n\n  : > \"$tmp_file\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$in_lightrag\" == \"yes\" && \"$in_ports\" == \"yes\" ]]; then\n      if [[ \"$line\" =~ ^[[:space:]]{4}[^[:space:]-] || \"$line\" =~ ^[[:space:]]{2}[^[:space:]] || \"$line\" =~ ^(volumes|networks): ]]; then\n        if [[ \"$inserted\" == \"no\" ]]; then\n          printf '      - \"%s\"\\n' \"$port_mapping\" >> \"$tmp_file\"\n          inserted=\"yes\"\n        fi\n        in_ports=\"no\"\n      fi\n    elif [[ \"$in_lightrag\" == \"yes\" && \"$line\" =~ ^[[:space:]]{2}[^[:space:]] && \"$line\" != \"  lightrag:\" ]]; then\n      if [[ \"$inserted\" == \"no\" ]]; then\n        printf '    ports:\\n' >> \"$tmp_file\"\n        printf '      - \"%s\"\\n' \"$port_mapping\" >> \"$tmp_file\"\n        inserted=\"yes\"\n      fi\n      in_lightrag=\"no\"\n    fi\n\n    printf '%s\\n' \"$line\" >> \"$tmp_file\"\n\n    if [[ \"$line\" == \"  lightrag:\" ]]; then\n      in_lightrag=\"yes\"\n      in_ports=\"no\"\n    elif [[ \"$in_lightrag\" == \"yes\" && \"$line\" == \"    ports:\" ]]; then\n      in_ports=\"yes\"\n    fi\n  done < \"$compose_file\"\n\n  if [[ \"$in_lightrag\" == \"yes\" && \"$inserted\" == \"no\" ]]; then\n    if [[ \"$in_ports\" != \"yes\" ]]; then\n      printf '    ports:\\n' >> \"$tmp_file\"\n    fi\n    printf '      - \"%s\"\\n' \"$port_mapping\" >> \"$tmp_file\"\n  fi\n\n  mv \"$tmp_file\" \"$compose_file\"\n}\n\nrepair_misplaced_lightrag_depends_on() {\n  local compose_file=\"$1\"\n  local tmp_file=\"${compose_file}.repair-lightrag-depends-on\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  local line\n  local in_services=\"no\"\n  local in_lightrag=\"no\"\n  local lightrag_has_depends_on=\"no\"\n  local candidate_service=\"\"\n  local candidate_root_service=\"\"\n  local captured_block=\"\"\n  local candidate_header=\"\"\n  local inserted=\"no\"\n  local in_candidate_service=\"no\"\n  local skipping_candidate_depends_on=\"no\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$line\" == \"services:\" ]]; then\n      in_services=\"yes\"\n      continue\n    fi\n\n    if [[ \"$in_services\" == \"yes\" && \"$line\" =~ ^[^[:space:]] && \"$line\" != \"services:\" ]]; then\n      break\n    fi\n\n    if [[ \"$in_services\" != \"yes\" ]]; then\n      continue\n    fi\n\n    if [[ \"$in_lightrag\" == \"yes\" ]]; then\n      if [[ \"$line\" == \"    depends_on:\" ]]; then\n        lightrag_has_depends_on=\"yes\"\n        break\n      fi\n\n      if [[ \"$line\" =~ ^[[:space:]]{2}([A-Za-z0-9_-]+):[[:space:]]*$ ]] && \\\n        [[ \"${BASH_REMATCH[1]}\" != \"lightrag\" ]]; then\n        candidate_service=\"${BASH_REMATCH[1]}\"\n        candidate_root_service=\"$(_managed_service_root_name \"$candidate_service\")\"\n        if [[ -z \"$candidate_root_service\" || \"$candidate_root_service\" == \"milvus\" ]]; then\n          break\n        fi\n        in_lightrag=\"no\"\n      fi\n      continue\n    fi\n\n    if [[ \"$line\" == \"  lightrag:\" ]]; then\n      in_lightrag=\"yes\"\n      continue\n    fi\n\n    if [[ -n \"$candidate_service\" ]]; then\n      if [[ \"$line\" == \"    depends_on:\" ]]; then\n        captured_block=\"    depends_on:\"$'\\n'\n        continue\n      fi\n\n      if [[ -n \"$captured_block\" ]]; then\n        if [[ \"$line\" =~ ^[[:space:]]{6} ]]; then\n          captured_block+=\"${line}\"$'\\n'\n          continue\n        fi\n        break\n      fi\n\n      if [[ \"$line\" =~ ^[[:space:]]{2}[^[:space:]] && \"$line\" != \"  ${candidate_service}:\" ]]; then\n        break\n      fi\n\n      if [[ \"$line\" =~ ^[^[:space:]] ]]; then\n        break\n      fi\n    fi\n  done < \"$compose_file\"\n\n  if [[ \"$lightrag_has_depends_on\" == \"yes\" || -z \"$captured_block\" || -z \"$candidate_service\" ]]; then\n    return 0\n  fi\n\n  candidate_header=\"  ${candidate_service}:\"\n  : > \"$tmp_file\"\n  in_lightrag=\"no\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$skipping_candidate_depends_on\" == \"yes\" ]]; then\n      if [[ \"$line\" =~ ^[[:space:]]{6} ]]; then\n        continue\n      fi\n      skipping_candidate_depends_on=\"no\"\n    fi\n\n    if [[ \"$in_lightrag\" == \"yes\" && \"$inserted\" == \"no\" ]] && \\\n      [[ ( \"$line\" =~ ^[[:space:]]{2}[^[:space:]] && \"$line\" != \"  lightrag:\" ) || \"$line\" =~ ^[^[:space:]] ]]; then\n      printf '%s' \"$captured_block\" >> \"$tmp_file\"\n      inserted=\"yes\"\n      in_lightrag=\"no\"\n    fi\n\n    if [[ \"$line\" == \"$candidate_header\" ]]; then\n      in_candidate_service=\"yes\"\n    elif [[ \"$in_candidate_service\" == \"yes\" ]] && \\\n      [[ ( \"$line\" =~ ^[[:space:]]{2}[^[:space:]] && \"$line\" != \"$candidate_header\" ) || \"$line\" =~ ^[^[:space:]] ]]; then\n      in_candidate_service=\"no\"\n    fi\n\n    if [[ \"$in_candidate_service\" == \"yes\" && \"$line\" == \"    depends_on:\" ]]; then\n      skipping_candidate_depends_on=\"yes\"\n      continue\n    fi\n\n    printf '%s\\n' \"$line\" >> \"$tmp_file\"\n\n    if [[ \"$line\" == \"  lightrag:\" ]]; then\n      in_lightrag=\"yes\"\n    fi\n  done < \"$compose_file\"\n\n  if [[ \"$in_lightrag\" == \"yes\" && \"$inserted\" == \"no\" ]]; then\n    printf '%s' \"$captured_block\" >> \"$tmp_file\"\n  fi\n\n  mv \"$tmp_file\" \"$compose_file\"\n}\n\ninject_lightrag_environment_overrides() {\n  local compose_file=\"$1\"\n  shift\n  inject_service_environment_overrides \"$compose_file\" \"lightrag\" \"$@\"\n}\n\ninject_lightrag_depends_on() {\n  local compose_file=\"$1\"\n  shift\n  local candidate_service\n  local managed_services=()\n  local tmp_file=\"${compose_file}.depends-on\"\n  _FILE_OPS_CLEANUP_TMP+=(\"$tmp_file\")\n  local line\n  local in_lightrag=\"no\"\n  local in_depends_on=\"no\"\n  local inserted=\"no\"\n  local insert_blank_after_depends_on=\"no\"\n  local current_dep_name=\"\"\n  local current_dep_block=\"\"\n  local dep_name=\"\"\n  local dep_tail=\"\"\n  local dep_service=\"\"\n  declare -A preserved_dep_blocks=()\n  declare -A preserved_dep_seen=()\n  local -a preserved_dep_order=()\n\n  for candidate_service in \"$@\"; do\n    if _is_wizard_managed_root_service_name \"$candidate_service\"; then\n      managed_services+=(\"$candidate_service\")\n    fi\n  done\n\n  _record_preserved_depends_on_entry() {\n    local service_name=\"$1\"\n    local block=\"$2\"\n\n    if [[ -z \"$service_name\" ]] || _is_wizard_managed_root_service_name \"$service_name\"; then\n      return 0\n    fi\n\n    if [[ -n \"${preserved_dep_seen[$service_name]+set}\" ]]; then\n      return 0\n    fi\n\n    preserved_dep_seen[\"$service_name\"]=1\n    preserved_dep_order+=(\"$service_name\")\n    preserved_dep_blocks[\"$service_name\"]=\"$block\"\n  }\n\n  _flush_current_depends_on_entry() {\n    if [[ -z \"$current_dep_name\" ]]; then\n      return 0\n    fi\n\n    _record_preserved_depends_on_entry \"$current_dep_name\" \"$current_dep_block\"\n    current_dep_name=\"\"\n    current_dep_block=\"\"\n  }\n\n  _trim_trailing_blank_lines() {\n    local file=\"$1\"\n    local trim_file=\"${file}.trim\"\n    _FILE_OPS_CLEANUP_TMP+=(\"$trim_file\")\n\n    awk '\n      { lines[NR] = $0 }\n      END {\n        last = NR\n        while (last > 0 && lines[last] == \"\") {\n          last--\n        }\n        for (i = 1; i <= last; i++) {\n          print lines[i]\n        }\n      }\n    ' \"$file\" > \"$trim_file\"\n\n    mv \"$trim_file\" \"$file\"\n  }\n\n  _write_lightrag_depends_on_block() {\n    local managed_service\n    local preserved_service\n\n    if ((${#preserved_dep_order[@]} == 0 && ${#managed_services[@]} == 0)); then\n      inserted=\"yes\"\n      return 0\n    fi\n\n    printf '    depends_on:\\n' >> \"$tmp_file\"\n\n    for preserved_service in \"${preserved_dep_order[@]}\"; do\n      printf '%s' \"${preserved_dep_blocks[$preserved_service]}\" >> \"$tmp_file\"\n    done\n\n    for managed_service in \"${managed_services[@]}\"; do\n      printf '      %s:\\n' \"$managed_service\" >> \"$tmp_file\"\n      printf '        condition: service_healthy\\n' >> \"$tmp_file\"\n    done\n\n    inserted=\"yes\"\n  }\n\n  : > \"$tmp_file\"\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$in_depends_on\" == \"yes\" ]]; then\n      if [[ -n \"$current_dep_name\" && \"$line\" =~ ^[[:space:]]{8} ]]; then\n        current_dep_block+=\"${line}\"$'\\n'\n        continue\n      fi\n\n      if [[ \"$line\" =~ ^[[:space:]]{6}-[[:space:]](.+)$ ]]; then\n        _flush_current_depends_on_entry\n        dep_service=\"$(_strip_wrapping_quotes \"${BASH_REMATCH[1]}\")\"\n        _record_preserved_depends_on_entry \\\n          \"$dep_service\" \\\n          \"$(printf '      %s:\\n        condition: service_started\\n' \"$dep_service\")\"\n        continue\n      fi\n\n      if [[ \"$line\" =~ ^[[:space:]]{6}([A-Za-z0-9_.-]+):[[:space:]]*(.*)$ ]]; then\n        _flush_current_depends_on_entry\n        dep_name=\"${BASH_REMATCH[1]}\"\n        dep_tail=\"${BASH_REMATCH[2]}\"\n        current_dep_name=\"$(_strip_wrapping_quotes \"$dep_name\")\"\n        if [[ -n \"$dep_tail\" ]]; then\n          current_dep_block=\"      ${current_dep_name}: ${dep_tail}\"$'\\n'\n        else\n          current_dep_block=\"      ${current_dep_name}:\"$'\\n'\n        fi\n        continue\n      fi\n\n      _flush_current_depends_on_entry\n      if [[ \"$inserted\" == \"no\" ]]; then\n        _trim_trailing_blank_lines \"$tmp_file\"\n        _write_lightrag_depends_on_block\n      fi\n      in_depends_on=\"no\"\n    fi\n\n    if [[ \"$in_lightrag\" == \"yes\" && \"$line\" == \"    depends_on:\" ]]; then\n      in_depends_on=\"yes\"\n      continue\n    fi\n\n    if [[ \"$in_lightrag\" == \"yes\" && \\\n          ( \"$line\" =~ ^[[:space:]]{2}[^[:space:]] || \"$line\" =~ ^[^[:space:]] ) && \\\n          \"$line\" != \"  lightrag:\" ]]; then\n      if [[ \"$inserted\" == \"no\" ]]; then\n        _trim_trailing_blank_lines \"$tmp_file\"\n        _write_lightrag_depends_on_block\n        insert_blank_after_depends_on=\"yes\"\n      fi\n      in_lightrag=\"no\"\n    fi\n\n    if [[ \"$insert_blank_after_depends_on\" == \"yes\" ]]; then\n      printf '\\n' >> \"$tmp_file\"\n      insert_blank_after_depends_on=\"no\"\n    fi\n\n    printf '%s\\n' \"$line\" >> \"$tmp_file\"\n\n    if [[ \"$line\" == \"  lightrag:\" ]]; then\n      in_lightrag=\"yes\"\n      inserted=\"no\"\n      insert_blank_after_depends_on=\"no\"\n      in_depends_on=\"no\"\n      current_dep_name=\"\"\n      current_dep_block=\"\"\n      preserved_dep_blocks=()\n      preserved_dep_seen=()\n      preserved_dep_order=()\n    fi\n  done < \"$compose_file\"\n\n  if [[ \"$in_depends_on\" == \"yes\" ]]; then\n    _flush_current_depends_on_entry\n    if [[ \"$inserted\" == \"no\" ]]; then\n      _trim_trailing_blank_lines \"$tmp_file\"\n      _write_lightrag_depends_on_block\n    fi\n  elif [[ \"$in_lightrag\" == \"yes\" && \"$inserted\" == \"no\" ]]; then\n    _trim_trailing_blank_lines \"$tmp_file\"\n    _write_lightrag_depends_on_block\n  fi\n\n  mv \"$tmp_file\" \"$compose_file\"\n}\n\n# Find the first generated compose file in priority order.\n# Prints the path if found, empty string if not.\nfind_generated_compose_file() {\n  local repo_root=\"${REPO_ROOT:-.}\"\n  local preferred_profile=\"\"\n  local preferred_candidate=\"\"\n  local candidates=(\n    \"final:$repo_root/docker-compose.final.yml\"\n    \"development:$repo_root/docker-compose.development.yml\"\n    \"production:$repo_root/docker-compose.production.yml\"\n    \"custom:$repo_root/docker-compose.custom.yml\"\n    \"local:$repo_root/docker-compose.local.yml\"\n  )\n  local candidate profile f\n\n  preferred_profile=\"$(_read_legacy_setup_profile_from_env \"$repo_root/.env\")\"\n  if [[ -n \"$preferred_profile\" ]]; then\n    for candidate in \"${candidates[@]}\"; do\n      profile=\"${candidate%%:*}\"\n      f=\"${candidate#*:}\"\n      if [[ \"$profile\" == \"$preferred_profile\" && -f \"$f\" ]]; then\n        printf '%s' \"$f\"\n        return 0\n      fi\n    done\n  fi\n\n  for candidate in \"${candidates[@]}\"; do\n    f=\"${candidate#*:}\"\n    if [[ -f \"$f\" ]]; then\n      printf '%s' \"$f\"\n      return 0\n    fi\n  done\n  printf ''\n}\n\n_read_legacy_setup_profile_from_env() {\n  local env_file=\"$1\"\n  local line value\n\n  if [[ ! -f \"$env_file\" ]]; then\n    return 0\n  fi\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$line\" =~ ^LIGHTRAG_SETUP_PROFILE=(.*)$ ]]; then\n      value=\"${BASH_REMATCH[1]}\"\n      if [[ \"$value\" =~ ^\\\".*\\\"$ ]]; then\n        value=\"${value:1:${#value}-2}\"\n      elif [[ \"$value\" =~ ^\\'.*\\'$ ]]; then\n        value=\"${value:1:${#value}-2}\"\n      fi\n      case \"$value\" in\n        development|production|custom|local)\n          printf '%s' \"$value\"\n          ;;\n      esac\n      return 0\n    fi\n  done < \"$env_file\"\n\n  return 0\n}\n\n# Detect service names in a compose file's services: block (excluding lightrag).\n# Prints one service name per line.\ndetect_compose_services() {\n  local compose_file=\"$1\"\n  local in_services=\"no\"\n  local line\n\n  if [[ ! -f \"$compose_file\" ]]; then\n    return 0\n  fi\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$line\" == \"services:\" ]]; then\n      in_services=\"yes\"\n      continue\n    fi\n    if [[ \"$in_services\" == \"yes\" ]]; then\n      if [[ \"$line\" =~ ^[^[:space:]] && \"$line\" != \"services:\" ]]; then\n        in_services=\"no\"\n        continue\n      fi\n      if [[ \"$line\" =~ ^[[:space:]]{2}([A-Za-z0-9_-]+):[[:space:]]*$ ]]; then\n        local svc_name=\"${BASH_REMATCH[1]}\"\n        if [[ \"$svc_name\" != \"lightrag\" ]]; then\n          printf '%s\\n' \"$svc_name\"\n        fi\n      fi\n    fi\n  done < \"$compose_file\"\n}\n\ndetect_managed_root_services() {\n  local compose_file=\"$1\"\n  local service_name\n  local root_service\n  declare -A seen_roots=()\n\n  while IFS= read -r service_name; do\n    root_service=\"$(_managed_service_root_name \"$service_name\")\"\n    if [[ -n \"$root_service\" && -z \"${seen_roots[$root_service]+set}\" ]]; then\n      seen_roots[\"$root_service\"]=1\n      printf '%s\\n' \"$root_service\"\n    fi\n  done < <(detect_compose_services \"$compose_file\")\n}\n"
  },
  {
    "path": "scripts/setup/lib/presets.sh",
    "content": "# Preset helpers for setup-managed overrides.\n\napply_preset_overwrite() {\n  local entry key value\n\n  for entry in \"$@\"; do\n    key=\"${entry%%=*}\"\n    value=\"${entry#*=}\"\n    ENV_VALUES[\"$key\"]=\"$value\"\n  done\n}\n"
  },
  {
    "path": "scripts/setup/lib/prompts.sh",
    "content": "# Prompt helpers for interactive setup.\n\nCLEAR_INPUT_SENTINEL=\"__LIGHTRAG_CLEAR__\"\n\n_truncate_for_display() {\n  local value=\"$1\"\n  local max=50\n  if [[ ${#value} -gt $max ]]; then\n    printf '%s' \"${value:0:$max}...\"\n  else\n    printf '%s' \"$value\"\n  fi\n}\n\nmask_sensitive_input() {\n  local prompt=\"$1\"\n  local value\n\n  read -r -p \"$prompt\" value\n  printf '%s' \"$value\"\n}\n\nprompt_secret_with_default() {\n  local prompt=\"$1\"\n  local default=\"${2:-}\"\n  local value\n\n  if [[ -n \"$default\" ]]; then\n    local display_default\n    display_default=\"$(_truncate_for_display \"$default\")\"\n    read -r -p \"$prompt [${display_default}]: \" value\n  else\n    read -r -p \"$prompt\" value\n  fi\n\n  if [[ -z \"$value\" ]]; then\n    value=\"$default\"\n  fi\n\n  printf '%s' \"$value\"\n}\n\nprompt_clearable_with_default() {\n  local prompt=\"$1\"\n  local default=\"${2:-}\"\n  local value\n  local prompt_text=\"$prompt\"\n\n  if [[ -n \"$default\" ]]; then\n    prompt_text=\"$prompt (Enter to keep, type 'clear' to remove)\"\n  else\n    prompt_text=\"$prompt (type 'clear' to remove)\"\n  fi\n\n  value=\"$(prompt_with_default \"$prompt_text\" \"$default\")\"\n  if [[ \"${value,,}\" == \"clear\" ]]; then\n    printf '%s' \"$CLEAR_INPUT_SENTINEL\"\n    return 0\n  fi\n\n  printf '%s' \"$value\"\n}\n\nprompt_clearable_secret_with_default() {\n  local prompt=\"$1\"\n  local default=\"${2:-}\"\n  local value\n  local prompt_text=\"$prompt\"\n\n  if [[ -n \"$default\" ]]; then\n    prompt_text=\"$prompt (Enter to keep, type 'clear' to remove)\"\n  else\n    prompt_text=\"$prompt (type 'clear' to remove)\"\n  fi\n\n  value=\"$(prompt_secret_with_default \"$prompt_text\" \"$default\")\"\n  if [[ \"${value,,}\" == \"clear\" ]]; then\n    printf '%s' \"$CLEAR_INPUT_SENTINEL\"\n    return 0\n  fi\n\n  printf '%s' \"$value\"\n}\n\nprompt_with_default() {\n  local prompt=\"$1\"\n  local default=\"$2\"\n  local value\n\n  if [[ -n \"$default\" ]]; then\n    read -r -p \"$prompt [$default]: \" value\n  else\n    read -r -p \"$prompt: \" value\n  fi\n\n  if [[ -z \"$value\" ]]; then\n    value=\"$default\"\n  fi\n\n  printf '%s' \"$value\"\n}\n\nstyle_prompt_text() {\n  local prompt=\"$1\"\n\n  if [[ -n \"${COLOR_YELLOW:-}\" && \"$prompt\" == *Docker* ]]; then\n    prompt=\"${prompt//Docker/${COLOR_YELLOW}Docker${COLOR_RESET}}\"\n  fi\n\n  printf '%s' \"$prompt\"\n}\n\nconfirm_default_no() {\n  local prompt=\"$1\"\n  local response\n  local styled_prompt\n\n  styled_prompt=\"$(style_prompt_text \"$prompt\")\"\n  while true; do\n    read -r -n 1 -p \"$styled_prompt [y/N]: \" response\n    echo\n    case \"$response\" in\n      y|Y) return 0 ;;\n      n|N|\"\") return 1 ;;\n    esac\n  done\n}\n\nconfirm_default_yes() {\n  local prompt=\"$1\"\n  local response\n  local styled_prompt\n\n  styled_prompt=\"$(style_prompt_text \"$prompt\")\"\n  while true; do\n    read -r -n 1 -p \"$styled_prompt [Y/n]: \" response\n    echo\n    case \"$response\" in\n      y|Y|\"\") return 0 ;;\n      n|N) return 1 ;;\n    esac\n  done\n}\n\nconfirm_required_yes_no() {\n  local prompt=\"$1\"\n  local response\n  local styled_prompt\n\n  styled_prompt=\"$(style_prompt_text \"$prompt\")\"\n\n  while true; do\n    printf '%b' \"$styled_prompt [yes/no]: \" >&2\n    read -r response\n    case \"${response,,}\" in\n      yes) return 0 ;;\n      no) return 1 ;;\n      *)\n        echo \"Please type 'yes' or 'no'.\" >&2\n        ;;\n    esac\n  done\n}\n\nprompt_until_valid() {\n  local prompt=\"$1\"\n  local default=\"$2\"\n  local validator=\"$3\"\n  shift 3\n  local value\n\n  while true; do\n    value=\"$(prompt_with_default \"$prompt\" \"$default\")\"\n    if \"$validator\" \"$value\" \"$@\"; then\n      printf '%s' \"$value\"\n      return 0\n    fi\n    echo \"Invalid value. Please try again.\"\n  done\n}\n\nprompt_secret_until_valid() {\n  local prompt=\"$1\"\n  local validator=\"$2\"\n  shift 2\n  local value\n\n  while true; do\n    value=\"$(mask_sensitive_input \"$prompt\")\"\n    if \"$validator\" \"$value\" \"$@\"; then\n      printf '%s' \"$value\"\n      return 0\n    fi\n    echo \"Invalid value. Please try again.\"\n  done\n}\n\nprompt_secret_until_valid_with_default() {\n  local prompt=\"$1\"\n  local default=\"$2\"\n  local validator=\"$3\"\n  shift 3\n  local value\n\n  while true; do\n    value=\"$(prompt_secret_with_default \"$prompt\" \"$default\")\"\n    if \"$validator\" \"$value\" \"$@\"; then\n      printf '%s' \"$value\"\n      return 0\n    fi\n    echo \"Invalid value. Please try again.\"\n  done\n}\n\nprompt_required_secret() {\n  local prompt=\"$1\"\n  local value\n\n  while true; do\n    value=\"$(mask_sensitive_input \"$prompt\")\"\n    if [[ -n \"$value\" ]]; then\n      printf '%s' \"$value\"\n      return 0\n    fi\n    echo \"Value cannot be empty. Please try again.\"\n  done\n}\n\nprompt_choice() {\n  local prompt=\"$1\"\n  local default=\"$2\"\n  shift 2\n  local options=(\"$@\")\n  local choice\n  local index=1\n  local default_index=\"\"\n  local count=\"${#options[@]}\"\n\n  for option in \"${options[@]}\"; do\n    if [[ \"$option\" == \"$default\" ]]; then\n      default_index=\"$index\"\n    fi\n    index=$((index + 1))\n  done\n\n  while true; do\n    printf '%s\\n' \"${COLOR_BLUE}${prompt}${COLOR_RESET} options:\" >&2\n    index=1\n    for option in \"${options[@]}\"; do\n      if [[ \"$index\" == \"$default_index\" ]]; then\n        printf '  %s) %s%s%s\\n' \\\n          \"${COLOR_GREEN}${index}${COLOR_RESET}\" \\\n          \"${COLOR_YELLOW}\" \\\n          \"$option\" \\\n          \"${COLOR_RESET}\" >&2\n      else\n        printf '  %s) %s\\n' \"${COLOR_GREEN}${index}${COLOR_RESET}\" \"$option\" >&2\n      fi\n      index=$((index + 1))\n    done\n    if [[ -n \"$default_index\" ]]; then\n      printf 'Enter number (default: %s): ' \"$default_index\" >&2\n    else\n      printf 'Enter number: ' >&2\n    fi\n\n    if ((count <= 9)); then\n      read -r -n 1 choice\n      printf '\\n' >&2\n    else\n      read -r choice\n    fi\n\n    if [[ -z \"$choice\" ]]; then\n      if [[ -n \"$default_index\" ]]; then\n        printf '%s' \"${options[default_index-1]}\"\n        return 0\n      fi\n    elif [[ \"$choice\" =~ ^[0-9]+$ ]] && ((choice >= 1 && choice <= count)); then\n      printf '%s' \"${options[choice-1]}\"\n      return 0\n    fi\n\n    printf '%s\\n' \"${COLOR_YELLOW}Invalid selection.${COLOR_RESET} Please enter a number between 1 and ${count}.\" >&2\n  done\n}\n"
  },
  {
    "path": "scripts/setup/lib/storage_requirements.sh",
    "content": "# Storage backend options and required environment variables.\n# shellcheck disable=SC2034\n\ndeclare -ag KV_STORAGE_OPTIONS=(\n  \"JsonKVStorage\"\n  \"RedisKVStorage\"\n  \"PGKVStorage\"\n  \"MongoKVStorage\"\n  \"OpenSearchKVStorage\"\n)\n\ndeclare -ag GRAPH_STORAGE_OPTIONS=(\n  \"NetworkXStorage\"\n  \"Neo4JStorage\"\n  \"PGGraphStorage\"\n  \"MongoGraphStorage\"\n  \"MemgraphStorage\"\n  \"OpenSearchGraphStorage\"\n)\n\ndeclare -ag VECTOR_STORAGE_OPTIONS=(\n  \"NanoVectorDBStorage\"\n  \"MilvusVectorDBStorage\"\n  \"PGVectorStorage\"\n  \"FaissVectorDBStorage\"\n  \"QdrantVectorDBStorage\"\n  \"MongoVectorDBStorage\"\n  \"OpenSearchVectorDBStorage\"\n)\n\ndeclare -ag DOC_STATUS_STORAGE_OPTIONS=(\n  \"JsonDocStatusStorage\"\n  \"RedisDocStatusStorage\"\n  \"PGDocStatusStorage\"\n  \"MongoDocStatusStorage\"\n  \"OpenSearchDocStatusStorage\"\n)\n\ndeclare -Ag STORAGE_ENV_REQUIREMENTS=(\n  [\"JsonKVStorage\"]=\"\"\n  [\"MongoKVStorage\"]=\"MONGO_URI MONGO_DATABASE\"\n  [\"RedisKVStorage\"]=\"REDIS_URI\"\n  [\"PGKVStorage\"]=\"POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DATABASE\"\n  [\"OpenSearchKVStorage\"]=\"OPENSEARCH_HOSTS OPENSEARCH_USER OPENSEARCH_PASSWORD\"\n  [\"NetworkXStorage\"]=\"\"\n  [\"Neo4JStorage\"]=\"NEO4J_URI NEO4J_USERNAME NEO4J_PASSWORD\"\n  [\"MongoGraphStorage\"]=\"MONGO_URI MONGO_DATABASE\"\n  [\"MemgraphStorage\"]=\"MEMGRAPH_URI\"\n  [\"PGGraphStorage\"]=\"POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DATABASE\"\n  [\"OpenSearchGraphStorage\"]=\"OPENSEARCH_HOSTS OPENSEARCH_USER OPENSEARCH_PASSWORD\"\n  [\"NanoVectorDBStorage\"]=\"\"\n  [\"MilvusVectorDBStorage\"]=\"MILVUS_URI MILVUS_DB_NAME\"\n  [\"PGVectorStorage\"]=\"POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DATABASE\"\n  [\"FaissVectorDBStorage\"]=\"\"\n  [\"QdrantVectorDBStorage\"]=\"QDRANT_URL\"\n  [\"MongoVectorDBStorage\"]=\"MONGO_URI MONGO_DATABASE\"\n  [\"OpenSearchVectorDBStorage\"]=\"OPENSEARCH_HOSTS OPENSEARCH_USER OPENSEARCH_PASSWORD\"\n  [\"JsonDocStatusStorage\"]=\"\"\n  [\"RedisDocStatusStorage\"]=\"REDIS_URI\"\n  [\"PGDocStatusStorage\"]=\"POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DATABASE\"\n  [\"MongoDocStatusStorage\"]=\"MONGO_URI MONGO_DATABASE\"\n  [\"OpenSearchDocStatusStorage\"]=\"OPENSEARCH_HOSTS OPENSEARCH_USER OPENSEARCH_PASSWORD\"\n)\n\ndeclare -Ag STORAGE_DB_TYPES=(\n  [\"MongoKVStorage\"]=\"mongodb\"\n  [\"MongoGraphStorage\"]=\"mongodb\"\n  [\"MongoVectorDBStorage\"]=\"mongodb\"\n  [\"MongoDocStatusStorage\"]=\"mongodb\"\n  [\"RedisKVStorage\"]=\"redis\"\n  [\"RedisDocStatusStorage\"]=\"redis\"\n  [\"PGKVStorage\"]=\"postgresql\"\n  [\"PGGraphStorage\"]=\"postgresql\"\n  [\"PGVectorStorage\"]=\"postgresql\"\n  [\"PGDocStatusStorage\"]=\"postgresql\"\n  [\"Neo4JStorage\"]=\"neo4j\"\n  [\"MemgraphStorage\"]=\"memgraph\"\n  [\"MilvusVectorDBStorage\"]=\"milvus\"\n  [\"QdrantVectorDBStorage\"]=\"qdrant\"\n  [\"OpenSearchKVStorage\"]=\"opensearch\"\n  [\"OpenSearchGraphStorage\"]=\"opensearch\"\n  [\"OpenSearchVectorDBStorage\"]=\"opensearch\"\n  [\"OpenSearchDocStatusStorage\"]=\"opensearch\"\n)\n"
  },
  {
    "path": "scripts/setup/lib/validation.sh",
    "content": "# Validation helpers for interactive setup.\n\nvalidate_uri() {\n  local uri=\"$1\"\n  local db_type=\"$2\"\n\n  if [[ -z \"$uri\" ]]; then\n    return 1\n  fi\n\n  case \"$db_type\" in\n    postgresql)\n      [[ \"$uri\" =~ ^postgres(ql)?://.+ ]]\n      return $?; ;;\n    neo4j)\n      [[ \"$uri\" =~ ^(neo4j(\\+s|\\+ssc)?|bolt)://.+ ]]\n      return $?; ;;\n    mongodb)\n      [[ \"$uri\" =~ ^mongodb(\\+srv)?://.+ ]]\n      return $?; ;;\n    redis)\n      [[ \"$uri\" =~ ^rediss?://.+ ]]\n      return $?; ;;\n    milvus|qdrant)\n      [[ \"$uri\" =~ ^https?://.+ ]]\n      return $?; ;;\n    memgraph)\n      [[ \"$uri\" =~ ^bolt://.+ ]]\n      return $?; ;;\n    *)\n      return 1\n      ;;\n  esac\n}\n\nvalidate_api_key() {\n  local key=\"$1\"\n  local provider=\"$2\"\n\n  if [[ -z \"$key\" ]]; then\n    return 1\n  fi\n\n  case \"$provider\" in\n    openai|openrouter)\n      return 0; ;;\n    *)\n      [[ ${#key} -ge 8 ]]\n      return $?; ;;\n  esac\n}\n\nvalidate_port() {\n  local port=\"$1\"\n\n  if [[ ! \"$port\" =~ ^[0-9]+$ ]]; then\n    return 1\n  fi\n\n  if (( port < 1 || port > 65535 )); then\n    return 1\n  fi\n\n  return 0\n}\n\nvalidate_non_empty() {\n  local value=\"$1\"\n\n  [[ -n \"$value\" ]]\n}\n\nvalidate_existing_file() {\n  local path=\"$1\"\n\n  [[ -n \"$path\" && -f \"$path\" ]]\n}\n\ncheck_storage_compatibility() {\n  local kv_storage=\"$1\"\n  local vector_storage=\"$2\"\n  local graph_storage=\"$3\"\n  local doc_status_storage=\"$4\"\n  local warnings=()\n\n  if [[ \"$vector_storage\" == \"MongoVectorDBStorage\" ]]; then\n    warnings+=(\"MongoDB vector storage requires an Atlas-capable deployment with Atlas Search / Vector Search support.\")\n  fi\n\n  if [[ \"$graph_storage\" == \"Neo4JStorage\" && \"$kv_storage\" == \"JsonKVStorage\" ]]; then\n    warnings+=(\"Neo4j graph with JSON KV storage is fine for dev, but not ideal for production.\")\n  fi\n\n  if [[ \"$graph_storage\" == \"NetworkXStorage\" ]]; then\n    warnings+=(\"NetworkX graph storage is memory-bound and suited for small datasets only.\")\n  fi\n\n  if [[ \"$vector_storage\" == \"FaissVectorDBStorage\" ]]; then\n    warnings+=(\"Faiss vector storage is local-only and requires manual persistence management.\")\n  fi\n\n  if [[ \"$kv_storage\" == \"JsonKVStorage\" || \"$doc_status_storage\" == \"JsonDocStatusStorage\" ]]; then\n    warnings+=(\"JSON-based KV/doc status storage is recommended only for local development.\")\n  fi\n\n  if ((${#warnings[@]} > 0)); then\n    echo \"${COLOR_YELLOW:-}Storage compatibility/performance warnings:${COLOR_RESET:-}\" >&2\n    for warning in \"${warnings[@]}\"; do\n      echo \"  - $warning\" >&2\n    done\n  fi\n\n  return 0\n}\n\nformat_error() {\n  local message=\"$1\"\n  local suggestion=\"${2:-}\"\n\n  echo \"${COLOR_RED:-}Error:${COLOR_RESET:-} $message\" >&2\n  if [[ -n \"$suggestion\" ]]; then\n    echo \"${COLOR_YELLOW:-}Hint:${COLOR_RESET:-} $suggestion\" >&2\n  fi\n}\n\ncontains_env_interpolation_syntax() {\n  local value=\"$1\"\n\n  [[ \"$value\" == *'${'* ]]\n}\n\nis_sensitive_env_key() {\n  local key=\"$1\"\n\n  case \"$key\" in\n    AUTH_ACCOUNTS|*API_KEY*|*ACCESS_KEY*|*PUBLIC_KEY*|*SECRET*|*PASSWORD*|*TOKEN*)\n      return 0\n      ;;\n    *)\n      return 1\n      ;;\n  esac\n}\n\nvalidate_sensitive_env_literals() {\n  local key value\n  local invalid_keys=()\n\n  for key in \"${!ENV_VALUES[@]}\"; do\n    if ! is_sensitive_env_key \"$key\"; then\n      continue\n    fi\n\n    value=\"${ENV_VALUES[$key]:-}\"\n    if [[ -n \"$value\" ]] && contains_env_interpolation_syntax \"$value\"; then\n      invalid_keys+=(\"$key\")\n    fi\n  done\n\n  if ((${#invalid_keys[@]} > 0)); then\n    format_error \\\n      \"Sensitive values must not contain \\${...} interpolation syntax: ${invalid_keys[*]}\" \\\n      \"Use literal values, plain \\$ characters, or inject those secrets via runtime environment variables instead of .env.\"\n    return 1\n  fi\n\n  return 0\n}\n\nvalidate_required_variables() {\n  local storages=(\"$@\")\n  local missing=()\n  local unknown=()\n  local storage required var\n\n  for storage in \"${storages[@]}\"; do\n    if [[ -z \"$storage\" ]]; then\n      continue\n    fi\n    if [[ ! -v \"STORAGE_ENV_REQUIREMENTS[$storage]\" ]]; then\n      unknown+=(\"$storage\")\n      continue\n    fi\n    required=\"${STORAGE_ENV_REQUIREMENTS[$storage]}\"\n    if [[ -z \"$required\" ]]; then\n      continue\n    fi\n    for var in $required; do\n      if [[ -z \"${ENV_VALUES[$var]:-}\" ]]; then\n        missing+=(\"$var\")\n      fi\n    done\n  done\n\n  if ((${#unknown[@]} > 0)); then\n    format_error \\\n      \"Unsupported storage selections: ${unknown[*]}\" \\\n      \"Use a supported LightRAG storage class name or rerun setup to pick a valid backend.\"\n    return 1\n  fi\n\n  if ((${#missing[@]} > 0)); then\n    format_error \"Missing required variables: ${missing[*]}\" \"Fill them in .env or re-run setup.\"\n    return 1\n  fi\n\n  return 0\n}\n\nvalidate_opensearch_hosts_format() {\n  local hosts=\"${1:-${ENV_VALUES[OPENSEARCH_HOSTS]:-}}\"\n  local entry=\"\"\n  local trimmed=\"\"\n  local has_host=\"no\"\n  local -a entries=()\n\n  if [[ \"$hosts\" == *\"://\"* ]]; then\n    format_error \\\n      \"OPENSEARCH_HOSTS must use bare host:port entries, not URLs.\" \\\n      \"Set comma-separated host:port values such as localhost:9200; control TLS with OPENSEARCH_USE_SSL.\"\n    return 1\n  fi\n\n  IFS=',' read -r -a entries <<< \"$hosts\"\n  for entry in \"${entries[@]}\"; do\n    trimmed=\"${entry#\"${entry%%[![:space:]]*}\"}\"\n    trimmed=\"${trimmed%\"${trimmed##*[![:space:]]}\"}\"\n    if [[ -z \"$trimmed\" ]]; then\n      format_error \\\n        \"OPENSEARCH_HOSTS must not contain empty host entries.\" \\\n        \"Use comma-separated host:port values such as localhost:9200 or host1:9200,host2:9200.\"\n      return 1\n    fi\n    has_host=\"yes\"\n  done\n\n  if [[ \"$has_host\" != \"yes\" ]]; then\n    format_error \\\n      \"OPENSEARCH_HOSTS must include at least one host:port entry.\" \\\n      \"Set it to a value such as localhost:9200.\"\n    return 1\n  fi\n\n  return 0\n}\n\nvalidate_opensearch_password_strength() {\n  local password=\"${1:-${ENV_VALUES[OPENSEARCH_PASSWORD]:-}}\"\n\n  if [[ ${#password} -lt 8 || ! \"$password\" =~ [A-Z] || ! \"$password\" =~ [a-z] || ! \"$password\" =~ [0-9] || ! \"$password\" =~ [^A-Za-z0-9] ]]; then\n    format_error \\\n      \"OpenSearch requires a strong OPENSEARCH_PASSWORD.\" \\\n      \"Use at least 8 characters with uppercase, lowercase, number, and special character.\"\n    return 1\n  fi\n\n  return 0\n}\n\nvalidate_opensearch_config() {\n  local deployment_mode=\"${1:-${ENV_VALUES[LIGHTRAG_SETUP_OPENSEARCH_DEPLOYMENT]:-}}\"\n  local hosts=\"${2:-${ENV_VALUES[OPENSEARCH_HOSTS]:-}}\"\n  local user=\"${3:-${ENV_VALUES[OPENSEARCH_USER]:-}}\"\n  local password=\"${4:-${ENV_VALUES[OPENSEARCH_PASSWORD]:-}}\"\n\n  if ! validate_opensearch_hosts_format \"$hosts\"; then\n    return 1\n  fi\n\n  if [[ -z \"$user\" || -z \"$password\" ]]; then\n    if [[ \"$deployment_mode\" == \"docker\" ]]; then\n      format_error \\\n        \"Bundled OpenSearch requires OPENSEARCH_USER and OPENSEARCH_PASSWORD.\" \\\n        \"Set both variables or rerun setup; the managed Docker service starts with security enabled.\"\n    else\n      format_error \\\n        \"OpenSearch requires both OPENSEARCH_USER and OPENSEARCH_PASSWORD.\" \\\n        \"This setup wizard only supports authenticated OpenSearch clusters. Set both values or rerun setup.\"\n    fi\n    return 1\n  fi\n\n  if ! validate_opensearch_password_strength \"$password\"; then\n    if [[ \"$deployment_mode\" == \"docker\" ]]; then\n      echo \"${COLOR_YELLOW:-}Hint:${COLOR_RESET:-} The managed Docker image also enforces this password strength at startup.\" >&2\n    fi\n    return 1\n  fi\n\n  return 0\n}\n\nvalidate_mongo_vector_storage_config() {\n  local vector_storage=\"$1\"\n  local mongo_uri=\"${2:-${ENV_VALUES[MONGO_URI]:-}}\"\n  local mongo_deployment=\"${3:-${ENV_VALUES[LIGHTRAG_SETUP_MONGODB_DEPLOYMENT]:-}}\"\n\n  if [[ \"$vector_storage\" != \"MongoVectorDBStorage\" ]]; then\n    return 0\n  fi\n\n  if [[ \"$mongo_deployment\" == \"docker\" ]]; then\n    format_error \\\n      \"MongoVectorDBStorage cannot use the local Docker MongoDB service managed by this setup wizard.\" \\\n      \"That service is MongoDB Community Edition without Atlas Search / Vector Search support. Use an Atlas-capable MongoDB endpoint instead.\"\n    return 1\n  fi\n\n  if ! validate_uri \"$mongo_uri\" mongodb; then\n    format_error \\\n      \"MongoVectorDBStorage requires a valid MongoDB URI.\" \\\n      \"Set MONGO_URI to a mongodb:// or mongodb+srv:// endpoint that supports Atlas Search / Vector Search.\"\n    return 1\n  fi\n\n  if [[ ! \"$mongo_uri\" =~ ^mongodb\\+srv:// ]]; then\n    format_error \\\n      \"MongoVectorDBStorage requires a MongoDB Atlas URI.\" \\\n      \"Set MONGO_URI to a mongodb+srv:// endpoint backed by Atlas Search / Vector Search.\"\n    return 1\n  fi\n\n  return 0\n}\n\nvalidate_auth_accounts_format() {\n  local auth_accounts=\"$1\"\n  local entry username password\n\n  if [[ -z \"$auth_accounts\" ]]; then\n    return 0\n  fi\n\n  if [[ \"$auth_accounts\" == ,* || \"$auth_accounts\" == *, || \"$auth_accounts\" == *\",,\"* ]]; then\n    return 1\n  fi\n\n  IFS=',' read -r -a entries <<< \"$auth_accounts\"\n  for entry in \"${entries[@]}\"; do\n    if [[ -z \"$entry\" || \"$entry\" != *:* ]]; then\n      return 1\n    fi\n\n    username=\"${entry%%:*}\"\n    password=\"${entry#*:}\"\n    if [[ -z \"$username\" || -z \"$password\" ]]; then\n      return 1\n    fi\n  done\n\n  return 0\n}\n\nwhitelist_exposes_api_routes() {\n  local whitelist_paths=\"$1\"\n  local entry trimmed_entry normalized_entry\n\n  IFS=',' read -r -a entries <<< \"$whitelist_paths\"\n  for entry in \"${entries[@]}\"; do\n    trimmed_entry=\"${entry#\"${entry%%[![:space:]]*}\"}\"\n    trimmed_entry=\"${trimmed_entry%\"${trimmed_entry##*[![:space:]]}\"}\"\n    normalized_entry=\"$trimmed_entry\"\n\n    if [[ \"$normalized_entry\" == *\"/*\" ]]; then\n      normalized_entry=\"${normalized_entry%/*}\"\n    fi\n    if [[ \"$normalized_entry\" != \"/\" ]]; then\n      normalized_entry=\"${normalized_entry%/}\"\n    fi\n\n    if [[ \"$normalized_entry\" == \"/api\" || \"$normalized_entry\" == /api/* ]]; then\n      return 0\n    fi\n  done\n\n  return 1\n}\n\nvalidate_security_config() {\n  local auth_accounts=\"${1:-${ENV_VALUES[AUTH_ACCOUNTS]:-}}\"\n  local token_secret=\"${2:-${ENV_VALUES[TOKEN_SECRET]:-}}\"\n  local _api_key=\"${3:-${ENV_VALUES[LIGHTRAG_API_KEY]:-}}\"\n  local _unused_flag=\"${4:-no}\"\n  local _unused_whitelist=\"${5:-${ENV_VALUES[WHITELIST_PATHS]:-}}\"\n  local _unused_whitelist_is_set=\"${6:-}\"\n\n  if [[ -z \"$auth_accounts\" ]]; then\n    return 0\n  fi\n\n  if ! validate_auth_accounts_format \"$auth_accounts\"; then\n    format_error \\\n      \"AUTH_ACCOUNTS must use comma-separated user:password pairs.\" \\\n      \"Use entries like admin:secret or admin:secret,reader:another-secret.\"\n    return 1\n  fi\n\n  if [[ -z \"$token_secret\" ]]; then\n    format_error \\\n      \"AUTH_ACCOUNTS is set but TOKEN_SECRET is missing.\" \\\n      \"Set a non-empty JWT signing secret before enabling account-based authentication.\"\n    return 1\n  fi\n\n  if [[ \"$token_secret\" == \"lightrag-jwt-default-secret\" ]]; then\n    format_error \\\n      \"TOKEN_SECRET must not use the built-in default value when AUTH_ACCOUNTS is enabled.\" \\\n      \"Generate a unique JWT signing secret and update TOKEN_SECRET.\"\n    return 1\n  fi\n\n  return 0\n}\n\ncheck_docker_availability() {\n  if ! command -v docker >/dev/null 2>&1; then\n    format_error \"Docker is not installed or not in PATH.\" \"Install Docker or disable docker service generation.\"\n    return 1\n  fi\n\n  if ! docker compose version >/dev/null 2>&1; then\n    format_error \"Docker Compose is not available.\" \"Install the Docker Compose plugin or use docker-compose.\"\n    return 1\n  fi\n\n  return 0\n}\n"
  },
  {
    "path": "scripts/setup/setup.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nif [[ -z \"${BASH_VERSINFO+x}\" || \"${BASH_VERSINFO[0]}\" -lt 4 ]]; then\n  echo \"Error: scripts/setup/setup.sh requires Bash 4 or newer.\" >&2\n  echo \"Hint: install a newer bash and run it via 'bash scripts/setup/setup.sh ...'.\" >&2\n  exit 1\nfi\n\nSCRIPT_DIR=\"$(CDPATH=\"\" cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nLIB_DIR=\"$SCRIPT_DIR/lib\"\n# shellcheck disable=SC2034\nTEMPLATES_DIR=\"$SCRIPT_DIR/templates\"\nREPO_ROOT=\"$(cd \"$SCRIPT_DIR/../..\" && pwd)\"\n\ndeclare -A ENV_VALUES\ndeclare -A ORIGINAL_ENV_VALUES\ndeclare -A COMPOSE_ENV_OVERRIDES\ndeclare -A COMPOSE_REWRITE_SERVICE_SET\ndeclare -A REQUIRED_DB_TYPES\ndeclare -A DOCKER_SERVICE_SET\ndeclare -A EXISTING_MANAGED_ROOT_SERVICE_SET\ndeclare -a DOCKER_SERVICES\nSSL_CERT_SOURCE_PATH=\"\"\nSSL_KEY_SOURCE_PATH=\"\"\nLIGHTRAG_COMPOSE_SERVER_PORT_MAPPING=\"\"\nNORMALIZED_SERVER_HOST_FOR_COMPOSE=\"\"\nFORCE_REWRITE_COMPOSE=\"no\"\nDEBUG=\"${DEBUG:-false}\"\n\nPRESET_VLLM_EMBEDDING=(\n  \"EMBEDDING_BINDING=openai\"\n  \"EMBEDDING_BINDING_HOST=http://localhost:8001/v1\"\n  \"EMBEDDING_MODEL=BAAI/bge-m3\"\n  \"EMBEDDING_DIM=1024\"\n  \"VLLM_EMBED_MODEL=BAAI/bge-m3\"\n  \"VLLM_EMBED_PORT=8001\"\n  \"VLLM_EMBED_DEVICE=cpu\"\n)\n\nPRESET_VLLM_RERANKER=(\n  \"RERANK_BINDING=cohere\"\n  \"LIGHTRAG_SETUP_RERANK_PROVIDER=vllm\"\n  \"RERANK_MODEL=BAAI/bge-reranker-v2-m3\"\n  \"RERANK_BINDING_HOST=http://localhost:8000/rerank\"\n  \"VLLM_RERANK_MODEL=BAAI/bge-reranker-v2-m3\"\n  \"VLLM_RERANK_PORT=8000\"\n  \"VLLM_RERANK_DEVICE=cpu\"\n)\nVLLM_SERVICES=(\n  \"vllm-embed\"\n  \"vllm-rerank\"\n)\n\nSTORAGE_SERVICES=(\n  \"postgres\"\n  \"neo4j\"\n  \"mongodb\"\n  \"redis\"\n  \"milvus\"\n  \"qdrant\"\n  \"memgraph\"\n  \"opensearch\"\n)\nDEFAULT_RUNTIME_TARGET=\"host\"\nCOMPOSE_LIGHTRAG_WORKING_DIR=\"/app/data/rag_storage\"\nCOMPOSE_LIGHTRAG_INPUT_DIR=\"/app/data/inputs\"\n# shellcheck disable=SC2034\nCOLOR_RESET=\"\"\nCOLOR_BOLD=\"\"\nCOLOR_BLUE=\"\"\nCOLOR_GREEN=\"\"\nCOLOR_YELLOW=\"\"\n# shellcheck disable=SC2034\nCOLOR_RED=\"\"\n\n# shellcheck disable=SC1091\nsource \"$LIB_DIR/storage_requirements.sh\"\n# shellcheck disable=SC1091\nsource \"$LIB_DIR/validation.sh\"\n# shellcheck disable=SC1091\nsource \"$LIB_DIR/prompts.sh\"\n# shellcheck disable=SC1091\nsource \"$LIB_DIR/file_ops.sh\"\n# shellcheck disable=SC1091\nsource \"$LIB_DIR/presets.sh\"\n\ninit_colors() {\n  if [[ -t 1 && -z \"${NO_COLOR:-}\" ]]; then\n    COLOR_RESET=$'\\033[0m'\n    COLOR_BOLD=$'\\033[1m'\n    COLOR_BLUE=$'\\033[34m'\n    COLOR_GREEN=$'\\033[32m'\n    COLOR_YELLOW=$'\\033[33m'\n    # shellcheck disable=SC2034\n    COLOR_RED=$'\\033[31m'\n  fi\n}\n\nreset_state() {\n  ENV_VALUES=()\n  ORIGINAL_ENV_VALUES=()\n  COMPOSE_ENV_OVERRIDES=()\n  COMPOSE_REWRITE_SERVICE_SET=()\n  REQUIRED_DB_TYPES=()\n  DOCKER_SERVICE_SET=()\n  EXISTING_MANAGED_ROOT_SERVICE_SET=()\n  DOCKER_SERVICES=()\n  SSL_CERT_SOURCE_PATH=\"\"\n  SSL_KEY_SOURCE_PATH=\"\"\n  LIGHTRAG_COMPOSE_SERVER_PORT_MAPPING=\"\"\n  NORMALIZED_SERVER_HOST_FOR_COMPOSE=\"\"\n}\n\nvalidate_runtime_target() {\n  local runtime_target=\"${1:-$DEFAULT_RUNTIME_TARGET}\"\n\n  case \"$runtime_target\" in\n    host|compose)\n      return 0\n      ;;\n    *)\n      format_error \\\n        \"Invalid LIGHTRAG_RUNTIME_TARGET: ${runtime_target}\" \\\n        \"Use 'host' or 'compose', or rerun the setup wizard to regenerate .env.\"\n      return 1\n      ;;\n  esac\n}\n\nset_runtime_target() {\n  local runtime_target=\"${1:-$DEFAULT_RUNTIME_TARGET}\"\n\n  if ! validate_runtime_target \"$runtime_target\"; then\n    return 1\n  fi\n\n  ENV_VALUES[\"LIGHTRAG_RUNTIME_TARGET\"]=\"$runtime_target\"\n}\n\nclear_deprecated_vllm_dtype_state() {\n  unset 'ENV_VALUES[VLLM_EMBED_DTYPE]'\n  unset 'ENV_VALUES[VLLM_RERANK_DTYPE]'\n}\n\nnormalize_vllm_rerank_binding_state() {\n  if [[ \"${ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]:-}\" == \"vllm\" ]]; then\n    ENV_VALUES[\"RERANK_BINDING\"]=\"cohere\"\n  fi\n}\n\nload_existing_env_if_present() {\n  local env_file=\"${REPO_ROOT}/.env\"\n\n  if [[ -f \"$env_file\" ]]; then\n    log_debug \"Loading existing .env defaults from $env_file\"\n    load_env_file \"$env_file\"\n    clear_deprecated_vllm_dtype_state\n    normalize_vllm_rerank_binding_state\n    if [[ \"${ENV_VALUES[SSL]:-false}\" == \"true\" ]]; then\n      SSL_CERT_SOURCE_PATH=\"${ENV_VALUES[SSL_CERTFILE]:-}\"\n      SSL_KEY_SOURCE_PATH=\"${ENV_VALUES[SSL_KEYFILE]:-}\"\n    fi\n\n    snapshot_original_env_values\n  fi\n}\n\nsnapshot_original_env_values() {\n  local key\n\n  ORIGINAL_ENV_VALUES=()\n  for key in \"${!ENV_VALUES[@]}\"; do\n    ORIGINAL_ENV_VALUES[\"$key\"]=\"${ENV_VALUES[$key]}\"\n  done\n}\n\nprepare_compose_output_from_existing() {\n  local output_file=\"$1\"\n  local existing_file=\"$2\"\n\n  if [[ -z \"$existing_file\" || \"$existing_file\" == \"$output_file\" || -f \"$output_file\" ]]; then\n    return 0\n  fi\n\n  if ! cp \"$existing_file\" \"$output_file\"; then\n    format_error \"Failed to prepare compose output at ${output_file}\" \\\n      \"Check file permissions and available disk space, then rerun setup.\"\n    return 1\n  fi\n\n  log_success \"Using ${existing_file} as merge input for ${output_file}\"\n}\n\nlog_debug() {\n  if [[ \"$DEBUG\" == \"true\" ]]; then\n    echo \"${COLOR_YELLOW}[debug]${COLOR_RESET} $*\"\n  fi\n}\n\nlog_info() {\n  echo \"${COLOR_BLUE}${COLOR_BOLD}$*${COLOR_RESET}\"\n}\n\nlog_warn() {\n  echo \"${COLOR_YELLOW}$*${COLOR_RESET}\"\n}\n\nlog_success() {\n  echo \"${COLOR_GREEN}$*${COLOR_RESET}\"\n}\n\nlog_step() {\n  echo \"${COLOR_BLUE}${COLOR_BOLD}$*${COLOR_RESET}\"\n}\n\nnormalize_loopback_uri_for_compose() {\n  local uri=\"$1\"\n\n  if [[ \"$uri\" =~ ^([a-zA-Z][a-zA-Z0-9+.-]*://)([^/?#]+@)?(localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0)([/:?].*)?$ ]]; then\n    printf '%s%shost.docker.internal%s' \\\n      \"${BASH_REMATCH[1]}\" \\\n      \"${BASH_REMATCH[2]}\" \\\n      \"${BASH_REMATCH[4]}\"\n    return 0\n  fi\n\n  printf '%s' \"$uri\"\n}\n\nnormalize_mongodb_uri_for_local_service() {\n  local uri=\"$1\"\n\n  if [[ \"$uri\" =~ ^mongodb://([^/?#]+@)?(mongodb|localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0)(:[0-9]+)?([/?#].*)?$ ]]; then\n    printf 'mongodb://localhost:27017%s' \"${BASH_REMATCH[4]:-/}\"\n    return 0\n  fi\n\n  printf '%s' \"$uri\"\n}\n\nnormalize_neo4j_uri_for_local_service() {\n  local uri=\"$1\"\n\n  if [[ \"$uri\" =~ ^([a-zA-Z][a-zA-Z0-9+.-]*://)([^/?#]+@)?(neo4j|localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0)(:[0-9]+)?([/?#].*)?$ ]]; then\n    printf '%s%slocalhost:7687%s' \\\n      \"${BASH_REMATCH[1]}\" \\\n      \"${BASH_REMATCH[2]}\" \\\n      \"${BASH_REMATCH[5]}\"\n    return 0\n  fi\n\n  printf '%s' \"$uri\"\n}\n\nnormalize_redis_uri_for_local_service() {\n  local uri=\"$1\"\n\n  if [[ \"$uri\" =~ ^rediss?://([^/?#]+@)?(redis|localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0)(:([0-9]+))?(/.*)?$ ]]; then\n    printf 'redis://localhost:6379%s' \"${BASH_REMATCH[5]:-/}\"\n    return 0\n  fi\n\n  printf '%s' \"$uri\"\n}\n\nnormalize_milvus_uri_for_local_service() {\n  local uri=\"$1\"\n\n  if [[ \"$uri\" =~ ^(https?://)([^/?#]+@)?(milvus|localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0)(:[0-9]+)?([/?#].*)?$ ]]; then\n    printf '%slocalhost:19530%s' \\\n      \"${BASH_REMATCH[1]}\" \\\n      \"${BASH_REMATCH[5]}\"\n    return 0\n  fi\n\n  printf '%s' \"$uri\"\n}\n\nnormalize_qdrant_uri_for_local_service() {\n  local uri=\"$1\"\n\n  if [[ \"$uri\" =~ ^(https?://)([^/?#]+@)?(qdrant|localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0)(:[0-9]+)?([/?#].*)?$ ]]; then\n    printf '%slocalhost:6333%s' \\\n      \"${BASH_REMATCH[1]}\" \\\n      \"${BASH_REMATCH[5]}\"\n    return 0\n  fi\n\n  printf '%s' \"$uri\"\n}\n\nnormalize_memgraph_uri_for_local_service() {\n  local uri=\"$1\"\n\n  if [[ \"$uri\" =~ ^(bolt://)([^/?#]+@)?(memgraph|localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0)(:[0-9]+)?([/?#].*)?$ ]]; then\n    printf 'bolt://localhost:7687%s' \"${BASH_REMATCH[5]}\"\n    return 0\n  fi\n\n  printf '%s' \"$uri\"\n}\n\nnormalize_loopback_host_for_compose() {\n  local host=\"$1\"\n\n  if [[ \"$host\" == \"localhost\" || \"$host\" == \"127.0.0.1\" || \"$host\" == \"0.0.0.0\" ]]; then\n    printf 'host.docker.internal'\n    return 0\n  fi\n\n  printf '%s' \"$host\"\n}\n\nnormalize_opensearch_hosts_for_compose() {\n  local hosts=\"$1\"\n  local entry=\"\"\n  local trimmed=\"\"\n  local normalized_entry=\"\"\n  local -a raw_entries=()\n  local -a normalized_entries=()\n\n  IFS=',' read -r -a raw_entries <<< \"$hosts\"\n  for entry in \"${raw_entries[@]}\"; do\n    trimmed=\"${entry#\"${entry%%[![:space:]]*}\"}\"\n    trimmed=\"${trimmed%\"${trimmed##*[![:space:]]}\"}\"\n    # OPENSEARCH_HOSTS is intentionally limited to bare host[:port] entries.\n    # TLS is configured separately via OPENSEARCH_USE_SSL, so scheme-bearing\n    # URLs are rejected during validation rather than normalized here.\n    normalized_entry=\"$trimmed\"\n\n    if [[ \"$trimmed\" =~ ^(localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0)(:[0-9]+)?$ ]]; then\n      normalized_entry=\"host.docker.internal${BASH_REMATCH[2]}\"\n    fi\n\n    normalized_entries+=(\"$normalized_entry\")\n  done\n\n  (\n    IFS=','\n    printf '%s' \"${normalized_entries[*]}\"\n  )\n}\n\nenv_value_is_true() {\n  local value=\"${1:-}\"\n\n  case \"${value,,}\" in\n    true|1|yes)\n      return 0\n      ;;\n    *)\n      return 1\n      ;;\n  esac\n}\n\nnormalize_server_host_for_compose() {\n  # Keep the published bind address/port configurable through compose-time\n  # variable expansion, while forcing the container itself to listen on the\n  # internal service defaults.\n  LIGHTRAG_COMPOSE_SERVER_PORT_MAPPING='${HOST:-0.0.0.0}:${PORT:-9621}:9621'\n  NORMALIZED_SERVER_HOST_FOR_COMPOSE=\"0.0.0.0\"\n}\n\nhost_cuda_available() {\n  command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi >/dev/null 2>&1\n}\n\nresolve_local_device_default() {\n  local configured_device=\"${1:-}\"\n\n  if [[ \"$configured_device\" == \"cpu\" || \"$configured_device\" == \"cuda\" ]]; then\n    printf '%s' \"$configured_device\"\n    return 0\n  fi\n\n  if host_cuda_available; then\n    printf 'cuda'\n  else\n    printf 'cpu'\n  fi\n}\n\ndefault_loopback_url() {\n  local port=\"$1\"\n  local path=\"${2:-}\"\n  printf 'http://localhost:%s%s' \"$port\" \"$path\"\n}\n\nuri_points_to_host() {\n  local uri=\"$1\"\n  shift\n  local host=\"\"\n  local allowed_host\n\n  if [[ \"$uri\" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*://([^/?#]+@)?(\\[[^]]+\\]|[^/:?#]+) ]]; then\n    host=\"${BASH_REMATCH[2]}\"\n    for allowed_host in \"$@\"; do\n      if [[ \"$host\" == \"$allowed_host\" ]]; then\n        return 0\n      fi\n    done\n  fi\n\n  return 1\n}\n\nprefer_local_service_uri() {\n  local current_uri=\"$1\"\n  local default_uri=\"$2\"\n  shift 2\n\n  if [[ -z \"$current_uri\" ]]; then\n    printf '%s' \"$default_uri\"\n    return 0\n  fi\n\n  if uri_points_to_host \"$current_uri\" \"$@\"; then\n    printf '%s' \"$current_uri\"\n    return 0\n  fi\n\n  printf '%s' \"$default_uri\"\n}\n\nset_compose_override() {\n  local key=\"$1\"\n  local value=\"${2:-}\"\n\n  if [[ -n \"$value\" ]]; then\n    COMPOSE_ENV_OVERRIDES[\"$key\"]=\"$value\"\n  else\n    unset \"COMPOSE_ENV_OVERRIDES[$key]\"\n  fi\n}\n\nset_managed_service_compose_overrides() {\n  local root_service=\"$1\"\n\n  case \"$root_service\" in\n    postgres)\n      if [[ -z \"${COMPOSE_ENV_OVERRIDES[POSTGRES_HOST]+set}\" ]]; then\n        set_compose_override \"POSTGRES_HOST\" \"postgres\"\n      fi\n      # The bundled postgres compose service always listens on 5432 internally.\n      if [[ -z \"${COMPOSE_ENV_OVERRIDES[POSTGRES_PORT]+set}\" ]]; then\n        set_compose_override \"POSTGRES_PORT\" \"5432\"\n      fi\n      ;;\n    neo4j)\n      if [[ -z \"${COMPOSE_ENV_OVERRIDES[NEO4J_URI]+set}\" ]]; then\n        set_compose_override \"NEO4J_URI\" \"neo4j://neo4j:7687\"\n      fi\n      ;;\n    mongodb)\n      if [[ -z \"${COMPOSE_ENV_OVERRIDES[MONGO_URI]+set}\" ]]; then\n        set_compose_override \"MONGO_URI\" \"mongodb://mongodb:27017/\"\n      fi\n      ;;\n    redis)\n      if [[ -z \"${COMPOSE_ENV_OVERRIDES[REDIS_URI]+set}\" ]]; then\n        set_compose_override \"REDIS_URI\" \"redis://redis:6379\"\n      fi\n      ;;\n    milvus)\n      if [[ -z \"${COMPOSE_ENV_OVERRIDES[MILVUS_URI]+set}\" ]]; then\n        set_compose_override \"MILVUS_URI\" \"http://milvus:19530\"\n      fi\n      ;;\n    qdrant)\n      if [[ -z \"${COMPOSE_ENV_OVERRIDES[QDRANT_URL]+set}\" ]]; then\n        set_compose_override \"QDRANT_URL\" \"http://qdrant:6333\"\n      fi\n      ;;\n    memgraph)\n      if [[ -z \"${COMPOSE_ENV_OVERRIDES[MEMGRAPH_URI]+set}\" ]]; then\n        set_compose_override \"MEMGRAPH_URI\" \"bolt://memgraph:7687\"\n      fi\n      ;;\n    opensearch)\n      if [[ -z \"${COMPOSE_ENV_OVERRIDES[OPENSEARCH_HOSTS]+set}\" ]]; then\n        set_compose_override \"OPENSEARCH_HOSTS\" \"opensearch:9200\"\n      fi\n      ;;\n  esac\n}\n\nprepare_compose_runtime_overrides() {\n  local normalized_value\n  local key\n  local root_service\n\n  # EMBEDDING_BINDING_HOST: when vllm-embed is part of this compose, the LightRAG\n  # container must reach it by Docker service name, not by a loopback address.\n  # This applies even when the wizard did not visit the embedding step (e.g.\n  # env_server_flow), because vllm-embed is detected and added to DOCKER_SERVICE_SET\n  # before prepare_compose_env_overrides is called.\n  if [[ -z \"${COMPOSE_ENV_OVERRIDES[EMBEDDING_BINDING_HOST]+set}\" ]]; then\n    if [[ -n \"${DOCKER_SERVICE_SET[vllm-embed]+set}\" ]]; then\n      set_compose_override \"EMBEDDING_BINDING_HOST\" \\\n        \"http://vllm-embed:${ENV_VALUES[VLLM_EMBED_PORT]:-8001}/v1\"\n    elif [[ -n \"${ENV_VALUES[EMBEDDING_BINDING_HOST]:-}\" ]]; then\n      normalized_value=\"$(normalize_loopback_uri_for_compose \"${ENV_VALUES[EMBEDDING_BINDING_HOST]}\")\"\n      if [[ \"$normalized_value\" != \"${ENV_VALUES[EMBEDDING_BINDING_HOST]}\" ]]; then\n        set_compose_override \"EMBEDDING_BINDING_HOST\" \"$normalized_value\"\n      fi\n    fi\n  fi\n\n  # RERANK_BINDING_HOST: same pattern for vllm-rerank.\n  if [[ -z \"${COMPOSE_ENV_OVERRIDES[RERANK_BINDING_HOST]+set}\" ]]; then\n    if [[ -n \"${DOCKER_SERVICE_SET[vllm-rerank]+set}\" ]]; then\n      set_compose_override \"RERANK_BINDING_HOST\" \\\n        \"http://vllm-rerank:${ENV_VALUES[VLLM_RERANK_PORT]:-8000}/rerank\"\n    elif [[ -n \"${ENV_VALUES[RERANK_BINDING_HOST]:-}\" ]]; then\n      normalized_value=\"$(normalize_loopback_uri_for_compose \"${ENV_VALUES[RERANK_BINDING_HOST]}\")\"\n      if [[ \"$normalized_value\" != \"${ENV_VALUES[RERANK_BINDING_HOST]}\" ]]; then\n        set_compose_override \"RERANK_BINDING_HOST\" \"$normalized_value\"\n      fi\n    fi\n  fi\n\n  for root_service in postgres neo4j mongodb redis milvus qdrant memgraph opensearch; do\n    if [[ -n \"${DOCKER_SERVICE_SET[$root_service]+set}\" ]]; then\n      set_managed_service_compose_overrides \"$root_service\"\n    fi\n  done\n\n  for key in \\\n    \"LLM_BINDING_HOST\" \\\n    \"REDIS_URI\" \\\n    \"MONGO_URI\" \\\n    \"NEO4J_URI\" \\\n    \"MILVUS_URI\" \\\n    \"QDRANT_URL\" \\\n    \"MEMGRAPH_URI\"; do\n    if [[ -n \"${COMPOSE_ENV_OVERRIDES[$key]+set}\" ]]; then\n      continue\n    fi\n    if [[ -n \"${ENV_VALUES[$key]:-}\" ]]; then\n      normalized_value=\"$(normalize_loopback_uri_for_compose \"${ENV_VALUES[$key]}\")\"\n      if [[ \"$normalized_value\" != \"${ENV_VALUES[$key]}\" ]]; then\n        set_compose_override \"$key\" \"$normalized_value\"\n      fi\n    fi\n  done\n\n  for key in \"OPENSEARCH_HOSTS\"; do\n    if [[ -n \"${COMPOSE_ENV_OVERRIDES[$key]+set}\" ]]; then\n      continue\n    fi\n    if [[ -n \"${ENV_VALUES[$key]:-}\" ]]; then\n      normalized_value=\"$(normalize_opensearch_hosts_for_compose \"${ENV_VALUES[$key]}\")\"\n      if [[ \"$normalized_value\" != \"${ENV_VALUES[$key]}\" ]]; then\n        set_compose_override \"$key\" \"$normalized_value\"\n      fi\n    fi\n  done\n\n  for key in \"POSTGRES_HOST\"; do\n    if [[ -n \"${COMPOSE_ENV_OVERRIDES[$key]+set}\" ]]; then\n      continue\n    fi\n    if [[ -n \"${ENV_VALUES[$key]:-}\" ]]; then\n      normalized_value=\"$(normalize_loopback_host_for_compose \"${ENV_VALUES[$key]}\")\"\n      if [[ \"$normalized_value\" != \"${ENV_VALUES[$key]}\" ]]; then\n        set_compose_override \"$key\" \"$normalized_value\"\n      fi\n    fi\n  done\n\n  normalize_server_host_for_compose \"${ENV_VALUES[HOST]:-0.0.0.0}\"\n  normalized_value=\"$NORMALIZED_SERVER_HOST_FOR_COMPOSE\"\n  set_compose_override \"HOST\" \"$normalized_value\"\n  set_compose_override \"PORT\" \"9621\"\n}\n\nprepare_compose_ssl_overrides() {\n  local cert_name=\"\"\n  local key_name=\"\"\n\n  if [[ -n \"$SSL_CERT_SOURCE_PATH\" ]]; then\n    cert_name=\"$(resolve_staged_ssl_basename \"cert\" \"$SSL_CERT_SOURCE_PATH\" \"$SSL_KEY_SOURCE_PATH\")\"\n    set_compose_override \"SSL_CERTFILE\" \"/app/data/certs/${cert_name}\"\n  fi\n\n  if [[ -n \"$SSL_KEY_SOURCE_PATH\" ]]; then\n    key_name=\"$(resolve_staged_ssl_basename \"key\" \"$SSL_KEY_SOURCE_PATH\" \"$SSL_CERT_SOURCE_PATH\")\"\n    set_compose_override \"SSL_KEYFILE\" \"/app/data/certs/${key_name}\"\n  fi\n}\n\nprepare_compose_data_path_overrides() {\n  # Compose mounts always bind the data directories into these container paths.\n  # Force lightrag to use them so values from the mounted .env cannot redirect\n  # storage into a different location.\n  set_compose_override \"WORKING_DIR\" \"$COMPOSE_LIGHTRAG_WORKING_DIR\"\n  set_compose_override \"INPUT_DIR\" \"$COMPOSE_LIGHTRAG_INPUT_DIR\"\n}\n\nprepare_compose_env_overrides() {\n  prepare_compose_data_path_overrides\n  prepare_compose_runtime_overrides\n  prepare_compose_ssl_overrides\n}\n\nadd_docker_service() {\n  local service=\"$1\"\n\n  if [[ -z \"${DOCKER_SERVICE_SET[$service]+set}\" ]]; then\n    DOCKER_SERVICE_SET[\"$service\"]=1\n    DOCKER_SERVICES+=(\"$service\")\n  fi\n}\n\nrestore_storage_docker_services_from_env() {\n  local db_type\n  local marker_key=\"\"\n  local service_name=\"\"\n  local db_types=(\"postgresql\" \"neo4j\" \"mongodb\" \"redis\" \"milvus\" \"qdrant\" \"memgraph\" \"opensearch\")\n\n  for db_type in \"${db_types[@]}\"; do\n    marker_key=\"$(storage_deployment_marker_key \"$db_type\")\"\n    if [[ -n \"$marker_key\" && \"${ENV_VALUES[$marker_key]:-}\" == \"docker\" ]]; then\n      service_name=\"$(storage_service_name_for_db_type \"$db_type\")\"\n      if [[ -n \"$service_name\" ]]; then\n        add_docker_service \"$service_name\"\n      fi\n    fi\n  done\n}\n\nrestore_vllm_docker_services_from_env() {\n  if [[ \"${ENV_VALUES[LIGHTRAG_SETUP_EMBEDDING_PROVIDER]:-}\" == \"vllm\" ]]; then\n    add_docker_service \"vllm-embed\"\n  fi\n\n  if [[ \"${ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]:-}\" == \"vllm\" ]]; then\n    add_docker_service \"vllm-rerank\"\n  fi\n}\n\ncompose_has_non_wizard_services() {\n  local compose_file=\"$1\"\n  local service_name=\"\"\n\n  if [[ -z \"$compose_file\" || ! -f \"$compose_file\" ]]; then\n    return 1\n  fi\n\n  while IFS= read -r service_name; do\n    if [[ -z \"$(_managed_service_root_name \"$service_name\")\" ]]; then\n      return 0\n    fi\n  done < <(detect_compose_services \"$compose_file\")\n\n  return 1\n}\n\nresolve_compose_output_action() {\n  local existing_compose=\"$1\"\n  local -n action_ref=\"$2\"\n  local -n runtime_target_ref=\"$3\"\n  local -n host_hint_ref=\"$4\"\n  local current_target=\"${ENV_VALUES[LIGHTRAG_RUNTIME_TARGET]:-$DEFAULT_RUNTIME_TARGET}\"\n\n  action_ref=\"write_env_only\"\n  runtime_target_ref=\"$DEFAULT_RUNTIME_TARGET\"\n  host_hint_ref=\"no\"\n\n  if ((${#DOCKER_SERVICES[@]} > 0)); then\n    action_ref=\"rewrite_compose\"\n    runtime_target_ref=\"compose\"\n    return 0\n  fi\n\n  if compose_has_non_wizard_services \"$existing_compose\"; then\n    action_ref=\"rewrite_compose\"\n    runtime_target_ref=\"compose\"\n    return 0\n  fi\n\n  if [[ -n \"$existing_compose\" ]]; then\n    if confirm_default_no \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\"; then\n      action_ref=\"delete_compose_and_switch_host\"\n      runtime_target_ref=\"host\"\n      host_hint_ref=\"yes\"\n    else\n      action_ref=\"rewrite_compose\"\n      runtime_target_ref=\"compose\"\n    fi\n    return 0\n  fi\n\n  if [[ \"$current_target\" == \"compose\" ]]; then\n    if confirm_default_yes \"Run LightRAG Server via Docker?\"; then\n      action_ref=\"rewrite_compose\"\n      runtime_target_ref=\"compose\"\n    else\n      host_hint_ref=\"yes\"\n    fi\n  else\n    if confirm_default_no \"Run LightRAG Server via Docker?\"; then\n      action_ref=\"rewrite_compose\"\n      runtime_target_ref=\"compose\"\n    else\n      host_hint_ref=\"yes\"\n    fi\n  fi\n}\n\nmark_compose_service_for_rewrite() {\n  local service=\"$1\"\n  local root_service=\"\"\n\n  root_service=\"$(_managed_service_root_name \"$service\")\"\n  if [[ -n \"$root_service\" ]]; then\n    COMPOSE_REWRITE_SERVICE_SET[\"$root_service\"]=1\n  fi\n}\n\nrecord_existing_managed_root_services() {\n  local compose_file=\"$1\"\n  local service_name\n  local root_service\n\n  EXISTING_MANAGED_ROOT_SERVICE_SET=()\n\n  if [[ -z \"$compose_file\" || ! -f \"$compose_file\" ]]; then\n    return 0\n  fi\n\n  while IFS= read -r service_name; do\n    root_service=\"$(_managed_service_root_name \"$service_name\")\"\n    if [[ -n \"$root_service\" ]]; then\n      EXISTING_MANAGED_ROOT_SERVICE_SET[\"$root_service\"]=1\n    fi\n  done < <(detect_managed_root_services \"$compose_file\")\n}\n\nbackup_existing_compose_if_generating() {\n  local generate_compose=\"${1:-no}\"\n  local existing_compose=\"${2:-}\"\n  local compose_backup_path=\"\"\n\n  if [[ \"$generate_compose\" != \"yes\" ]]; then\n    return 0\n  fi\n\n  compose_backup_path=\"$(backup_compose_file \"$existing_compose\")\" || return 1\n  if [[ -n \"$compose_backup_path\" ]]; then\n    log_success \"Backed up existing compose file to $compose_backup_path\"\n  fi\n}\n\nbackup_existing_compose_for_action() {\n  local compose_action=\"${1:-write_env_only}\"\n  local existing_compose=\"${2:-}\"\n  local compose_backup_path=\"\"\n\n  case \"$compose_action\" in\n    rewrite_compose|delete_compose_and_switch_host)\n      ;;\n    *)\n      return 0\n      ;;\n  esac\n\n  compose_backup_path=\"$(backup_compose_file \"$existing_compose\")\" || return 1\n  if [[ -n \"$compose_backup_path\" ]]; then\n    log_success \"Backed up existing compose file to $compose_backup_path\"\n  fi\n}\n\nremove_existing_compose_file() {\n  local compose_file=\"${1:-}\"\n\n  if [[ -z \"$compose_file\" || ! -f \"$compose_file\" ]]; then\n    return 0\n  fi\n\n  if ! rm \"$compose_file\"; then\n    format_error \"Failed to remove ${compose_file}\" \\\n      \"Check file permissions, then remove the compose file manually or rerun setup.\"\n    return 1\n  fi\n\n  log_success \"Removed ${compose_file}\"\n}\n\nexisting_managed_root_service_present() {\n  local root_service=\"$1\"\n\n  [[ -n \"${EXISTING_MANAGED_ROOT_SERVICE_SET[$root_service]+set}\" ]]\n}\n\nenv_value_changed_from_original() {\n  local key=\"$1\"\n  local missing_marker=\"__LIGHTRAG_MISSING__\"\n  local current_value=\"${ENV_VALUES[$key]-$missing_marker}\"\n  local original_value=\"${ORIGINAL_ENV_VALUES[$key]-$missing_marker}\"\n\n  [[ \"$current_value\" != \"$original_value\" ]]\n}\n\nany_env_value_changed_from_original() {\n  local key\n\n  for key in \"$@\"; do\n    if env_value_changed_from_original \"$key\"; then\n      return 0\n    fi\n  done\n\n  return 1\n}\n\ncompose_template_variant_for_service() {\n  local service=\"$1\"\n  local snapshot=\"${2:-current}\"\n  local device=\"\"\n\n  case \"$service\" in\n    milvus)\n      if [[ \"$snapshot\" == \"original\" ]]; then\n        device=\"${ORIGINAL_ENV_VALUES[MILVUS_DEVICE]:-cpu}\"\n      else\n        device=\"${ENV_VALUES[MILVUS_DEVICE]:-cpu}\"\n      fi\n      ;;\n    qdrant)\n      if [[ \"$snapshot\" == \"original\" ]]; then\n        device=\"${ORIGINAL_ENV_VALUES[QDRANT_DEVICE]:-cpu}\"\n      else\n        device=\"${ENV_VALUES[QDRANT_DEVICE]:-cpu}\"\n      fi\n      ;;\n    vllm-embed)\n      if [[ \"$snapshot\" == \"original\" ]]; then\n        device=\"${ORIGINAL_ENV_VALUES[VLLM_EMBED_DEVICE]:-cpu}\"\n      else\n        device=\"${ENV_VALUES[VLLM_EMBED_DEVICE]:-cpu}\"\n      fi\n      ;;\n    vllm-rerank)\n      if [[ \"$snapshot\" == \"original\" ]]; then\n        device=\"${ORIGINAL_ENV_VALUES[VLLM_RERANK_DEVICE]:-cpu}\"\n      else\n        device=\"${ENV_VALUES[VLLM_RERANK_DEVICE]:-cpu}\"\n      fi\n      ;;\n    *)\n      printf 'default'\n      return 0\n      ;;\n  esac\n\n  if [[ \"$device\" == \"cuda\" ]]; then\n    printf 'gpu'\n  else\n    printf 'cpu'\n  fi\n}\n\nconfigure_base_compose_rewrites() {\n  if [[ \"$FORCE_REWRITE_COMPOSE\" == \"yes\" ]]; then\n    return 0\n  fi\n\n  if existing_managed_root_service_present \"vllm-embed\" && \\\n    [[ -n \"${DOCKER_SERVICE_SET[vllm-embed]+set}\" ]] && \\\n    [[ \"$(compose_template_variant_for_service \"vllm-embed\" \"current\")\" != \\\n      \"$(compose_template_variant_for_service \"vllm-embed\" \"original\")\" ]]; then\n    mark_compose_service_for_rewrite \"vllm-embed\"\n  fi\n\n  if existing_managed_root_service_present \"vllm-rerank\" && \\\n    [[ -n \"${DOCKER_SERVICE_SET[vllm-rerank]+set}\" ]] && \\\n    [[ \"$(compose_template_variant_for_service \"vllm-rerank\" \"current\")\" != \\\n      \"$(compose_template_variant_for_service \"vllm-rerank\" \"original\")\" ]]; then\n    mark_compose_service_for_rewrite \"vllm-rerank\"\n  fi\n}\n\nconfigure_storage_compose_rewrites() {\n  if [[ \"$FORCE_REWRITE_COMPOSE\" == \"yes\" ]]; then\n    return 0\n  fi\n\n  if existing_managed_root_service_present \"postgres\" && \\\n    [[ -n \"${DOCKER_SERVICE_SET[postgres]+set}\" ]] && \\\n    any_env_value_changed_from_original \"POSTGRES_USER\" \"POSTGRES_PASSWORD\" \"POSTGRES_DATABASE\"; then\n    mark_compose_service_for_rewrite \"postgres\"\n  fi\n\n  if existing_managed_root_service_present \"neo4j\" && \\\n    [[ -n \"${DOCKER_SERVICE_SET[neo4j]+set}\" ]] && \\\n    any_env_value_changed_from_original \"NEO4J_DATABASE\"; then\n    mark_compose_service_for_rewrite \"neo4j\"\n  fi\n\n  if existing_managed_root_service_present \"milvus\" && \\\n    [[ -n \"${DOCKER_SERVICE_SET[milvus]+set}\" ]] && \\\n    [[ \"$(compose_template_variant_for_service \"milvus\" \"current\")\" != \\\n      \"$(compose_template_variant_for_service \"milvus\" \"original\")\" ]]; then\n    mark_compose_service_for_rewrite \"milvus\"\n  fi\n\n  if existing_managed_root_service_present \"qdrant\" && \\\n    [[ -n \"${DOCKER_SERVICE_SET[qdrant]+set}\" ]] && \\\n    [[ \"$(compose_template_variant_for_service \"qdrant\" \"current\")\" != \\\n      \"$(compose_template_variant_for_service \"qdrant\" \"original\")\" ]]; then\n    mark_compose_service_for_rewrite \"qdrant\"\n  fi\n}\n\nselect_storage_backends() {\n  local deployment_type=\"$1\"\n  local kv_default=\"JsonKVStorage\"\n  local vector_default=\"NanoVectorDBStorage\"\n  local graph_default=\"NetworkXStorage\"\n  local doc_default=\"JsonDocStatusStorage\"\n  local kv_storage vector_storage graph_storage doc_storage\n\n  if [[ \"$deployment_type\" == \"production\" ]]; then\n    kv_default=\"PGKVStorage\"\n    vector_default=\"MilvusVectorDBStorage\"\n    graph_default=\"Neo4JStorage\"\n    doc_default=\"PGDocStatusStorage\"\n  fi\n\n  kv_default=\"${ENV_VALUES[LIGHTRAG_KV_STORAGE]:-$kv_default}\"\n  vector_default=\"${ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]:-$vector_default}\"\n  graph_default=\"${ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]:-$graph_default}\"\n  doc_default=\"${ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]:-$doc_default}\"\n\n  while true; do\n    kv_storage=\"$(prompt_choice \"KV storage\" \"$kv_default\" \"${KV_STORAGE_OPTIONS[@]}\")\"\n    vector_storage=\"$(prompt_choice \"Vector storage\" \"$vector_default\" \"${VECTOR_STORAGE_OPTIONS[@]}\")\"\n    graph_storage=\"$(prompt_choice \"Graph storage\" \"$graph_default\" \"${GRAPH_STORAGE_OPTIONS[@]}\")\"\n    doc_storage=\"$(prompt_choice \"Doc status storage\" \"$doc_default\" \"${DOC_STATUS_STORAGE_OPTIONS[@]}\")\"\n\n    if check_storage_compatibility \"$kv_storage\" \"$vector_storage\" \"$graph_storage\" \"$doc_storage\"; then\n      break\n    fi\n\n    if confirm_default_no \"Proceed with these storage selections anyway?\"; then\n      break\n    fi\n  done\n\n  ENV_VALUES[\"LIGHTRAG_KV_STORAGE\"]=\"$kv_storage\"\n  ENV_VALUES[\"LIGHTRAG_VECTOR_STORAGE\"]=\"$vector_storage\"\n  ENV_VALUES[\"LIGHTRAG_GRAPH_STORAGE\"]=\"$graph_storage\"\n  ENV_VALUES[\"LIGHTRAG_DOC_STATUS_STORAGE\"]=\"$doc_storage\"\n\n  for storage in \"$kv_storage\" \"$vector_storage\" \"$graph_storage\" \"$doc_storage\"; do\n    if [[ -n \"${STORAGE_DB_TYPES[$storage]:-}\" ]]; then\n      REQUIRED_DB_TYPES[\"${STORAGE_DB_TYPES[$storage]}\"]=1\n    fi\n  done\n}\n\ninitialize_default_storage_backends() {\n  # env-base does not prompt for storage, but its generated .env must remain\n  # self-consistent for first-run users who have not run env-storage yet.\n  ENV_VALUES[\"LIGHTRAG_KV_STORAGE\"]=\"${ENV_VALUES[LIGHTRAG_KV_STORAGE]:-JsonKVStorage}\"\n  ENV_VALUES[\"LIGHTRAG_VECTOR_STORAGE\"]=\"${ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]:-NanoVectorDBStorage}\"\n  ENV_VALUES[\"LIGHTRAG_GRAPH_STORAGE\"]=\"${ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]:-NetworkXStorage}\"\n  ENV_VALUES[\"LIGHTRAG_DOC_STATUS_STORAGE\"]=\"${ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]:-JsonDocStatusStorage}\"\n}\n\nstorage_service_name_for_db_type() {\n  local db_type=\"$1\"\n\n  case \"$db_type\" in\n    postgresql)\n      printf 'postgres'\n      ;;\n    neo4j|mongodb|redis|milvus|qdrant|memgraph|opensearch)\n      printf '%s' \"$db_type\"\n      ;;\n    *)\n      printf ''\n      ;;\n  esac\n}\n\nstorage_deployment_marker_key() {\n  local db_type=\"$1\"\n\n  case \"$db_type\" in\n    postgresql)\n      printf 'LIGHTRAG_SETUP_POSTGRES_DEPLOYMENT'\n      ;;\n    neo4j)\n      printf 'LIGHTRAG_SETUP_NEO4J_DEPLOYMENT'\n      ;;\n    mongodb)\n      printf 'LIGHTRAG_SETUP_MONGODB_DEPLOYMENT'\n      ;;\n    redis)\n      printf 'LIGHTRAG_SETUP_REDIS_DEPLOYMENT'\n      ;;\n    milvus)\n      printf 'LIGHTRAG_SETUP_MILVUS_DEPLOYMENT'\n      ;;\n    qdrant)\n      printf 'LIGHTRAG_SETUP_QDRANT_DEPLOYMENT'\n      ;;\n    memgraph)\n      printf 'LIGHTRAG_SETUP_MEMGRAPH_DEPLOYMENT'\n      ;;\n    opensearch)\n      printf 'LIGHTRAG_SETUP_OPENSEARCH_DEPLOYMENT'\n      ;;\n    *)\n      printf ''\n      ;;\n  esac\n}\n\nstorage_default_docker_for_db_type() {\n  local db_type=\"$1\"\n  local marker_key\n\n  marker_key=\"$(storage_deployment_marker_key \"$db_type\")\"\n  if [[ -n \"$marker_key\" && \"${ENV_VALUES[$marker_key]:-}\" == \"docker\" ]]; then\n    printf 'yes'\n  else\n    printf 'no'\n  fi\n}\n\npersist_storage_deployment_choice() {\n  local db_type=\"$1\"\n  local deployment_mode=\"${2:-no}\"\n  local marker_key\n\n  marker_key=\"$(storage_deployment_marker_key \"$db_type\")\"\n  if [[ -z \"$marker_key\" ]]; then\n    return 0\n  fi\n\n  case \"$deployment_mode\" in\n    yes|docker)\n      ENV_VALUES[\"$marker_key\"]=\"docker\"\n      ;;\n    no|'')\n      unset \"ENV_VALUES[$marker_key]\"\n      ;;\n    *)\n      ENV_VALUES[\"$marker_key\"]=\"$deployment_mode\"\n      ;;\n  esac\n}\n\nclear_unused_storage_deployment_markers() {\n  local db_type\n\n  for db_type in postgresql neo4j mongodb redis milvus qdrant memgraph opensearch; do\n    if [[ -z \"${REQUIRED_DB_TYPES[$db_type]+set}\" ]]; then\n      persist_storage_deployment_choice \"$db_type\" \"no\"\n    fi\n  done\n}\n\ncollect_database_config() {\n  local db_type=\"$1\"\n  local default_docker=\"${2:-no}\"\n  local service_name=\"\"\n  local deployment_mode=\"no\"\n\n  # Storage collector rule for this wizard:\n  # - Existing ENV_VALUES loaded from .env are user-owned configuration.\n  # - Collectors should use those values as defaults and preserve them when they\n  #   are already set, even for Docker-managed services.\n  # - A collector may normalize the stored form, or write a hard default only\n  #   when the key is absent.\n  # Keep future storage collectors aligned with this behavior so rerunning the\n  # wizard does not silently erase explicit .env overrides.\n\n  case \"$db_type\" in\n    postgresql)\n      collect_postgres_config \"$default_docker\"\n      ;;\n    neo4j)\n      collect_neo4j_config \"$default_docker\"\n      ;;\n    mongodb)\n      collect_mongodb_config \"$default_docker\"\n      ;;\n    redis)\n      collect_redis_config \"$default_docker\"\n      ;;\n    milvus)\n      collect_milvus_config \"$default_docker\"\n      ;;\n    qdrant)\n      collect_qdrant_config \"$default_docker\"\n      ;;\n    memgraph)\n      collect_memgraph_config \"$default_docker\"\n      ;;\n    opensearch)\n      collect_opensearch_config \"$default_docker\"\n      ;;\n    *)\n      echo \"Unknown database type: $db_type\" >&2\n      return 1\n      ;;\n  esac\n\n  service_name=\"$(storage_service_name_for_db_type \"$db_type\")\"\n  if [[ -n \"$service_name\" && -n \"${DOCKER_SERVICE_SET[$service_name]+set}\" ]]; then\n    deployment_mode=\"docker\"\n  fi\n  persist_storage_deployment_choice \"$db_type\" \"$deployment_mode\"\n}\n\ncollect_postgres_config() {\n  local default_docker=\"${1:-no}\"\n  local use_docker=\"no\"\n  local host port user password database\n  local existing_user=\"\" existing_password=\"\" existing_database=\"\"\n\n  if [[ \"$default_docker\" == \"yes\" ]]; then\n    if confirm_default_yes \"Run PostgreSQL locally via Docker?\"; then\n      use_docker=\"yes\"\n    fi\n  else\n    if confirm_default_no \"Run PostgreSQL locally via Docker?\"; then\n      use_docker=\"yes\"\n    fi\n  fi\n\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    add_docker_service \"postgres\"\n    host=\"${ENV_VALUES[POSTGRES_HOST]:-localhost}\"\n    if [[ \"$host\" != \"localhost\" && \"$host\" != \"127.0.0.1\" && \"$host\" != \"0.0.0.0\" && \"$host\" != \"postgres\" ]]; then\n      host=\"localhost\"\n    elif [[ \"$host\" == \"postgres\" ]]; then\n      host=\"localhost\"\n    fi\n  else\n    host=\"${ENV_VALUES[POSTGRES_HOST]:-localhost}\"\n  fi\n\n  host=\"$(prompt_with_default \"PostgreSQL host\" \"$host\")\"\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    port=\"5432\"\n    set_compose_override \"POSTGRES_HOST\" \"postgres\"\n    set_compose_override \"POSTGRES_PORT\" \"5432\"\n  else\n    port=\"$(prompt_until_valid \"PostgreSQL port\" \"${ENV_VALUES[POSTGRES_PORT]:-5432}\" validate_port)\"\n    set_compose_override \"POSTGRES_HOST\" \"\"\n    set_compose_override \"POSTGRES_PORT\" \"\"\n  fi\n\n  existing_user=\"${ORIGINAL_ENV_VALUES[POSTGRES_USER]-${ENV_VALUES[POSTGRES_USER]:-}}\"\n  existing_password=\"${ORIGINAL_ENV_VALUES[POSTGRES_PASSWORD]-${ENV_VALUES[POSTGRES_PASSWORD]:-}}\"\n  existing_database=\"${ORIGINAL_ENV_VALUES[POSTGRES_DATABASE]-${ENV_VALUES[POSTGRES_DATABASE]:-}}\"\n  if [[ \"$use_docker\" == \"yes\" && -z \"$existing_user\" && -z \"$existing_password\" ]]; then\n    user=\"rag\"\n    password=\"rag\"\n  else\n    user=\"$(prompt_with_default \"PostgreSQL user\" \"${existing_user:-rag}\")\"\n    password=\"$(prompt_secret_with_default \"PostgreSQL password: \" \"${existing_password:-rag}\")\"\n  fi\n  if [[ \"$use_docker\" == \"yes\" && -z \"$existing_database\" ]]; then\n    database=\"rag\"\n  else\n    database=\"$(prompt_with_default \"PostgreSQL database\" \"${existing_database:-lightrag}\")\"\n  fi\n\n  ENV_VALUES[\"POSTGRES_HOST\"]=\"$host\"\n  ENV_VALUES[\"POSTGRES_PORT\"]=\"$port\"\n  ENV_VALUES[\"POSTGRES_USER\"]=\"$user\"\n  ENV_VALUES[\"POSTGRES_PASSWORD\"]=\"$password\"\n  ENV_VALUES[\"POSTGRES_DATABASE\"]=\"$database\"\n}\n\ncollect_neo4j_config() {\n  local default_docker=\"${1:-no}\"\n  local use_docker=\"no\"\n  local uri username password database\n  local existing_username=\"\" existing_password=\"\" existing_database=\"\"\n\n  if [[ \"$default_docker\" == \"yes\" ]]; then\n    if confirm_default_yes \"Run Neo4j locally via Docker?\"; then\n      use_docker=\"yes\"\n    fi\n  else\n    if confirm_default_no \"Run Neo4j locally via Docker?\"; then\n      use_docker=\"yes\"\n    fi\n  fi\n\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    add_docker_service \"neo4j\"\n    uri=\"$(prefer_local_service_uri \"${ENV_VALUES[NEO4J_URI]:-}\" \"neo4j://localhost:7687\" \"neo4j\" \"localhost\" \"127.0.0.1\" \"0.0.0.0\")\"\n  else\n    uri=\"${ENV_VALUES[NEO4J_URI]:-neo4j://localhost:7687}\"\n  fi\n\n  uri=\"$(prompt_until_valid \"Neo4j URI\" \"$uri\" validate_uri neo4j)\"\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    uri=\"$(normalize_neo4j_uri_for_local_service \"$uri\")\"\n  fi\n  existing_username=\"${ORIGINAL_ENV_VALUES[NEO4J_USERNAME]-${ENV_VALUES[NEO4J_USERNAME]:-}}\"\n  existing_password=\"${ORIGINAL_ENV_VALUES[NEO4J_PASSWORD]-${ENV_VALUES[NEO4J_PASSWORD]:-}}\"\n  existing_database=\"${ORIGINAL_ENV_VALUES[NEO4J_DATABASE]-${ENV_VALUES[NEO4J_DATABASE]:-}}\"\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    username=\"$(prompt_until_valid \"Neo4j username\" \"${existing_username:-neo4j}\" validate_non_empty)\"\n    password=\"$(prompt_secret_until_valid_with_default \"Neo4j password: \" \"${existing_password:-neo4j_password}\" validate_non_empty)\"\n    if [[ -n \"$existing_database\" ]]; then\n      database=\"$(prompt_with_default \"Neo4j database\" \"$existing_database\")\"\n    else\n      database=\"neo4j\"\n    fi\n  else\n    username=\"$(prompt_with_default \"Neo4j username\" \"${existing_username:-neo4j}\")\"\n    password=\"$(prompt_secret_with_default \"Neo4j password: \" \"${existing_password:-neo4j_password}\")\"\n    database=\"$(prompt_with_default \"Neo4j database\" \"${existing_database:-neo4j}\")\"\n  fi\n\n  ENV_VALUES[\"NEO4J_URI\"]=\"$uri\"\n  ENV_VALUES[\"NEO4J_USERNAME\"]=\"$username\"\n  ENV_VALUES[\"NEO4J_PASSWORD\"]=\"$password\"\n  ENV_VALUES[\"NEO4J_DATABASE\"]=\"$database\"\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    set_compose_override \"NEO4J_URI\" \"neo4j://neo4j:7687\"\n  else\n    set_compose_override \"NEO4J_URI\" \"\"\n  fi\n}\n\ncollect_mongodb_config() {\n  local default_docker=\"${1:-no}\"\n  local use_docker=\"no\"\n  local uri database\n  local existing_database=\"\"\n  local vector_search_required=\"no\"\n\n  if [[ \"${ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]:-}\" == \"MongoVectorDBStorage\" ]]; then\n    vector_search_required=\"yes\"\n  fi\n\n  if [[ \"$vector_search_required\" == \"yes\" ]]; then\n    log_warn \"MongoVectorDBStorage cannot use the local Docker MongoDB service from this setup wizard.\"\n    log_warn \"Reason: the bundled local Docker MongoDB service is MongoDB Community Edition, but MongoVectorDBStorage requires Atlas Search / Vector Search support.\"\n    log_warn \"Provide a MongoDB endpoint that supports Atlas Search / Vector Search, such as MongoDB Atlas or Atlas local.\"\n    uri=\"${ENV_VALUES[MONGO_URI]:-mongodb+srv://cluster.example.mongodb.net/}\"\n  else\n    if [[ \"$default_docker\" == \"yes\" ]]; then\n      if confirm_default_yes \"Run MongoDB locally via Docker?\"; then\n        use_docker=\"yes\"\n      fi\n    else\n      if confirm_default_no \"Run MongoDB locally via Docker?\"; then\n        use_docker=\"yes\"\n      fi\n    fi\n\n    if [[ \"$use_docker\" == \"yes\" ]]; then\n      add_docker_service \"mongodb\"\n      uri=\"$(prefer_local_service_uri \"${ENV_VALUES[MONGO_URI]:-}\" \"mongodb://localhost:27017/\" \"mongodb\" \"localhost\" \"127.0.0.1\" \"0.0.0.0\")\"\n    else\n      uri=\"${ENV_VALUES[MONGO_URI]:-mongodb://localhost:27017/}\"\n    fi\n  fi\n\n  if [[ \"$vector_search_required\" == \"yes\" ]]; then\n    uri=\"$(prompt_until_valid \"MongoDB URI (must support Atlas Search / Vector Search)\" \"$uri\" validate_uri mongodb)\"\n  else\n    uri=\"$(prompt_until_valid \"MongoDB URI\" \"$uri\" validate_uri mongodb)\"\n  fi\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    uri=\"$(normalize_mongodb_uri_for_local_service \"$uri\")\"\n  fi\n  existing_database=\"${ORIGINAL_ENV_VALUES[MONGO_DATABASE]-${ENV_VALUES[MONGO_DATABASE]:-}}\"\n  database=\"$(prompt_with_default \"MongoDB database\" \"${existing_database:-LightRAG}\")\"\n\n  ENV_VALUES[\"MONGO_URI\"]=\"$uri\"\n  ENV_VALUES[\"MONGO_DATABASE\"]=\"$database\"\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    set_compose_override \"MONGO_URI\" \"mongodb://mongodb:27017/\"\n  else\n    set_compose_override \"MONGO_URI\" \"\"\n  fi\n}\n\ncollect_redis_config() {\n  local default_docker=\"${1:-no}\"\n  local use_docker=\"no\"\n  local uri\n\n  if [[ \"$default_docker\" == \"yes\" ]]; then\n    if confirm_default_yes \"Run Redis locally via Docker?\"; then\n      use_docker=\"yes\"\n    fi\n  else\n    if confirm_default_no \"Run Redis locally via Docker?\"; then\n      use_docker=\"yes\"\n    fi\n  fi\n\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    add_docker_service \"redis\"\n    uri=\"$(prefer_local_service_uri \"${ENV_VALUES[REDIS_URI]:-}\" \"redis://localhost:6379\" \"redis\" \"localhost\" \"127.0.0.1\" \"0.0.0.0\")\"\n  else\n    uri=\"${ENV_VALUES[REDIS_URI]:-redis://localhost:6379}\"\n  fi\n\n  uri=\"$(prompt_until_valid \"Redis URI\" \"$uri\" validate_uri redis)\"\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    uri=\"$(normalize_redis_uri_for_local_service \"$uri\")\"\n  fi\n  ENV_VALUES[\"REDIS_URI\"]=\"$uri\"\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    set_compose_override \"REDIS_URI\" \"redis://redis:6379\"\n  else\n    set_compose_override \"REDIS_URI\" \"\"\n  fi\n}\n\ncollect_milvus_config() {\n  local default_docker=\"${1:-no}\"\n  local use_docker=\"no\"\n  local uri db_name milvus_device=\"\"\n  local existing_db_name=\"\" existing_device=\"\"\n\n  if [[ \"$default_docker\" == \"yes\" ]]; then\n    if confirm_default_yes \"Run Milvus locally via Docker?\"; then\n      use_docker=\"yes\"\n    fi\n  else\n    if confirm_default_no \"Run Milvus locally via Docker?\"; then\n      use_docker=\"yes\"\n    fi\n  fi\n\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    add_docker_service \"milvus\"\n    uri=\"$(prefer_local_service_uri \"${ENV_VALUES[MILVUS_URI]:-}\" \"http://localhost:19530\" \"milvus\" \"localhost\" \"127.0.0.1\" \"0.0.0.0\")\"\n  else\n    uri=\"${ENV_VALUES[MILVUS_URI]:-http://localhost:19530}\"\n  fi\n\n  uri=\"$(prompt_until_valid \"Milvus URI\" \"$uri\" validate_uri milvus)\"\n  existing_db_name=\"${ORIGINAL_ENV_VALUES[MILVUS_DB_NAME]-${ENV_VALUES[MILVUS_DB_NAME]:-}}\"\n  existing_device=\"${ORIGINAL_ENV_VALUES[MILVUS_DEVICE]-${ENV_VALUES[MILVUS_DEVICE]:-}}\"\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    milvus_device=\"$(resolve_local_device_default \"$existing_device\")\"\n    milvus_device=\"$(prompt_choice \"Milvus device\" \"$milvus_device\" \"cpu\" \"cuda\")\"\n    if [[ \"$milvus_device\" == \"cuda\" ]] && ! host_cuda_available; then\n      log_warn \"CUDA device selected for Milvus but no NVIDIA driver detected on host.\"\n    fi\n    uri=\"$(normalize_milvus_uri_for_local_service \"$uri\")\"\n    if [[ -z \"${ENV_VALUES[MINIO_ACCESS_KEY_ID]:-}\" ]]; then\n      ENV_VALUES[\"MINIO_ACCESS_KEY_ID\"]=\"minioadmin\"\n    fi\n    if [[ -z \"${ENV_VALUES[MINIO_SECRET_ACCESS_KEY]:-}\" ]]; then\n      ENV_VALUES[\"MINIO_SECRET_ACCESS_KEY\"]=\"minioadmin\"\n    fi\n  fi\n  db_name=\"$(prompt_with_default \"Milvus database name\" \"${existing_db_name:-lightrag}\")\"\n\n  ENV_VALUES[\"MILVUS_URI\"]=\"$uri\"\n  ENV_VALUES[\"MILVUS_DB_NAME\"]=\"$db_name\"\n  if [[ -n \"$milvus_device\" ]]; then\n    ENV_VALUES[\"MILVUS_DEVICE\"]=\"$milvus_device\"\n  fi\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    set_compose_override \"MILVUS_URI\" \"http://milvus:19530\"\n  else\n    set_compose_override \"MILVUS_URI\" \"\"\n  fi\n}\n\ncollect_qdrant_config() {\n  local default_docker=\"${1:-no}\"\n  local use_docker=\"no\"\n  local url qdrant_device=\"\"\n  local existing_device=\"\"\n\n  if [[ \"$default_docker\" == \"yes\" ]]; then\n    if confirm_default_yes \"Run Qdrant locally via Docker?\"; then\n      use_docker=\"yes\"\n    fi\n  else\n    if confirm_default_no \"Run Qdrant locally via Docker?\"; then\n      use_docker=\"yes\"\n    fi\n  fi\n\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    add_docker_service \"qdrant\"\n    url=\"$(prefer_local_service_uri \"${ENV_VALUES[QDRANT_URL]:-}\" \"http://localhost:6333\" \"qdrant\" \"localhost\" \"127.0.0.1\" \"0.0.0.0\")\"\n  else\n    url=\"${ENV_VALUES[QDRANT_URL]:-http://localhost:6333}\"\n  fi\n\n  url=\"$(prompt_until_valid \"Qdrant URL\" \"$url\" validate_uri qdrant)\"\n  existing_device=\"${ORIGINAL_ENV_VALUES[QDRANT_DEVICE]-${ENV_VALUES[QDRANT_DEVICE]:-}}\"\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    qdrant_device=\"$(resolve_local_device_default \"$existing_device\")\"\n    qdrant_device=\"$(prompt_choice \"Qdrant device\" \"$qdrant_device\" \"cpu\" \"cuda\")\"\n    if [[ \"$qdrant_device\" == \"cuda\" ]] && ! host_cuda_available; then\n      log_warn \"CUDA device selected for Qdrant but no NVIDIA driver detected on host.\"\n    fi\n    url=\"$(normalize_qdrant_uri_for_local_service \"$url\")\"\n  fi\n  ENV_VALUES[\"QDRANT_URL\"]=\"$url\"\n  if [[ -n \"$qdrant_device\" ]]; then\n    ENV_VALUES[\"QDRANT_DEVICE\"]=\"$qdrant_device\"\n  fi\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    set_compose_override \"QDRANT_URL\" \"http://qdrant:6333\"\n  else\n    set_compose_override \"QDRANT_URL\" \"\"\n  fi\n}\n\ncollect_memgraph_config() {\n  local default_docker=\"${1:-no}\"\n  local use_docker=\"no\"\n  local uri\n\n  if [[ \"$default_docker\" == \"yes\" ]]; then\n    if confirm_default_yes \"Run Memgraph locally via Docker?\"; then\n      use_docker=\"yes\"\n    fi\n  else\n    if confirm_default_no \"Run Memgraph locally via Docker?\"; then\n      use_docker=\"yes\"\n    fi\n  fi\n\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    add_docker_service \"memgraph\"\n    uri=\"$(prefer_local_service_uri \"${ENV_VALUES[MEMGRAPH_URI]:-}\" \"bolt://localhost:7687\" \"memgraph\" \"localhost\" \"127.0.0.1\" \"0.0.0.0\")\"\n  else\n    uri=\"${ENV_VALUES[MEMGRAPH_URI]:-bolt://localhost:7687}\"\n  fi\n\n  uri=\"$(prompt_until_valid \"Memgraph URI\" \"$uri\" validate_uri memgraph)\"\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    uri=\"$(normalize_memgraph_uri_for_local_service \"$uri\")\"\n  fi\n  ENV_VALUES[\"MEMGRAPH_URI\"]=\"$uri\"\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    set_compose_override \"MEMGRAPH_URI\" \"bolt://memgraph:7687\"\n  else\n    set_compose_override \"MEMGRAPH_URI\" \"\"\n  fi\n}\n\ncollect_opensearch_config() {\n  local default_docker=\"${1:-no}\"\n  local use_docker=\"no\"\n  local hosts user password\n  local existing_user=\"\" existing_password=\"\"\n  local existing_use_ssl=\"\" existing_verify_certs=\"\"\n  local use_ssl=\"true\"\n  local verify_certs=\"false\"\n  local use_ssl_default=\"yes\"\n  local verify_certs_default=\"no\"\n\n  if [[ \"$default_docker\" == \"yes\" ]]; then\n    if confirm_default_yes \"Run OpenSearch locally via Docker?\"; then\n      use_docker=\"yes\"\n    fi\n  else\n    if confirm_default_no \"Run OpenSearch locally via Docker?\"; then\n      use_docker=\"yes\"\n    fi\n  fi\n\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    add_docker_service \"opensearch\"\n    hosts=\"$(prefer_local_service_uri \"${ENV_VALUES[OPENSEARCH_HOSTS]:-}\" \"localhost:9200\" \"opensearch\" \"localhost\" \"127.0.0.1\" \"0.0.0.0\")\"\n  else\n    hosts=\"${ENV_VALUES[OPENSEARCH_HOSTS]:-localhost:9200}\"\n  fi\n\n  existing_user=\"${ORIGINAL_ENV_VALUES[OPENSEARCH_USER]-${ENV_VALUES[OPENSEARCH_USER]:-}}\"\n  existing_password=\"${ORIGINAL_ENV_VALUES[OPENSEARCH_PASSWORD]-${ENV_VALUES[OPENSEARCH_PASSWORD]:-}}\"\n  existing_use_ssl=\"${ORIGINAL_ENV_VALUES[OPENSEARCH_USE_SSL]-${ENV_VALUES[OPENSEARCH_USE_SSL]:-}}\"\n  existing_verify_certs=\"${ORIGINAL_ENV_VALUES[OPENSEARCH_VERIFY_CERTS]-${ENV_VALUES[OPENSEARCH_VERIFY_CERTS]:-}}\"\n\n  hosts=\"$(prompt_until_valid \"OpenSearch hosts (host:port, comma-separated)\" \"$hosts\" validate_opensearch_hosts_format)\"\n  user=\"$(prompt_with_default \"OpenSearch user\" \"${existing_user:-admin}\")\"\n  password=\"$(prompt_secret_until_valid_with_default \"OpenSearch password: \" \"${existing_password:-LightRAG2026_!@}\" validate_opensearch_password_strength)\"\n\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    if [[ -n \"$existing_use_ssl\" ]]; then\n      env_value_is_true \"$existing_use_ssl\" && use_ssl=\"true\" || use_ssl=\"false\"\n    else\n      use_ssl=\"true\"\n    fi\n    verify_certs=\"false\"\n  else\n    if [[ -n \"$existing_use_ssl\" ]] && ! env_value_is_true \"$existing_use_ssl\"; then\n      use_ssl_default=\"no\"\n    fi\n\n    if [[ \"$use_ssl_default\" == \"yes\" ]]; then\n      confirm_default_yes \"Use SSL for OpenSearch?\" && use_ssl=\"true\" || use_ssl=\"false\"\n    else\n      confirm_default_no \"Use SSL for OpenSearch?\" && use_ssl=\"true\" || use_ssl=\"false\"\n    fi\n\n    if [[ \"$use_ssl\" == \"true\" ]]; then\n      if [[ -n \"$existing_verify_certs\" ]] && env_value_is_true \"$existing_verify_certs\"; then\n        verify_certs_default=\"yes\"\n      fi\n\n      if [[ \"$verify_certs_default\" == \"yes\" ]]; then\n        confirm_default_yes \"Verify OpenSearch TLS certificates?\" && verify_certs=\"true\" || verify_certs=\"false\"\n      else\n        confirm_default_no \"Verify OpenSearch TLS certificates?\" && verify_certs=\"true\" || verify_certs=\"false\"\n      fi\n    fi\n  fi\n\n  ENV_VALUES[\"OPENSEARCH_HOSTS\"]=\"$hosts\"\n  ENV_VALUES[\"OPENSEARCH_USER\"]=\"$user\"\n  ENV_VALUES[\"OPENSEARCH_PASSWORD\"]=\"$password\"\n  ENV_VALUES[\"OPENSEARCH_USE_SSL\"]=\"$use_ssl\"\n  ENV_VALUES[\"OPENSEARCH_VERIFY_CERTS\"]=\"$verify_certs\"\n\n  if [[ \"$use_docker\" == \"yes\" ]]; then\n    set_compose_override \"OPENSEARCH_HOSTS\" \"opensearch:9200\"\n  else\n    set_compose_override \"OPENSEARCH_HOSTS\" \"\"\n  fi\n}\n\nclear_bedrock_credentials() {\n  unset 'ENV_VALUES[AWS_ACCESS_KEY_ID]'\n  unset 'ENV_VALUES[AWS_SECRET_ACCESS_KEY]'\n  unset 'ENV_VALUES[AWS_SESSION_TOKEN]'\n  unset 'ENV_VALUES[AWS_REGION]'\n}\n\nbedrock_binding_in_use() {\n  [[ \"${ENV_VALUES[LLM_BINDING]:-}\" == \"aws_bedrock\" ||\n    \"${ENV_VALUES[EMBEDDING_BINDING]:-}\" == \"aws_bedrock\" ]]\n}\n\nclear_bedrock_credentials_if_unused() {\n  if ! bedrock_binding_in_use; then\n    clear_bedrock_credentials\n  fi\n}\n\ncollect_bedrock_credentials() {\n  local access_key secret_key session_token region\n\n  log_info \"Bedrock uses the AWS credential chain instead of LLM_BINDING_API_KEY/EMBEDDING_BINDING_API_KEY.\"\n  if [[ -n \"${ENV_VALUES[AWS_ACCESS_KEY_ID]:-}\" && -n \"${ENV_VALUES[AWS_SECRET_ACCESS_KEY]:-}\" ]]; then\n    if confirm_default_yes \"Reuse existing AWS Bedrock credentials?\"; then\n      region=\"$(prompt_with_default \"AWS region\" \"${ENV_VALUES[AWS_REGION]:-us-east-1}\")\"\n      ENV_VALUES[\"AWS_REGION\"]=\"$region\"\n      return 0\n    fi\n  fi\n\n  if confirm_default_no \"Store explicit AWS Bedrock credentials in .env?\"; then\n    access_key=\"$(prompt_required_secret \"AWS access key ID: \")\"\n    secret_key=\"$(prompt_required_secret \"AWS secret access key: \")\"\n    session_token=\"$(mask_sensitive_input \"AWS session token (optional): \")\"\n    region=\"$(prompt_with_default \"AWS region\" \"${ENV_VALUES[AWS_REGION]:-us-east-1}\")\"\n\n    ENV_VALUES[\"AWS_ACCESS_KEY_ID\"]=\"$access_key\"\n    ENV_VALUES[\"AWS_SECRET_ACCESS_KEY\"]=\"$secret_key\"\n    ENV_VALUES[\"AWS_REGION\"]=\"$region\"\n    if [[ -n \"$session_token\" ]]; then\n      ENV_VALUES[\"AWS_SESSION_TOKEN\"]=\"$session_token\"\n    else\n      unset 'ENV_VALUES[AWS_SESSION_TOKEN]'\n    fi\n    return 0\n  fi\n\n  log_info \"Using the ambient AWS credential chain (for example IAM roles, AWS profiles, or aws sso login).\"\n  clear_bedrock_credentials\n  region=\"$(prompt_clearable_with_default \"AWS region (optional)\" \"${ENV_VALUES[AWS_REGION]:-}\")\"\n  apply_clearable_env_value \"AWS_REGION\" \"$region\"\n}\n\nstore_optional_env_value() {\n  local key=\"$1\"\n  local value=\"${2:-}\"\n\n  if [[ -n \"$value\" ]]; then\n    ENV_VALUES[\"$key\"]=\"$value\"\n  else\n    unset \"ENV_VALUES[$key]\"\n  fi\n}\n\nprovider_default_or_existing() {\n  local selected_binding=\"$1\"\n  local existing_binding=\"${2:-}\"\n  local existing_value=\"${3:-}\"\n  local default_value=\"${4:-}\"\n\n  if [[ \"$selected_binding\" == \"$existing_binding\" && -n \"$existing_value\" ]]; then\n    printf '%s' \"$existing_value\"\n    return 0\n  fi\n\n  printf '%s' \"$default_value\"\n}\n\ndefault_llm_model_for_binding() {\n  local binding=\"$1\"\n\n  case \"$binding\" in\n    openai|azure_openai)\n      printf 'gpt-5-mini'\n      ;;\n    ollama|lollms|openai-ollama)\n      printf 'mistral-nemo:latest'\n      ;;\n    gemini)\n      printf 'gemini-flash-latest'\n      ;;\n    aws_bedrock)\n      printf 'anthropic.claude-3-5-sonnet-20241022-v2:0'\n      ;;\n    *)\n      printf 'gpt-5-mini'\n      ;;\n  esac\n}\n\ndefault_embedding_model_for_binding() {\n  local binding=\"$1\"\n\n  case \"$binding\" in\n    openai|azure_openai)\n      printf 'text-embedding-3-large'\n      ;;\n    ollama)\n      printf 'bge-m3:latest'\n      ;;\n    jina)\n      printf 'jina-embeddings-v4'\n      ;;\n    gemini)\n      printf 'gemini-embedding-001'\n      ;;\n    aws_bedrock)\n      printf 'amazon.titan-embed-text-v2:0'\n      ;;\n    lollms)\n      printf 'lollms_embedding_model'\n      ;;\n    *)\n      printf 'text-embedding-3-large'\n      ;;\n  esac\n}\n\ndefault_embedding_dim_for_binding() {\n  local binding=\"$1\"\n\n  case \"$binding\" in\n    openai|azure_openai)\n      printf '3072'\n      ;;\n    ollama|aws_bedrock|lollms)\n      printf '1024'\n      ;;\n    jina)\n      printf '2048'\n      ;;\n    gemini)\n      printf '1536'\n      ;;\n    *)\n      printf '3072'\n      ;;\n  esac\n}\n\ncollect_llm_config() {\n  local options=(\"openai\" \"azure_openai\" \"ollama\" \"openai-ollama\" \"lollms\" \"gemini\" \"aws_bedrock\")\n  local current_binding=\"${ENV_VALUES[LLM_BINDING]:-openai}\"\n  local binding model model_default host host_default api_key\n\n  binding=\"$(prompt_choice \"LLM provider\" \"$current_binding\" \"${options[@]}\")\"\n  model_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[LLM_MODEL]:-}\" \"$(default_llm_model_for_binding \"$binding\")\")\"\n  model=\"$(prompt_with_default \"LLM model\" \"$model_default\")\"\n\n  case \"$binding\" in\n    ollama)\n      host_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[LLM_BINDING_HOST]:-}\" \"$(default_loopback_url 11434)\")\"\n      host=\"$(prompt_with_default \"Ollama host\" \"$host_default\")\"\n      api_key=\"\"\n      ;;\n    openai-ollama)\n      host_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[LLM_BINDING_HOST]:-}\" \"$(default_loopback_url 11434 \"/v1\")\")\"\n      host=\"$(prompt_with_default \"OpenAI-compatible Ollama endpoint\" \"$host_default\")\"\n      api_key=\"$(prompt_secret_until_valid_with_default \"LLM API key: \" \"${ENV_VALUES[LLM_BINDING_API_KEY]:-}\" validate_api_key openai)\"\n      ;;\n    lollms)\n      host_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[LLM_BINDING_HOST]:-}\" \"http://localhost:9600\")\"\n      host=\"$(prompt_with_default \"LoLLMs host\" \"$host_default\")\"\n      api_key=\"\"\n      ;;\n    azure_openai)\n      host_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[LLM_BINDING_HOST]:-}\" \"https://example.openai.azure.com/\")\"\n      host=\"$(prompt_with_default \"Azure OpenAI endpoint\" \"$host_default\")\"\n      api_key=\"$(prompt_secret_until_valid_with_default \"Azure OpenAI API key: \" \"${ENV_VALUES[LLM_BINDING_API_KEY]:-}\" validate_api_key azure_openai)\"\n      ;;\n    gemini)\n      host_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[LLM_BINDING_HOST]:-}\" \"https://generativelanguage.googleapis.com\")\"\n      host=\"$(prompt_with_default \"Gemini endpoint\" \"$host_default\")\"\n      api_key=\"$(prompt_secret_until_valid_with_default \"Gemini API key: \" \"${ENV_VALUES[LLM_BINDING_API_KEY]:-}\" validate_api_key gemini)\"\n      ;;\n    aws_bedrock)\n      host=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[LLM_BINDING_HOST]:-}\" \"https://bedrock.amazonaws.com\")\"\n      api_key=\"\"\n      collect_bedrock_credentials\n      ;;\n    *)\n      host_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[LLM_BINDING_HOST]:-}\" \"https://api.openai.com/v1\")\"\n      host=\"$(prompt_with_default \"LLM endpoint\" \"$host_default\")\"\n      api_key=\"$(prompt_secret_until_valid_with_default \"LLM API key: \" \"${ENV_VALUES[LLM_BINDING_API_KEY]:-}\" validate_api_key \"$binding\")\"\n      ;;\n  esac\n\n  ENV_VALUES[\"LLM_BINDING\"]=\"$binding\"\n  ENV_VALUES[\"LLM_MODEL\"]=\"$model\"\n  ENV_VALUES[\"LLM_BINDING_HOST\"]=\"$host\"\n  store_optional_env_value \"LLM_BINDING_API_KEY\" \"$api_key\"\n  clear_bedrock_credentials_if_unused\n}\n\ncollect_embedding_config() {\n  local options=(\"openai\" \"azure_openai\" \"ollama\" \"jina\" \"lollms\" \"gemini\" \"aws_bedrock\")\n  local current_binding=\"${ENV_VALUES[EMBEDDING_BINDING]:-openai}\"\n  local binding model model_default host host_default api_key dim dim_default\n\n  if [[ \"${ENV_VALUES[LLM_BINDING]:-}\" == \"openai-ollama\" ]]; then\n    binding=\"ollama\"\n    if [[ \"$current_binding\" != \"ollama\" ]]; then\n      log_info \"openai-ollama uses Ollama embeddings. Forcing embedding provider to ollama.\"\n    fi\n  else\n    binding=\"$(prompt_choice \"Embedding provider\" \"$current_binding\" \"${options[@]}\")\"\n  fi\n  model_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[EMBEDDING_MODEL]:-}\" \"$(default_embedding_model_for_binding \"$binding\")\")\"\n  dim_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[EMBEDDING_DIM]:-}\" \"$(default_embedding_dim_for_binding \"$binding\")\")\"\n  model=\"$(prompt_with_default \"Embedding model\" \"$model_default\")\"\n  dim=\"$(prompt_with_default \"Embedding dimension\" \"$dim_default\")\"\n\n  local llm_host_fallback=\"\" llm_api_key_default=\"\"\n  if [[ \"$binding\" == \"${ENV_VALUES[LLM_BINDING]:-}\" ]]; then\n    llm_host_fallback=\"${ENV_VALUES[LLM_BINDING_HOST]:-}\"\n    llm_api_key_default=\"${ENV_VALUES[LLM_BINDING_API_KEY]:-}\"\n  fi\n\n  case \"$binding\" in\n    ollama)\n      host_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[EMBEDDING_BINDING_HOST]:-}\" \"${llm_host_fallback:-$(default_loopback_url 11434)}\")\"\n      host=\"$(prompt_with_default \"Ollama embedding host\" \"$host_default\")\"\n      api_key=\"\"\n      ;;\n    lollms)\n      host_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[EMBEDDING_BINDING_HOST]:-}\" \"${llm_host_fallback:-http://localhost:9600}\")\"\n      host=\"$(prompt_with_default \"LoLLMs embedding host\" \"$host_default\")\"\n      api_key=\"\"\n      ;;\n    azure_openai)\n      host_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[EMBEDDING_BINDING_HOST]:-}\" \"${llm_host_fallback:-https://example.openai.azure.com/}\")\"\n      host=\"$(prompt_with_default \"Azure OpenAI endpoint\" \"$host_default\")\"\n      api_key=\"$(prompt_secret_until_valid_with_default \"Azure OpenAI API key: \" \"${ENV_VALUES[EMBEDDING_BINDING_API_KEY]:-$llm_api_key_default}\" validate_api_key azure_openai)\"\n      ;;\n    gemini)\n      host_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[EMBEDDING_BINDING_HOST]:-}\" \"${llm_host_fallback:-https://generativelanguage.googleapis.com}\")\"\n      host=\"$(prompt_with_default \"Gemini endpoint\" \"$host_default\")\"\n      api_key=\"$(prompt_secret_until_valid_with_default \"Gemini API key: \" \"${ENV_VALUES[EMBEDDING_BINDING_API_KEY]:-$llm_api_key_default}\" validate_api_key gemini)\"\n      ;;\n    aws_bedrock)\n      host=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[EMBEDDING_BINDING_HOST]:-}\" \"${llm_host_fallback:-https://bedrock.amazonaws.com}\")\"\n      api_key=\"\"\n      collect_bedrock_credentials\n      ;;\n    jina)\n      host_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[EMBEDDING_BINDING_HOST]:-}\" \"${llm_host_fallback:-https://api.jina.ai/v1/embeddings}\")\"\n      host=\"$(prompt_with_default \"Jina endpoint\" \"$host_default\")\"\n      api_key=\"$(prompt_secret_until_valid_with_default \"Jina API key: \" \"${ENV_VALUES[EMBEDDING_BINDING_API_KEY]:-$llm_api_key_default}\" validate_api_key jina)\"\n      ;;\n    *)\n      host_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[EMBEDDING_BINDING_HOST]:-}\" \"${llm_host_fallback:-https://api.openai.com/v1}\")\"\n      host=\"$(prompt_with_default \"Embedding endpoint\" \"$host_default\")\"\n      api_key=\"$(prompt_secret_until_valid_with_default \"Embedding API key: \" \"${ENV_VALUES[EMBEDDING_BINDING_API_KEY]:-$llm_api_key_default}\" validate_api_key \"$binding\")\"\n      ;;\n  esac\n\n  ENV_VALUES[\"EMBEDDING_BINDING\"]=\"$binding\"\n  ENV_VALUES[\"EMBEDDING_MODEL\"]=\"$model\"\n  ENV_VALUES[\"EMBEDDING_DIM\"]=\"$dim\"\n  ENV_VALUES[\"EMBEDDING_BINDING_HOST\"]=\"$host\"\n  store_optional_env_value \"EMBEDDING_BINDING_API_KEY\" \"$api_key\"\n  clear_bedrock_credentials_if_unused\n  # User chose a remote provider — clear the Docker deployment marker.\n  unset 'ENV_VALUES[LIGHTRAG_SETUP_EMBEDDING_PROVIDER]'\n}\n\ncollect_rerank_config() {\n  # Pass \"yes\" to skip the \"Enable reranking?\" prompt (caller already asked it).\n  # The optional second argument is retained for caller compatibility.\n  local skip_enable_check=\"${1:-no}\"\n  local _docker_choice_override=\"${2:-prompt}\"\n  local options=(\"cohere\" \"jina\" \"aliyun\")\n  local current_binding=\"${ENV_VALUES[RERANK_BINDING]:-cohere}\"\n  local binding model host api_key\n  local default_model=\"\" default_host=\"\" model_default=\"\" host_default=\"\"\n  local previous_provider=\"${ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]:-}\"\n\n  unset 'ENV_VALUES[VLLM_RERANK_DTYPE]'\n\n  if [[ \"$skip_enable_check\" != \"yes\" ]]; then\n    local rerank_was_enabled=\"no\"\n    if [[ -n \"${ENV_VALUES[RERANK_BINDING]:-}\" && \"${ENV_VALUES[RERANK_BINDING]}\" != \"null\" ]]; then\n      rerank_was_enabled=\"yes\"\n    fi\n\n    local rerank_enabled=\"no\"\n    if [[ \"$rerank_was_enabled\" == \"yes\" ]]; then\n      confirm_default_yes \"Enable reranking?\" && rerank_enabled=\"yes\"\n    else\n      confirm_default_no \"Enable reranking?\" && rerank_enabled=\"yes\"\n    fi\n\n    if [[ \"$rerank_enabled\" != \"yes\" ]]; then\n      ENV_VALUES[\"RERANK_BINDING\"]=\"null\"\n      unset 'ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]'\n      return\n    fi\n  fi\n\n  if [[ \"$current_binding\" == \"null\" ]]; then\n    current_binding=\"cohere\"\n  fi\n\n  binding=\"$(prompt_choice \"Rerank provider\" \"$current_binding\" \"${options[@]}\")\"\n  case \"$binding\" in\n    cohere)\n      default_model=\"rerank-v3.5\"\n      default_host=\"https://api.cohere.com/v2/rerank\"\n      ;;\n    jina)\n      default_model=\"jina-reranker-v2-base-multilingual\"\n      default_host=\"https://api.jina.ai/v1/rerank\"\n      ;;\n    aliyun)\n      default_model=\"gte-rerank-v2\"\n      default_host=\"https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank\"\n      ;;\n    *)\n      default_model=\"\"\n      default_host=\"\"\n      ;;\n  esac\n\n  if [[ \"$previous_provider\" == \"vllm\" ]]; then\n    # Switching away from local vLLM should replace stale localhost/model values.\n    model_default=\"$default_model\"\n    host_default=\"$default_host\"\n  else\n    model_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[RERANK_MODEL]:-}\" \"$default_model\")\"\n    host_default=\"$(provider_default_or_existing \"$binding\" \"$current_binding\" \"${ENV_VALUES[RERANK_BINDING_HOST]:-}\" \"$default_host\")\"\n  fi\n\n  model=\"$(prompt_with_default \"Rerank model\" \"$model_default\")\"\n  host=\"$(prompt_with_default \"Rerank endpoint\" \"$host_default\")\"\n  api_key=\"$(prompt_secret_until_valid_with_default \"Rerank API key: \" \"${ENV_VALUES[RERANK_BINDING_API_KEY]:-}\" validate_api_key \"$binding\")\"\n\n  ENV_VALUES[\"RERANK_BINDING\"]=\"$binding\"\n  # Only env_base_flow's Docker branch should keep the local vLLM setup marker.\n  unset 'ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]'\n  if [[ -n \"$model\" ]]; then\n    ENV_VALUES[\"RERANK_MODEL\"]=\"$model\"\n  fi\n  if [[ -n \"$host\" ]]; then\n    ENV_VALUES[\"RERANK_BINDING_HOST\"]=\"$host\"\n  fi\n  store_optional_env_value \"RERANK_BINDING_API_KEY\" \"$api_key\"\n}\n\ncollect_server_config() {\n  local host port title description summary_language\n\n  host=\"$(prompt_with_default \"Server host\" \"${ENV_VALUES[HOST]:-0.0.0.0}\")\"\n  port=\"$(prompt_until_valid \"Server port\" \"${ENV_VALUES[PORT]:-9621}\" validate_port)\"\n  title=\"$(prompt_with_default \"WebUI title\" \"${ENV_VALUES[WEBUI_TITLE]:-My Graph KB}\")\"\n  description=\"$(prompt_with_default \"WebUI description\" \"${ENV_VALUES[WEBUI_DESCRIPTION]:-Simple and Fast Graph Based RAG System}\")\"\n  summary_language=\"$(prompt_with_default \"Summary language\" \"${ENV_VALUES[SUMMARY_LANGUAGE]:-English}\")\"\n\n  ENV_VALUES[\"HOST\"]=\"$host\"\n  ENV_VALUES[\"PORT\"]=\"$port\"\n  ENV_VALUES[\"WEBUI_TITLE\"]=\"$title\"\n  ENV_VALUES[\"WEBUI_DESCRIPTION\"]=\"$description\"\n  ENV_VALUES[\"SUMMARY_LANGUAGE\"]=\"$summary_language\"\n}\n\ncollect_ssl_config() {\n  local cert key\n  local ssl_enabled_default=\"no\"\n\n  case \"${ENV_VALUES[SSL]:-}\" in\n    true|TRUE|True|1|yes|YES|Yes|y|Y|on|ON|On|t|T)\n      ssl_enabled_default=\"yes\"\n      ;;\n  esac\n\n  if [[ \"$ssl_enabled_default\" == \"yes\" ]]; then\n    if ! confirm_default_yes \"Enable SSL/TLS for the API server?\"; then\n      unset 'ENV_VALUES[SSL]'\n      unset 'ENV_VALUES[SSL_CERTFILE]'\n      unset 'ENV_VALUES[SSL_KEYFILE]'\n      SSL_CERT_SOURCE_PATH=\"\"\n      SSL_KEY_SOURCE_PATH=\"\"\n      return\n    fi\n  else\n    if ! confirm_default_no \"Enable SSL/TLS for the API server?\"; then\n      unset 'ENV_VALUES[SSL]'\n      unset 'ENV_VALUES[SSL_CERTFILE]'\n      unset 'ENV_VALUES[SSL_KEYFILE]'\n      SSL_CERT_SOURCE_PATH=\"\"\n      SSL_KEY_SOURCE_PATH=\"\"\n      return\n    fi\n  fi\n\n  cert=\"$(prompt_until_valid \"SSL certificate file\" \"${ENV_VALUES[SSL_CERTFILE]:-}\" validate_existing_file)\"\n  key=\"$(prompt_until_valid \"SSL key file\" \"${ENV_VALUES[SSL_KEYFILE]:-}\" validate_existing_file)\"\n\n  ENV_VALUES[\"SSL\"]=\"true\"\n  ENV_VALUES[\"SSL_CERTFILE\"]=\"$cert\"\n  ENV_VALUES[\"SSL_KEYFILE\"]=\"$key\"\n  SSL_CERT_SOURCE_PATH=\"$cert\"\n  SSL_KEY_SOURCE_PATH=\"$key\"\n}\n\ncollect_security_config() {\n  local required=\"${1:-no}\"\n  local default_yes=\"${2:-no}\"\n  local auth_accounts token_secret token_expire api_key whitelist\n  local confirm_result=1\n  local whitelist_default=\"\"\n  local whitelist_is_set=\"no\"\n\n  if [[ -n \"${ENV_VALUES[WHITELIST_PATHS]+set}\" ]]; then\n    whitelist_default=\"${ENV_VALUES[WHITELIST_PATHS]}\"\n    whitelist_is_set=\"yes\"\n  fi\n\n  if [[ \"$default_yes\" == \"yes\" ]]; then\n    if confirm_default_yes \"Configure authentication and API key settings?\"; then\n      confirm_result=0\n    fi\n  else\n    if confirm_default_no \"Configure authentication and API key settings?\"; then\n      confirm_result=0\n    fi\n  fi\n\n  if ((confirm_result != 0)); then\n    if [[ \"$required\" == \"yes\" ]]; then\n      echo \"Warning: production deployments should configure AUTH_ACCOUNTS; API keys are optional on top.\" >&2\n    fi\n    return\n  fi\n\n  echo \"Press Enter to keep an existing value. Type 'clear' to remove it.\" >&2\n\n  if [[ \"$whitelist_is_set\" == \"no\" ]]; then\n    whitelist_default=\"/health\"\n  elif [[ \"$required\" == \"yes\" && \"$whitelist_default\" == \"/health,/api/*\" ]]; then\n    whitelist_default=\"/health\"\n  fi\n\n  auth_accounts=\"$(prompt_clearable_with_default \"Auth accounts (user:pass,comma-separated)\" \"${ENV_VALUES[AUTH_ACCOUNTS]:-}\")\"\n  token_secret=\"$(prompt_clearable_secret_with_default \"JWT token secret: \" \"${ENV_VALUES[TOKEN_SECRET]:-}\")\"\n  token_expire=\"$(prompt_clearable_with_default \"Token expire hours\" \"${ENV_VALUES[TOKEN_EXPIRE_HOURS]:-48}\")\"\n  api_key=\"$(prompt_clearable_secret_with_default \"LightRAG API key: \" \"${ENV_VALUES[LIGHTRAG_API_KEY]:-}\")\"\n  whitelist=\"$(prompt_clearable_with_default \"Whitelist paths (comma-separated)\" \"$whitelist_default\")\"\n  if [[ \"$whitelist_is_set\" == \"yes\" && -z \"$whitelist_default\" && -z \"$whitelist\" ]]; then\n    whitelist=\"$CLEAR_INPUT_SENTINEL\"\n  fi\n\n  if [[ -z \"$token_secret\" ]]; then\n    token_secret=\"$(openssl rand -hex 32 2>/dev/null || LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 64)\"\n    log_info \"Generated TOKEN_SECRET and saved to .env.\"\n  fi\n\n  apply_clearable_env_value \"AUTH_ACCOUNTS\" \"$auth_accounts\"\n  apply_clearable_env_value \"TOKEN_SECRET\" \"$token_secret\"\n  apply_clearable_env_value \"TOKEN_EXPIRE_HOURS\" \"$token_expire\"\n  apply_clearable_env_value \"LIGHTRAG_API_KEY\" \"$api_key\"\n  apply_clearable_env_value \"WHITELIST_PATHS\" \"$whitelist\" \"empty\"\n}\n\napply_clearable_env_value() {\n  local key=\"$1\"\n  local value=\"${2:-}\"\n  local clear_mode=\"${3:-unset}\"\n\n  if [[ \"$clear_mode\" == \"empty\" && \"$value\" == \"$CLEAR_INPUT_SENTINEL\" ]]; then\n    ENV_VALUES[\"$key\"]=\"\"\n    return 0\n  fi\n\n  if [[ \"$value\" == \"$CLEAR_INPUT_SENTINEL\" || -z \"$value\" ]]; then\n    unset \"ENV_VALUES[$key]\"\n    return 0\n  fi\n\n  ENV_VALUES[\"$key\"]=\"$value\"\n}\n\ncollect_observability_config() {\n  local secret_key public_key host\n\n  if ! confirm_default_no \"Enable Langfuse observability?\"; then\n    unset 'ENV_VALUES[LANGFUSE_ENABLE_TRACE]'\n    unset 'ENV_VALUES[LANGFUSE_SECRET_KEY]'\n    unset 'ENV_VALUES[LANGFUSE_PUBLIC_KEY]'\n    unset 'ENV_VALUES[LANGFUSE_HOST]'\n    return\n  fi\n\n  secret_key=\"$(prompt_secret_until_valid_with_default \"Langfuse secret key: \" \"${ENV_VALUES[LANGFUSE_SECRET_KEY]:-}\" validate_api_key langfuse)\"\n  public_key=\"$(prompt_secret_until_valid_with_default \"Langfuse public key: \" \"${ENV_VALUES[LANGFUSE_PUBLIC_KEY]:-}\" validate_api_key langfuse)\"\n  host=\"$(prompt_with_default \"Langfuse host\" \"${ENV_VALUES[LANGFUSE_HOST]:-https://cloud.langfuse.com}\")\"\n\n  if [[ -n \"$secret_key\" ]]; then\n    ENV_VALUES[\"LANGFUSE_SECRET_KEY\"]=\"$secret_key\"\n  fi\n  if [[ -n \"$public_key\" ]]; then\n    ENV_VALUES[\"LANGFUSE_PUBLIC_KEY\"]=\"$public_key\"\n  fi\n  if [[ -n \"$host\" ]]; then\n    ENV_VALUES[\"LANGFUSE_HOST\"]=\"$host\"\n  fi\n  ENV_VALUES[\"LANGFUSE_ENABLE_TRACE\"]=\"true\"\n}\n\n\nshow_summary() {\n  local key\n  local value\n\n  echo\n  log_info \"Configuration summary:\"\n  if ((${#ENV_VALUES[@]} > 0)); then\n    local -a sorted_keys\n    mapfile -t sorted_keys < <(printf '%s\\n' \"${!ENV_VALUES[@]}\" | sort)\n    for key in \"${sorted_keys[@]}\"; do\n      value=\"${ENV_VALUES[$key]}\"\n      if is_sensitive_env_key \"$key\"; then\n        value=\"***\"\n      fi\n      printf '  %s=%s\\n' \"$key\" \"$value\"\n    done\n  fi\n\n  if ((${#DOCKER_SERVICES[@]} > 0)); then\n    echo\n    log_info \"Docker services to include:\"\n    for service in \"${DOCKER_SERVICES[@]}\"; do\n      echo \"  - $service\"\n    done\n    echo \"  Compose file: docker-compose.final.yml\"\n  fi\n}\n\n# Preserve already-staged SSL mounts when regenerating compose output. The\n# setup wizards treat .env as the configuration for the current target runtime,\n# not as a single file guaranteed to work for both host and Docker Compose at\n# the same time. A later wizard run may rewrite .env again when the operator\n# switches between host and compose workflows.\nprepare_inherited_ssl_assets_for_compose() {\n  local existing_compose=\"${1:-}\"\n  local staged_cert_source=\"$SSL_CERT_SOURCE_PATH\"\n  local staged_key_source=\"$SSL_KEY_SOURCE_PATH\"\n  local preserved_cert_path=\"\"\n  local preserved_key_path=\"\"\n\n  if [[ -n \"$SSL_CERT_SOURCE_PATH\" ]] && ! validate_existing_file \"$SSL_CERT_SOURCE_PATH\"; then\n    if [[ -n \"$existing_compose\" ]]; then\n      preserved_cert_path=\"$(read_service_environment_value \"$existing_compose\" \"lightrag\" \"SSL_CERTFILE\" || true)\"\n    fi\n    if [[ \"$preserved_cert_path\" == /app/data/certs/* ]]; then\n      log_warn \"SSL_CERTFILE source is missing; preserving the existing compose SSL certificate mount.\"\n      staged_cert_source=\"\"\n      ENV_VALUES[\"SSL_CERTFILE\"]=\"$preserved_cert_path\"\n      set_compose_override \"SSL_CERTFILE\" \"$preserved_cert_path\"\n    else\n      format_error \"Invalid SSL_CERTFILE\" \\\n        \"Set it to an existing certificate file, disable SSL, or rerun the wizard to choose a new certificate.\"\n      return 1\n    fi\n  fi\n\n  if [[ -n \"$SSL_KEY_SOURCE_PATH\" ]] && ! validate_existing_file \"$SSL_KEY_SOURCE_PATH\"; then\n    if [[ -n \"$existing_compose\" ]]; then\n      preserved_key_path=\"$(read_service_environment_value \"$existing_compose\" \"lightrag\" \"SSL_KEYFILE\" || true)\"\n    fi\n    if [[ \"$preserved_key_path\" == /app/data/certs/* ]]; then\n      log_warn \"SSL_KEYFILE source is missing; preserving the existing compose SSL key mount.\"\n      staged_key_source=\"\"\n      ENV_VALUES[\"SSL_KEYFILE\"]=\"$preserved_key_path\"\n      set_compose_override \"SSL_KEYFILE\" \"$preserved_key_path\"\n    else\n      format_error \"Invalid SSL_KEYFILE\" \\\n        \"Set it to an existing private key file, disable SSL, or rerun the wizard to choose a new key.\"\n      return 1\n    fi\n  fi\n\n  SSL_CERT_SOURCE_PATH=\"$staged_cert_source\"\n  SSL_KEY_SOURCE_PATH=\"$staged_key_source\"\n\n  if [[ -n \"$SSL_CERT_SOURCE_PATH\" || -n \"$SSL_KEY_SOURCE_PATH\" ]]; then\n    stage_ssl_assets \"$SSL_CERT_SOURCE_PATH\" \"$SSL_KEY_SOURCE_PATH\"\n  fi\n}\n\nprepare_managed_service_assets_for_compose() {\n  local existing_compose=\"${1:-}\"\n\n  if ! prepare_inherited_ssl_assets_for_compose \"$existing_compose\"; then\n    return 1\n  fi\n\n  if [[ -n \"${DOCKER_SERVICE_SET[redis]:-}\" ]]; then\n    stage_redis_config_asset || return 1\n  fi\n}\n\nenv_base_flow() {\n  local vllm_embed_api_key=\"\"\n  local vllm_rerank_api_key=\"\"\n  local existing_vllm_embed_model=\"\"\n  local existing_embedding_dim=\"\"\n  local existing_vllm_embed_port=\"\"\n  local existing_vllm_embed_host=\"\"\n  local existing_vllm_embed_device=\"\"\n  local previous_embedding_provider=\"\"\n  local existing_vllm_rerank_model=\"\"\n  local existing_vllm_rerank_port=\"\"\n  local existing_vllm_rerank_host=\"\"\n  local existing_vllm_rerank_device=\"\"\n  local previous_rerank_provider=\"\"\n  if host_cuda_available; then\n    log_info \"GPU detected: NVIDIA GPU found. New local vLLM services default to CUDA (GPU image + float16).\"\n  else\n    log_info \"GPU detection: no NVIDIA GPU found. New local vLLM services default to CPU image + float32.\"\n  fi\n\n  reset_state\n  load_existing_env_if_present\n  initialize_default_storage_backends\n\n  log_info \"Base configuration wizard (LLM / Embedding / Reranker)\"\n  echo \"This wizard only modifies LLM, embedding, and reranker settings.\"\n  echo \"Storage, server, and security settings are preserved.\"\n  echo \"\"\n\n  log_step \"LLM configuration\"\n  collect_llm_config\n  echo \"\"\n\n  # ── Embedding ────────────────────────────────────────────────────────────────\n  log_step \"Embedding configuration\"\n  local docker_embed_default=\"no\"\n  previous_embedding_provider=\"${ENV_VALUES[LIGHTRAG_SETUP_EMBEDDING_PROVIDER]:-}\"\n  if [[ \"$previous_embedding_provider\" == \"vllm\" ]]; then\n    docker_embed_default=\"yes\"\n  fi\n\n  local use_docker_embed=\"no\"\n  if [[ \"$docker_embed_default\" == \"yes\" ]]; then\n    confirm_default_yes \"Run embedding model locally via Docker (vLLM)?\" && use_docker_embed=\"yes\" || use_docker_embed=\"no\"\n  else\n    confirm_default_no \"Run embedding model locally via Docker (vLLM)?\" && use_docker_embed=\"yes\" || use_docker_embed=\"no\"\n  fi\n\n  if [[ \"$use_docker_embed\" == \"yes\" ]]; then\n    existing_vllm_embed_model=\"${ENV_VALUES[VLLM_EMBED_MODEL]:-}\"\n    existing_embedding_dim=\"${ENV_VALUES[EMBEDDING_DIM]:-}\"\n    existing_vllm_embed_port=\"${ENV_VALUES[VLLM_EMBED_PORT]:-}\"\n    existing_vllm_embed_host=\"${ENV_VALUES[EMBEDDING_BINDING_HOST]:-}\"\n    existing_vllm_embed_device=\"${ENV_VALUES[VLLM_EMBED_DEVICE]:-}\"\n    apply_preset_overwrite \"${PRESET_VLLM_EMBEDDING[@]}\"\n    if [[ -n \"$existing_vllm_embed_port\" ]]; then\n      ENV_VALUES[\"VLLM_EMBED_PORT\"]=\"$existing_vllm_embed_port\"\n    fi\n    if [[ -n \"$existing_embedding_dim\" ]]; then\n      ENV_VALUES[\"EMBEDDING_DIM\"]=\"$existing_embedding_dim\"\n    fi\n    if [[ \"$previous_embedding_provider\" == \"vllm\" && -n \"$existing_vllm_embed_host\" ]]; then\n      ENV_VALUES[\"EMBEDDING_BINDING_HOST\"]=\"$existing_vllm_embed_host\"\n    else\n      ENV_VALUES[\"EMBEDDING_BINDING_HOST\"]=\"http://localhost:${ENV_VALUES[VLLM_EMBED_PORT]:-8001}/v1\"\n    fi\n    local embed_model\n    embed_model=\"$(prompt_with_default \"Embedding model\" \"${existing_vllm_embed_model:-${ENV_VALUES[VLLM_EMBED_MODEL]:-BAAI/bge-m3}}\")\"\n    ENV_VALUES[\"VLLM_EMBED_MODEL\"]=\"$embed_model\"\n    ENV_VALUES[\"EMBEDDING_MODEL\"]=\"$embed_model\"\n\n    local vllm_embed_device\n    vllm_embed_device=\"$(resolve_local_device_default \"$existing_vllm_embed_device\")\"\n    ENV_VALUES[\"VLLM_EMBED_DEVICE\"]=\"$vllm_embed_device\"\n    ENV_VALUES[\"LIGHTRAG_SETUP_EMBEDDING_PROVIDER\"]=\"vllm\"\n\n    vllm_embed_api_key=\"${ENV_VALUES[VLLM_EMBED_API_KEY]:-${ENV_VALUES[EMBEDDING_BINDING_API_KEY]:-}}\"\n    if [[ -z \"$vllm_embed_api_key\" ]]; then\n      vllm_embed_api_key=\"$(openssl rand -hex 16 2>/dev/null || LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32)\"\n    fi\n    ENV_VALUES[\"VLLM_EMBED_API_KEY\"]=\"$vllm_embed_api_key\"\n    ENV_VALUES[\"EMBEDDING_BINDING_API_KEY\"]=\"$vllm_embed_api_key\"\n    add_docker_service \"vllm-embed\"\n    set_compose_override \"EMBEDDING_BINDING_HOST\" \\\n      \"http://vllm-embed:${ENV_VALUES[VLLM_EMBED_PORT]:-8001}/v1\"\n  else\n    collect_embedding_config\n  fi\n  echo \"\"\n\n  # ── Reranker ─────────────────────────────────────────────────────────────────\n  log_step \"Reranker configuration\"\n  local rerank_enabled_default=\"no\"\n  if [[ -n \"${ENV_VALUES[RERANK_BINDING]:-}\" && \"${ENV_VALUES[RERANK_BINDING]}\" != \"null\" ]]; then\n    rerank_enabled_default=\"yes\"\n  fi\n  previous_rerank_provider=\"${ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]:-}\"\n\n  local enable_reranking=\"no\"\n  if [[ \"$rerank_enabled_default\" == \"yes\" ]]; then\n    confirm_default_yes \"Enable reranking?\" && enable_reranking=\"yes\" || enable_reranking=\"no\"\n  else\n    confirm_default_no \"Enable reranking?\" && enable_reranking=\"yes\" || enable_reranking=\"no\"\n  fi\n\n  if [[ \"$enable_reranking\" == \"yes\" ]]; then\n    local docker_rerank_default=\"no\"\n    if [[ \"$previous_rerank_provider\" == \"vllm\" ]]; then\n      docker_rerank_default=\"yes\"\n    fi\n\n    local use_docker_rerank=\"no\"\n    if [[ \"$docker_rerank_default\" == \"yes\" ]]; then\n      confirm_default_yes \"Run rerank service locally via Docker?\" && use_docker_rerank=\"yes\" || use_docker_rerank=\"no\"\n    else\n      confirm_default_no \"Run rerank service locally via Docker?\" && use_docker_rerank=\"yes\" || use_docker_rerank=\"no\"\n    fi\n\n    if [[ \"$use_docker_rerank\" == \"yes\" ]]; then\n      existing_vllm_rerank_model=\"${ENV_VALUES[VLLM_RERANK_MODEL]:-}\"\n      existing_vllm_rerank_port=\"${ENV_VALUES[VLLM_RERANK_PORT]:-}\"\n      existing_vllm_rerank_host=\"${ENV_VALUES[RERANK_BINDING_HOST]:-}\"\n      existing_vllm_rerank_device=\"${ENV_VALUES[VLLM_RERANK_DEVICE]:-}\"\n      apply_preset_overwrite \"${PRESET_VLLM_RERANKER[@]}\"\n      local rerank_model rerank_port\n      if [[ -n \"$existing_vllm_rerank_port\" ]]; then\n        ENV_VALUES[\"VLLM_RERANK_PORT\"]=\"$existing_vllm_rerank_port\"\n      fi\n      if [[ \"$previous_rerank_provider\" == \"vllm\" && -n \"$existing_vllm_rerank_host\" ]]; then\n        ENV_VALUES[\"RERANK_BINDING_HOST\"]=\"$existing_vllm_rerank_host\"\n      else\n        ENV_VALUES[\"RERANK_BINDING_HOST\"]=\"http://localhost:${ENV_VALUES[VLLM_RERANK_PORT]:-8000}/rerank\"\n      fi\n      rerank_model=\"$(prompt_with_default \"Rerank model\" \"${existing_vllm_rerank_model:-${ENV_VALUES[VLLM_RERANK_MODEL]:-BAAI/bge-reranker-v2-m3}}\")\"\n      rerank_port=\"${ENV_VALUES[VLLM_RERANK_PORT]:-8000}\"\n      ENV_VALUES[\"VLLM_RERANK_MODEL\"]=\"$rerank_model\"\n      ENV_VALUES[\"RERANK_MODEL\"]=\"$rerank_model\"\n      ENV_VALUES[\"VLLM_RERANK_PORT\"]=\"$rerank_port\"\n\n      local vllm_rerank_device\n      vllm_rerank_device=\"$(resolve_local_device_default \"$existing_vllm_rerank_device\")\"\n      ENV_VALUES[\"VLLM_RERANK_DEVICE\"]=\"$vllm_rerank_device\"\n      ENV_VALUES[\"LIGHTRAG_SETUP_RERANK_PROVIDER\"]=\"vllm\"\n\n      vllm_rerank_api_key=\"${ENV_VALUES[VLLM_RERANK_API_KEY]:-${ENV_VALUES[RERANK_BINDING_API_KEY]:-}}\"\n      if [[ -z \"$vllm_rerank_api_key\" ]]; then\n        vllm_rerank_api_key=\"$(openssl rand -hex 16 2>/dev/null || LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32)\"\n      fi\n      ENV_VALUES[\"VLLM_RERANK_API_KEY\"]=\"$vllm_rerank_api_key\"\n      ENV_VALUES[\"RERANK_BINDING_API_KEY\"]=\"$vllm_rerank_api_key\"\n      add_docker_service \"vllm-rerank\"\n      set_compose_override \"RERANK_BINDING_HOST\" \\\n        \"http://vllm-rerank:${rerank_port}/rerank\"\n    else\n      # Reranking enabled but not via Docker — ask provider/host/model/api_key\n      collect_rerank_config \"yes\" \"no\"\n    fi\n  else\n    ENV_VALUES[\"RERANK_BINDING\"]=\"null\"\n    unset 'ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]'\n  fi\n  echo \"\"\n\n  finalize_base_setup\n}\n\nfinalize_base_setup() {\n  local backup_path\n  local compose_file\n  local existing_compose\n  local compose_action=\"write_env_only\"\n  local runtime_target=\"$DEFAULT_RUNTIME_TARGET\"\n  local show_host_start_hint=\"no\"\n  local svc_names=\"\"\n\n  if [[ ! -f \"${REPO_ROOT}/env.example\" ]]; then\n    format_error \"env.example is missing in $REPO_ROOT\" \"Restore env.example before running setup.\"\n    return 1\n  fi\n  if [[ ! -w \"$REPO_ROOT\" ]]; then\n    format_error \"No write permission in $REPO_ROOT\" \"Run the setup from a writable directory.\"\n    return 1\n  fi\n\n  if ! validate_sensitive_env_literals; then\n    return 1\n  fi\n\n  show_summary\n\n  if ! confirm_required_yes_no \"${COLOR_YELLOW}Ready to proceed and write .env${COLOR_RESET}\"; then\n    log_warn \"Setup cancelled.\"\n    return 1\n  fi\n\n  existing_compose=\"$(find_generated_compose_file)\"\n  compose_file=\"${REPO_ROOT}/docker-compose.final.yml\"\n  record_existing_managed_root_services \"$existing_compose\"\n  restore_storage_docker_services_from_env\n\n  configure_base_compose_rewrites\n\n  if ((${#DOCKER_SERVICES[@]} > 0)); then\n    # LightRAG depends on managed Docker services; it must run via Docker.\n    svc_names=\"$(printf '%s ' \"${DOCKER_SERVICES[@]}\")\"\n    svc_names=\"${svc_names% }\"\n    echo \"LightRAG requires Docker services: ${svc_names}\"\n    if ! confirm_default_yes \"${COLOR_YELLOW}The compose file will be created/updated. Continue?${COLOR_RESET}\"; then\n      log_warn \"Setup cancelled.\"\n      return 1\n    fi\n    compose_action=\"rewrite_compose\"\n    runtime_target=\"compose\"\n  else\n    resolve_compose_output_action \\\n      \"$existing_compose\" \\\n      compose_action \\\n      runtime_target \\\n      show_host_start_hint\n  fi\n\n  if [[ \"$compose_action\" == \"rewrite_compose\" ]]; then\n    backup_existing_compose_for_action \"$compose_action\" \"$existing_compose\" || return 1\n    if ! prepare_managed_service_assets_for_compose \"$existing_compose\"; then\n      return 1\n    fi\n    prepare_compose_env_overrides\n  elif [[ \"$compose_action\" == \"delete_compose_and_switch_host\" ]]; then\n    backup_existing_compose_for_action \"$compose_action\" \"$existing_compose\" || return 1\n  fi\n\n  backup_path=\"$(backup_env_file)\"\n  if [[ -n \"$backup_path\" ]]; then\n    log_success \"Backed up existing .env to $backup_path\"\n  fi\n\n  clear_deprecated_vllm_dtype_state\n  set_runtime_target \"$runtime_target\" || return 1\n  generate_env_file \"${REPO_ROOT}/env.example\" \"${REPO_ROOT}/.env\"\n  log_success \"Wrote .env\"\n\n  case \"$compose_action\" in\n    rewrite_compose)\n      prepare_compose_output_from_existing \"$compose_file\" \"$existing_compose\" || return 1\n      generate_docker_compose \"$compose_file\"\n      log_success \"Wrote ${compose_file}\"\n      echo \"  To start: docker compose -f ${compose_file} up -d\"\n      ;;\n    delete_compose_and_switch_host)\n      remove_existing_compose_file \"$existing_compose\" || return 1\n      echo \"  To start: lightrag-server\"\n      ;;\n    *)\n      if [[ \"$show_host_start_hint\" == \"yes\" ]]; then\n        echo \"  To start: lightrag-server\"\n      fi\n      ;;\n  esac\n}\n\nenv_storage_flow() {\n  local env_file=\"${REPO_ROOT}/.env\"\n  local db_type\n  local db_order=(\"postgresql\" \"neo4j\" \"mongodb\" \"redis\" \"milvus\" \"qdrant\" \"memgraph\" \"opensearch\")\n\n  if [[ ! -f \"$env_file\" ]]; then\n    format_error \"No .env file found.\" \"Run 'make env-base' first to configure LLM and embedding.\"\n    return 1\n  fi\n\n  reset_state\n  load_existing_env_if_present\n\n  log_info \"Storage configuration wizard\"\n  echo \"This wizard only modifies storage backend settings.\"\n  echo \"LLM, embedding, reranker, server, and security settings are preserved.\"\n  echo \"\"\n\n  log_step \"Storage backend selection\"\n  select_storage_backends \"custom\"\n  log_debug \"Storage selections: kv=${ENV_VALUES[LIGHTRAG_KV_STORAGE]:-} vector=${ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]:-} graph=${ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]:-} doc=${ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]:-}\"\n  clear_unused_storage_deployment_markers\n\n  log_step \"Database configuration\"\n  for db_type in \"${db_order[@]}\"; do\n    if [[ -n \"${REQUIRED_DB_TYPES[$db_type]+set}\" ]]; then\n      collect_database_config \"$db_type\" \"$(storage_default_docker_for_db_type \"$db_type\")\"\n      echo \"\"\n    fi\n  done\n\n  finalize_storage_setup\n}\n\nfinalize_storage_setup() {\n  local backup_path\n  local compose_file\n  local existing_compose\n  local compose_action=\"write_env_only\"\n  local runtime_target=\"$DEFAULT_RUNTIME_TARGET\"\n  local show_host_start_hint=\"no\"\n\n  if [[ ! -f \"${REPO_ROOT}/env.example\" ]]; then\n    format_error \"env.example is missing in $REPO_ROOT\" \"Restore env.example before running setup.\"\n    return 1\n  fi\n  if [[ ! -w \"$REPO_ROOT\" ]]; then\n    format_error \"No write permission in $REPO_ROOT\" \"Run the setup from a writable directory.\"\n    return 1\n  fi\n\n  if [[ -n \"${ENV_VALUES[LIGHTRAG_KV_STORAGE]:-}\" ]]; then\n    if ! validate_required_variables \\\n      \"${ENV_VALUES[LIGHTRAG_KV_STORAGE]}\" \\\n      \"${ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]}\" \\\n      \"${ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]}\" \\\n      \"${ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]}\"; then\n      return 1\n    fi\n  fi\n\n  if ! validate_mongo_vector_storage_config \\\n    \"${ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]:-}\" \\\n    \"${ENV_VALUES[MONGO_URI]:-}\" \\\n    \"${ENV_VALUES[LIGHTRAG_SETUP_MONGODB_DEPLOYMENT]:-}\"; then\n    return 1\n  fi\n\n  if ! validate_sensitive_env_literals; then\n    return 1\n  fi\n\n  show_summary\n\n  if ! confirm_required_yes_no \"${COLOR_YELLOW}Ready to proceed and write .env${COLOR_RESET}\"; then\n    log_warn \"Setup cancelled.\"\n    return 1\n  fi\n\n  existing_compose=\"$(find_generated_compose_file)\"\n  compose_file=\"${REPO_ROOT}/docker-compose.final.yml\"\n  record_existing_managed_root_services \"$existing_compose\"\n  restore_vllm_docker_services_from_env\n  configure_storage_compose_rewrites\n  resolve_compose_output_action \\\n    \"$existing_compose\" \\\n    compose_action \\\n    runtime_target \\\n    show_host_start_hint\n\n  if [[ \"$compose_action\" == \"rewrite_compose\" ]]; then\n    backup_existing_compose_for_action \"$compose_action\" \"$existing_compose\" || return 1\n    if ! prepare_managed_service_assets_for_compose \"$existing_compose\"; then\n      return 1\n    fi\n    prepare_compose_env_overrides\n  elif [[ \"$compose_action\" == \"delete_compose_and_switch_host\" ]]; then\n    backup_existing_compose_for_action \"$compose_action\" \"$existing_compose\" || return 1\n  fi\n\n  backup_path=\"$(backup_env_file)\"\n  if [[ -n \"$backup_path\" ]]; then\n    log_success \"Backed up existing .env to $backup_path\"\n  fi\n\n  clear_deprecated_vllm_dtype_state\n  set_runtime_target \"$runtime_target\" || return 1\n  generate_env_file \"${REPO_ROOT}/env.example\" \"${REPO_ROOT}/.env\"\n  log_success \"Wrote .env\"\n\n  case \"$compose_action\" in\n    rewrite_compose)\n      prepare_compose_output_from_existing \"$compose_file\" \"$existing_compose\" || return 1\n      generate_docker_compose \"$compose_file\"\n      log_success \"Wrote ${compose_file}\"\n      echo \"  To start: docker compose -f ${compose_file} up -d\"\n      ;;\n    delete_compose_and_switch_host)\n      remove_existing_compose_file \"$existing_compose\" || return 1\n      echo \"  To start: lightrag-server\"\n      ;;\n    *)\n      if [[ \"$show_host_start_hint\" == \"yes\" ]]; then\n        echo \"  To start: lightrag-server\"\n      fi\n      ;;\n  esac\n}\n\nenv_server_flow() {\n  local env_file=\"${REPO_ROOT}/.env\"\n\n  if [[ ! -f \"$env_file\" ]]; then\n    format_error \"No .env file found.\" \"Run 'make env-base' first to configure LLM and embedding.\"\n    return 1\n  fi\n\n  reset_state\n  load_existing_env_if_present\n\n  log_info \"Server configuration wizard\"\n  echo \"This wizard only modifies server, security, and SSL settings.\"\n  echo \"LLM, embedding, reranker, and storage settings are preserved.\"\n  echo \"\"\n\n  log_step \"Server configuration\"\n  collect_server_config\n  echo \"\"\n  log_step \"Security configuration\"\n  collect_security_config \"no\" \"no\"\n  echo \"\"\n  log_step \"SSL configuration\"\n  collect_ssl_config\n  echo \"\"\n\n  finalize_server_setup\n}\n\nfinalize_server_setup() {\n  local backup_path\n  local compose_file\n  local existing_compose\n  local compose_action=\"write_env_only\"\n  local runtime_target=\"$DEFAULT_RUNTIME_TARGET\"\n  local show_host_start_hint=\"no\"\n\n  if [[ ! -f \"${REPO_ROOT}/env.example\" ]]; then\n    format_error \"env.example is missing in $REPO_ROOT\" \"Restore env.example before running setup.\"\n    return 1\n  fi\n  if [[ ! -w \"$REPO_ROOT\" ]]; then\n    format_error \"No write permission in $REPO_ROOT\" \"Run the setup from a writable directory.\"\n    return 1\n  fi\n\n  if ! validate_sensitive_env_literals; then\n    return 1\n  fi\n\n  if ! validate_security_config \\\n    \"${ENV_VALUES[AUTH_ACCOUNTS]:-}\" \\\n    \"${ENV_VALUES[TOKEN_SECRET]:-}\" \\\n    \"${ENV_VALUES[LIGHTRAG_API_KEY]:-}\"; then\n    return 1\n  fi\n\n  show_summary\n\n  if ! confirm_required_yes_no \"${COLOR_YELLOW}Ready to proceed and write .env${COLOR_RESET}\"; then\n    log_warn \"Setup cancelled.\"\n    return 1\n  fi\n\n  existing_compose=\"$(find_generated_compose_file)\"\n  compose_file=\"${REPO_ROOT}/docker-compose.final.yml\"\n  record_existing_managed_root_services \"$existing_compose\"\n  restore_storage_docker_services_from_env\n  restore_vllm_docker_services_from_env\n  resolve_compose_output_action \\\n    \"$existing_compose\" \\\n    compose_action \\\n    runtime_target \\\n    show_host_start_hint\n\n  if [[ \"$compose_action\" == \"rewrite_compose\" ]]; then\n    backup_existing_compose_for_action \"$compose_action\" \"$existing_compose\" || return 1\n    if ! prepare_managed_service_assets_for_compose \"$existing_compose\"; then\n      return 1\n    fi\n    prepare_compose_env_overrides\n  elif [[ \"$compose_action\" == \"delete_compose_and_switch_host\" ]]; then\n    backup_existing_compose_for_action \"$compose_action\" \"$existing_compose\" || return 1\n    if [[ -n \"$SSL_CERT_SOURCE_PATH\" ]] && ! validate_existing_file \"$SSL_CERT_SOURCE_PATH\"; then\n      format_error \"Invalid SSL_CERTFILE\" \\\n        \"Set it to an existing certificate file, disable SSL, or rerun the wizard to choose a new certificate.\"\n      return 1\n    fi\n\n    if [[ -n \"$SSL_KEY_SOURCE_PATH\" ]] && ! validate_existing_file \"$SSL_KEY_SOURCE_PATH\"; then\n      format_error \"Invalid SSL_KEYFILE\" \\\n        \"Set it to an existing private key file, disable SSL, or rerun the wizard to choose a new key.\"\n      return 1\n    fi\n  else\n    if [[ -n \"$SSL_CERT_SOURCE_PATH\" ]] && ! validate_existing_file \"$SSL_CERT_SOURCE_PATH\"; then\n      format_error \"Invalid SSL_CERTFILE\" \\\n        \"Set it to an existing certificate file, disable SSL, or rerun the wizard to choose a new certificate.\"\n      return 1\n    fi\n\n    if [[ -n \"$SSL_KEY_SOURCE_PATH\" ]] && ! validate_existing_file \"$SSL_KEY_SOURCE_PATH\"; then\n      format_error \"Invalid SSL_KEYFILE\" \\\n        \"Set it to an existing private key file, disable SSL, or rerun the wizard to choose a new key.\"\n      return 1\n    fi\n  fi\n\n  backup_path=\"$(backup_env_file)\"\n  if [[ -n \"$backup_path\" ]]; then\n    log_success \"Backed up existing .env to $backup_path\"\n  fi\n\n  clear_deprecated_vllm_dtype_state\n  set_runtime_target \"$runtime_target\" || return 1\n  generate_env_file \"${REPO_ROOT}/env.example\" \"${REPO_ROOT}/.env\"\n  log_success \"Wrote .env\"\n\n  case \"$compose_action\" in\n    rewrite_compose)\n      prepare_compose_output_from_existing \"$compose_file\" \"$existing_compose\" || return 1\n      generate_docker_compose \"$compose_file\"\n      log_success \"Wrote ${compose_file}\"\n      log_success \"Server port and security settings updated in compose.\"\n      echo \"  To restart: docker compose -f ${compose_file} up -d --force-recreate lightrag\"\n      ;;\n    delete_compose_and_switch_host)\n      remove_existing_compose_file \"$existing_compose\" || return 1\n      echo \"  To start: lightrag-server\"\n      ;;\n    *)\n      if [[ \"$show_host_start_hint\" == \"yes\" ]]; then\n        echo \"  To start: lightrag-server\"\n      fi\n      ;;\n  esac\n}\n\nload_env_file() {\n  local env_file=\"$1\"\n  local line key value\n\n  if [[ ! -f \"$env_file\" ]]; then\n    format_error \".env file not found at $env_file\" \"Run make env-base to generate it.\"\n    return 1\n  fi\n\n  while IFS= read -r line || [[ -n \"$line\" ]]; do\n    if [[ \"$line\" =~ ^[A-Z0-9_]+= ]]; then\n      key=\"${line%%=*}\"\n      value=\"${line#*=}\"\n      if [[ \"$value\" =~ ^\\\".*\\\"$ ]]; then\n        value=\"${value:1:${#value}-2}\"\n        value=\"${value//\\\\\\$/\\$}\"\n        value=\"${value//\\\\\\\"/\\\"}\"\n        value=\"${value//\\\\\\\\/\\\\}\"\n      elif [[ \"$value\" =~ ^\\'.*\\'$ ]]; then\n        value=\"${value:1:${#value}-2}\"\n      fi\n      ENV_VALUES[\"$key\"]=\"$value\"\n    fi\n  done < \"$env_file\"\n}\n\nvalidate_ssl_runtime_path() {\n  local path=\"$1\"\n  local runtime_target=\"${ENV_VALUES[LIGHTRAG_RUNTIME_TARGET]:-$DEFAULT_RUNTIME_TARGET}\"\n  local staged_path=\"\"\n\n  if validate_existing_file \"$path\"; then\n    return 0\n  fi\n\n  if [[ \"$runtime_target\" == \"compose\" && \"$path\" == /app/data/certs/* ]]; then\n    staged_path=\"${REPO_ROOT}/data/certs/${path#/app/data/certs/}\"\n    validate_existing_file \"$staged_path\"\n    return $?\n  fi\n\n  return 1\n}\n\nvalidate_env_file() {\n  local env_file=\"${REPO_ROOT}/.env\"\n  local errors=0\n  local kv vector graph doc_status\n  local runtime_target\n  local storage db_type\n  local -A referenced_db_types=()\n\n  reset_state\n\n  if ! load_env_file \"$env_file\"; then\n    return 1\n  fi\n\n  kv=\"${ENV_VALUES[LIGHTRAG_KV_STORAGE]:-}\"\n  vector=\"${ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]:-}\"\n  graph=\"${ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]:-}\"\n  doc_status=\"${ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]:-}\"\n  runtime_target=\"${ENV_VALUES[LIGHTRAG_RUNTIME_TARGET]:-$DEFAULT_RUNTIME_TARGET}\"\n\n  for storage in \"$kv\" \"$vector\" \"$graph\" \"$doc_status\"; do\n    if [[ -z \"$storage\" ]]; then\n      continue\n    fi\n    db_type=\"${STORAGE_DB_TYPES[$storage]:-}\"\n    if [[ -n \"$db_type\" ]]; then\n      referenced_db_types[\"$db_type\"]=1\n    fi\n  done\n\n  if ! validate_runtime_target \"$runtime_target\"; then\n    errors=1\n  fi\n\n  if [[ -z \"$kv\" || -z \"$vector\" || -z \"$graph\" || -z \"$doc_status\" ]]; then\n    format_error \"Storage selections are missing in .env\" \"Set LIGHTRAG_*_STORAGE variables.\"\n    return 1\n  fi\n\n  if ! validate_mongo_vector_storage_config \\\n    \"$vector\" \\\n    \"${ENV_VALUES[MONGO_URI]:-}\" \\\n    \"${ENV_VALUES[LIGHTRAG_SETUP_MONGODB_DEPLOYMENT]:-}\"; then\n    errors=1\n  fi\n\n  if ! validate_required_variables \"$kv\" \"$vector\" \"$graph\" \"$doc_status\"; then\n    errors=1\n  fi\n\n  if ! validate_security_config \\\n    \"${ENV_VALUES[AUTH_ACCOUNTS]:-}\" \\\n    \"${ENV_VALUES[TOKEN_SECRET]:-}\" \\\n    \"${ENV_VALUES[LIGHTRAG_API_KEY]:-}\"; then\n    errors=1\n  fi\n\n  if ! validate_sensitive_env_literals; then\n    errors=1\n  fi\n\n  if [[ \"${ENV_VALUES[SSL]:-false}\" == \"true\" ]]; then\n    if ! validate_ssl_runtime_path \"${ENV_VALUES[SSL_CERTFILE]:-}\"; then\n      format_error \"Invalid SSL_CERTFILE\" \"Set it to an existing certificate file when SSL=true.\"\n      errors=1\n    fi\n    if ! validate_ssl_runtime_path \"${ENV_VALUES[SSL_KEYFILE]:-}\"; then\n      format_error \"Invalid SSL_KEYFILE\" \"Set it to an existing private key file when SSL=true.\"\n      errors=1\n    fi\n  fi\n\n  if [[ -n \"${referenced_db_types[neo4j]+set}\" ]] && [[ -n \"${ENV_VALUES[NEO4J_URI]:-}\" ]] && ! validate_uri \"${ENV_VALUES[NEO4J_URI]}\" neo4j; then\n    format_error \"Invalid NEO4J_URI\" \"Use neo4j:// or bolt:// format.\"\n    errors=1\n  fi\n  if [[ -n \"${referenced_db_types[mongodb]+set}\" ]] && [[ -n \"${ENV_VALUES[MONGO_URI]:-}\" ]] && ! validate_uri \"${ENV_VALUES[MONGO_URI]}\" mongodb; then\n    format_error \"Invalid MONGO_URI\" \"Use mongodb:// or mongodb+srv:// format.\"\n    errors=1\n  fi\n  if [[ -n \"${referenced_db_types[redis]+set}\" ]] && [[ -n \"${ENV_VALUES[REDIS_URI]:-}\" ]] && ! validate_uri \"${ENV_VALUES[REDIS_URI]}\" redis; then\n    format_error \"Invalid REDIS_URI\" \"Use redis:// or rediss:// format.\"\n    errors=1\n  fi\n  if [[ -n \"${referenced_db_types[milvus]+set}\" ]] && [[ -n \"${ENV_VALUES[MILVUS_URI]:-}\" ]] && ! validate_uri \"${ENV_VALUES[MILVUS_URI]}\" milvus; then\n    format_error \"Invalid MILVUS_URI\" \"Use http://host:port format.\"\n    errors=1\n  fi\n  if [[ -n \"${referenced_db_types[qdrant]+set}\" ]] && [[ -n \"${ENV_VALUES[QDRANT_URL]:-}\" ]] && ! validate_uri \"${ENV_VALUES[QDRANT_URL]}\" qdrant; then\n    format_error \"Invalid QDRANT_URL\" \"Use http://host:port format.\"\n    errors=1\n  fi\n  if [[ -n \"${referenced_db_types[memgraph]+set}\" ]] && [[ -n \"${ENV_VALUES[MEMGRAPH_URI]:-}\" ]] && ! validate_uri \"${ENV_VALUES[MEMGRAPH_URI]}\" memgraph; then\n    format_error \"Invalid MEMGRAPH_URI\" \"Use bolt://host:port format.\"\n    errors=1\n  fi\n  if [[ -n \"${referenced_db_types[postgresql]+set}\" ]] && [[ -n \"${ENV_VALUES[POSTGRES_PORT]:-}\" ]] && ! validate_port \"${ENV_VALUES[POSTGRES_PORT]}\"; then\n    format_error \"Invalid POSTGRES_PORT\" \"Use a port between 1 and 65535.\"\n    errors=1\n  fi\n  if [[ -n \"${referenced_db_types[opensearch]+set}\" ]] && [[ -v 'ENV_VALUES[OPENSEARCH_HOSTS]' ]] && [[ -z \"${ENV_VALUES[OPENSEARCH_HOSTS]}\" ]]; then\n    format_error \"Empty OPENSEARCH_HOSTS\" \"Set it to host:port (e.g. localhost:9200).\"\n    errors=1\n  fi\n  if [[ -n \"${referenced_db_types[opensearch]+set}\" ]]; then\n    if ! validate_opensearch_config \\\n      \"${ENV_VALUES[LIGHTRAG_SETUP_OPENSEARCH_DEPLOYMENT]:-}\" \\\n      \"${ENV_VALUES[OPENSEARCH_HOSTS]:-}\" \\\n      \"${ENV_VALUES[OPENSEARCH_USER]:-}\" \\\n      \"${ENV_VALUES[OPENSEARCH_PASSWORD]:-}\"; then\n      errors=1\n    fi\n  fi\n  if ((errors != 0)); then\n    return 1\n  fi\n\n  log_success \"Validation passed.\"\n}\n\nreport_security_issue() {\n  local message=\"$1\"\n  local suggestion=\"${2:-}\"\n\n  echo \"${COLOR_YELLOW:-}Security issue:${COLOR_RESET:-} $message\"\n  if [[ -n \"$suggestion\" ]]; then\n    echo \"  Suggestion: $suggestion\"\n  fi\n}\n\nsecurity_check_env_file() {\n  local env_file=\"${REPO_ROOT}/.env\"\n  local findings=0\n  local auth_accounts=\"\"\n  local token_secret=\"\"\n  local api_key=\"\"\n  local whitelist_paths=\"\"\n  local whitelist_is_set=\"no\"\n  local effective_whitelist=\"\"\n  local kv=\"\"\n  local vector=\"\"\n  local graph=\"\"\n  local doc_status=\"\"\n  local storage=\"\"\n  local db_type=\"\"\n  local opensearch_in_use=\"no\"\n  local key value\n  local invalid_sensitive_keys=()\n  local -A referenced_db_types=()\n\n  reset_state\n\n  if ! load_env_file \"$env_file\"; then\n    return 1\n  fi\n\n  auth_accounts=\"${ENV_VALUES[AUTH_ACCOUNTS]:-}\"\n  token_secret=\"${ENV_VALUES[TOKEN_SECRET]:-}\"\n  api_key=\"${ENV_VALUES[LIGHTRAG_API_KEY]:-}\"\n  kv=\"${ENV_VALUES[LIGHTRAG_KV_STORAGE]:-}\"\n  vector=\"${ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]:-}\"\n  graph=\"${ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]:-}\"\n  doc_status=\"${ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]:-}\"\n  if [[ -n \"${ENV_VALUES[WHITELIST_PATHS]+set}\" ]]; then\n    whitelist_paths=\"${ENV_VALUES[WHITELIST_PATHS]}\"\n    whitelist_is_set=\"yes\"\n  fi\n\n  for storage in \"$kv\" \"$vector\" \"$graph\" \"$doc_status\"; do\n    if [[ -z \"$storage\" ]]; then\n      continue\n    fi\n    if [[ ! -v \"STORAGE_DB_TYPES[$storage]\" ]]; then\n      continue\n    fi\n    db_type=\"${STORAGE_DB_TYPES[$storage]}\"\n    if [[ -n \"$db_type\" ]]; then\n      referenced_db_types[\"$db_type\"]=1\n    fi\n  done\n\n  if [[ -n \"${referenced_db_types[opensearch]+set}\" || \"${ENV_VALUES[LIGHTRAG_SETUP_OPENSEARCH_DEPLOYMENT]:-}\" == \"docker\" ]]; then\n    opensearch_in_use=\"yes\"\n  fi\n\n  for key in \"${!ENV_VALUES[@]}\"; do\n    if ! is_sensitive_env_key \"$key\"; then\n      continue\n    fi\n    value=\"${ENV_VALUES[$key]:-}\"\n    if [[ -n \"$value\" ]] && contains_env_interpolation_syntax \"$value\"; then\n      invalid_sensitive_keys+=(\"$key\")\n    fi\n  done\n\n  if ((${#invalid_sensitive_keys[@]} > 0)); then\n    report_security_issue \\\n      \"Sensitive values still contain \\${...} interpolation syntax: ${invalid_sensitive_keys[*]}\" \\\n      \"Replace them with literal values or inject those secrets at runtime.\"\n    findings=$((findings + 1))\n  fi\n\n  if [[ -z \"$auth_accounts\" && -z \"$api_key\" ]]; then\n    report_security_issue \\\n      \"No API protection is configured.\" \\\n      \"Set AUTH_ACCOUNTS and TOKEN_SECRET, add LIGHTRAG_API_KEY, or put the service behind a trusted reverse proxy.\"\n    findings=$((findings + 1))\n  fi\n\n  if [[ -n \"$auth_accounts\" ]]; then\n    if ! validate_auth_accounts_format \"$auth_accounts\"; then\n      report_security_issue \\\n        \"AUTH_ACCOUNTS is malformed.\" \\\n        \"Use comma-separated user:password pairs such as admin:secret or admin:secret,reader:another-secret.\"\n      findings=$((findings + 1))\n    fi\n\n    if [[ -z \"$token_secret\" ]]; then\n      report_security_issue \\\n        \"AUTH_ACCOUNTS is set but TOKEN_SECRET is missing.\" \\\n        \"Set a non-empty JWT signing secret before enabling account-based authentication.\"\n      findings=$((findings + 1))\n    elif [[ \"$token_secret\" == \"lightrag-jwt-default-secret\" ]]; then\n      report_security_issue \\\n        \"TOKEN_SECRET still uses the built-in default value.\" \\\n        \"Generate a unique JWT signing secret and update TOKEN_SECRET.\"\n      findings=$((findings + 1))\n    fi\n\n    effective_whitelist=\"$whitelist_paths\"\n    if [[ \"$whitelist_is_set\" != \"yes\" ]]; then\n      effective_whitelist=\"/health,/api/*\"\n    fi\n    if whitelist_exposes_api_routes \"$effective_whitelist\"; then\n      report_security_issue \\\n        \"WHITELIST_PATHS exposes /api routes while AUTH_ACCOUNTS is enabled.\" \\\n        \"Use a minimal whitelist such as /health,/docs and keep /api routes authenticated.\"\n      findings=$((findings + 1))\n    fi\n  fi\n\n  if [[ -z \"$auth_accounts\" && -n \"$api_key\" ]]; then\n    effective_whitelist=\"$whitelist_paths\"\n    if [[ \"$whitelist_is_set\" != \"yes\" ]]; then\n      effective_whitelist=\"/health,/api/*\"\n    fi\n    if whitelist_exposes_api_routes \"$effective_whitelist\"; then\n      report_security_issue \\\n        \"WHITELIST_PATHS exposes /api routes while LIGHTRAG_API_KEY is the only active auth mechanism.\" \\\n        \"Use a minimal whitelist such as /health,/docs and keep /api routes protected by the API key.\"\n      findings=$((findings + 1))\n    fi\n  fi\n\n  if [[ \"$opensearch_in_use\" == \"yes\" ]] && [[ -n \"${ENV_VALUES[OPENSEARCH_PASSWORD]:-}\" ]]; then\n    local os_pass=\"${ENV_VALUES[OPENSEARCH_PASSWORD]}\"\n    if [[ \"$os_pass\" == \"admin\" || \"$os_pass\" == \"LightRAG2026_!@\" ]]; then\n      report_security_issue \\\n        \"OPENSEARCH_PASSWORD uses a well-known default value.\" \\\n        \"Set a unique, strong password for the OpenSearch admin account.\"\n      findings=$((findings + 1))\n    fi\n  fi\n\n  if ((findings == 0)); then\n    log_success \"No obvious security issues found in ${env_file}.\"\n    return 0\n  fi\n\n  log_warn \"Security check found ${findings} issue(s) in ${env_file}.\"\n  return 1\n}\n\nbackup_only() {\n  local backup_path\n  local compose_backup_path\n\n  backup_path=\"$(backup_env_file)\"\n  if [[ -z \"$backup_path\" ]]; then\n    format_error \"No .env file found to back up.\" \"Create one with make env-base first.\"\n    return 1\n  fi\n  echo \"Backed up .env to $backup_path\"\n\n  compose_backup_path=\"$(backup_compose_file)\" || return 1\n  if [[ -n \"$compose_backup_path\" ]]; then\n    echo \"Backed up compose file to $compose_backup_path\"\n  fi\n}\n\nprint_help() {\n  cat <<'HELP'\nUsage: scripts/setup/setup.sh [--base|--storage|--server|--validate|--security-check|--backup] [--rewrite-compose]\n\nOptions:\n  --base         Configure LLM, embedding, and reranker (run first)\n  --storage      Configure storage backends and databases (requires .env)\n  --server       Configure server, security, and SSL (requires .env)\n  --validate     Validate an existing .env file\n  --security-check  Audit an existing .env for security risks\n  --backup       Backup the current .env and generated compose file when present\n  --rewrite-compose  Force regeneration of all wizard-managed compose services\n  --debug        Enable debug logging\n  --help         Show this help message\nHELP\n}\n\n_sigint_handler() {\n  echo \"\"\n  echo \"Setup interrupted.\"\n  exit 130\n}\n\nmain() {\n  trap '_sigint_handler' INT\n  init_colors\n  local mode=\"help\"\n\n  while [[ $# -gt 0 ]]; do\n    case \"$1\" in\n      --base)\n        mode=\"base\"\n        ;;\n      --storage)\n        mode=\"storage\"\n        ;;\n      --server)\n        mode=\"server\"\n        ;;\n      --validate)\n        mode=\"validate\"\n        ;;\n      --security-check)\n        mode=\"security-check\"\n        ;;\n      --backup)\n        mode=\"backup\"\n        ;;\n      --debug)\n        DEBUG=\"true\"\n        ;;\n      --rewrite-compose)\n        FORCE_REWRITE_COMPOSE=\"yes\"\n        ;;\n      --help|-h)\n        mode=\"help\"\n        ;;\n      *)\n        echo \"Unknown option: $1\" >&2\n        print_help\n        return 1\n        ;;\n    esac\n    shift\n  done\n\n  case \"$mode\" in\n    base)\n      env_base_flow\n      ;;\n    storage)\n      env_storage_flow\n      ;;\n    server)\n      env_server_flow\n      ;;\n    validate)\n      validate_env_file\n      ;;\n    security-check)\n      security_check_env_file\n      ;;\n    backup)\n      backup_only\n      ;;\n    *)\n      print_help\n      ;;\n  esac\n}\n\nif [[ \"${BASH_SOURCE[0]}\" == \"$0\" ]]; then\n  main \"$@\"\nfi\n"
  },
  {
    "path": "scripts/setup/templates/memgraph.yml",
    "content": "  memgraph:\n    image: memgraph/memgraph:latest\n    ports:\n      - \"${MEMGRAPH_BOLT_PORT:-7687}:7687\"\n    volumes:\n      - memgraph_data:/var/lib/memgraph\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' 7687)\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 20\n      start_period: 10s\n    stop_grace_period: 30s\n    restart: unless-stopped\n"
  },
  {
    "path": "scripts/setup/templates/milvus-gpu.yml",
    "content": "  milvus:\n    image: milvusdb/milvus:v2.6.11-gpu\n    command: [\"milvus\", \"run\", \"standalone\"]\n    security_opt:\n      - seccomp:unconfined\n    environment:\n      ETCD_ENDPOINTS: milvus-etcd:2379\n      MINIO_ADDRESS: milvus-minio:9000\n      MINIO_ACCESS_KEY_ID: \"${MINIO_ACCESS_KEY_ID:?missing}\"\n      MINIO_SECRET_ACCESS_KEY: \"${MINIO_SECRET_ACCESS_KEY:?missing}\"\n    # ports:\n    #   - \"19530:19530\"\n    #   - \"9091:9091\"\n    volumes:\n      - milvus_data:/var/lib/milvus\n    deploy:\n      resources:\n        reservations:\n          devices:\n            - driver: nvidia\n              capabilities: [\"gpu\"]\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' 19530)\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 20\n      start_period: 10s\n    depends_on:\n      milvus-etcd:\n        condition: service_healthy\n      milvus-minio:\n        condition: service_healthy\n    restart: unless-stopped\n\n  milvus-etcd:\n    image: quay.io/coreos/etcd:v3.5.25\n    environment:\n      ETCD_AUTO_COMPACTION_MODE: revision\n      ETCD_AUTO_COMPACTION_RETENTION: \"1000\"\n      ETCD_QUOTA_BACKEND_BYTES: \"4294967296\"\n      ETCD_SNAPSHOT_COUNT: \"50000\"\n    volumes:\n      - milvus-etcd_data:/etcd\n    command: >\n      etcd\n      -advertise-client-urls=http://0.0.0.0:2379\n      -listen-client-urls=http://0.0.0.0:2379\n      -data-dir /etcd\n    healthcheck:\n      test: [\"CMD\", \"etcdctl\", \"endpoint\", \"health\"]\n      interval: 30s\n      timeout: 20s\n      retries: 3\n    stop_grace_period: 30s\n    restart: unless-stopped\n\n  milvus-minio:\n    image: minio/minio:RELEASE.2025-09-07T16-13-09Z\n    environment:\n      MINIO_ROOT_USER: \"${MINIO_ACCESS_KEY_ID:?missing}\"\n      MINIO_ROOT_PASSWORD: \"${MINIO_SECRET_ACCESS_KEY:?missing}\"\n    volumes:\n      - milvus-minio_data:/minio_data\n    command: minio server /minio_data --console-address \":9001\"\n    healthcheck:\n      test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:9000/minio/health/live\"]\n      interval: 30s\n      timeout: 20s\n      retries: 3\n    stop_grace_period: 30s\n    restart: unless-stopped\n"
  },
  {
    "path": "scripts/setup/templates/milvus.yml",
    "content": "  milvus:\n    image: milvusdb/milvus:v2.6.11\n    command: [\"milvus\", \"run\", \"standalone\"]\n    security_opt:\n      - seccomp:unconfined\n    environment:\n      ETCD_ENDPOINTS: milvus-etcd:2379\n      MINIO_ADDRESS: milvus-minio:9000\n      MINIO_ACCESS_KEY_ID: \"${MINIO_ACCESS_KEY_ID:?missing}\"\n      MINIO_SECRET_ACCESS_KEY: \"${MINIO_SECRET_ACCESS_KEY:?missing}\"\n    # ports:\n    #   - \"19530:19530\"\n    #   - \"9091:9091\"\n    volumes:\n      - milvus_data:/var/lib/milvus\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' 19530)\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 20\n      start_period: 10s\n    depends_on:\n      milvus-etcd:\n        condition: service_healthy\n      milvus-minio:\n        condition: service_healthy\n    restart: unless-stopped\n\n  milvus-etcd:\n    image: quay.io/coreos/etcd:v3.5.25\n    environment:\n      ETCD_AUTO_COMPACTION_MODE: revision\n      ETCD_AUTO_COMPACTION_RETENTION: \"1000\"\n      ETCD_QUOTA_BACKEND_BYTES: \"4294967296\"\n      ETCD_SNAPSHOT_COUNT: \"50000\"\n    volumes:\n      - milvus-etcd_data:/etcd\n    command: >\n      etcd\n      -advertise-client-urls=http://0.0.0.0:2379\n      -listen-client-urls=http://0.0.0.0:2379\n      -data-dir /etcd\n    healthcheck:\n      test: [\"CMD\", \"etcdctl\", \"endpoint\", \"health\"]\n      interval: 30s\n      timeout: 20s\n      retries: 3\n    stop_grace_period: 30s\n    restart: unless-stopped\n\n  milvus-minio:\n    image: minio/minio:RELEASE.2025-09-07T16-13-09Z\n    environment:\n      MINIO_ROOT_USER: \"${MINIO_ACCESS_KEY_ID:?missing}\"\n      MINIO_ROOT_PASSWORD: \"${MINIO_SECRET_ACCESS_KEY:?missing}\"\n    volumes:\n      - milvus-minio_data:/minio_data\n    command: minio server /minio_data --console-address \":9001\"\n    healthcheck:\n      test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:9000/minio/health/live\"]\n      interval: 30s\n      timeout: 20s\n      retries: 3\n    stop_grace_period: 30s\n    restart: unless-stopped\n"
  },
  {
    "path": "scripts/setup/templates/mongodb.yml",
    "content": "  mongodb:\n    image: mongo:8.2.4\n    # ports:\n    #   - \"27017:27017\"\n    volumes:\n      - mongo_data:/data/db\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' 27017)\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 20\n      start_period: 10s\n    stop_grace_period: 30s\n    restart: unless-stopped\n"
  },
  {
    "path": "scripts/setup/templates/neo4j.yml",
    "content": "  neo4j:\n    image: neo4j:5-community\n    # ports:\n    #   - \"7474:7474\"\n    #   - \"7687:7687\"\n    volumes:\n      - neo4j_data:/data\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' 7687)\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 20\n      start_period: 10s\n    stop_grace_period: 30s\n    restart: unless-stopped\n"
  },
  {
    "path": "scripts/setup/templates/opensearch.yml",
    "content": "  opensearch:\n    image: opensearchproject/opensearch:3\n    environment:\n      - discovery.type=single-node\n      - OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_PASSWORD:?missing}\n      - OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m\n    # ports:\n    #   - \"9200:9200\"\n    ulimits:\n      memlock:\n        soft: -1\n        hard: -1\n      nofile:\n        soft: 65536\n        hard: 65536\n    volumes:\n      - opensearch_data:/usr/share/opensearch/data\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' 9200)\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 10s\n      timeout: 5s\n      retries: 30\n      start_period: 30s\n    stop_grace_period: 30s\n    restart: unless-stopped\n"
  },
  {
    "path": "scripts/setup/templates/postgres.yml",
    "content": "  postgres:\n    image: gzdaniel/postgres-for-rag:16.6\n    command: [\"sh\", \"-c\", \"service postgresql start && sleep infinity\"]\n    # ports:\n    #   - \"5432:5432\"\n    volumes:\n      - postgres_data:/var/lib/postgresql\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' 5432)\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 20\n      start_period: 10s\n    stop_grace_period: 30s\n    restart: unless-stopped\n"
  },
  {
    "path": "scripts/setup/templates/qdrant-gpu.yml",
    "content": "  qdrant:\n    image: qdrant/qdrant:gpu-nvidia-latest\n    # ports:\n    #   - \"6333:6333\"\n    volumes:\n      - qdrant_data:/qdrant/storage\n    deploy:\n      resources:\n        reservations:\n          devices:\n            - driver: nvidia\n              capabilities: [\"gpu\"]\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' 6333)\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 20\n      start_period: 10s\n    stop_grace_period: 30s\n    restart: unless-stopped\n"
  },
  {
    "path": "scripts/setup/templates/qdrant.yml",
    "content": "  qdrant:\n    image: qdrant/qdrant:latest\n    # ports:\n    #   - \"6333:6333\"\n    volumes:\n      - qdrant_data:/qdrant/storage\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' 6333)\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 20\n      start_period: 10s\n    stop_grace_period: 30s\n    restart: unless-stopped\n"
  },
  {
    "path": "scripts/setup/templates/redis.conf.template",
    "content": "# redis.conf - setup-managed default for the bundled Redis service\n\n# Network settings\nbind 0.0.0.0\nport 6379\nprotected-mode no\n\n# General settings\ndaemonize no\nloglevel warning\nlogfile \"/data/redis.log\"\ndir /data\n\n# RDB persistence\nsave 900 1\nsave 300 10\nsave 60 1000\nstop-writes-on-bgsave-error yes\nrdbcompression yes\nrdbchecksum yes\ndbfilename dump.rdb\n\n# AOF persistence\nappendonly yes\nappendfilename \"appendonly.aof\"\nappendfsync everysec\nno-appendfsync-on-rewrite no\nauto-aof-rewrite-percentage 100\nauto-aof-rewrite-min-size 64mb\n\n# Client limits\nmaxclients 10000\n\n# Memory management\nmaxmemory 4gb\nmaxmemory-policy noeviction\n"
  },
  {
    "path": "scripts/setup/templates/redis.yml",
    "content": "  redis:\n    image: redis:latest\n    command: [\"redis-server\", \"/usr/local/etc/redis/redis.conf\"]\n    # ports:\n    #   - \"6379:6379\"\n    volumes:\n      - redis_data:/data\n      - ./data/config/redis.conf:/usr/local/etc/redis/redis.conf:ro\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' 6379)\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 20\n      start_period: 10s\n    stop_grace_period: 30s\n    restart: unless-stopped\n"
  },
  {
    "path": "scripts/setup/templates/vllm-embed-gpu.yml",
    "content": "  vllm-embed:\n    image: vllm/vllm-openai:latest\n    runtime: nvidia\n    command: >\n      --model ${VLLM_EMBED_MODEL:-BAAI/bge-m3}\n      --port ${VLLM_EMBED_PORT:-8001}\n      --dtype float16\n      --api-key ${VLLM_EMBED_API_KEY}\n      ${VLLM_EMBED_EXTRA_ARGS:-}\n    environment:\n      NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}\n      NVIDIA_DRIVER_CAPABILITIES: ${NVIDIA_DRIVER_CAPABILITIES:-compute,utility}\n    ports:\n      - \"${VLLM_EMBED_PORT:-8001}:${VLLM_EMBED_PORT:-8001}\"\n    volumes:\n      - vllm_embed_cache:/root/.cache/huggingface\n    ipc: host\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' ${VLLM_EMBED_PORT:-8001})\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 120\n      start_period: 10s\n    deploy:\n      resources:\n        reservations:\n          devices:\n            - driver: nvidia\n              count: all\n              capabilities: [gpu]\n    restart: unless-stopped\n"
  },
  {
    "path": "scripts/setup/templates/vllm-embed.yml",
    "content": "  vllm-embed:\n    image: vllm/vllm-openai-cpu:latest\n    command: >\n      --model ${VLLM_EMBED_MODEL:-BAAI/bge-m3}\n      --port ${VLLM_EMBED_PORT:-8001}\n      --dtype float32\n      --api-key ${VLLM_EMBED_API_KEY}\n      ${VLLM_EMBED_EXTRA_ARGS:-}\n    ports:\n      - \"${VLLM_EMBED_PORT:-8001}:${VLLM_EMBED_PORT:-8001}\"\n    volumes:\n      - vllm_embed_cache:/root/.cache/huggingface\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' ${VLLM_EMBED_PORT:-8001})\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 120\n      start_period: 10s\n    restart: unless-stopped\n"
  },
  {
    "path": "scripts/setup/templates/vllm-rerank-gpu.yml",
    "content": "  vllm-rerank:\n    image: vllm/vllm-openai:latest\n    runtime: nvidia\n    command: >\n      --model ${VLLM_RERANK_MODEL:-BAAI/bge-reranker-v2-m3}\n      --port ${VLLM_RERANK_PORT:-8000}\n      --dtype float16\n      --api-key ${VLLM_RERANK_API_KEY}\n      ${VLLM_RERANK_EXTRA_ARGS:-}\n    environment:\n      NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}\n      NVIDIA_DRIVER_CAPABILITIES: ${NVIDIA_DRIVER_CAPABILITIES:-compute,utility}\n    ports:\n      - \"${VLLM_RERANK_PORT:-8000}:${VLLM_RERANK_PORT:-8000}\"\n    volumes:\n      - vllm_rerank_cache:/root/.cache/huggingface\n    ipc: host\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' ${VLLM_RERANK_PORT:-8000})\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 120\n      start_period: 10s\n    deploy:\n      resources:\n        reservations:\n          devices:\n            - driver: nvidia\n              count: all\n              capabilities: [gpu]\n    restart: unless-stopped\n"
  },
  {
    "path": "scripts/setup/templates/vllm-rerank.yml",
    "content": "  vllm-rerank:\n    image: vllm/vllm-openai-cpu:latest\n    command: >\n      --model ${VLLM_RERANK_MODEL:-BAAI/bge-reranker-v2-m3}\n      --port ${VLLM_RERANK_PORT:-8000}\n      --dtype float32\n      --api-key ${VLLM_RERANK_API_KEY}\n      ${VLLM_RERANK_EXTRA_ARGS:-}\n    ports:\n      - \"${VLLM_RERANK_PORT:-8000}:${VLLM_RERANK_PORT:-8000}\"\n    volumes:\n      - vllm_rerank_cache:/root/.cache/huggingface\n    healthcheck:\n      test:\n        - CMD-SHELL\n        - 'PORT_HEX=\"$(printf ''%04X'' ${VLLM_RERANK_PORT:-8000})\"; cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep -q \":$${PORT_HEX} \"'\n      interval: 5s\n      timeout: 3s\n      retries: 120\n      start_period: 10s\n    restart: unless-stopped\n"
  },
  {
    "path": "scripts/test.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\nROOT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" && pwd)\"\ncd \"$ROOT_DIR\"\n\nif [ \"$#\" -eq 0 ]; then\n    set -- tests\nfi\n\ndeclare -a TRIED=()\n\nrun_python() {\n    local candidate=\"$1\"\n    local label=\"$2\"\n    local resolved=\"\"\n    shift 2\n\n    TRIED+=(\"$label: $candidate\")\n\n    if [[ \"$candidate\" == */* ]]; then\n        if [ ! -x \"$candidate\" ]; then\n            return 1\n        fi\n        resolved=\"$candidate\"\n    else\n        resolved=\"$(command -v \"$candidate\" 2>/dev/null || true)\"\n        if [ -z \"$resolved\" ]; then\n            return 1\n        fi\n    fi\n\n    if \"$resolved\" -c \"import pytest\" >/dev/null 2>&1; then\n        printf \"Using %s: %s\\n\" \"$label\" \"$resolved\"\n        exec \"$resolved\" -m pytest \"$@\"\n    fi\n\n    return 1\n}\n\nrun_uv() {\n    if ! command -v uv >/dev/null 2>&1 || [ ! -f \"$ROOT_DIR/uv.lock\" ]; then\n        return 1\n    fi\n\n    TRIED+=(\"uv-managed environment: uv run python -m pytest\")\n\n    if uv run python -c \"import pytest\" >/dev/null 2>&1; then\n        printf \"Using uv-managed environment\\n\"\n        exec uv run python -m pytest \"$@\"\n    fi\n\n    return 1\n}\n\nif [ -n \"${PYTHON:-}\" ]; then\n    if run_python \"$PYTHON\" \"PYTHON override\" \"$@\"; then\n        exit 0\n    fi\n    printf \"Configured PYTHON does not provide pytest: %s\\n\" \"$PYTHON\" >&2\n    exit 1\nfi\n\nif [ -n \"${VIRTUAL_ENV:-}\" ]; then\n    run_python \"$VIRTUAL_ENV/bin/python\" \"active virtualenv\" \"$@\" || true\nfi\n\nrun_uv \"$@\" || true\nrun_python \"$ROOT_DIR/.venv/bin/python\" \"repo .venv\" \"$@\" || true\nrun_python \"$ROOT_DIR/venv/bin/python\" \"repo venv\" \"$@\" || true\nrun_python python \"PATH python\" \"$@\" || true\nrun_python python3 \"PATH python3\" \"$@\" || true\n\nprintf \"Unable to find a Python environment with pytest available.\\n\" >&2\nprintf \"Tried:\\n\" >&2\nfor entry in \"${TRIED[@]}\"; do\n    printf \"  - %s\\n\" \"$entry\" >&2\ndone\nprintf \"Set PYTHON=/path/to/python, activate a virtualenv, create .venv/venv, or sync the project environment.\\n\" >&2\nexit 1\n"
  },
  {
    "path": "setup.py",
    "content": "# Minimal setup.py for backward compatibility\n# Primary configuration is now in pyproject.toml\n\nfrom setuptools import setup\n\nsetup()\n"
  },
  {
    "path": "tests/README_WORKSPACE_ISOLATION_TESTS.md",
    "content": "# Workspace Isolation Test Suite\n\n## Overview\nComprehensive test coverage for LightRAG's workspace isolation feature, ensuring that different workspaces (projects) can coexist independently without data contamination or resource conflicts.\n\n## Test Architecture\n\n### Design Principles\n1. **Concurrency-Based Assertions**: Instead of timing-based tests (which are flaky), we measure actual concurrent lock holders\n2. **Timeline Validation**: Finite state machine validates proper sequential execution\n3. **Performance Metrics**: Each test reports execution metrics for debugging and optimization\n4. **Configurable Stress Testing**: Environment variables control test intensity\n\n## Test Categories\n\n### 1. Data Isolation Tests\n**Tests:** 1, 4, 8, 9, 10\n**Purpose:** Verify that data in one workspace doesn't leak into another\n\n- **Test 1: Pipeline Status Isolation** - Core shared data structures remain separate\n- **Test 4: Multi-Workspace Concurrency** - Concurrent operations don't interfere\n- **Test 8: Update Flags Isolation** - Flag management respects workspace boundaries\n- **Test 9: Empty Workspace Standardization** - Edge case handling for empty workspace strings\n- **Test 10: JsonKVStorage Integration** - Storage layer properly isolates data\n\n### 2. Lock Mechanism Tests\n**Tests:** 2, 5, 6\n**Purpose:** Validate that locking mechanisms allow parallelism across workspaces while enforcing serialization within workspaces\n\n- **Test 2: Lock Mechanism** - Different workspaces run in parallel, same workspace serializes\n- **Test 5: Re-entrance Protection** - Prevent deadlocks from re-entrant lock acquisition\n- **Test 6: Namespace Lock Isolation** - Different namespaces within same workspace are independent\n\n### 3. Backward Compatibility Tests\n**Test:** 3\n**Purpose:** Ensure legacy code without workspace parameters still functions correctly\n\n- Default workspace fallback behavior\n- Empty workspace handling\n- None vs empty string normalization\n\n### 4. Error Handling Tests\n**Test:** 7\n**Purpose:** Validate guardrails for invalid configurations\n\n- Missing workspace validation\n- Workspace normalization\n- Edge case handling\n\n### 5. End-to-End Integration Tests\n**Test:** 11\n**Purpose:** Validate complete LightRAG workflows maintain isolation\n\n- Full document insertion pipeline\n- File system separation\n- Data content verification\n\n## Running Tests\n\n### Basic Usage\n```bash\n# Run all workspace isolation tests\npytest tests/test_workspace_isolation.py -v\n\n# Run specific test\npytest tests/test_workspace_isolation.py::test_lock_mechanism -v\n\n# Run with detailed output\npytest tests/test_workspace_isolation.py -v -s\n```\n\n### Environment Configuration\n\n#### Stress Testing\nEnable stress testing with configurable number of workers:\n```bash\n# Enable stress mode with default 3 workers\nLIGHTRAG_STRESS_TEST=true pytest tests/test_workspace_isolation.py -v\n\n# Custom number of workers (e.g., 10)\nLIGHTRAG_STRESS_TEST=true LIGHTRAG_TEST_WORKERS=10 pytest tests/test_workspace_isolation.py -v\n```\n\n#### Keep Test Artifacts\nPreserve temporary directories for manual inspection:\n```bash\n# Keep test artifacts (useful for debugging)\nLIGHTRAG_KEEP_ARTIFACTS=true pytest tests/test_workspace_isolation.py -v\n```\n\n#### Combined Example\n```bash\n# Stress test with 20 workers and keep artifacts\nLIGHTRAG_STRESS_TEST=true \\\nLIGHTRAG_TEST_WORKERS=20 \\\nLIGHTRAG_KEEP_ARTIFACTS=true \\\npytest tests/test_workspace_isolation.py::test_lock_mechanism -v -s\n```\n\n### CI/CD Integration\n```bash\n# Recommended CI/CD command (no artifacts, default workers)\npytest tests/test_workspace_isolation.py -v --tb=short\n```\n\n## Test Implementation Details\n\n### Helper Functions\n\n#### `_measure_lock_parallelism`\nMeasures actual concurrency rather than wall-clock time.\n\n**Returns:**\n- `max_parallel`: Peak number of concurrent lock holders\n- `timeline`: Ordered list of (task_name, event) tuples\n- `metrics`: Dict with performance data (duration, concurrency, workers)\n\n**Example:**\n```python\nworkload = [\n    (\"task1\", \"workspace1\", \"namespace\"),\n    (\"task2\", \"workspace2\", \"namespace\"),\n]\nmax_parallel, timeline, metrics = await _measure_lock_parallelism(workload)\n\n# Assert on actual behavior, not timing\nassert max_parallel >= 2  # Two different workspaces should run concurrently\n```\n\n#### `_assert_no_timeline_overlap`\nValidates sequential execution using finite state machine.\n\n**Validates:**\n- No overlapping lock acquisitions\n- Proper lock release ordering\n- All locks properly released\n\n**Example:**\n```python\ntimeline = [\n    (\"task1\", \"start\"),\n    (\"task1\", \"end\"),\n    (\"task2\", \"start\"),\n    (\"task2\", \"end\"),\n]\n_assert_no_timeline_overlap(timeline)  # Passes - no overlap\n\ntimeline_bad = [\n    (\"task1\", \"start\"),\n    (\"task2\", \"start\"),  # ERROR: task2 started before task1 ended\n    (\"task1\", \"end\"),\n]\n_assert_no_timeline_overlap(timeline_bad)  # Raises AssertionError\n```\n\n## Configuration Variables\n\n| Variable | Type | Default | Description |\n|----------|------|---------|-------------|\n| `LIGHTRAG_STRESS_TEST` | bool | `false` | Enable stress testing mode |\n| `LIGHTRAG_TEST_WORKERS` | int | `3` | Number of parallel workers in stress mode |\n| `LIGHTRAG_KEEP_ARTIFACTS` | bool | `false` | Keep temporary test directories |\n\n## Performance Benchmarks\n\n### Expected Performance (Reference System)\n- **Test 1-9**: < 1s each\n- **Test 10**: < 2s (includes file I/O)\n- **Test 11**: < 5s (includes full RAG pipeline)\n- **Total Suite**: < 15s\n\n### Stress Test Performance\nWith `LIGHTRAG_TEST_WORKERS=10`:\n- **Test 2 (Parallel)**: ~0.05s (10 workers, all concurrent)\n- **Test 2 (Serial)**: ~0.10s (2 workers, serialized)\n\n## Troubleshooting\n\n### Common Issues\n\n#### Flaky Test Failures\n**Symptom:** Tests pass locally but fail in CI/CD\n**Cause:** System under heavy load, timing-based assertions\n**Solution:** Our tests use concurrency-based assertions, not timing. If failures persist, check the `timeline` output in error messages.\n\n#### Resource Cleanup Errors\n**Symptom:** \"Directory not empty\" or \"Cannot remove directory\"\n**Cause:** Concurrent test execution or OS file locking\n**Solution:** Run tests serially (`pytest -n 1`) or use `LIGHTRAG_KEEP_ARTIFACTS=true` to inspect state\n\n#### Lock Timeout Errors\n**Symptom:** \"Lock acquisition timeout\"\n**Cause:** Deadlock or resource starvation\n**Solution:** Check test output for deadlock patterns, review lock acquisition order\n\n### Debug Tips\n\n1. **Enable verbose output:**\n   ```bash\n   pytest tests/test_workspace_isolation.py -v -s\n   ```\n\n2. **Run single test with artifacts:**\n   ```bash\n   LIGHTRAG_KEEP_ARTIFACTS=true pytest tests/test_workspace_isolation.py::test_json_kv_storage_workspace_isolation -v -s\n   ```\n\n3. **Check performance metrics:**\n   Look for the \"Performance:\" lines in test output showing duration and concurrency.\n\n4. **Inspect timeline on failure:**\n   Timeline data is included in assertion error messages.\n\n## Contributing\n\n### Adding New Tests\n\n1. **Follow naming convention:** `test_<feature>_<aspect>`\n2. **Add purpose/scope comments:** Explain what and why\n3. **Use helper functions:** `_measure_lock_parallelism`, `_assert_no_timeline_overlap`\n4. **Document assertions:** Explain expected behavior in assertions\n5. **Update this README:** Add test to appropriate category\n\n### Test Template\n```python\n@pytest.mark.asyncio\nasync def test_new_feature():\n    \"\"\"\n    Brief description of what this test validates.\n    \"\"\"\n    # Purpose: Why this test exists\n    # Scope: What functions/classes this tests\n    print(\"\\n\" + \"=\" * 60)\n    print(\"TEST N: Feature Name\")\n    print(\"=\" * 60)\n\n    # Test implementation\n    # ...\n\n    print(\"✅ PASSED: Feature Name\")\n    print(f\"   Validation details\")\n```\n\n## Related Documentation\n\n- [Workspace Isolation Design Doc](../docs/LightRAG_concurrent_explain.md)\n- [Project Intelligence](.clinerules/01-basic.md)\n- [Memory Bank](../.memory-bank/)\n\n## Test Coverage Matrix\n\n| Component | Data Isolation | Lock Mechanism | Backward Compat | Error Handling | E2E |\n|-----------|:--------------:|:--------------:|:---------------:|:--------------:|:---:|\n| shared_storage | ✅ T1, T4 | ✅ T2, T5, T6 | ✅ T3 | ✅ T7 | ✅ T11 |\n| update_flags | ✅ T8 | - | - | - | - |\n| JsonKVStorage | ✅ T10 | - | - | - | ✅ T11 |\n| LightRAG Core | - | - | - | - | ✅ T11 |\n| Namespace | ✅ T9 | - | ✅ T3 | ✅ T7 | - |\n\n**Legend:** T# = Test number\n\n## Version History\n\n- **v2.0** (2025-01-18): Added performance metrics, stress testing, configurable cleanup\n- **v1.0** (Initial): Basic workspace isolation tests with timing-based assertions\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "\"\"\"\nPytest configuration for LightRAG tests.\n\nThis file provides command-line options and fixtures for test configuration.\n\"\"\"\n\nimport pytest\n\n\ndef pytest_configure(config):\n    \"\"\"Register custom markers for LightRAG tests.\"\"\"\n    config.addinivalue_line(\n        \"markers\", \"offline: marks tests as offline (no external dependencies)\"\n    )\n    config.addinivalue_line(\n        \"markers\",\n        \"integration: marks tests requiring external services (skipped by default)\",\n    )\n    config.addinivalue_line(\"markers\", \"requires_db: marks tests requiring database\")\n    config.addinivalue_line(\n        \"markers\", \"requires_api: marks tests requiring LightRAG API server\"\n    )\n\n\ndef pytest_addoption(parser):\n    \"\"\"Add custom command-line options for LightRAG tests.\"\"\"\n\n    parser.addoption(\n        \"--keep-artifacts\",\n        action=\"store_true\",\n        default=False,\n        help=\"Keep test artifacts (temporary directories and files) after test completion for inspection\",\n    )\n\n    parser.addoption(\n        \"--stress-test\",\n        action=\"store_true\",\n        default=False,\n        help=\"Enable stress test mode with more intensive workloads\",\n    )\n\n    parser.addoption(\n        \"--test-workers\",\n        action=\"store\",\n        default=3,\n        type=int,\n        help=\"Number of parallel workers for stress tests (default: 3)\",\n    )\n\n    parser.addoption(\n        \"--run-integration\",\n        action=\"store_true\",\n        default=False,\n        help=\"Run integration tests that require external services (database, API server, etc.)\",\n    )\n\n\ndef pytest_collection_modifyitems(config, items):\n    \"\"\"Modify test collection to skip integration tests by default.\n\n    Integration tests are skipped unless --run-integration flag is provided.\n    This allows running offline tests quickly without needing external services.\n    \"\"\"\n    if config.getoption(\"--run-integration\"):\n        # If --run-integration is specified, run all tests\n        return\n\n    skip_integration = pytest.mark.skip(\n        reason=\"Requires external services(DB/API), use --run-integration to run\"\n    )\n\n    for item in items:\n        if \"integration\" in item.keywords:\n            item.add_marker(skip_integration)\n\n\n@pytest.fixture(scope=\"session\")\ndef keep_test_artifacts(request):\n    \"\"\"\n    Fixture to determine whether to keep test artifacts.\n\n    Priority: CLI option > Environment variable > Default (False)\n    \"\"\"\n    import os\n\n    # Check CLI option first\n    if request.config.getoption(\"--keep-artifacts\"):\n        return True\n\n    # Fall back to environment variable\n    return os.getenv(\"LIGHTRAG_KEEP_ARTIFACTS\", \"false\").lower() == \"true\"\n\n\n@pytest.fixture(scope=\"session\")\ndef stress_test_mode(request):\n    \"\"\"\n    Fixture to determine whether stress test mode is enabled.\n\n    Priority: CLI option > Environment variable > Default (False)\n    \"\"\"\n    import os\n\n    # Check CLI option first\n    if request.config.getoption(\"--stress-test\"):\n        return True\n\n    # Fall back to environment variable\n    return os.getenv(\"LIGHTRAG_STRESS_TEST\", \"false\").lower() == \"true\"\n\n\n@pytest.fixture(scope=\"session\")\ndef parallel_workers(request):\n    \"\"\"\n    Fixture to determine the number of parallel workers for stress tests.\n\n    Priority: CLI option > Environment variable > Default (3)\n    \"\"\"\n    import os\n\n    # Check CLI option first\n    cli_workers = request.config.getoption(\"--test-workers\")\n    if cli_workers != 3:  # Non-default value provided\n        return cli_workers\n\n    # Fall back to environment variable\n    return int(os.getenv(\"LIGHTRAG_TEST_WORKERS\", \"3\"))\n\n\n@pytest.fixture(scope=\"session\")\ndef run_integration_tests(request):\n    \"\"\"\n    Fixture to determine whether to run integration tests.\n\n    Priority: CLI option > Environment variable > Default (False)\n    \"\"\"\n    import os\n\n    # Check CLI option first\n    if request.config.getoption(\"--run-integration\"):\n        return True\n\n    # Fall back to environment variable\n    return os.getenv(\"LIGHTRAG_RUN_INTEGRATION\", \"false\").lower() == \"true\"\n"
  },
  {
    "path": "tests/test_aquery_data_endpoint.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest script: Demonstrates usage of aquery_data FastAPI endpoint\nQuery content: Who is the author of LightRAG\n\nUpdated to handle the new data format where:\n- Response includes status, message, data, and metadata fields at top level\n- Actual query results (entities, relationships, chunks, references) are nested under 'data' field\n- Includes backward compatibility with legacy format\n\"\"\"\n\nimport pytest\nimport requests\nimport time\nimport json\nfrom typing import Dict, Any, List, Optional\n\n# API configuration\nAPI_KEY = \"your-secure-api-key-here-123\"\nBASE_URL = \"http://localhost:9621\"\n\n# Unified authentication headers\nAUTH_HEADERS = {\"Content-Type\": \"application/json\", \"X-API-Key\": API_KEY}\n\n\ndef validate_references_format(references: List[Dict[str, Any]]) -> bool:\n    \"\"\"Validate the format of references list\"\"\"\n    if not isinstance(references, list):\n        print(f\"❌ References should be a list, got {type(references)}\")\n        return False\n\n    for i, ref in enumerate(references):\n        if not isinstance(ref, dict):\n            print(f\"❌ Reference {i} should be a dict, got {type(ref)}\")\n            return False\n\n        required_fields = [\"reference_id\", \"file_path\"]\n        for field in required_fields:\n            if field not in ref:\n                print(f\"❌ Reference {i} missing required field: {field}\")\n                return False\n\n            if not isinstance(ref[field], str):\n                print(\n                    f\"❌ Reference {i} field '{field}' should be string, got {type(ref[field])}\"\n                )\n                return False\n\n    return True\n\n\ndef parse_streaming_response(\n    response_text: str,\n) -> tuple[Optional[List[Dict]], List[str], List[str]]:\n    \"\"\"Parse streaming response and extract references, response chunks, and errors\"\"\"\n    references = None\n    response_chunks = []\n    errors = []\n\n    lines = response_text.strip().split(\"\\n\")\n\n    for line in lines:\n        line = line.strip()\n        if not line or line.startswith(\"data: \"):\n            if line.startswith(\"data: \"):\n                line = line[6:]  # Remove 'data: ' prefix\n\n        if not line:\n            continue\n\n        try:\n            data = json.loads(line)\n\n            if \"references\" in data:\n                references = data[\"references\"]\n            if \"response\" in data:\n                response_chunks.append(data[\"response\"])\n            if \"error\" in data:\n                errors.append(data[\"error\"])\n\n        except json.JSONDecodeError:\n            # Skip non-JSON lines (like SSE comments)\n            continue\n\n    return references, response_chunks, errors\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef test_query_endpoint_references():\n    \"\"\"Test /query endpoint references functionality\"\"\"\n\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Testing /query endpoint references functionality\")\n    print(\"=\" * 60)\n\n    query_text = \"who authored LightRAG\"\n    endpoint = f\"{BASE_URL}/query\"\n\n    # Test 1: References enabled (default)\n    print(\"\\n🧪 Test 1: References enabled (default)\")\n    print(\"-\" * 40)\n\n    try:\n        response = requests.post(\n            endpoint,\n            json={\"query\": query_text, \"mode\": \"mix\", \"include_references\": True},\n            headers=AUTH_HEADERS,\n            timeout=30,\n        )\n\n        if response.status_code == 200:\n            data = response.json()\n\n            # Check response structure\n            if \"response\" not in data:\n                print(\"❌ Missing 'response' field\")\n                return False\n\n            if \"references\" not in data:\n                print(\"❌ Missing 'references' field when include_references=True\")\n                return False\n\n            references = data[\"references\"]\n            if references is None:\n                print(\"❌ References should not be None when include_references=True\")\n                return False\n\n            if not validate_references_format(references):\n                return False\n\n            print(f\"✅ References enabled: Found {len(references)} references\")\n            print(f\"   Response length: {len(data['response'])} characters\")\n\n            # Display reference list\n            if references:\n                print(\"   📚 Reference List:\")\n                for i, ref in enumerate(references, 1):\n                    ref_id = ref.get(\"reference_id\", \"Unknown\")\n                    file_path = ref.get(\"file_path\", \"Unknown\")\n                    print(f\"      {i}. ID: {ref_id} | File: {file_path}\")\n\n        else:\n            print(f\"❌ Request failed: {response.status_code}\")\n            print(f\"   Error: {response.text}\")\n            return False\n\n    except Exception as e:\n        print(f\"❌ Test 1 failed: {str(e)}\")\n        return False\n\n    # Test 2: References disabled\n    print(\"\\n🧪 Test 2: References disabled\")\n    print(\"-\" * 40)\n\n    try:\n        response = requests.post(\n            endpoint,\n            json={\"query\": query_text, \"mode\": \"mix\", \"include_references\": False},\n            headers=AUTH_HEADERS,\n            timeout=30,\n        )\n\n        if response.status_code == 200:\n            data = response.json()\n\n            # Check response structure\n            if \"response\" not in data:\n                print(\"❌ Missing 'response' field\")\n                return False\n\n            references = data.get(\"references\")\n            if references is not None:\n                print(\"❌ References should be None when include_references=False\")\n                return False\n\n            print(\"✅ References disabled: No references field present\")\n            print(f\"   Response length: {len(data['response'])} characters\")\n\n        else:\n            print(f\"❌ Request failed: {response.status_code}\")\n            print(f\"   Error: {response.text}\")\n            return False\n\n    except Exception as e:\n        print(f\"❌ Test 2 failed: {str(e)}\")\n        return False\n\n    print(\"\\n✅ /query endpoint references tests passed!\")\n    return True\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef test_query_stream_endpoint_references():\n    \"\"\"Test /query/stream endpoint references functionality\"\"\"\n\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Testing /query/stream endpoint references functionality\")\n    print(\"=\" * 60)\n\n    query_text = \"who authored LightRAG\"\n    endpoint = f\"{BASE_URL}/query/stream\"\n\n    # Test 1: Streaming with references enabled\n    print(\"\\n🧪 Test 1: Streaming with references enabled\")\n    print(\"-\" * 40)\n\n    try:\n        response = requests.post(\n            endpoint,\n            json={\"query\": query_text, \"mode\": \"mix\", \"include_references\": True},\n            headers=AUTH_HEADERS,\n            timeout=30,\n            stream=True,\n        )\n\n        if response.status_code == 200:\n            # Collect streaming response\n            full_response = \"\"\n            for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):\n                if chunk:\n                    # Ensure chunk is string type\n                    if isinstance(chunk, bytes):\n                        chunk = chunk.decode(\"utf-8\")\n                    full_response += chunk\n\n            # Parse streaming response\n            references, response_chunks, errors = parse_streaming_response(\n                full_response\n            )\n\n            if errors:\n                print(f\"❌ Errors in streaming response: {errors}\")\n                return False\n\n            if references is None:\n                print(\"❌ No references found in streaming response\")\n                return False\n\n            if not validate_references_format(references):\n                return False\n\n            if not response_chunks:\n                print(\"❌ No response chunks found in streaming response\")\n                return False\n\n            print(f\"✅ Streaming with references: Found {len(references)} references\")\n            print(f\"   Response chunks: {len(response_chunks)}\")\n            print(\n                f\"   Total response length: {sum(len(chunk) for chunk in response_chunks)} characters\"\n            )\n\n            # Display reference list\n            if references:\n                print(\"   📚 Reference List:\")\n                for i, ref in enumerate(references, 1):\n                    ref_id = ref.get(\"reference_id\", \"Unknown\")\n                    file_path = ref.get(\"file_path\", \"Unknown\")\n                    print(f\"      {i}. ID: {ref_id} | File: {file_path}\")\n\n        else:\n            print(f\"❌ Request failed: {response.status_code}\")\n            print(f\"   Error: {response.text}\")\n            return False\n\n    except Exception as e:\n        print(f\"❌ Test 1 failed: {str(e)}\")\n        return False\n\n    # Test 2: Streaming with references disabled\n    print(\"\\n🧪 Test 2: Streaming with references disabled\")\n    print(\"-\" * 40)\n\n    try:\n        response = requests.post(\n            endpoint,\n            json={\"query\": query_text, \"mode\": \"mix\", \"include_references\": False},\n            headers=AUTH_HEADERS,\n            timeout=30,\n            stream=True,\n        )\n\n        if response.status_code == 200:\n            # Collect streaming response\n            full_response = \"\"\n            for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):\n                if chunk:\n                    # Ensure chunk is string type\n                    if isinstance(chunk, bytes):\n                        chunk = chunk.decode(\"utf-8\")\n                    full_response += chunk\n\n            # Parse streaming response\n            references, response_chunks, errors = parse_streaming_response(\n                full_response\n            )\n\n            if errors:\n                print(f\"❌ Errors in streaming response: {errors}\")\n                return False\n\n            if references is not None:\n                print(\"❌ References should be None when include_references=False\")\n                return False\n\n            if not response_chunks:\n                print(\"❌ No response chunks found in streaming response\")\n                return False\n\n            print(\"✅ Streaming without references: No references present\")\n            print(f\"   Response chunks: {len(response_chunks)}\")\n            print(\n                f\"   Total response length: {sum(len(chunk) for chunk in response_chunks)} characters\"\n            )\n\n        else:\n            print(f\"❌ Request failed: {response.status_code}\")\n            print(f\"   Error: {response.text}\")\n            return False\n\n    except Exception as e:\n        print(f\"❌ Test 2 failed: {str(e)}\")\n        return False\n\n    print(\"\\n✅ /query/stream endpoint references tests passed!\")\n    return True\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef test_references_consistency():\n    \"\"\"Test references consistency across all endpoints\"\"\"\n\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Testing references consistency across endpoints\")\n    print(\"=\" * 60)\n\n    query_text = \"who authored LightRAG\"\n    query_params = {\n        \"query\": query_text,\n        \"mode\": \"mix\",\n        \"top_k\": 10,\n        \"chunk_top_k\": 8,\n        \"include_references\": True,\n    }\n\n    references_data = {}\n\n    # Test /query endpoint\n    print(\"\\n🧪 Testing /query endpoint\")\n    print(\"-\" * 40)\n\n    try:\n        response = requests.post(\n            f\"{BASE_URL}/query\", json=query_params, headers=AUTH_HEADERS, timeout=30\n        )\n\n        if response.status_code == 200:\n            data = response.json()\n            references_data[\"query\"] = data.get(\"references\", [])\n            print(f\"✅ /query: {len(references_data['query'])} references\")\n        else:\n            print(f\"❌ /query failed: {response.status_code}\")\n            return False\n\n    except Exception as e:\n        print(f\"❌ /query test failed: {str(e)}\")\n        return False\n\n    # Test /query/stream endpoint\n    print(\"\\n🧪 Testing /query/stream endpoint\")\n    print(\"-\" * 40)\n\n    try:\n        response = requests.post(\n            f\"{BASE_URL}/query/stream\",\n            json=query_params,\n            headers=AUTH_HEADERS,\n            timeout=30,\n            stream=True,\n        )\n\n        if response.status_code == 200:\n            full_response = \"\"\n            for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):\n                if chunk:\n                    # Ensure chunk is string type\n                    if isinstance(chunk, bytes):\n                        chunk = chunk.decode(\"utf-8\")\n                    full_response += chunk\n\n            references, _, errors = parse_streaming_response(full_response)\n\n            if errors:\n                print(f\"❌ Errors: {errors}\")\n                return False\n\n            references_data[\"stream\"] = references or []\n            print(f\"✅ /query/stream: {len(references_data['stream'])} references\")\n        else:\n            print(f\"❌ /query/stream failed: {response.status_code}\")\n            return False\n\n    except Exception as e:\n        print(f\"❌ /query/stream test failed: {str(e)}\")\n        return False\n\n    # Test /query/data endpoint\n    print(\"\\n🧪 Testing /query/data endpoint\")\n    print(\"-\" * 40)\n\n    try:\n        response = requests.post(\n            f\"{BASE_URL}/query/data\",\n            json=query_params,\n            headers=AUTH_HEADERS,\n            timeout=30,\n        )\n\n        if response.status_code == 200:\n            data = response.json()\n            query_data = data.get(\"data\", {})\n            references_data[\"data\"] = query_data.get(\"references\", [])\n            print(f\"✅ /query/data: {len(references_data['data'])} references\")\n        else:\n            print(f\"❌ /query/data failed: {response.status_code}\")\n            return False\n\n    except Exception as e:\n        print(f\"❌ /query/data test failed: {str(e)}\")\n        return False\n\n    # Compare references consistency\n    print(\"\\n🔍 Comparing references consistency\")\n    print(\"-\" * 40)\n\n    # Convert to sets of (reference_id, file_path) tuples for comparison\n    def refs_to_set(refs):\n        return set(\n            (ref.get(\"reference_id\", \"\"), ref.get(\"file_path\", \"\")) for ref in refs\n        )\n\n    query_refs = refs_to_set(references_data[\"query\"])\n    stream_refs = refs_to_set(references_data[\"stream\"])\n    data_refs = refs_to_set(references_data[\"data\"])\n\n    # Check consistency\n    consistency_passed = True\n\n    if query_refs != stream_refs:\n        print(\"❌ References mismatch between /query and /query/stream\")\n        print(f\"   /query only: {query_refs - stream_refs}\")\n        print(f\"   /query/stream only: {stream_refs - query_refs}\")\n        consistency_passed = False\n\n    if query_refs != data_refs:\n        print(\"❌ References mismatch between /query and /query/data\")\n        print(f\"   /query only: {query_refs - data_refs}\")\n        print(f\"   /query/data only: {data_refs - query_refs}\")\n        consistency_passed = False\n\n    if stream_refs != data_refs:\n        print(\"❌ References mismatch between /query/stream and /query/data\")\n        print(f\"   /query/stream only: {stream_refs - data_refs}\")\n        print(f\"   /query/data only: {data_refs - stream_refs}\")\n        consistency_passed = False\n\n    if consistency_passed:\n        print(\"✅ All endpoints return consistent references\")\n        print(f\"   Common references count: {len(query_refs)}\")\n\n        # Display common reference list\n        if query_refs:\n            print(\"   📚 Common Reference List:\")\n            for i, (ref_id, file_path) in enumerate(sorted(query_refs), 1):\n                print(f\"      {i}. ID: {ref_id} | File: {file_path}\")\n\n    return consistency_passed\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef test_aquery_data_endpoint():\n    \"\"\"Test the /query/data endpoint\"\"\"\n\n    # Use unified configuration\n    endpoint = f\"{BASE_URL}/query/data\"\n\n    # Query request\n    query_request = {\n        \"query\": \"who authored LighRAG\",\n        \"mode\": \"mix\",  # Use mixed mode to get the most comprehensive results\n        \"top_k\": 20,\n        \"chunk_top_k\": 15,\n        \"max_entity_tokens\": 4000,\n        \"max_relation_tokens\": 4000,\n        \"max_total_tokens\": 16000,\n        \"enable_rerank\": True,\n    }\n\n    print(\"=\" * 60)\n    print(\"LightRAG aquery_data endpoint test\")\n    print(\n        \"   Returns structured data including entities, relationships and text chunks\"\n    )\n    print(\"   Can be used for custom processing and analysis\")\n    print(\"=\" * 60)\n    print(f\"Query content: {query_request['query']}\")\n    print(f\"Query mode: {query_request['mode']}\")\n    print(f\"API endpoint: {endpoint}\")\n    print(\"-\" * 60)\n\n    try:\n        # Send request\n        print(\"Sending request...\")\n        start_time = time.time()\n\n        response = requests.post(\n            endpoint, json=query_request, headers=AUTH_HEADERS, timeout=30\n        )\n\n        end_time = time.time()\n        response_time = end_time - start_time\n\n        print(f\"Response time: {response_time:.2f} seconds\")\n        print(f\"HTTP status code: {response.status_code}\")\n\n        if response.status_code == 200:\n            data = response.json()\n            print_query_results(data)\n        else:\n            print(f\"Request failed: {response.status_code}\")\n            print(f\"Error message: {response.text}\")\n\n    except requests.exceptions.ConnectionError:\n        print(\"❌ Connection failed: Please ensure LightRAG API service is running\")\n        print(\"   Start command: python -m lightrag.api.lightrag_server\")\n    except requests.exceptions.Timeout:\n        print(\"❌ Request timeout: Query processing took too long\")\n    except Exception as e:\n        print(f\"❌ Error occurred: {str(e)}\")\n\n\ndef print_query_results(data: Dict[str, Any]):\n    \"\"\"Format and print query results\"\"\"\n\n    # Check for new data format with status and message\n    status = data.get(\"status\", \"unknown\")\n    message = data.get(\"message\", \"\")\n\n    print(f\"\\n📋 Query Status: {status}\")\n    if message:\n        print(f\"📋 Message: {message}\")\n\n    # Handle new nested data format\n    query_data = data.get(\"data\", {})\n\n    # Fallback to old format if new format is not present\n    if not query_data and any(\n        key in data for key in [\"entities\", \"relationships\", \"chunks\"]\n    ):\n        print(\"   (Using legacy data format)\")\n        query_data = data\n\n    entities = query_data.get(\"entities\", [])\n    relationships = query_data.get(\"relationships\", [])\n    chunks = query_data.get(\"chunks\", [])\n    references = query_data.get(\"references\", [])\n\n    print(\"\\n📊 Query result statistics:\")\n    print(f\"   Entity count: {len(entities)}\")\n    print(f\"   Relationship count: {len(relationships)}\")\n    print(f\"   Text chunk count: {len(chunks)}\")\n    print(f\"   Reference count: {len(references)}\")\n\n    # Print metadata (now at top level in new format)\n    metadata = data.get(\"metadata\", {})\n    if metadata:\n        print(\"\\n🔍 Query metadata:\")\n        print(f\"   Query mode: {metadata.get('query_mode', 'unknown')}\")\n\n        keywords = metadata.get(\"keywords\", {})\n        if keywords:\n            high_level = keywords.get(\"high_level\", [])\n            low_level = keywords.get(\"low_level\", [])\n            if high_level:\n                print(f\"   High-level keywords: {', '.join(high_level)}\")\n            if low_level:\n                print(f\"   Low-level keywords: {', '.join(low_level)}\")\n\n        processing_info = metadata.get(\"processing_info\", {})\n        if processing_info:\n            print(\"   Processing info:\")\n            for key, value in processing_info.items():\n                print(f\"     {key}: {value}\")\n\n    # Print entity information\n    if entities:\n        print(\"\\n👥 Retrieved entities (first 5):\")\n        for i, entity in enumerate(entities[:5]):\n            entity_name = entity.get(\"entity_name\", \"Unknown\")\n            entity_type = entity.get(\"entity_type\", \"Unknown\")\n            description = entity.get(\"description\", \"No description\")\n            file_path = entity.get(\"file_path\", \"Unknown source\")\n            reference_id = entity.get(\"reference_id\", \"No reference\")\n\n            print(f\"   {i + 1}. {entity_name} ({entity_type})\")\n            print(\n                f\"      Description: {description[:100]}{'...' if len(description) > 100 else ''}\"\n            )\n            print(f\"      Source: {file_path}\")\n            print(f\"      Reference ID: {reference_id}\")\n            print()\n\n    # Print relationship information\n    if relationships:\n        print(\"🔗 Retrieved relationships (first 5):\")\n        for i, rel in enumerate(relationships[:5]):\n            src = rel.get(\"src_id\", \"Unknown\")\n            tgt = rel.get(\"tgt_id\", \"Unknown\")\n            description = rel.get(\"description\", \"No description\")\n            keywords = rel.get(\"keywords\", \"No keywords\")\n            file_path = rel.get(\"file_path\", \"Unknown source\")\n            reference_id = rel.get(\"reference_id\", \"No reference\")\n\n            print(f\"   {i + 1}. {src} → {tgt}\")\n            print(f\"      Keywords: {keywords}\")\n            print(\n                f\"      Description: {description[:100]}{'...' if len(description) > 100 else ''}\"\n            )\n            print(f\"      Source: {file_path}\")\n            print(f\"      Reference ID: {reference_id}\")\n            print()\n\n    # Print text chunk information\n    if chunks:\n        print(\"📄 Retrieved text chunks (first 3):\")\n        for i, chunk in enumerate(chunks[:3]):\n            content = chunk.get(\"content\", \"No content\")\n            file_path = chunk.get(\"file_path\", \"Unknown source\")\n            chunk_id = chunk.get(\"chunk_id\", \"Unknown ID\")\n            reference_id = chunk.get(\"reference_id\", \"No reference\")\n\n            print(f\"   {i + 1}. Text chunk ID: {chunk_id}\")\n            print(f\"      Source: {file_path}\")\n            print(f\"      Reference ID: {reference_id}\")\n            print(\n                f\"      Content: {content[:200]}{'...' if len(content) > 200 else ''}\"\n            )\n            print()\n\n    # Print references information (new in updated format)\n    if references:\n        print(\"📚 References:\")\n        for i, ref in enumerate(references):\n            reference_id = ref.get(\"reference_id\", \"Unknown ID\")\n            file_path = ref.get(\"file_path\", \"Unknown source\")\n            print(f\"   {i + 1}. Reference ID: {reference_id}\")\n            print(f\"      File Path: {file_path}\")\n            print()\n\n    print(\"=\" * 60)\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef compare_with_regular_query():\n    \"\"\"Compare results between regular query and data query\"\"\"\n\n    query_text = \"LightRAG的作者是谁\"\n\n    print(\"\\n🔄 Comparison test: Regular query vs Data query\")\n    print(\"-\" * 60)\n\n    # Regular query\n    try:\n        print(\"1. Regular query (/query):\")\n        regular_response = requests.post(\n            f\"{BASE_URL}/query\",\n            json={\"query\": query_text, \"mode\": \"mix\"},\n            headers=AUTH_HEADERS,\n            timeout=30,\n        )\n\n        if regular_response.status_code == 200:\n            regular_data = regular_response.json()\n            response_text = regular_data.get(\"response\", \"No response\")\n            print(\n                f\"   Generated answer: {response_text[:300]}{'...' if len(response_text) > 300 else ''}\"\n            )\n        else:\n            print(f\"   Regular query failed: {regular_response.status_code}\")\n            if regular_response.status_code == 403:\n                print(\"   Authentication failed - Please check API Key configuration\")\n            elif regular_response.status_code == 401:\n                print(\"   Unauthorized - Please check authentication information\")\n            print(f\"   Error details: {regular_response.text}\")\n\n    except Exception as e:\n        print(f\"   Regular query error: {str(e)}\")\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef run_all_reference_tests():\n    \"\"\"Run all reference-related tests\"\"\"\n\n    print(\"\\n\" + \"🚀\" * 20)\n    print(\"LightRAG References Test Suite\")\n    print(\"🚀\" * 20)\n\n    all_tests_passed = True\n\n    # Test 1: /query endpoint references\n    try:\n        if not test_query_endpoint_references():\n            all_tests_passed = False\n    except Exception as e:\n        print(f\"❌ /query endpoint test failed with exception: {str(e)}\")\n        all_tests_passed = False\n\n    # Test 2: /query/stream endpoint references\n    try:\n        if not test_query_stream_endpoint_references():\n            all_tests_passed = False\n    except Exception as e:\n        print(f\"❌ /query/stream endpoint test failed with exception: {str(e)}\")\n        all_tests_passed = False\n\n    # Test 3: References consistency across endpoints\n    try:\n        if not test_references_consistency():\n            all_tests_passed = False\n    except Exception as e:\n        print(f\"❌ References consistency test failed with exception: {str(e)}\")\n        all_tests_passed = False\n\n    # Final summary\n    print(\"\\n\" + \"=\" * 60)\n    print(\"TEST SUITE SUMMARY\")\n    print(\"=\" * 60)\n\n    if all_tests_passed:\n        print(\"🎉 ALL TESTS PASSED!\")\n        print(\"✅ /query endpoint references functionality works correctly\")\n        print(\"✅ /query/stream endpoint references functionality works correctly\")\n        print(\"✅ References are consistent across all endpoints\")\n        print(\"\\n🔧 System is ready for production use with reference support!\")\n    else:\n        print(\"❌ SOME TESTS FAILED!\")\n        print(\"Please check the error messages above and fix the issues.\")\n        print(\"\\n🔧 System needs attention before production deployment.\")\n\n    return all_tests_passed\n\n\nif __name__ == \"__main__\":\n    import sys\n\n    if len(sys.argv) > 1 and sys.argv[1] == \"--references-only\":\n        # Run only the new reference tests\n        success = run_all_reference_tests()\n        sys.exit(0 if success else 1)\n    else:\n        # Run original tests plus new reference tests\n        print(\"Running original aquery_data endpoint test...\")\n        test_aquery_data_endpoint()\n\n        print(\"\\nRunning comparison test...\")\n        compare_with_regular_query()\n\n        print(\"\\nRunning new reference tests...\")\n        run_all_reference_tests()\n\n        print(\"\\n💡 Usage tips:\")\n        print(\"1. Ensure LightRAG API service is running\")\n        print(\"2. Adjust base_url and authentication information as needed\")\n        print(\"3. Modify query parameters to test different retrieval strategies\")\n        print(\"4. Data query results can be used for further analysis and processing\")\n        print(\"5. Run with --references-only flag to test only reference functionality\")\n"
  },
  {
    "path": "tests/test_batch_embeddings.py",
    "content": "\"\"\"\nTests for batch embedding pre-computation in _perform_kg_search().\n\nVerifies that kg_query batches all needed embeddings (query, ll_keywords,\nhl_keywords) into a single embedding API call instead of 3 sequential calls.\n\"\"\"\n\nfrom unittest.mock import AsyncMock, MagicMock\n\nimport numpy as np\nimport pytest\n\nfrom lightrag.base import QueryParam\n\n\ndef _make_mock_embedding_func(dim=1536):\n    \"\"\"Create a mock async embedding function that returns distinct vectors per input.\"\"\"\n\n    async def _embed(texts, **kwargs):\n        return np.array(\n            [np.full(dim, i + 1, dtype=np.float32) for i in range(len(texts))]\n        )\n\n    mock = AsyncMock(side_effect=_embed)\n    return mock\n\n\ndef _make_mock_kv_storage(embedding_func, global_config=None):\n    mock = MagicMock()\n    mock.embedding_func = embedding_func\n    mock.global_config = global_config or {\"kg_chunk_pick_method\": \"VECTOR\"}\n    return mock\n\n\ndef _make_mock_vdb():\n    \"\"\"Create a mock VDB whose query() records the query_embedding it receives.\"\"\"\n    mock = AsyncMock()\n    mock.query = AsyncMock(return_value=[])\n    mock.cosine_better_than_threshold = 0.2\n    return mock\n\n\ndef _make_mock_graph():\n    mock = AsyncMock()\n    return mock\n\n\n@pytest.mark.offline\n@pytest.mark.asyncio\nasync def test_hybrid_mode_batches_embeddings():\n    \"\"\"In hybrid mode with both keywords, embedding_func should be called exactly once.\"\"\"\n    from lightrag.operate import _perform_kg_search\n\n    embed_func = _make_mock_embedding_func()\n    text_chunks_db = _make_mock_kv_storage(embed_func)\n    entities_vdb = _make_mock_vdb()\n    relationships_vdb = _make_mock_vdb()\n    knowledge_graph = _make_mock_graph()\n\n    query_param = QueryParam(mode=\"hybrid\", top_k=5)\n\n    await _perform_kg_search(\n        query=\"test query\",\n        ll_keywords=\"entity1, entity2\",\n        hl_keywords=\"theme1, theme2\",\n        knowledge_graph_inst=knowledge_graph,\n        entities_vdb=entities_vdb,\n        relationships_vdb=relationships_vdb,\n        text_chunks_db=text_chunks_db,\n        query_param=query_param,\n    )\n\n    # The embedding function should be called exactly once with all 3 texts batched\n    assert (\n        embed_func.call_count == 1\n    ), f\"Expected 1 batched embedding call, got {embed_func.call_count}\"\n    call_args = embed_func.call_args[0][0]\n    assert len(call_args) == 3, f\"Expected 3 texts in batch, got {len(call_args)}\"\n    assert call_args == [\"test query\", \"entity1, entity2\", \"theme1, theme2\"]\n\n\n@pytest.mark.offline\n@pytest.mark.asyncio\nasync def test_hybrid_mode_passes_embeddings_to_vdbs():\n    \"\"\"Pre-computed embeddings should be forwarded to entities and relationships VDB queries.\"\"\"\n    from lightrag.operate import _perform_kg_search\n\n    embed_func = _make_mock_embedding_func()\n    text_chunks_db = _make_mock_kv_storage(embed_func)\n    entities_vdb = _make_mock_vdb()\n    relationships_vdb = _make_mock_vdb()\n    knowledge_graph = _make_mock_graph()\n\n    query_param = QueryParam(mode=\"hybrid\", top_k=5)\n\n    await _perform_kg_search(\n        query=\"test query\",\n        ll_keywords=\"entity keywords\",\n        hl_keywords=\"theme keywords\",\n        knowledge_graph_inst=knowledge_graph,\n        entities_vdb=entities_vdb,\n        relationships_vdb=relationships_vdb,\n        text_chunks_db=text_chunks_db,\n        query_param=query_param,\n    )\n\n    # entities_vdb.query should receive ll_embedding (index 1 → all 2s)\n    entities_call = entities_vdb.query.call_args\n    assert entities_call is not None, \"entities_vdb.query was not called\"\n    ll_embedding = entities_call.kwargs.get(\"query_embedding\")\n    assert ll_embedding is not None, \"ll_embedding was not passed to entities_vdb.query\"\n    assert np.all(\n        ll_embedding == 2.0\n    ), f\"Expected ll_embedding=[2,2,...], got {ll_embedding[:3]}\"\n\n    # relationships_vdb.query should receive hl_embedding (index 2 → all 3s)\n    rel_call = relationships_vdb.query.call_args\n    assert rel_call is not None, \"relationships_vdb.query was not called\"\n    hl_embedding = rel_call.kwargs.get(\"query_embedding\")\n    assert (\n        hl_embedding is not None\n    ), \"hl_embedding was not passed to relationships_vdb.query\"\n    assert np.all(\n        hl_embedding == 3.0\n    ), f\"Expected hl_embedding=[3,3,...], got {hl_embedding[:3]}\"\n\n\n@pytest.mark.offline\n@pytest.mark.asyncio\nasync def test_local_mode_skips_hl_keywords():\n    \"\"\"In local mode, should only embed query + ll_keywords (skip hl_keywords).\"\"\"\n    from lightrag.operate import _perform_kg_search\n\n    embed_func = _make_mock_embedding_func()\n    text_chunks_db = _make_mock_kv_storage(embed_func)\n    entities_vdb = _make_mock_vdb()\n    relationships_vdb = _make_mock_vdb()\n    knowledge_graph = _make_mock_graph()\n\n    query_param = QueryParam(mode=\"local\", top_k=5)\n\n    await _perform_kg_search(\n        query=\"test query\",\n        ll_keywords=\"entity keywords\",\n        hl_keywords=\"theme keywords\",\n        knowledge_graph_inst=knowledge_graph,\n        entities_vdb=entities_vdb,\n        relationships_vdb=relationships_vdb,\n        text_chunks_db=text_chunks_db,\n        query_param=query_param,\n    )\n\n    assert embed_func.call_count == 1\n    call_args = embed_func.call_args[0][0]\n    assert len(call_args) == 2, f\"Expected 2 texts (query + ll), got {len(call_args)}\"\n    assert \"theme keywords\" not in call_args\n\n\n@pytest.mark.offline\n@pytest.mark.asyncio\nasync def test_global_mode_skips_ll_keywords():\n    \"\"\"In global mode, should only embed query + hl_keywords (skip ll_keywords).\"\"\"\n    from lightrag.operate import _perform_kg_search\n\n    embed_func = _make_mock_embedding_func()\n    text_chunks_db = _make_mock_kv_storage(embed_func)\n    entities_vdb = _make_mock_vdb()\n    relationships_vdb = _make_mock_vdb()\n    knowledge_graph = _make_mock_graph()\n\n    query_param = QueryParam(mode=\"global\", top_k=5)\n\n    await _perform_kg_search(\n        query=\"test query\",\n        ll_keywords=\"entity keywords\",\n        hl_keywords=\"theme keywords\",\n        knowledge_graph_inst=knowledge_graph,\n        entities_vdb=entities_vdb,\n        relationships_vdb=relationships_vdb,\n        text_chunks_db=text_chunks_db,\n        query_param=query_param,\n    )\n\n    assert embed_func.call_count == 1\n    call_args = embed_func.call_args[0][0]\n    assert len(call_args) == 2, f\"Expected 2 texts (query + hl), got {len(call_args)}\"\n    assert \"entity keywords\" not in call_args\n\n\n@pytest.mark.offline\n@pytest.mark.asyncio\nasync def test_embedding_failure_falls_back_gracefully():\n    \"\"\"If batch embedding fails, VDB queries should still work (fallback to individual calls).\"\"\"\n    from lightrag.operate import _perform_kg_search\n\n    embed_func = AsyncMock(side_effect=RuntimeError(\"API error\"))\n    text_chunks_db = _make_mock_kv_storage(embed_func)\n    entities_vdb = _make_mock_vdb()\n    relationships_vdb = _make_mock_vdb()\n    knowledge_graph = _make_mock_graph()\n\n    query_param = QueryParam(mode=\"hybrid\", top_k=5)\n\n    # Should not raise — graceful degradation\n    await _perform_kg_search(\n        query=\"test query\",\n        ll_keywords=\"entity keywords\",\n        hl_keywords=\"theme keywords\",\n        knowledge_graph_inst=knowledge_graph,\n        entities_vdb=entities_vdb,\n        relationships_vdb=relationships_vdb,\n        text_chunks_db=text_chunks_db,\n        query_param=query_param,\n    )\n\n    # VDB queries should still be called (with query_embedding=None fallback)\n    entities_call = entities_vdb.query.call_args\n    assert entities_call is not None\n    assert entities_call.kwargs.get(\"query_embedding\") is None\n\n    rel_call = relationships_vdb.query.call_args\n    assert rel_call is not None\n    assert rel_call.kwargs.get(\"query_embedding\") is None\n"
  },
  {
    "path": "tests/test_chunking.py",
    "content": "import pytest\n\nfrom lightrag.exceptions import ChunkTokenLimitExceededError\nfrom lightrag.operate import chunking_by_token_size\nfrom lightrag.utils import Tokenizer, TokenizerInterface\n\n\nclass DummyTokenizer(TokenizerInterface):\n    \"\"\"Simple 1:1 character-to-token mapping.\"\"\"\n\n    def encode(self, content: str):\n        return [ord(ch) for ch in content]\n\n    def decode(self, tokens):\n        return \"\".join(chr(token) for token in tokens)\n\n\nclass MultiTokenCharacterTokenizer(TokenizerInterface):\n    \"\"\"\n    Tokenizer where character-to-token ratio is non-uniform.\n    This helps catch bugs where code incorrectly counts characters instead of tokens.\n\n    Mapping:\n    - Uppercase letters: 2 tokens each\n    - Punctuation (!, ?, .): 3 tokens each\n    - Other characters: 1 token each\n    \"\"\"\n\n    def encode(self, content: str):\n        tokens = []\n        for ch in content:\n            if ch.isupper():  # Uppercase = 2 tokens\n                tokens.extend([ord(ch), ord(ch) + 1000])\n            elif ch in [\"!\", \"?\", \".\"]:  # Punctuation = 3 tokens\n                tokens.extend([ord(ch), ord(ch) + 2000, ord(ch) + 3000])\n            else:  # Regular chars = 1 token\n                tokens.append(ord(ch))\n        return tokens\n\n    def decode(self, tokens):\n        # Simplified decode for testing\n        result = []\n        i = 0\n        while i < len(tokens):\n            base_token = tokens[i]\n            # Check if this is part of a multi-token sequence\n            if (\n                i + 2 < len(tokens)\n                and tokens[i + 1] == base_token + 2000\n                and tokens[i + 2] == base_token + 3000\n            ):\n                # 3-token punctuation\n                result.append(chr(base_token))\n                i += 3\n            elif i + 1 < len(tokens) and tokens[i + 1] == base_token + 1000:\n                # 2-token uppercase\n                result.append(chr(base_token))\n                i += 2\n            else:\n                # Single token\n                result.append(chr(base_token))\n                i += 1\n        return \"\".join(result)\n\n\ndef make_tokenizer() -> Tokenizer:\n    return Tokenizer(model_name=\"dummy\", tokenizer=DummyTokenizer())\n\n\ndef make_multi_token_tokenizer() -> Tokenizer:\n    return Tokenizer(model_name=\"multi\", tokenizer=MultiTokenCharacterTokenizer())\n\n\n# ============================================================================\n# Tests for split_by_character_only=True (raises error on oversized chunks)\n# ============================================================================\n\n\n@pytest.mark.offline\ndef test_split_by_character_only_within_limit():\n    \"\"\"Test chunking when all chunks are within token limit.\"\"\"\n    tokenizer = make_tokenizer()\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        \"alpha\\n\\nbeta\",\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=True,\n        chunk_token_size=10,\n    )\n\n    assert [chunk[\"content\"] for chunk in chunks] == [\"alpha\", \"beta\"]\n\n\n@pytest.mark.offline\ndef test_split_by_character_only_exceeding_limit_raises():\n    \"\"\"Test that oversized chunks raise ChunkTokenLimitExceededError.\"\"\"\n    tokenizer = make_tokenizer()\n    oversized = \"a\" * 12\n\n    with pytest.raises(ChunkTokenLimitExceededError) as excinfo:\n        chunking_by_token_size(\n            tokenizer,\n            oversized,\n            split_by_character=\"\\n\\n\",\n            split_by_character_only=True,\n            chunk_token_size=5,\n        )\n\n    err = excinfo.value\n    assert err.chunk_tokens == len(oversized)\n    assert err.chunk_token_limit == 5\n\n\n@pytest.mark.offline\ndef test_chunk_error_includes_preview():\n    \"\"\"Test that error message includes chunk preview.\"\"\"\n    tokenizer = make_tokenizer()\n    oversized = \"x\" * 100\n\n    with pytest.raises(ChunkTokenLimitExceededError) as excinfo:\n        chunking_by_token_size(\n            tokenizer,\n            oversized,\n            split_by_character=\"\\n\\n\",\n            split_by_character_only=True,\n            chunk_token_size=10,\n        )\n\n    err = excinfo.value\n    # Preview should be first 80 chars of a 100-char string\n    assert err.chunk_preview == \"x\" * 80\n    assert \"Preview:\" in str(err)\n\n\n@pytest.mark.offline\ndef test_split_by_character_only_at_exact_limit():\n    \"\"\"Test chunking when chunk is exactly at token limit.\"\"\"\n    tokenizer = make_tokenizer()\n    exact_size = \"a\" * 10\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        exact_size,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=True,\n        chunk_token_size=10,\n    )\n\n    assert len(chunks) == 1\n    assert chunks[0][\"content\"] == exact_size\n    assert chunks[0][\"tokens\"] == 10\n\n\n@pytest.mark.offline\ndef test_split_by_character_only_one_over_limit():\n    \"\"\"Test that chunk with one token over limit raises error.\"\"\"\n    tokenizer = make_tokenizer()\n    one_over = \"a\" * 11\n\n    with pytest.raises(ChunkTokenLimitExceededError) as excinfo:\n        chunking_by_token_size(\n            tokenizer,\n            one_over,\n            split_by_character=\"\\n\\n\",\n            split_by_character_only=True,\n            chunk_token_size=10,\n        )\n\n    err = excinfo.value\n    assert err.chunk_tokens == 11\n    assert err.chunk_token_limit == 10\n\n\n# ============================================================================\n# Tests for split_by_character_only=False (recursive splitting)\n# ============================================================================\n\n\n@pytest.mark.offline\ndef test_split_recursive_oversized_chunk():\n    \"\"\"Test recursive splitting of oversized chunk with split_by_character_only=False.\"\"\"\n    tokenizer = make_tokenizer()\n    # 30 chars - should split into chunks of size 10\n    oversized = \"a\" * 30\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        oversized,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=False,\n        chunk_token_size=10,\n        chunk_overlap_token_size=0,\n    )\n\n    # Should create 3 chunks of 10 tokens each\n    assert len(chunks) == 3\n    assert all(chunk[\"tokens\"] == 10 for chunk in chunks)\n    assert all(chunk[\"content\"] == \"a\" * 10 for chunk in chunks)\n\n\n@pytest.mark.offline\ndef test_split_with_chunk_overlap():\n    \"\"\"\n    Test chunk splitting with overlap using distinctive content.\n\n    With distinctive characters, we can verify overlap positions are exact.\n    Misaligned overlap would produce wrong content and fail the test.\n    \"\"\"\n    tokenizer = make_tokenizer()\n    # Each character is unique - enables exact position verification\n    content = \"0123456789abcdefghijklmno\"  # 25 chars\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=False,\n        chunk_token_size=10,\n        chunk_overlap_token_size=3,\n    )\n\n    # With overlap=3, step size = chunk_size - overlap = 10 - 3 = 7\n    # Chunks start at positions: 0, 7, 14, 21\n    assert len(chunks) == 4\n\n    # Verify exact content and token counts\n    assert chunks[0][\"tokens\"] == 10\n    assert chunks[0][\"content\"] == \"0123456789\"  # [0:10]\n\n    assert chunks[1][\"tokens\"] == 10\n    assert chunks[1][\"content\"] == \"789abcdefg\"  # [7:17] - overlaps with \"789\"\n\n    assert chunks[2][\"tokens\"] == 10\n    assert chunks[2][\"content\"] == \"efghijklmn\"  # [14:24] - overlaps with \"efg\"\n\n    assert chunks[3][\"tokens\"] == 4\n    assert chunks[3][\"content\"] == \"lmno\"  # [21:25] - overlaps with \"lmn\"\n\n\n@pytest.mark.offline\ndef test_split_multiple_chunks_with_mixed_sizes():\n    \"\"\"Test splitting text with multiple chunks of different sizes.\"\"\"\n    tokenizer = make_tokenizer()\n    # \"small\\n\\nlarge_chunk_here\\n\\nmedium\"\n    # small: 5 tokens, large_chunk_here: 16 tokens, medium: 6 tokens\n    content = \"small\\n\\n\" + \"a\" * 16 + \"\\n\\nmedium\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=False,\n        chunk_token_size=10,\n        chunk_overlap_token_size=2,\n    )\n\n    # First chunk \"small\" should be kept as is (5 tokens)\n    # Second chunk (16 tokens) should be split into 2 chunks\n    # Third chunk \"medium\" should be kept as is (6 tokens)\n    assert len(chunks) == 4\n    assert chunks[0][\"content\"] == \"small\"\n    assert chunks[0][\"tokens\"] == 5\n\n\n@pytest.mark.offline\ndef test_split_exact_boundary():\n    \"\"\"Test splitting at exact chunk boundaries.\"\"\"\n    tokenizer = make_tokenizer()\n    # Exactly 20 chars, should split into 2 chunks of 10\n    content = \"a\" * 20\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=False,\n        chunk_token_size=10,\n        chunk_overlap_token_size=0,\n    )\n\n    assert len(chunks) == 2\n    assert chunks[0][\"tokens\"] == 10\n    assert chunks[1][\"tokens\"] == 10\n\n\n@pytest.mark.offline\ndef test_split_very_large_text():\n    \"\"\"Test splitting very large text into multiple chunks.\"\"\"\n    tokenizer = make_tokenizer()\n    # 100 chars should create 10 chunks with chunk_size=10, overlap=0\n    content = \"a\" * 100\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=False,\n        chunk_token_size=10,\n        chunk_overlap_token_size=0,\n    )\n\n    assert len(chunks) == 10\n    assert all(chunk[\"tokens\"] == 10 for chunk in chunks)\n\n\n# ============================================================================\n# Edge Cases\n# ============================================================================\n\n\n@pytest.mark.offline\ndef test_empty_content():\n    \"\"\"Test chunking with empty content.\"\"\"\n    tokenizer = make_tokenizer()\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        \"\",\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=True,\n        chunk_token_size=10,\n    )\n\n    assert len(chunks) == 1\n    assert chunks[0][\"content\"] == \"\"\n    assert chunks[0][\"tokens\"] == 0\n\n\n@pytest.mark.offline\ndef test_single_character():\n    \"\"\"Test chunking with single character.\"\"\"\n    tokenizer = make_tokenizer()\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        \"a\",\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=True,\n        chunk_token_size=10,\n    )\n\n    assert len(chunks) == 1\n    assert chunks[0][\"content\"] == \"a\"\n    assert chunks[0][\"tokens\"] == 1\n\n\n@pytest.mark.offline\ndef test_no_delimiter_in_content():\n    \"\"\"Test chunking when content has no delimiter.\"\"\"\n    tokenizer = make_tokenizer()\n    content = \"a\" * 30\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",  # Delimiter not in content\n        split_by_character_only=False,\n        chunk_token_size=10,\n        chunk_overlap_token_size=0,\n    )\n\n    # Should still split based on token size\n    assert len(chunks) == 3\n    assert all(chunk[\"tokens\"] == 10 for chunk in chunks)\n\n\n@pytest.mark.offline\ndef test_no_split_character():\n    \"\"\"Test chunking without split_by_character (None).\"\"\"\n    tokenizer = make_tokenizer()\n    content = \"a\" * 30\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=None,\n        split_by_character_only=False,\n        chunk_token_size=10,\n        chunk_overlap_token_size=0,\n    )\n\n    # Should split based purely on token size\n    assert len(chunks) == 3\n    assert all(chunk[\"tokens\"] == 10 for chunk in chunks)\n\n\n# ============================================================================\n# Parameter Combinations\n# ============================================================================\n\n\n@pytest.mark.offline\ndef test_different_delimiter_newline():\n    \"\"\"Test with single newline delimiter.\"\"\"\n    tokenizer = make_tokenizer()\n    content = \"alpha\\nbeta\\ngamma\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\",\n        split_by_character_only=True,\n        chunk_token_size=10,\n    )\n\n    assert len(chunks) == 3\n    assert [c[\"content\"] for c in chunks] == [\"alpha\", \"beta\", \"gamma\"]\n\n\n@pytest.mark.offline\ndef test_delimiter_based_splitting_verification():\n    \"\"\"\n    Verify that chunks are actually split at delimiter positions.\n\n    This test ensures split_by_character truly splits at the delimiter,\n    not at arbitrary positions.\n    \"\"\"\n    tokenizer = make_tokenizer()\n\n    # Content with clear delimiter boundaries\n    content = \"part1||part2||part3||part4\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"||\",\n        split_by_character_only=True,\n        chunk_token_size=20,\n    )\n\n    # Should split exactly at || delimiters\n    assert len(chunks) == 4\n    assert chunks[0][\"content\"] == \"part1\"\n    assert chunks[1][\"content\"] == \"part2\"\n    assert chunks[2][\"content\"] == \"part3\"\n    assert chunks[3][\"content\"] == \"part4\"\n\n    # Verify delimiter is not included in chunks\n    for chunk in chunks:\n        assert \"||\" not in chunk[\"content\"]\n\n\n@pytest.mark.offline\ndef test_multi_character_delimiter_splitting():\n    \"\"\"\n    Verify that multi-character delimiters are correctly recognized and not partially matched.\n\n    Tests various multi-character delimiter scenarios to ensure the entire delimiter\n    sequence is used for splitting, not individual characters.\n    \"\"\"\n    tokenizer = make_tokenizer()\n\n    # Test 1: Multi-character delimiter that contains single chars also present elsewhere\n    content = \"data<SEP>more<SEP>final\"\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"<SEP>\",\n        split_by_character_only=True,\n        chunk_token_size=50,\n    )\n\n    assert len(chunks) == 3\n    assert chunks[0][\"content\"] == \"data\"\n    assert chunks[1][\"content\"] == \"more\"\n    assert chunks[2][\"content\"] == \"final\"\n    # Verify full delimiter is not in chunks, not just parts\n    for chunk in chunks:\n        assert \"<SEP>\" not in chunk[\"content\"]\n\n    # Test 2: Delimiter appears in middle of content\n    content = \"first><second><third\"\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"><\",  # Multi-char delimiter\n        split_by_character_only=True,\n        chunk_token_size=50,\n    )\n\n    # Should split at \"><\" delimiter\n    assert len(chunks) == 3\n    assert chunks[0][\"content\"] == \"first\"\n    assert chunks[1][\"content\"] == \"second\"\n    assert chunks[2][\"content\"] == \"third\"\n\n    # Test 3: Three-character delimiter\n    content = \"section1[***]section2[***]section3\"\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"[***]\",\n        split_by_character_only=True,\n        chunk_token_size=50,\n    )\n\n    assert len(chunks) == 3\n    assert chunks[0][\"content\"] == \"section1\"\n    assert chunks[1][\"content\"] == \"section2\"\n    assert chunks[2][\"content\"] == \"section3\"\n\n    # Test 4: Delimiter with special regex characters (should be treated literally)\n    content = \"partA...partB...partC\"\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"...\",\n        split_by_character_only=True,\n        chunk_token_size=50,\n    )\n\n    assert len(chunks) == 3\n    assert chunks[0][\"content\"] == \"partA\"\n    assert chunks[1][\"content\"] == \"partB\"\n    assert chunks[2][\"content\"] == \"partC\"\n\n\n@pytest.mark.offline\ndef test_delimiter_partial_match_not_split():\n    \"\"\"\n    Verify that partial matches of multi-character delimiters don't cause splits.\n\n    Only the complete delimiter sequence should trigger a split.\n    \"\"\"\n    tokenizer = make_tokenizer()\n\n    # Content contains \"||\" delimiter but also contains single \"|\"\n    content = \"data|single||data|with|pipes||final\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"||\",  # Only split on double pipe\n        split_by_character_only=True,\n        chunk_token_size=50,\n    )\n\n    # Should split only at \"||\", not at single \"|\"\n    assert len(chunks) == 3\n    assert chunks[0][\"content\"] == \"data|single\"\n    assert chunks[1][\"content\"] == \"data|with|pipes\"\n    assert chunks[2][\"content\"] == \"final\"\n\n    # Single \"|\" should remain in content, but not double \"||\"\n    assert \"|\" in chunks[0][\"content\"]\n    assert \"|\" in chunks[1][\"content\"]\n    assert \"||\" not in chunks[0][\"content\"]\n    assert \"||\" not in chunks[1][\"content\"]\n\n\n@pytest.mark.offline\ndef test_no_delimiter_forces_token_based_split():\n    \"\"\"\n    Verify that when split_by_character doesn't appear in content,\n    chunking falls back to token-based splitting.\n    \"\"\"\n    tokenizer = make_tokenizer()\n\n    # Content without the specified delimiter\n    content = \"0123456789abcdefghijklmnop\"  # 26 chars, no \"\\n\\n\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",  # Delimiter not in content\n        split_by_character_only=False,\n        chunk_token_size=10,\n        chunk_overlap_token_size=0,\n    )\n\n    # Should fall back to token-based splitting\n    assert len(chunks) == 3\n    assert chunks[0][\"content\"] == \"0123456789\"  # [0:10]\n    assert chunks[1][\"content\"] == \"abcdefghij\"  # [10:20]\n    assert chunks[2][\"content\"] == \"klmnop\"  # [20:26]\n\n    # Verify it didn't somehow split at the delimiter that doesn't exist\n    for chunk in chunks:\n        assert \"\\n\\n\" not in chunk[\"content\"]\n\n\n@pytest.mark.offline\ndef test_delimiter_at_exact_chunk_boundary():\n    \"\"\"\n    Verify correct behavior when delimiter appears exactly at chunk token limit.\n    \"\"\"\n    tokenizer = make_tokenizer()\n\n    # \"segment1\\n\\nsegment2\" where each segment is within limit\n    content = \"12345\\n\\nabcde\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=True,\n        chunk_token_size=10,\n    )\n\n    # Should split at delimiter, not at token count\n    assert len(chunks) == 2\n    assert chunks[0][\"content\"] == \"12345\"\n    assert chunks[1][\"content\"] == \"abcde\"\n\n\n@pytest.mark.offline\ndef test_different_delimiter_comma():\n    \"\"\"Test with comma delimiter.\"\"\"\n    tokenizer = make_tokenizer()\n    content = \"one,two,three\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\",\",\n        split_by_character_only=True,\n        chunk_token_size=10,\n    )\n\n    assert len(chunks) == 3\n    assert [c[\"content\"] for c in chunks] == [\"one\", \"two\", \"three\"]\n\n\n@pytest.mark.offline\ndef test_zero_overlap():\n    \"\"\"Test with zero overlap (no overlap).\"\"\"\n    tokenizer = make_tokenizer()\n    content = \"a\" * 20\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=None,\n        split_by_character_only=False,\n        chunk_token_size=10,\n        chunk_overlap_token_size=0,\n    )\n\n    # Should create exactly 2 chunks with no overlap\n    assert len(chunks) == 2\n    assert chunks[0][\"tokens\"] == 10\n    assert chunks[1][\"tokens\"] == 10\n\n\n@pytest.mark.offline\ndef test_large_overlap():\n    \"\"\"\n    Test with overlap close to chunk size using distinctive content.\n\n    Large overlap (9 out of 10) means step size is only 1, creating many overlapping chunks.\n    Distinctive characters ensure each chunk has correct positioning.\n    \"\"\"\n    tokenizer = make_tokenizer()\n    # Use distinctive characters to verify exact positions\n    content = \"0123456789abcdefghijklmnopqrst\"  # 30 chars\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=None,\n        split_by_character_only=False,\n        chunk_token_size=10,\n        chunk_overlap_token_size=9,\n    )\n\n    # With overlap=9, step size = 10 - 9 = 1\n    # Chunks start at: 0, 1, 2, 3, ..., 20\n    # Total chunks = 21 (from position 0 to 20, each taking 10 tokens)\n    # Wait, let me recalculate: range(0, 30, 1) gives positions 0-29\n    # But each chunk is 10 tokens, so last chunk starts at position 20\n    # Actually: positions are 0, 1, 2, ..., 20 (21 chunks) for a 30-char string\n    # No wait: for i in range(0, 30, 1): if i + 10 <= 30, we can create a chunk\n    # So positions: 0-20 (chunks of size 10), then 21-29 would be partial\n    # Actually the loop is: for start in range(0, len(tokens), step):\n    # range(0, 30, 1) = [0, 1, 2, ..., 29], so 30 chunks total\n    assert len(chunks) == 30\n\n    # Verify first few chunks have correct content with proper overlap\n    assert chunks[0][\"content\"] == \"0123456789\"  # [0:10]\n    assert (\n        chunks[1][\"content\"] == \"123456789a\"\n    )  # [1:11] - overlaps 9 chars with previous\n    assert (\n        chunks[2][\"content\"] == \"23456789ab\"\n    )  # [2:12] - overlaps 9 chars with previous\n    assert chunks[3][\"content\"] == \"3456789abc\"  # [3:13]\n\n    # Verify last chunk\n    assert chunks[-1][\"content\"] == \"t\"  # [29:30] - last char only\n\n\n# ============================================================================\n# Chunk Order Index Tests\n# ============================================================================\n\n\n@pytest.mark.offline\ndef test_chunk_order_index_simple():\n    \"\"\"Test that chunk_order_index is correctly assigned.\"\"\"\n    tokenizer = make_tokenizer()\n    content = \"a\\n\\nb\\n\\nc\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=True,\n        chunk_token_size=10,\n    )\n\n    assert len(chunks) == 3\n    assert chunks[0][\"chunk_order_index\"] == 0\n    assert chunks[1][\"chunk_order_index\"] == 1\n    assert chunks[2][\"chunk_order_index\"] == 2\n\n\n@pytest.mark.offline\ndef test_chunk_order_index_with_splitting():\n    \"\"\"Test chunk_order_index with recursive splitting.\"\"\"\n    tokenizer = make_tokenizer()\n    content = \"a\" * 30\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=None,\n        split_by_character_only=False,\n        chunk_token_size=10,\n        chunk_overlap_token_size=0,\n    )\n\n    assert len(chunks) == 3\n    assert chunks[0][\"chunk_order_index\"] == 0\n    assert chunks[1][\"chunk_order_index\"] == 1\n    assert chunks[2][\"chunk_order_index\"] == 2\n\n\n# ============================================================================\n# Integration Tests\n# ============================================================================\n\n\n@pytest.mark.offline\ndef test_mixed_size_chunks_no_error():\n    \"\"\"Test that mixed size chunks work without error in recursive mode.\"\"\"\n    tokenizer = make_tokenizer()\n    # Mix of small and large chunks\n    content = \"small\\n\\n\" + \"a\" * 50 + \"\\n\\nmedium\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=False,\n        chunk_token_size=10,\n        chunk_overlap_token_size=2,\n    )\n\n    # Should handle all chunks without error\n    assert len(chunks) > 0\n    # Small chunk should remain intact\n    assert chunks[0][\"content\"] == \"small\"\n    # Large chunk should be split into multiple pieces\n    assert any(chunk[\"content\"] == \"a\" * 10 for chunk in chunks)\n    # Last chunk should contain \"medium\"\n    assert any(\"medium\" in chunk[\"content\"] for chunk in chunks)\n\n\n@pytest.mark.offline\ndef test_whitespace_handling():\n    \"\"\"Test that whitespace is properly handled in chunk content.\"\"\"\n    tokenizer = make_tokenizer()\n    content = \"  alpha  \\n\\n  beta  \"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=True,\n        chunk_token_size=20,\n    )\n\n    # Content should be stripped\n    assert chunks[0][\"content\"] == \"alpha\"\n    assert chunks[1][\"content\"] == \"beta\"\n\n\n@pytest.mark.offline\ndef test_consecutive_delimiters():\n    \"\"\"Test handling of consecutive delimiters.\"\"\"\n    tokenizer = make_tokenizer()\n    content = \"alpha\\n\\n\\n\\nbeta\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=True,\n        chunk_token_size=20,\n    )\n\n    # Should split on delimiter and include empty chunks\n    assert len(chunks) >= 2\n    assert \"alpha\" in [c[\"content\"] for c in chunks]\n    assert \"beta\" in [c[\"content\"] for c in chunks]\n\n\n# ============================================================================\n# Token vs Character Counting Tests (Multi-Token Characters)\n# ============================================================================\n\n\n@pytest.mark.offline\ndef test_token_counting_not_character_counting():\n    \"\"\"\n    Verify chunking uses token count, not character count.\n\n    With MultiTokenCharacterTokenizer:\n    - \"aXa\" = 3 chars but 4 tokens (a=1, X=2, a=1)\n\n    This test would PASS if code incorrectly used character count (3 <= 3)\n    but correctly FAILS because token count (4 > 3).\n    \"\"\"\n    tokenizer = make_multi_token_tokenizer()\n\n    # \"aXa\" = 3 characters, 4 tokens\n    content = \"aXa\"\n\n    with pytest.raises(ChunkTokenLimitExceededError) as excinfo:\n        chunking_by_token_size(\n            tokenizer,\n            content,\n            split_by_character=\"\\n\\n\",\n            split_by_character_only=True,\n            chunk_token_size=3,  # 3 token limit\n        )\n\n    err = excinfo.value\n    assert err.chunk_tokens == 4  # Should be 4 tokens, not 3 characters\n    assert err.chunk_token_limit == 3\n\n\n@pytest.mark.offline\ndef test_token_limit_with_punctuation():\n    \"\"\"\n    Test that punctuation token expansion is handled correctly.\n\n    \"Hi!\" = 3 chars but 6 tokens (H=2, i=1, !=3)\n    \"\"\"\n    tokenizer = make_multi_token_tokenizer()\n\n    # \"Hi!\" = 3 characters, 6 tokens (H=2, i=1, !=3)\n    content = \"Hi!\"\n\n    with pytest.raises(ChunkTokenLimitExceededError) as excinfo:\n        chunking_by_token_size(\n            tokenizer,\n            content,\n            split_by_character=\"\\n\\n\",\n            split_by_character_only=True,\n            chunk_token_size=4,\n        )\n\n    err = excinfo.value\n    assert err.chunk_tokens == 6\n    assert err.chunk_token_limit == 4\n\n\n@pytest.mark.offline\ndef test_multi_token_within_limit():\n    \"\"\"Test that multi-token characters work when within limit.\"\"\"\n    tokenizer = make_multi_token_tokenizer()\n\n    # \"Hi\" = 2 chars, 3 tokens (H=2, i=1)\n    content = \"Hi\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=True,\n        chunk_token_size=5,\n    )\n\n    assert len(chunks) == 1\n    assert chunks[0][\"tokens\"] == 3\n    assert chunks[0][\"content\"] == \"Hi\"\n\n\n@pytest.mark.offline\ndef test_recursive_split_with_multi_token_chars():\n    \"\"\"\n    Test recursive splitting respects token boundaries, not character boundaries.\n\n    \"AAAAA\" = 5 chars but 10 tokens (each A = 2 tokens)\n    With chunk_size=6, should split at token positions, not character positions.\n    \"\"\"\n    tokenizer = make_multi_token_tokenizer()\n\n    # \"AAAAA\" = 5 characters, 10 tokens\n    content = \"AAAAA\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=False,\n        chunk_token_size=6,\n        chunk_overlap_token_size=0,\n    )\n\n    # Should split into: [0:6]=3 chars, [6:10]=2 chars\n    # Not [0:3]=6 tokens, [3:5]=4 tokens (character-based would be wrong)\n    assert len(chunks) == 2\n    assert chunks[0][\"tokens\"] == 6\n    assert chunks[1][\"tokens\"] == 4\n\n\n@pytest.mark.offline\ndef test_overlap_uses_token_count():\n    \"\"\"\n    Verify overlap calculation uses token count, not character count.\n\n    \"aAaAa\" = 5 chars, 7 tokens (a=1, A=2, a=1, A=2, a=1)\n    \"\"\"\n    tokenizer = make_multi_token_tokenizer()\n\n    # \"aAaAa\" = 5 characters, 7 tokens\n    content = \"aAaAa\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=False,\n        chunk_token_size=4,\n        chunk_overlap_token_size=2,\n    )\n\n    # Chunks start at token positions: 0, 2, 4, 6\n    # [0:4]=2 chars, [2:6]=2.5 chars, [4:7]=1.5 chars\n    assert len(chunks) == 4\n    assert chunks[0][\"tokens\"] == 4\n    assert chunks[1][\"tokens\"] == 4\n    assert chunks[2][\"tokens\"] == 3\n    assert chunks[3][\"tokens\"] == 1\n\n\n@pytest.mark.offline\ndef test_mixed_multi_token_content():\n    \"\"\"Test chunking with mixed single and multi-token characters.\"\"\"\n    tokenizer = make_multi_token_tokenizer()\n\n    # \"hello\\n\\nWORLD!\" = 12 chars\n    # hello = 5 tokens, WORLD = 10 tokens (5 chars × 2), ! = 3 tokens\n    # Total = 18 tokens\n    content = \"hello\\n\\nWORLD!\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=True,\n        chunk_token_size=20,\n    )\n\n    assert len(chunks) == 2\n    assert chunks[0][\"content\"] == \"hello\"\n    assert chunks[0][\"tokens\"] == 5\n    assert chunks[1][\"content\"] == \"WORLD!\"\n    assert chunks[1][\"tokens\"] == 13  # 10 + 3\n\n\n@pytest.mark.offline\ndef test_exact_token_boundary_multi_token():\n    \"\"\"Test splitting exactly at token limit with multi-token characters.\"\"\"\n    tokenizer = make_multi_token_tokenizer()\n\n    # \"AAA\" = 3 chars, 6 tokens (each A = 2 tokens)\n    content = \"AAA\"\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=\"\\n\\n\",\n        split_by_character_only=True,\n        chunk_token_size=6,\n    )\n\n    assert len(chunks) == 1\n    assert chunks[0][\"tokens\"] == 6\n    assert chunks[0][\"content\"] == \"AAA\"\n\n\n@pytest.mark.offline\ndef test_multi_token_overlap_with_distinctive_content():\n    \"\"\"\n    Verify overlap works correctly with multi-token characters using distinctive content.\n\n    With non-uniform tokenization, overlap must be calculated in token space, not character space.\n    Distinctive characters ensure we catch any misalignment.\n\n    Content: \"abcABCdef\"\n    - \"abc\" = 3 tokens (1+1+1)\n    - \"ABC\" = 6 tokens (2+2+2)\n    - \"def\" = 3 tokens (1+1+1)\n    - Total = 12 tokens\n    \"\"\"\n    tokenizer = make_multi_token_tokenizer()\n\n    # Distinctive content with mixed single and multi-token chars\n    content = \"abcABCdef\"  # 9 chars, 12 tokens\n\n    chunks = chunking_by_token_size(\n        tokenizer,\n        content,\n        split_by_character=None,\n        split_by_character_only=False,\n        chunk_token_size=6,\n        chunk_overlap_token_size=2,\n    )\n\n    # With chunk_size=6, overlap=2, step=4\n    # Chunks start at token positions: 0, 4, 8\n    # Chunk 0: tokens [0:6] = \"abcA\" (tokens: a=1, b=1, c=1, A=2, total=5... wait)\n    # Let me recalculate:\n    # \"a\"=1, \"b\"=1, \"c\"=1, \"A\"=2, \"B\"=2, \"C\"=2, \"d\"=1, \"e\"=1, \"f\"=1\n    # Token positions: a=0, b=1, c=2, A=3-4, B=5-6, C=7-8, d=9, e=10, f=11\n    # Chunk 0 [0:6]: covers \"abc\" (tokens 0-2) + partial \"ABC\" (tokens 3-5, which is \"AB\")\n    # But we need to figure out what characters that maps to...\n    #\n    # Actually, let's think in terms of token slicing:\n    # tokens = [a, b, c, A1, A2, B1, B2, C1, C2, d, e, f]\n    # Chunk 0 [0:6]: [a, b, c, A1, A2, B1] - decode to \"abcAB\"\n    # Chunk 1 [4:10]: [A2, B1, B2, C1, C2, d] - decode to \"ABCd\"\n    # Chunk 2 [8:12]: [C2, d, e, f] - decode to... this is problematic\n    #\n    # The issue is that multi-token characters might get split across chunks.\n    # Let me verify what the actual chunking does...\n\n    assert len(chunks) == 3\n\n    # Just verify token counts are correct - content may vary due to character splitting\n    assert chunks[0][\"tokens\"] == 6\n    assert chunks[1][\"tokens\"] == 6\n    assert chunks[2][\"tokens\"] == 4\n\n\n@pytest.mark.offline\ndef test_decode_preserves_content():\n    \"\"\"Verify that decode correctly reconstructs original content.\"\"\"\n    tokenizer = make_multi_token_tokenizer()\n\n    test_strings = [\n        \"Hello\",\n        \"WORLD\",\n        \"Test!\",\n        \"Mixed?Case.\",\n        \"ABC123xyz\",\n    ]\n\n    for original in test_strings:\n        tokens = tokenizer.encode(original)\n        decoded = tokenizer.decode(tokens)\n        assert decoded == original, f\"Failed to decode: {original}\"\n"
  },
  {
    "path": "tests/test_curl_aquery_data.sh",
    "content": "#!/bin/bash\n\n# LightRAG aquery_data endpoint test script\n# Use curl command to test the new /query/data endpoint and validate the new data format\n\necho \"🚀 LightRAG aquery_data Endpoint Test (New Data Format Validation)\"\necho \"==================================================\"\n\n# Base URL (adjust according to actual deployment)\nBASE_URL=\"http://localhost:9621\"\n\n# Color definitions\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\n# Test result statistics\nTOTAL_TESTS=0\nPASSED_TESTS=0\nFAILED_TESTS=0\n\n# Function to validate success response format\nvalidate_success_response() {\n    local response=\"$1\"\n    local test_name=\"$2\"\n    local expected_mode=\"$3\"\n\n    echo -e \"${BLUE}Validating $test_name response format...${NC}\"\n\n    # Check if valid JSON\n    if ! echo \"$response\" | jq . >/dev/null 2>&1; then\n        echo -e \"${RED}❌ Response is not valid JSON format${NC}\"\n        return 1\n    fi\n\n    # Validate required fields\n    local status=$(echo \"$response\" | jq -r '.status // \"missing\"')\n    local message=$(echo \"$response\" | jq -r '.message // \"missing\"')\n    local data_exists=$(echo \"$response\" | jq 'has(\"data\")')\n    local metadata_exists=$(echo \"$response\" | jq 'has(\"metadata\")')\n\n    echo \"  Status: $status\"\n    echo \"  Message: $message\"\n\n    # Validate data structure\n    if [[ \"$data_exists\" == \"true\" ]]; then\n        local entities_count=$(echo \"$response\" | jq '.data.entities | length // 0')\n        local relationships_count=$(echo \"$response\" | jq '.data.relationships | length // 0')\n        local chunks_count=$(echo \"$response\" | jq '.data.chunks | length // 0')\n        local references_count=$(echo \"$response\" | jq '.data.references | length // 0')\n\n        echo \"  Data.entities: $entities_count\"\n        echo \"  Data.relationships: $relationships_count\"\n        echo \"  Data.chunks: $chunks_count\"\n        echo \"  Data.references: $references_count\"\n    else\n        echo -e \"${RED}  ❌ Missing 'data' field${NC}\"\n        return 1\n    fi\n\n    # Validate metadata\n    if [[ \"$metadata_exists\" == \"true\" ]]; then\n        local query_mode=$(echo \"$response\" | jq -r '.metadata.query_mode // \"missing\"')\n        local keywords_exists=$(echo \"$response\" | jq 'has(\"metadata\") and (.metadata | has(\"keywords\"))')\n        local processing_info_exists=$(echo \"$response\" | jq 'has(\"metadata\") and (.metadata | has(\"processing_info\"))')\n\n        echo \"  Metadata.query_mode: $query_mode\"\n        echo \"  Metadata.keywords: $keywords_exists\"\n        echo \"  Metadata.processing_info: $processing_info_exists\"\n\n        # Validate if query mode matches\n        if [[ \"$expected_mode\" != \"\" && \"$query_mode\" != \"$expected_mode\" ]]; then\n            echo -e \"${YELLOW}  ⚠️  Query mode mismatch: expected '$expected_mode', actual '$query_mode'${NC}\"\n        fi\n    else\n        echo -e \"${RED}  ❌ Missing 'metadata' field${NC}\"\n        return 1\n    fi\n\n    # Validate status\n    if [[ \"$status\" == \"success\" ]]; then\n        echo -e \"${GREEN}  ✅ Response format validation passed${NC}\"\n        return 0\n    else\n        echo -e \"${RED}  ❌ Status is not 'success': $status${NC}\"\n        return 1\n    fi\n}\n\n# Function to validate error response format\nvalidate_error_response() {\n    local response=\"$1\"\n    local test_name=\"$2\"\n\n    echo -e \"${BLUE}Validating $test_name response format...${NC}\"\n\n    # Check if valid JSON\n    if ! echo \"$response\" | jq . >/dev/null 2>&1; then\n        echo -e \"${RED}❌ Response is not valid JSON format${NC}\"\n        return 1\n    fi\n\n    # Validate required fields\n    local status=$(echo \"$response\" | jq -r '.status // \"missing\"')\n    local message=$(echo \"$response\" | jq -r '.message // \"missing\"')\n    local data_exists=$(echo \"$response\" | jq 'has(\"data\")')\n    local metadata_exists=$(echo \"$response\" | jq 'has(\"metadata\")')\n\n    echo \"  Status: $status\"\n    echo \"  Message: $message\"\n\n    # Validate basic structure exists\n    if [[ \"$data_exists\" != \"true\" ]]; then\n        echo -e \"${RED}  ❌ Missing 'data' field${NC}\"\n        return 1\n    fi\n\n    if [[ \"$metadata_exists\" != \"true\" ]]; then\n        echo -e \"${RED}  ❌ Missing 'metadata' field${NC}\"\n        return 1\n    fi\n\n    echo \"  Data: {}\"\n    echo \"  Metadata: {}\"\n\n    # Validate status should be failure\n    if [[ \"$status\" == \"failure\" ]]; then\n        echo -e \"${GREEN}  ✅ Error response format validation passed${NC}\"\n        return 0\n    else\n        echo -e \"${RED}  ❌ Status is not 'failure': $status${NC}\"\n        return 1\n    fi\n}\n\n# Function to run success test\nrun_success_test() {\n    local test_name=\"$1\"\n    local query_data=\"$2\"\n    local expected_mode=\"$3\"\n    local print_json=\"${4:-false}\"  # Optional parameter: whether to print JSON response (default: false)\n\n    echo \"\"\n    echo \"==================================\"\n    echo -e \"${BLUE}$test_name${NC}\"\n    echo \"==================================\"\n\n    TOTAL_TESTS=$((TOTAL_TESTS + 1))\n\n    # Send request\n    echo \"Sending request...\"\n    local response=$(curl -s -X POST \"${BASE_URL}/query/data\" \\\n      -H \"Content-Type: application/json\" \\\n      -H \"X-API-Key: your-secure-api-key-here-123\" \\\n      -d \"$query_data\")\n\n    # Check if curl succeeded\n    if [[ $? -ne 0 ]]; then\n        echo -e \"${RED}❌ Request failed - cannot connect to server${NC}\"\n        FAILED_TESTS=$((FAILED_TESTS + 1))\n        return 1\n    fi\n\n    # Print JSON response if requested\n    if [[ \"$print_json\" == \"true\" ]]; then\n        echo \"\"\n        echo \"Response JSON:\"\n        echo \"$response\" | jq '.' 2>/dev/null || echo \"$response\"\n        echo \"\"\n    fi\n\n    # Validate response\n    if validate_success_response \"$response\" \"$test_name\" \"$expected_mode\"; then\n        PASSED_TESTS=$((PASSED_TESTS + 1))\n        echo -e \"${GREEN}✅ $test_name test passed${NC}\"\n    else\n        FAILED_TESTS=$((FAILED_TESTS + 1))\n        echo -e \"${RED}❌ $test_name test failed${NC}\"\n        echo \"Raw response:\"\n        echo \"$response\" | jq '.' 2>/dev/null || echo \"$response\"\n    fi\n}\n\n# Function to run error test\nrun_error_test() {\n    local test_name=\"$1\"\n    local query_data=\"$2\"\n\n    echo \"\"\n    echo \"==================================\"\n    echo -e \"${BLUE}$test_name${NC}\"\n    echo \"==================================\"\n\n    TOTAL_TESTS=$((TOTAL_TESTS + 1))\n\n    # Send request\n    echo \"Sending request...\"\n    local response=$(curl -s -X POST \"${BASE_URL}/query/data\" \\\n      -H \"Content-Type: application/json\" \\\n      -H \"X-API-Key: your-secure-api-key-here-123\" \\\n      -d \"$query_data\")\n\n    # Check if curl succeeded\n    if [[ $? -ne 0 ]]; then\n        echo -e \"${RED}❌ Request failed - cannot connect to server${NC}\"\n        FAILED_TESTS=$((FAILED_TESTS + 1))\n        return 1\n    fi\n\n    # Validate response\n    if validate_error_response \"$response\" \"$test_name\"; then\n        PASSED_TESTS=$((PASSED_TESTS + 1))\n        echo -e \"${GREEN}✅ $test_name test passed${NC}\"\n    else\n        FAILED_TESTS=$((FAILED_TESTS + 1))\n        echo -e \"${RED}❌ $test_name test failed${NC}\"\n        echo \"Raw response:\"\n        echo \"$response\" | jq '.' 2>/dev/null || echo \"$response\"\n    fi\n}\n\n# Start tests\necho \"Starting tests for new /query/data endpoint data format...\"\necho \"\"\n\n# Test 1: Basic query test (mix mode)\nrun_success_test \"1. Basic Query Test (mix mode)\" '{\n    \"query\": \"What is GraphRAG\",\n    \"mode\": \"mix\",\n    \"top_k\": 5\n}' \"mix\" \"true\" # Output full JSON\n\n# Test 2: Detailed parameter query test (hybrid mode)\nrun_success_test \"2. Detailed Parameter Query Test (hybrid mode)\" '{\n    \"query\": \"What is GraphRAG\",\n    \"mode\": \"hybrid\",\n    \"top_k\": 5,\n    \"chunk_top_k\": 8,\n    \"max_entity_tokens\": 4000,\n    \"max_relation_tokens\": 4000,\n    \"max_total_tokens\": 16000,\n    \"enable_rerank\": true,\n    \"response_type\": \"Multiple Paragraphs\"\n}' \"hybrid\"\n\n# Output test result statistics\necho \"\"\necho \"==================================================\"\necho -e \"${BLUE}Test Result Statistics${NC}\"\necho \"==================================================\"\necho -e \"Total tests: ${BLUE}$TOTAL_TESTS${NC}\"\necho -e \"Passed tests: ${GREEN}$PASSED_TESTS${NC}\"\necho -e \"Failed tests: ${RED}$FAILED_TESTS${NC}\"\n\nif [[ $FAILED_TESTS -eq 0 ]]; then\n    echo -e \"${GREEN}🎉 All tests passed! New data format adaptation successful!${NC}\"\n    exit 0\nelse\n    echo -e \"${RED}⚠️  $FAILED_TESTS test(s) failed, please check the issues${NC}\"\n    exit 1\nfi\n\necho \"\"\necho \"💡 Usage Instructions:\"\necho \"1. Ensure LightRAG API service is running (python -m lightrag.api.lightrag_server)\"\necho \"2. Adjust BASE_URL as needed\"\necho \"3. If authentication is required, add -H \\\"Authorization: Bearer your-token\\\"\"\necho \"4. Install jq for better JSON formatting output: brew install jq (macOS) or apt install jq (Ubuntu)\"\necho \"5. Script will automatically validate new data format structure: status, message, data, metadata\"\n"
  },
  {
    "path": "tests/test_description_api_validation.py",
    "content": "import pytest\n\nfrom lightrag.constants import SOURCE_IDS_LIMIT_METHOD_KEEP\nfrom lightrag.operate import (\n    _merge_nodes_then_upsert,\n    _handle_single_relationship_extraction,\n)\nfrom lightrag import utils_graph\n\n\nclass DummyGraphStorage:\n    def __init__(self, node=None):\n        self.node = node\n        self.upserted_nodes = []\n\n    async def get_node(self, node_id):\n        return self.node\n\n    async def upsert_node(self, node_id, node_data):\n        self.upserted_nodes.append((node_id, node_data))\n        self.node = dict(node_data)\n\n\nclass DummyVectorStorage:\n    def __init__(self):\n        self.global_config = {\"workspace\": \"test\"}\n\n    async def upsert(self, data):\n        return None\n\n    async def delete(self, ids):\n        return None\n\n    async def get_by_id(self, id_):\n        return None\n\n    async def index_done_callback(self):\n        return True\n\n\nclass DummyAsyncContext:\n    async def __aenter__(self):\n        return None\n\n    async def __aexit__(self, exc_type, exc, tb):\n        return False\n\n\n@pytest.mark.asyncio\nasync def test_merge_nodes_then_upsert_handles_missing_legacy_description():\n    graph = DummyGraphStorage(node={\"source_id\": \"chunk-1\"})\n    global_config = {\n        \"source_ids_limit_method\": SOURCE_IDS_LIMIT_METHOD_KEEP,\n        \"max_source_ids_per_entity\": 20,\n    }\n\n    result = await _merge_nodes_then_upsert(\n        entity_name=\"LegacyEntity\",\n        nodes_data=[],\n        knowledge_graph_inst=graph,\n        entity_vdb=None,\n        global_config=global_config,\n    )\n\n    assert result[\"description\"] == \"Entity LegacyEntity\"\n    assert graph.upserted_nodes[-1][1][\"description\"] == \"Entity LegacyEntity\"\n\n\n@pytest.mark.asyncio\nasync def test_acreate_entity_rejects_empty_description():\n    with pytest.raises(ValueError, match=\"description cannot be empty\"):\n        await utils_graph.acreate_entity(\n            chunk_entity_relation_graph=None,\n            entities_vdb=None,\n            relationships_vdb=None,\n            entity_name=\"EntityA\",\n            entity_data={\"description\": \"   \"},\n        )\n\n\n@pytest.mark.asyncio\nasync def test_acreate_relation_rejects_empty_description():\n    with pytest.raises(ValueError, match=\"description cannot be empty\"):\n        await utils_graph.acreate_relation(\n            chunk_entity_relation_graph=None,\n            entities_vdb=None,\n            relationships_vdb=None,\n            source_entity=\"A\",\n            target_entity=\"B\",\n            relation_data={\"description\": \"\"},\n        )\n\n\n@pytest.mark.asyncio\nasync def test_aedit_entity_rejects_empty_description():\n    with pytest.raises(ValueError, match=\"description cannot be empty\"):\n        await utils_graph.aedit_entity(\n            chunk_entity_relation_graph=None,\n            entities_vdb=None,\n            relationships_vdb=None,\n            entity_name=\"EntityA\",\n            updated_data={\"description\": None},\n        )\n\n\n@pytest.mark.asyncio\nasync def test_aedit_relation_rejects_empty_description():\n    with pytest.raises(ValueError, match=\"description cannot be empty\"):\n        await utils_graph.aedit_relation(\n            chunk_entity_relation_graph=None,\n            entities_vdb=None,\n            relationships_vdb=None,\n            source_entity=\"A\",\n            target_entity=\"B\",\n            updated_data={\"description\": \"   \"},\n        )\n\n\n@pytest.mark.asyncio\nasync def test_aedit_entity_allows_updates_without_description(monkeypatch):\n    async def fake_edit_impl(*args, **kwargs):\n        return {\"entity_name\": \"EntityA\", \"description\": \"kept\", \"source_id\": \"chunk-1\"}\n\n    monkeypatch.setattr(\n        utils_graph, \"get_storage_keyed_lock\", lambda *a, **k: DummyAsyncContext()\n    )\n    monkeypatch.setattr(utils_graph, \"_edit_entity_impl\", fake_edit_impl)\n\n    result = await utils_graph.aedit_entity(\n        chunk_entity_relation_graph=None,\n        entities_vdb=DummyVectorStorage(),\n        relationships_vdb=DummyVectorStorage(),\n        entity_name=\"EntityA\",\n        updated_data={\"entity_type\": \"ORG\"},\n    )\n\n    assert result[\"operation_summary\"][\"operation_status\"] == \"success\"\n\n\n@pytest.mark.asyncio\nasync def test_handle_single_relationship_extraction_ignores_empty_description():\n    relation = await _handle_single_relationship_extraction(\n        [\"relation\", \"Alice\", \"Bob\", \"works_with\", \"   \"],\n        chunk_key=\"chunk-1\",\n        timestamp=1,\n    )\n\n    assert relation is None\n"
  },
  {
    "path": "tests/test_dimension_mismatch.py",
    "content": "\"\"\"\nTests for dimension mismatch handling during migration.\n\nThis test module verifies that both PostgreSQL and Qdrant storage backends\nproperly detect and handle vector dimension mismatches when migrating from\nlegacy collections/tables to new ones with different embedding models.\n\"\"\"\n\nimport json\nimport pytest\nfrom unittest.mock import MagicMock, AsyncMock, patch\n\nfrom lightrag.kg.qdrant_impl import QdrantVectorDBStorage\nfrom lightrag.kg.postgres_impl import PGVectorStorage\nfrom lightrag.exceptions import DataMigrationError\n\n\n# Note: Tests should use proper table names that have DDL templates\n# Valid base tables: LIGHTRAG_VDB_CHUNKS, LIGHTRAG_VDB_ENTITIES, LIGHTRAG_VDB_RELATIONSHIPS,\n#                    LIGHTRAG_DOC_CHUNKS, LIGHTRAG_DOC_FULL_DOCS, LIGHTRAG_DOC_TEXT_CHUNKS\n\n\nclass TestQdrantDimensionMismatch:\n    \"\"\"Test suite for Qdrant dimension mismatch handling.\"\"\"\n\n    def test_qdrant_dimension_mismatch_raises_error(self):\n        \"\"\"\n        Test that Qdrant raises DataMigrationError when dimensions don't match.\n\n        Scenario: Legacy collection has 1536d vectors, new model expects 3072d.\n        Expected: DataMigrationError is raised to prevent data corruption.\n        \"\"\"\n        from qdrant_client import models\n\n        # Setup mock client\n        client = MagicMock()\n\n        # Mock legacy collection with 1536d vectors\n        legacy_collection_info = MagicMock()\n        legacy_collection_info.config.params.vectors.size = 1536\n\n        # Setup collection existence checks\n        def collection_exists_side_effect(name):\n            if (\n                name == \"lightrag_vdb_chunks\"\n            ):  # legacy (matches _find_legacy_collection pattern)\n                return True\n            elif name == \"lightrag_chunks_model_3072d\":  # new\n                return False\n            return False\n\n        client.collection_exists.side_effect = collection_exists_side_effect\n        client.get_collection.return_value = legacy_collection_info\n        client.count.return_value.count = 100  # Legacy has data\n\n        # Patch _find_legacy_collection to return the legacy collection name\n        with patch(\n            \"lightrag.kg.qdrant_impl._find_legacy_collection\",\n            return_value=\"lightrag_vdb_chunks\",\n        ):\n            # Call setup_collection with 3072d (different from legacy 1536d)\n            # Should raise DataMigrationError due to dimension mismatch\n            with pytest.raises(DataMigrationError) as exc_info:\n                QdrantVectorDBStorage.setup_collection(\n                    client,\n                    \"lightrag_chunks_model_3072d\",\n                    namespace=\"chunks\",\n                    workspace=\"test\",\n                    vectors_config=models.VectorParams(\n                        size=3072, distance=models.Distance.COSINE\n                    ),\n                    hnsw_config=models.HnswConfigDiff(\n                        payload_m=16,\n                        m=0,\n                    ),\n                    model_suffix=\"model_3072d\",\n                )\n\n        # Verify error message contains dimension information\n        assert \"3072\" in str(exc_info.value) or \"1536\" in str(exc_info.value)\n\n        # Verify new collection was NOT created (error raised before creation)\n        client.create_collection.assert_not_called()\n\n        # Verify migration was NOT attempted\n        client.scroll.assert_not_called()\n        client.upsert.assert_not_called()\n\n    def test_qdrant_dimension_match_proceed_migration(self):\n        \"\"\"\n        Test that Qdrant proceeds with migration when dimensions match.\n\n        Scenario: Legacy collection has 1536d vectors, new model also expects 1536d.\n        Expected: Migration proceeds normally.\n        \"\"\"\n        from qdrant_client import models\n\n        client = MagicMock()\n\n        # Mock legacy collection with 1536d vectors (matching new)\n        legacy_collection_info = MagicMock()\n        legacy_collection_info.config.params.vectors.size = 1536\n\n        def collection_exists_side_effect(name):\n            if name == \"lightrag_chunks\":  # legacy\n                return True\n            elif name == \"lightrag_chunks_model_1536d\":  # new\n                return False\n            return False\n\n        client.collection_exists.side_effect = collection_exists_side_effect\n        client.get_collection.return_value = legacy_collection_info\n\n        # Track whether upsert has been called (migration occurred)\n        migration_done = {\"value\": False}\n\n        def upsert_side_effect(*args, **kwargs):\n            migration_done[\"value\"] = True\n            return MagicMock()\n\n        client.upsert.side_effect = upsert_side_effect\n\n        # Mock count to return different values based on collection name and migration state\n        # Before migration: new collection has 0 records\n        # After migration: new collection has 1 record (matching migrated data)\n        def count_side_effect(collection_name, **kwargs):\n            result = MagicMock()\n            if collection_name == \"lightrag_chunks\":  # legacy\n                result.count = 1  # Legacy has 1 record\n            elif collection_name == \"lightrag_chunks_model_1536d\":  # new\n                # Return 0 before migration, 1 after migration\n                result.count = 1 if migration_done[\"value\"] else 0\n            else:\n                result.count = 0\n            return result\n\n        client.count.side_effect = count_side_effect\n\n        # Mock scroll to return sample data (1 record for easier verification)\n        sample_point = MagicMock()\n        sample_point.id = \"test_id\"\n        sample_point.vector = [0.1] * 1536\n        sample_point.payload = {\"id\": \"test\"}\n        client.scroll.return_value = ([sample_point], None)\n\n        # Mock _find_legacy_collection to return the legacy collection name\n        with patch(\n            \"lightrag.kg.qdrant_impl._find_legacy_collection\",\n            return_value=\"lightrag_chunks\",\n        ):\n            # Call setup_collection with matching 1536d\n            QdrantVectorDBStorage.setup_collection(\n                client,\n                \"lightrag_chunks_model_1536d\",\n                namespace=\"chunks\",\n                workspace=\"test\",\n                vectors_config=models.VectorParams(\n                    size=1536, distance=models.Distance.COSINE\n                ),\n                hnsw_config=models.HnswConfigDiff(\n                    payload_m=16,\n                    m=0,\n                ),\n                model_suffix=\"model_1536d\",\n            )\n\n        # Verify migration WAS attempted\n        client.create_collection.assert_called_once()\n        client.scroll.assert_called()\n        client.upsert.assert_called()\n\n\nclass TestPostgresDimensionMismatch:\n    \"\"\"Test suite for PostgreSQL dimension mismatch handling.\"\"\"\n\n    async def test_postgres_dimension_mismatch_raises_error_metadata(self):\n        \"\"\"\n        Test that PostgreSQL raises DataMigrationError when dimensions don't match.\n\n        Scenario: Legacy table has 1536d vectors, new model expects 3072d.\n        Expected: DataMigrationError is raised to prevent data corruption.\n        \"\"\"\n        # Setup mock database\n        db = AsyncMock()\n\n        # Mock check_table_exists\n        async def mock_check_table_exists(table_name):\n            if table_name == \"LIGHTRAG_DOC_CHUNKS\":  # legacy\n                return True\n            elif table_name == \"LIGHTRAG_DOC_CHUNKS_model_3072d\":  # new\n                return False\n            return False\n\n        db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)\n\n        # Mock table existence and dimension checks\n        async def query_side_effect(query, params, **kwargs):\n            if \"COUNT(*)\" in query:\n                return {\"count\": 100}  # Legacy has data\n            elif \"SELECT content_vector FROM\" in query:\n                # Return sample vector with 1536 dimensions\n                return {\"content_vector\": [0.1] * 1536}\n            return {}\n\n        db.query.side_effect = query_side_effect\n        db.execute = AsyncMock()\n        db._create_vector_index = AsyncMock()\n\n        # Call setup_table with 3072d (different from legacy 1536d)\n        # Should raise DataMigrationError due to dimension mismatch\n        with pytest.raises(DataMigrationError) as exc_info:\n            await PGVectorStorage.setup_table(\n                db,\n                \"LIGHTRAG_DOC_CHUNKS_model_3072d\",\n                legacy_table_name=\"LIGHTRAG_DOC_CHUNKS\",\n                base_table=\"LIGHTRAG_DOC_CHUNKS\",\n                embedding_dim=3072,\n                workspace=\"test\",\n            )\n\n        # Verify error message contains dimension information\n        assert \"3072\" in str(exc_info.value) or \"1536\" in str(exc_info.value)\n\n    async def test_postgres_dimension_mismatch_raises_error_sampling(self):\n        \"\"\"\n        Test that PostgreSQL raises error when dimensions don't match (via sampling).\n\n        Scenario: Legacy table vector sampling detects 1536d vs expected 3072d.\n        Expected: DataMigrationError is raised to prevent data corruption.\n        \"\"\"\n        db = AsyncMock()\n\n        # Mock check_table_exists\n        async def mock_check_table_exists(table_name):\n            if table_name == \"LIGHTRAG_DOC_CHUNKS\":  # legacy\n                return True\n            elif table_name == \"LIGHTRAG_DOC_CHUNKS_model_3072d\":  # new\n                return False\n            return False\n\n        db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)\n\n        # Mock table existence and dimension checks\n        async def query_side_effect(query, params, **kwargs):\n            if \"information_schema.tables\" in query:\n                if params[0] == \"LIGHTRAG_DOC_CHUNKS\":  # legacy\n                    return {\"exists\": True}\n                elif params[0] == \"LIGHTRAG_DOC_CHUNKS_model_3072d\":  # new\n                    return {\"exists\": False}\n            elif \"COUNT(*)\" in query:\n                return {\"count\": 100}  # Legacy has data\n            elif \"SELECT content_vector FROM\" in query:\n                # Return sample vector with 1536 dimensions as a JSON string\n                return {\"content_vector\": json.dumps([0.1] * 1536)}\n            return {}\n\n        db.query.side_effect = query_side_effect\n        db.execute = AsyncMock()\n        db._create_vector_index = AsyncMock()\n\n        # Call setup_table with 3072d (different from legacy 1536d)\n        # Should raise DataMigrationError due to dimension mismatch\n        with pytest.raises(DataMigrationError) as exc_info:\n            await PGVectorStorage.setup_table(\n                db,\n                \"LIGHTRAG_DOC_CHUNKS_model_3072d\",\n                legacy_table_name=\"LIGHTRAG_DOC_CHUNKS\",\n                base_table=\"LIGHTRAG_DOC_CHUNKS\",\n                embedding_dim=3072,\n                workspace=\"test\",\n            )\n\n        # Verify error message contains dimension information\n        assert \"3072\" in str(exc_info.value) or \"1536\" in str(exc_info.value)\n\n    async def test_postgres_dimension_match_proceed_migration(self):\n        \"\"\"\n        Test that PostgreSQL proceeds with migration when dimensions match.\n\n        Scenario: Legacy table has 1536d vectors, new model also expects 1536d.\n        Expected: Migration proceeds normally.\n        \"\"\"\n        db = AsyncMock()\n\n        # Track migration state\n        migration_done = {\"value\": False}\n\n        # Define exactly 2 records for consistency\n        mock_records = [\n            {\n                \"id\": \"test1\",\n                \"content_vector\": [0.1] * 1536,\n                \"workspace\": \"test\",\n            },\n            {\n                \"id\": \"test2\",\n                \"content_vector\": [0.2] * 1536,\n                \"workspace\": \"test\",\n            },\n        ]\n\n        # Mock check_table_exists\n        async def mock_check_table_exists(table_name):\n            if table_name == \"LIGHTRAG_DOC_CHUNKS\":  # legacy exists\n                return True\n            elif table_name == \"LIGHTRAG_DOC_CHUNKS_model_1536d\":  # new doesn't exist\n                return False\n            return False\n\n        db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)\n\n        async def query_side_effect(query, params, **kwargs):\n            multirows = kwargs.get(\"multirows\", False)\n            query_upper = query.upper()\n\n            if \"information_schema.tables\" in query:\n                if params[0] == \"LIGHTRAG_DOC_CHUNKS\":  # legacy\n                    return {\"exists\": True}\n                elif params[0] == \"LIGHTRAG_DOC_CHUNKS_model_1536d\":  # new\n                    return {\"exists\": False}\n            elif \"COUNT(*)\" in query_upper:\n                # Return different counts based on table name in query and migration state\n                if \"LIGHTRAG_DOC_CHUNKS_MODEL_1536D\" in query_upper:\n                    # After migration: return migrated count, before: return 0\n                    return {\n                        \"count\": len(mock_records) if migration_done[\"value\"] else 0\n                    }\n                # Legacy table always has 2 records (matching mock_records)\n                return {\"count\": len(mock_records)}\n            elif \"PG_ATTRIBUTE\" in query_upper:\n                return {\"vector_dim\": 1536}  # Legacy has matching 1536d\n            elif \"SELECT\" in query_upper and \"FROM\" in query_upper and multirows:\n                # Return sample data for migration using keyset pagination\n                # Handle keyset pagination: params = [workspace, limit] or [workspace, last_id, limit]\n                if \"id >\" in query.lower():\n                    # Keyset pagination: params = [workspace, last_id, limit]\n                    last_id = params[1] if len(params) > 1 else None\n                    # Find records after last_id\n                    found_idx = -1\n                    for i, rec in enumerate(mock_records):\n                        if rec[\"id\"] == last_id:\n                            found_idx = i\n                            break\n                    if found_idx >= 0:\n                        return mock_records[found_idx + 1 :]\n                    return []\n                else:\n                    # First batch: params = [workspace, limit]\n                    return mock_records\n            return {}\n\n        db.query.side_effect = query_side_effect\n\n        # Mock _run_with_retry to track when migration happens\n        migration_executed = []\n\n        async def mock_run_with_retry(operation, *args, **kwargs):\n            migration_executed.append(True)\n            migration_done[\"value\"] = True\n            return None\n\n        db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry)\n        db.execute = AsyncMock()\n        db._create_vector_index = AsyncMock()\n\n        # Call setup_table with matching 1536d\n        await PGVectorStorage.setup_table(\n            db,\n            \"LIGHTRAG_DOC_CHUNKS_model_1536d\",\n            legacy_table_name=\"LIGHTRAG_DOC_CHUNKS\",\n            base_table=\"LIGHTRAG_DOC_CHUNKS\",\n            embedding_dim=1536,\n            workspace=\"test\",\n        )\n\n        # Verify migration WAS called (via _run_with_retry for batch operations)\n        assert len(migration_executed) > 0, \"Migration should have been executed\"\n"
  },
  {
    "path": "tests/test_doc_status_chunk_preservation.py",
    "content": "import asyncio\nfrom datetime import datetime, timezone\nfrom types import MethodType\nfrom uuid import uuid4\n\nimport numpy as np\nimport pytest\n\nimport lightrag.lightrag as lightrag_module\nfrom lightrag.base import DocStatus\nfrom lightrag.lightrag import LightRAG\nfrom lightrag.utils import EmbeddingFunc, Tokenizer, compute_mdhash_id\n\npytestmark = pytest.mark.offline\n\n\nclass _SimpleTokenizerImpl:\n    def encode(self, content: str) -> list[int]:\n        return [ord(ch) for ch in content]\n\n    def decode(self, tokens: list[int]) -> str:\n        return \"\".join(chr(t) for t in tokens)\n\n\nasync def _dummy_embedding(texts: list[str]) -> np.ndarray:\n    return np.ones((len(texts), 8), dtype=float)\n\n\nasync def _dummy_llm(*args, **kwargs) -> str:\n    return \"ok\"\n\n\ndef _deterministic_chunking(\n    tokenizer,\n    content: str,\n    split_by_character,\n    split_by_character_only: bool,\n    chunk_overlap_token_size: int,\n    chunk_token_size: int,\n) -> list[dict]:\n    return [\n        {\"tokens\": 1, \"content\": f\"{content}::chunk1\", \"chunk_order_index\": 0},\n        {\"tokens\": 1, \"content\": f\"{content}::chunk2\", \"chunk_order_index\": 1},\n    ]\n\n\ndef _failing_chunking(\n    tokenizer,\n    content: str,\n    split_by_character,\n    split_by_character_only: bool,\n    chunk_overlap_token_size: int,\n    chunk_token_size: int,\n) -> list[dict]:\n    raise RuntimeError(\"chunking fail sentinel\")\n\n\ndef _status_to_text(status: object) -> str:\n    if isinstance(status, DocStatus):\n        return status.value\n    return str(status).replace(\"DocStatus.\", \"\").lower()\n\n\nasync def _build_rag(tmp_path, test_name: str, chunking_func) -> LightRAG:\n    workspace = f\"{test_name}_{uuid4().hex[:8]}\"\n    rag = LightRAG(\n        working_dir=str(tmp_path / test_name),\n        workspace=workspace,\n        llm_model_func=_dummy_llm,\n        embedding_func=EmbeddingFunc(\n            embedding_dim=8,\n            max_token_size=8192,\n            func=_dummy_embedding,\n        ),\n        tokenizer=Tokenizer(\"test-tokenizer\", _SimpleTokenizerImpl()),\n        chunking_func=chunking_func,\n        max_parallel_insert=1,\n    )\n    await rag.initialize_storages()\n    return rag\n\n\nasync def _seed_chunk_cache_entries(\n    rag: LightRAG, chunk_ids: list[str], prefix: str\n) -> list[str]:\n    updates = {}\n    cache_records = {}\n    cache_ids: list[str] = []\n\n    for idx, chunk_id in enumerate(chunk_ids):\n        chunk_data = await rag.text_chunks.get_by_id(chunk_id)\n        assert chunk_data is not None\n        cache_id = f\"{prefix}-cache-{idx}\"\n        chunk_data[\"llm_cache_list\"] = [cache_id]\n        updates[chunk_id] = chunk_data\n        cache_records[cache_id] = {\"cache_type\": \"extract\", \"return\": f\"cached-{idx}\"}\n        cache_ids.append(cache_id)\n\n    await rag.text_chunks.upsert(updates)\n    await rag.llm_response_cache.upsert(cache_records)\n    return cache_ids\n\n\n@pytest.mark.asyncio\nasync def test_extract_failure_preserves_chunks_and_allows_delete_with_cache_cleanup(\n    tmp_path, monkeypatch\n):\n    rag = await _build_rag(tmp_path, \"extract_failure_cleanup\", _deterministic_chunking)\n    try:\n        content = \"extract failure document\"\n        file_path = \"extract_failure.txt\"\n        doc_id = compute_mdhash_id(content, prefix=\"doc-\")\n        await rag.apipeline_enqueue_documents(input=content, file_paths=file_path)\n\n        async def fail_extract(self, chunks, pipeline_status, pipeline_status_lock):\n            raise RuntimeError(\"extract fail sentinel\")\n\n        rag._process_extract_entities = MethodType(fail_extract, rag)\n\n        await rag.apipeline_process_enqueue_documents()\n\n        doc_status = await rag.doc_status.get_by_id(doc_id)\n        assert doc_status is not None\n        assert _status_to_text(doc_status[\"status\"]) == \"failed\"\n        chunk_ids = doc_status.get(\"chunks_list\", [])\n        assert len(chunk_ids) == 2\n        assert doc_status.get(\"chunks_count\") == 2\n\n        cache_ids = await _seed_chunk_cache_entries(rag, chunk_ids, \"extract\")\n\n        result = await rag.adelete_by_doc_id(doc_id, delete_llm_cache=True)\n        assert result.status == \"success\"\n\n        deleted_chunks = await rag.text_chunks.get_by_ids(chunk_ids)\n        assert all(item is None for item in deleted_chunks)\n        deleted_cache = [\n            await rag.llm_response_cache.get_by_id(cid) for cid in cache_ids\n        ]\n        assert all(item is None for item in deleted_cache)\n        assert await rag.doc_status.get_by_id(doc_id) is None\n    finally:\n        await rag.finalize_storages()\n\n\n@pytest.mark.asyncio\nasync def test_extract_failure_before_chunking_preserves_previous_chunk_snapshot(\n    tmp_path,\n):\n    rag = await _build_rag(tmp_path, \"extract_failure_pre_chunking\", _failing_chunking)\n    try:\n        content = \"chunking failure document\"\n        file_path = \"chunking_failure.txt\"\n        doc_id = compute_mdhash_id(content, prefix=\"doc-\")\n        await rag.apipeline_enqueue_documents(input=content, file_paths=file_path)\n\n        previous_chunks = [\"chunk-old-1\", \"chunk-old-2\", \"chunk-old-3\"]\n        existing = await rag.doc_status.get_by_id(doc_id)\n        assert existing is not None\n        await rag.doc_status.upsert(\n            {\n                doc_id: {\n                    \"status\": DocStatus.FAILED,\n                    \"content_summary\": existing[\"content_summary\"],\n                    \"content_length\": existing[\"content_length\"],\n                    \"chunks_count\": len(previous_chunks),\n                    \"chunks_list\": previous_chunks,\n                    \"created_at\": existing[\"created_at\"],\n                    \"updated_at\": datetime.now(timezone.utc).isoformat(),\n                    \"file_path\": existing[\"file_path\"],\n                    \"track_id\": existing[\"track_id\"],\n                    \"error_msg\": \"previous failure\",\n                    \"metadata\": {\"source\": \"test\"},\n                }\n            }\n        )\n\n        await rag.apipeline_process_enqueue_documents()\n\n        failed_status = await rag.doc_status.get_by_id(doc_id)\n        assert failed_status is not None\n        assert _status_to_text(failed_status[\"status\"]) == \"failed\"\n        assert failed_status.get(\"chunks_list\") == previous_chunks\n        assert failed_status.get(\"chunks_count\") == len(previous_chunks)\n    finally:\n        await rag.finalize_storages()\n\n\n@pytest.mark.asyncio\nasync def test_merge_failure_preserves_chunks_and_skip_cache_cleanup_when_disabled(\n    tmp_path, monkeypatch\n):\n    rag = await _build_rag(\n        tmp_path, \"merge_failure_keep_cache\", _deterministic_chunking\n    )\n    try:\n        content = \"merge failure document\"\n        file_path = \"merge_failure.txt\"\n        doc_id = compute_mdhash_id(content, prefix=\"doc-\")\n        await rag.apipeline_enqueue_documents(input=content, file_paths=file_path)\n\n        async def ok_extract(self, chunks, pipeline_status, pipeline_status_lock):\n            return {\"chunk_count\": len(chunks)}\n\n        async def fail_merge(**kwargs):\n            raise RuntimeError(\"merge fail sentinel\")\n\n        rag._process_extract_entities = MethodType(ok_extract, rag)\n        monkeypatch.setattr(lightrag_module, \"merge_nodes_and_edges\", fail_merge)\n\n        await rag.apipeline_process_enqueue_documents()\n\n        doc_status = await rag.doc_status.get_by_id(doc_id)\n        assert doc_status is not None\n        assert _status_to_text(doc_status[\"status\"]) == \"failed\"\n        chunk_ids = doc_status.get(\"chunks_list\", [])\n        assert len(chunk_ids) == 2\n        assert doc_status.get(\"chunks_count\") == 2\n\n        cache_ids = await _seed_chunk_cache_entries(rag, chunk_ids, \"merge\")\n        result = await rag.adelete_by_doc_id(doc_id, delete_llm_cache=False)\n        assert result.status == \"success\"\n\n        remaining_cache = [\n            await rag.llm_response_cache.get_by_id(cid) for cid in cache_ids\n        ]\n        assert all(item is not None for item in remaining_cache)\n    finally:\n        await rag.finalize_storages()\n\n\n@pytest.mark.asyncio\nasync def test_validate_and_fix_consistency_preserves_chunks_on_reset(tmp_path):\n    rag = await _build_rag(tmp_path, \"reset_preserve_chunks\", _deterministic_chunking)\n    try:\n        failed_doc_id = \"doc-failed-reset\"\n        processing_doc_id = \"doc-processing-reset\"\n        inferred_count_doc_id = \"doc-inferred-count-reset\"\n\n        now = datetime.now(timezone.utc).isoformat()\n        await rag.full_docs.upsert(\n            {\n                failed_doc_id: {\"content\": \"failed doc\", \"file_path\": \"failed.txt\"},\n                processing_doc_id: {\n                    \"content\": \"processing doc\",\n                    \"file_path\": \"processing.txt\",\n                },\n                inferred_count_doc_id: {\n                    \"content\": \"inferred count doc\",\n                    \"file_path\": \"inferred.txt\",\n                },\n            }\n        )\n        await rag.doc_status.upsert(\n            {\n                failed_doc_id: {\n                    \"status\": DocStatus.FAILED,\n                    \"content_summary\": \"failed\",\n                    \"content_length\": 10,\n                    \"chunks_count\": 2,\n                    \"chunks_list\": [\"f-1\", \"f-2\"],\n                    \"created_at\": now,\n                    \"updated_at\": now,\n                    \"file_path\": \"failed.txt\",\n                    \"track_id\": \"track-1\",\n                    \"error_msg\": \"old error\",\n                    \"metadata\": {\"old\": True},\n                },\n                processing_doc_id: {\n                    \"status\": DocStatus.PROCESSING,\n                    \"content_summary\": \"processing\",\n                    \"content_length\": 12,\n                    \"chunks_count\": 1,\n                    \"chunks_list\": [\"p-1\"],\n                    \"created_at\": now,\n                    \"updated_at\": now,\n                    \"file_path\": \"processing.txt\",\n                    \"track_id\": \"track-2\",\n                    \"error_msg\": \"old error\",\n                    \"metadata\": {\"old\": True},\n                },\n                inferred_count_doc_id: {\n                    \"status\": DocStatus.FAILED,\n                    \"content_summary\": \"inferred\",\n                    \"content_length\": 14,\n                    \"chunks_list\": [\"i-1\", \"i-2\", \"i-3\"],\n                    \"created_at\": now,\n                    \"updated_at\": now,\n                    \"file_path\": \"inferred.txt\",\n                    \"track_id\": \"track-3\",\n                    \"error_msg\": \"old error\",\n                    \"metadata\": {\"old\": True},\n                },\n            }\n        )\n\n        failed_docs = await rag.doc_status.get_docs_by_status(DocStatus.FAILED)\n        processing_docs = await rag.doc_status.get_docs_by_status(DocStatus.PROCESSING)\n        to_process_docs = {**failed_docs, **processing_docs}\n\n        pipeline_status = {\"latest_message\": \"\", \"history_messages\": []}\n        await rag._validate_and_fix_document_consistency(\n            to_process_docs=to_process_docs,\n            pipeline_status=pipeline_status,\n            pipeline_status_lock=asyncio.Lock(),\n        )\n\n        failed_reset = await rag.doc_status.get_by_id(failed_doc_id)\n        assert failed_reset is not None\n        assert _status_to_text(failed_reset[\"status\"]) == \"pending\"\n        assert failed_reset.get(\"chunks_list\") == [\"f-1\", \"f-2\"]\n        assert failed_reset.get(\"chunks_count\") == 2\n\n        processing_reset = await rag.doc_status.get_by_id(processing_doc_id)\n        assert processing_reset is not None\n        assert _status_to_text(processing_reset[\"status\"]) == \"pending\"\n        assert processing_reset.get(\"chunks_list\") == [\"p-1\"]\n        assert processing_reset.get(\"chunks_count\") == 1\n\n        inferred_count_reset = await rag.doc_status.get_by_id(inferred_count_doc_id)\n        assert inferred_count_reset is not None\n        assert _status_to_text(inferred_count_reset[\"status\"]) == \"pending\"\n        assert inferred_count_reset.get(\"chunks_list\") == [\"i-1\", \"i-2\", \"i-3\"]\n        assert inferred_count_reset.get(\"chunks_count\") == 3\n    finally:\n        await rag.finalize_storages()\n"
  },
  {
    "path": "tests/test_document_file_path_normalization.py",
    "content": "import sys\n\nimport pytest\n\nsys.argv = sys.argv[:1]\n\nfrom lightrag.api.routers.document_routes import (  # noqa: E402\n    DocStatusResponse,\n    normalize_file_path,\n    pipeline_index_texts,\n)\nfrom lightrag.base import DocStatus  # noqa: E402\n\n\nclass DummyRAG:\n    def __init__(self):\n        self.enqueued_calls = []\n        self.processed = False\n\n    async def apipeline_enqueue_documents(self, input, file_paths=None, track_id=None):\n        self.enqueued_calls.append(\n            {\"input\": input, \"file_paths\": file_paths, \"track_id\": track_id}\n        )\n\n    async def apipeline_process_enqueue_documents(self):\n        self.processed = True\n\n\n@pytest.mark.asyncio\nasync def test_pipeline_index_texts_normalizes_missing_file_sources():\n    rag = DummyRAG()\n\n    await pipeline_index_texts(\n        rag,\n        texts=[\"alpha\"],\n        file_sources=[None],\n        track_id=\"track-1\",\n    )\n\n    assert rag.enqueued_calls == [\n        {\n            \"input\": [\"alpha\"],\n            \"file_paths\": [\"unknown_source\"],\n            \"track_id\": \"track-1\",\n        }\n    ]\n    assert rag.processed is True\n\n\ndef test_doc_status_response_uses_non_null_unknown_source():\n    response = DocStatusResponse(\n        id=\"doc-1\",\n        content_summary=\"summary\",\n        content_length=5,\n        status=DocStatus.PENDING,\n        created_at=\"2026-03-19T00:00:00+00:00\",\n        updated_at=\"2026-03-19T00:00:00+00:00\",\n        file_path=normalize_file_path(None),\n    )\n\n    assert response.file_path == \"unknown_source\"\n"
  },
  {
    "path": "tests/test_extract_entities.py",
    "content": "\"\"\"Tests for entity extraction gleaning token limit guard.\"\"\"\n\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\n\nfrom lightrag.utils import Tokenizer, TokenizerInterface\n\n\nclass DummyTokenizer(TokenizerInterface):\n    \"\"\"Simple 1:1 character-to-token mapping for testing.\"\"\"\n\n    def encode(self, content: str):\n        return [ord(ch) for ch in content]\n\n    def decode(self, tokens):\n        return \"\".join(chr(token) for token in tokens)\n\n\ndef _make_global_config(\n    max_extract_input_tokens: int = 20480,\n    entity_extract_max_gleaning: int = 1,\n) -> dict:\n    \"\"\"Build a minimal global_config dict for extract_entities.\"\"\"\n    tokenizer = Tokenizer(\"dummy\", DummyTokenizer())\n    return {\n        \"llm_model_func\": AsyncMock(return_value=\"\"),\n        \"entity_extract_max_gleaning\": entity_extract_max_gleaning,\n        \"addon_params\": {},\n        \"tokenizer\": tokenizer,\n        \"max_extract_input_tokens\": max_extract_input_tokens,\n        \"llm_model_max_async\": 1,\n    }\n\n\n# Minimal valid extraction result that _process_extraction_result can parse\n_EXTRACTION_RESULT = (\n    \"(entity<|#|>TEST_ENTITY<|#|>CONCEPT<|#|>A test entity)<|COMPLETE|>\"\n)\n\n\ndef _make_chunks(content: str = \"Test content.\") -> dict[str, dict]:\n    return {\n        \"chunk-001\": {\n            \"tokens\": len(content),\n            \"content\": content,\n            \"full_doc_id\": \"doc-001\",\n            \"chunk_order_index\": 0,\n        }\n    }\n\n\n@pytest.mark.offline\n@pytest.mark.asyncio\nasync def test_gleaning_skipped_when_tokens_exceed_limit():\n    \"\"\"Gleaning should be skipped when estimated tokens exceed max_extract_input_tokens.\"\"\"\n    from lightrag.operate import extract_entities\n\n    # Use a very small token limit so the gleaning context will exceed it\n    global_config = _make_global_config(\n        max_extract_input_tokens=10,\n        entity_extract_max_gleaning=1,\n    )\n\n    llm_func = global_config[\"llm_model_func\"]\n    llm_func.return_value = _EXTRACTION_RESULT\n\n    with patch(\"lightrag.operate.logger\") as mock_logger:\n        await extract_entities(\n            chunks=_make_chunks(),\n            global_config=global_config,\n        )\n\n    # LLM should be called exactly once (initial extraction only, no gleaning)\n    assert llm_func.await_count == 1\n    # Warning should be logged about skipping gleaning\n    mock_logger.warning.assert_called_once()\n    warning_msg = mock_logger.warning.call_args[0][0]\n    assert \"Gleaning stopped\" in warning_msg\n    assert \"exceeded limit\" in warning_msg\n\n\n@pytest.mark.offline\n@pytest.mark.asyncio\nasync def test_gleaning_proceeds_when_tokens_within_limit():\n    \"\"\"Gleaning should proceed when estimated tokens are within max_extract_input_tokens.\"\"\"\n    from lightrag.operate import extract_entities\n\n    # Use a very large token limit so gleaning will proceed\n    global_config = _make_global_config(\n        max_extract_input_tokens=999999,\n        entity_extract_max_gleaning=1,\n    )\n\n    llm_func = global_config[\"llm_model_func\"]\n    llm_func.return_value = _EXTRACTION_RESULT\n\n    with patch(\"lightrag.operate.logger\"):\n        await extract_entities(\n            chunks=_make_chunks(),\n            global_config=global_config,\n        )\n\n    # LLM should be called twice (initial extraction + gleaning)\n    assert llm_func.await_count == 2\n\n\n@pytest.mark.offline\n@pytest.mark.asyncio\nasync def test_no_gleaning_when_max_gleaning_zero():\n    \"\"\"No gleaning when entity_extract_max_gleaning is 0, regardless of token limit.\"\"\"\n    from lightrag.operate import extract_entities\n\n    global_config = _make_global_config(\n        max_extract_input_tokens=999999,\n        entity_extract_max_gleaning=0,\n    )\n\n    llm_func = global_config[\"llm_model_func\"]\n    llm_func.return_value = _EXTRACTION_RESULT\n\n    with patch(\"lightrag.operate.logger\"):\n        await extract_entities(\n            chunks=_make_chunks(),\n            global_config=global_config,\n        )\n\n    # LLM should be called exactly once (initial extraction only)\n    assert llm_func.await_count == 1\n"
  },
  {
    "path": "tests/test_faiss_meta_inconsistency.py",
    "content": "\"\"\"\nRegression tests for Faiss meta/index inconsistency handling.\n\nVerifies that FaissVectorDBStorage gracefully handles cases where\nmeta.json has more rows than the Faiss index (e.g., after a crash\nduring save), and that delete/upsert operations don't crash.\n\"\"\"\n\nimport json\nimport os\nimport tempfile\n\nimport numpy as np\nimport pytest\n\nfaiss = pytest.importorskip(\"faiss\")\n\n\n@pytest.mark.offline\nclass TestFaissMetaInconsistency:\n    \"\"\"Test that stale metadata rows are handled gracefully.\"\"\"\n\n    def _create_index_and_meta(self, tmp_dir, dim=4, n_vectors=3, n_extra_meta=2):\n        \"\"\"\n        Helper: create a Faiss index with `n_vectors` vectors and a meta.json\n        that has `n_vectors + n_extra_meta` entries (simulating a crash where\n        meta was written but index wasn't fully updated).\n        \"\"\"\n        index_file = os.path.join(tmp_dir, \"faiss_index_test.index\")\n        meta_file = index_file + \".meta.json\"\n\n        # Build real index with n_vectors\n        index = faiss.IndexFlatIP(dim)\n        vectors = np.random.rand(n_vectors, dim).astype(np.float32)\n        # Normalize for cosine similarity\n        norms = np.linalg.norm(vectors, axis=1, keepdims=True)\n        vectors = vectors / norms\n        index.add(vectors)\n        faiss.write_index(index, index_file)\n\n        # Build meta with extra rows beyond index.ntotal\n        meta = {}\n        for i in range(n_vectors):\n            meta[str(i)] = {\"__id__\": f\"id_{i}\", \"content\": f\"text_{i}\"}\n        for i in range(n_vectors, n_vectors + n_extra_meta):\n            meta[str(i)] = {\"__id__\": f\"stale_{i}\", \"content\": f\"stale_{i}\"}\n\n        with open(meta_file, \"w\", encoding=\"utf-8\") as f:\n            json.dump(meta, f)\n\n        return index_file, meta_file, vectors\n\n    def test_load_skips_invalid_metadata_rows(self):\n        \"\"\"\n        Loading an index where meta.json has fids beyond index.ntotal\n        should skip those rows with a warning, not crash.\n        \"\"\"\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            dim = 4\n            n_vectors = 3\n            n_extra = 2\n            index_file, meta_file, vectors = self._create_index_and_meta(\n                tmp_dir, dim=dim, n_vectors=n_vectors, n_extra_meta=n_extra\n            )\n\n            # Manually load and verify behavior\n            index = faiss.read_index(index_file)\n            with open(meta_file, \"r\", encoding=\"utf-8\") as f:\n                stored_dict = json.load(f)\n\n            assert len(stored_dict) == n_vectors + n_extra\n\n            # Simulate the load logic from _load_faiss_index\n            id_to_meta = {}\n            skipped = 0\n            for fid_str, meta in stored_dict.items():\n                fid = int(fid_str)\n                if fid >= index.ntotal:\n                    skipped += 1\n                    continue\n                if \"__vector__\" not in meta:\n                    meta[\"__vector__\"] = index.reconstruct(fid).tolist()\n                id_to_meta[fid] = meta\n\n            assert len(id_to_meta) == n_vectors\n            assert skipped == n_extra\n\n            # Verify reconstructed vectors match originals\n            for fid in range(n_vectors):\n                reconstructed = np.array(\n                    id_to_meta[fid][\"__vector__\"], dtype=np.float32\n                )\n                np.testing.assert_allclose(reconstructed, vectors[fid], atol=1e-6)\n\n    def test_remove_with_missing_vector_uses_reconstruct(self):\n        \"\"\"\n        _remove_faiss_ids should reconstruct vectors from the index\n        when __vector__ is not present in metadata.\n        \"\"\"\n        dim = 4\n        n_vectors = 3\n\n        index = faiss.IndexFlatIP(dim)\n        vectors = np.random.rand(n_vectors, dim).astype(np.float32)\n        norms = np.linalg.norm(vectors, axis=1, keepdims=True)\n        vectors = vectors / norms\n        index.add(vectors)\n\n        # Metadata WITHOUT __vector__ (as stored on disk after our PR)\n        id_to_meta = {}\n        for i in range(n_vectors):\n            id_to_meta[i] = {\"__id__\": f\"id_{i}\", \"content\": f\"text_{i}\"}\n\n        # Simulate rebuild logic from _remove_faiss_ids (remove fid=1)\n        fid_list = [1]\n        keep_fids = [fid for fid in id_to_meta if fid not in fid_list]\n\n        vectors_to_keep = []\n        new_id_to_meta = {}\n        for new_fid, old_fid in enumerate(keep_fids):\n            vec_meta = id_to_meta[old_fid]\n            if \"__vector__\" in vec_meta:\n                vec = vec_meta[\"__vector__\"]\n            elif old_fid < index.ntotal:\n                vec = index.reconstruct(old_fid).tolist()\n                vec_meta[\"__vector__\"] = vec\n            else:\n                continue\n            vectors_to_keep.append(vec)\n            new_id_to_meta[new_fid] = vec_meta\n\n        assert len(vectors_to_keep) == 2\n        assert len(new_id_to_meta) == 2\n        # Verify the kept vectors match originals (fid 0 and 2)\n        np.testing.assert_allclose(\n            np.array(vectors_to_keep[0], dtype=np.float32), vectors[0], atol=1e-6\n        )\n        np.testing.assert_allclose(\n            np.array(vectors_to_keep[1], dtype=np.float32), vectors[2], atol=1e-6\n        )\n\n    def test_atomic_save_meta(self):\n        \"\"\"\n        _save_faiss_index should write meta.json atomically via temp file + os.replace.\n        Verify no .tmp file remains after save.\n        \"\"\"\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            meta_file = os.path.join(tmp_dir, \"test.meta.json\")\n            tmp_meta_file = meta_file + \".tmp\"\n\n            serializable_dict = {\"0\": {\"__id__\": \"id_0\", \"content\": \"text_0\"}}\n\n            # Simulate atomic write\n            with open(tmp_meta_file, \"w\", encoding=\"utf-8\") as f:\n                json.dump(serializable_dict, f)\n            os.replace(tmp_meta_file, meta_file)\n\n            assert os.path.exists(meta_file)\n            assert not os.path.exists(tmp_meta_file)\n\n            with open(meta_file, \"r\", encoding=\"utf-8\") as f:\n                loaded = json.load(f)\n            assert loaded == serializable_dict\n"
  },
  {
    "path": "tests/test_graph_storage.py",
    "content": "#!/usr/bin/env python\n\"\"\"\nGeneral-purpose graph storage test program.\n\nThis program selects the graph storage type to use based on the LIGHTRAG_GRAPH_STORAGE configuration in .env,\nand tests its basic and advanced operations.\n\nSupported graph storage types include:\n- NetworkXStorage\n- Neo4JStorage\n- MongoDBStorage\n- PGGraphStorage\n- MemgraphStorage\n\"\"\"\n\nimport asyncio\nimport os\nimport sys\nimport importlib\nimport numpy as np\nimport pytest\nfrom dotenv import load_dotenv\nfrom ascii_colors import ASCIIColors\n\n# Add the project root directory to the Python path\nsys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\n\nfrom lightrag.types import KnowledgeGraph\nfrom lightrag.kg import (\n    STORAGE_IMPLEMENTATIONS,\n    STORAGE_ENV_REQUIREMENTS,\n    STORAGES,\n    verify_storage_implementation,\n)\nfrom lightrag.kg.shared_storage import initialize_share_data\nfrom lightrag.constants import GRAPH_FIELD_SEP\n\n\n# Mock embedding function that returns random vectors\nasync def mock_embedding_func(texts):\n    return np.random.rand(len(texts), 10)  # Return 10-dimensional random vectors\n\n\ndef check_env_file():\n    \"\"\"\n    Check if the .env file exists and issue a warning if it does not.\n    Returns True to continue execution, False to exit.\n    \"\"\"\n    if not os.path.exists(\".env\"):\n        warning_msg = \"Warning: .env file not found in the current directory. This may affect storage configuration loading.\"\n        ASCIIColors.yellow(warning_msg)\n\n        # Check if running in an interactive terminal\n        if sys.stdin.isatty():\n            response = input(\"Do you want to continue? (yes/no): \")\n            if response.lower() != \"yes\":\n                ASCIIColors.red(\"Test program cancelled.\")\n                return False\n    return True\n\n\nasync def initialize_graph_storage():\n    \"\"\"\n    Initialize the corresponding graph storage instance based on environment variables.\n    Returns the initialized storage instance.\n    \"\"\"\n    # Get the graph storage type from environment variables\n    graph_storage_type = os.getenv(\"LIGHTRAG_GRAPH_STORAGE\", \"NetworkXStorage\")\n\n    # Validate the storage type\n    try:\n        verify_storage_implementation(\"GRAPH_STORAGE\", graph_storage_type)\n    except ValueError as e:\n        ASCIIColors.red(f\"Error: {str(e)}\")\n        ASCIIColors.yellow(\n            f\"Supported graph storage types: {', '.join(STORAGE_IMPLEMENTATIONS['GRAPH_STORAGE']['implementations'])}\"\n        )\n        return None\n\n    # Check for required environment variables\n    required_env_vars = STORAGE_ENV_REQUIREMENTS.get(graph_storage_type, [])\n    missing_env_vars = [var for var in required_env_vars if not os.getenv(var)]\n\n    if missing_env_vars:\n        ASCIIColors.red(\n            f\"Error: {graph_storage_type} requires the following environment variables, but they are not set: {', '.join(missing_env_vars)}\"\n        )\n        return None\n\n    # Dynamically import the corresponding module\n    module_path = STORAGES.get(graph_storage_type)\n    if not module_path:\n        ASCIIColors.red(f\"Error: Module path for {graph_storage_type} not found.\")\n        return None\n\n    try:\n        module = importlib.import_module(module_path, package=\"lightrag\")\n        storage_class = getattr(module, graph_storage_type)\n    except (ImportError, AttributeError) as e:\n        ASCIIColors.red(f\"Error: Failed to import {graph_storage_type}: {str(e)}\")\n        return None\n\n    # Initialize the storage instance\n    global_config = {\n        \"embedding_batch_num\": 10,  # Batch size\n        \"vector_db_storage_cls_kwargs\": {\n            \"cosine_better_than_threshold\": 0.5  # Cosine similarity threshold\n        },\n        \"working_dir\": os.environ.get(\n            \"WORKING_DIR\", \"./rag_storage\"\n        ),  # Working directory\n    }\n\n    # Initialize shared_storage for all storage types (required for locks)\n    initialize_share_data()  # Use single-process mode (workers=1)\n\n    try:\n        storage = storage_class(\n            namespace=\"test_graph\",\n            workspace=\"test_workspace\",\n            global_config=global_config,\n            embedding_func=mock_embedding_func,\n        )\n\n        # Initialize the connection\n        await storage.initialize()\n        return storage\n    except Exception as e:\n        ASCIIColors.red(f\"Error: Failed to initialize {graph_storage_type}: {str(e)}\")\n        return None\n\n\n@pytest.mark.integration\n@pytest.mark.requires_db\nasync def test_graph_basic(storage):\n    \"\"\"\n    Test basic graph database operations:\n    1. Use upsert_node to insert two nodes.\n    2. Use upsert_edge to insert an edge connecting the two nodes.\n    3. Use get_node to read a node.\n    4. Use get_edge to read an edge.\n    \"\"\"\n    try:\n        # 1. Insert the first node\n        node1_id = \"Artificial Intelligence\"\n        node1_data = {\n            \"entity_id\": node1_id,\n            \"description\": \"Artificial intelligence is a branch of computer science that aims to understand the essence of intelligence and produce a new kind of intelligent machine that can react in a manner similar to human intelligence.\",\n            \"keywords\": \"AI,Machine Learning,Deep Learning\",\n            \"entity_type\": \"Technology Field\",\n        }\n        print(f\"Inserting node 1: {node1_id}\")\n        await storage.upsert_node(node1_id, node1_data)\n\n        # 2. Insert the second node\n        node2_id = \"Machine Learning\"\n        node2_data = {\n            \"entity_id\": node2_id,\n            \"description\": \"Machine learning is a branch of artificial intelligence that uses statistical methods to enable computer systems to learn without being explicitly programmed.\",\n            \"keywords\": \"Supervised Learning,Unsupervised Learning,Reinforcement Learning\",\n            \"entity_type\": \"Technology Field\",\n        }\n        print(f\"Inserting node 2: {node2_id}\")\n        await storage.upsert_node(node2_id, node2_data)\n\n        # 3. Insert the connecting edge\n        edge_data = {\n            \"relationship\": \"includes\",\n            \"weight\": 1.0,\n            \"description\": \"The field of artificial intelligence includes the subfield of machine learning.\",\n        }\n        print(f\"Inserting edge: {node1_id} -> {node2_id}\")\n        await storage.upsert_edge(node1_id, node2_id, edge_data)\n\n        # 4. Read node properties\n        print(f\"Reading node properties: {node1_id}\")\n        node1_props = await storage.get_node(node1_id)\n        if node1_props:\n            print(f\"Successfully read node properties: {node1_id}\")\n            print(\n                f\"Node description: {node1_props.get('description', 'No description')}\"\n            )\n            print(f\"Node type: {node1_props.get('entity_type', 'No type')}\")\n            print(f\"Node keywords: {node1_props.get('keywords', 'No keywords')}\")\n            # Verify that the returned properties are correct\n            assert (\n                node1_props.get(\"entity_id\") == node1_id\n            ), f\"Node ID mismatch: expected {node1_id}, got {node1_props.get('entity_id')}\"\n            assert (\n                node1_props.get(\"description\") == node1_data[\"description\"]\n            ), \"Node description mismatch\"\n            assert (\n                node1_props.get(\"entity_type\") == node1_data[\"entity_type\"]\n            ), \"Node type mismatch\"\n        else:\n            print(f\"Failed to read node properties: {node1_id}\")\n            assert False, f\"Failed to read node properties: {node1_id}\"\n\n        # 5. Read edge properties\n        print(f\"Reading edge properties: {node1_id} -> {node2_id}\")\n        edge_props = await storage.get_edge(node1_id, node2_id)\n        if edge_props:\n            print(f\"Successfully read edge properties: {node1_id} -> {node2_id}\")\n            print(\n                f\"Edge relationship: {edge_props.get('relationship', 'No relationship')}\"\n            )\n            print(\n                f\"Edge description: {edge_props.get('description', 'No description')}\"\n            )\n            print(f\"Edge weight: {edge_props.get('weight', 'No weight')}\")\n            # Verify that the returned properties are correct\n            assert (\n                edge_props.get(\"relationship\") == edge_data[\"relationship\"]\n            ), \"Edge relationship mismatch\"\n            assert (\n                edge_props.get(\"description\") == edge_data[\"description\"]\n            ), \"Edge description mismatch\"\n            assert (\n                edge_props.get(\"weight\") == edge_data[\"weight\"]\n            ), \"Edge weight mismatch\"\n        else:\n            print(f\"Failed to read edge properties: {node1_id} -> {node2_id}\")\n            assert False, f\"Failed to read edge properties: {node1_id} -> {node2_id}\"\n\n        # 5.1 Verify undirected graph property - read reverse edge properties\n        print(f\"Reading reverse edge properties: {node2_id} -> {node1_id}\")\n        reverse_edge_props = await storage.get_edge(node2_id, node1_id)\n        if reverse_edge_props:\n            print(\n                f\"Successfully read reverse edge properties: {node2_id} -> {node1_id}\"\n            )\n            print(\n                f\"Reverse edge relationship: {reverse_edge_props.get('relationship', 'No relationship')}\"\n            )\n            print(\n                f\"Reverse edge description: {reverse_edge_props.get('description', 'No description')}\"\n            )\n            print(\n                f\"Reverse edge weight: {reverse_edge_props.get('weight', 'No weight')}\"\n            )\n            # Verify that forward and reverse edge properties are the same\n            assert (\n                edge_props == reverse_edge_props\n            ), \"Forward and reverse edge properties are not consistent, undirected graph property verification failed\"\n            print(\n                \"Undirected graph property verification successful: forward and reverse edge properties are consistent\"\n            )\n        else:\n            print(f\"Failed to read reverse edge properties: {node2_id} -> {node1_id}\")\n            assert False, f\"Failed to read reverse edge properties: {node2_id} -> {node1_id}, undirected graph property verification failed\"\n\n        print(\"Basic tests completed, data is preserved in the database.\")\n        return True\n\n    except Exception as e:\n        ASCIIColors.red(f\"An error occurred during the test: {str(e)}\")\n        return False\n\n\n@pytest.mark.integration\n@pytest.mark.requires_db\nasync def test_graph_advanced(storage):\n    \"\"\"\n    Test advanced graph database operations:\n    1. Use node_degree to get the degree of a node.\n    2. Use edge_degree to get the degree of an edge.\n    3. Use get_node_edges to get all edges of a node.\n    4. Use get_all_labels to get all labels.\n    5. Use get_knowledge_graph to get a knowledge graph.\n    6. Use delete_node to delete a node.\n    7. Use remove_nodes to delete multiple nodes.\n    8. Use remove_edges to delete edges.\n    9. Use drop to clean up data.\n    \"\"\"\n    try:\n        # 1. Insert test data\n        # Insert node 1: Artificial Intelligence\n        node1_id = \"Artificial Intelligence\"\n        node1_data = {\n            \"entity_id\": node1_id,\n            \"description\": \"Artificial intelligence is a branch of computer science that aims to understand the essence of intelligence and produce a new kind of intelligent machine that can react in a manner similar to human intelligence.\",\n            \"keywords\": \"AI,Machine Learning,Deep Learning\",\n            \"entity_type\": \"Technology Field\",\n        }\n        print(f\"Inserting node 1: {node1_id}\")\n        await storage.upsert_node(node1_id, node1_data)\n\n        # Insert node 2: Machine Learning\n        node2_id = \"Machine Learning\"\n        node2_data = {\n            \"entity_id\": node2_id,\n            \"description\": \"Machine learning is a branch of artificial intelligence that uses statistical methods to enable computer systems to learn without being explicitly programmed.\",\n            \"keywords\": \"Supervised Learning,Unsupervised Learning,Reinforcement Learning\",\n            \"entity_type\": \"Technology Field\",\n        }\n        print(f\"Inserting node 2: {node2_id}\")\n        await storage.upsert_node(node2_id, node2_data)\n\n        # Insert node 3: Deep Learning\n        node3_id = \"Deep Learning\"\n        node3_data = {\n            \"entity_id\": node3_id,\n            \"description\": \"Deep learning is a branch of machine learning that uses multi-layered neural networks to simulate the learning process of the human brain.\",\n            \"keywords\": \"Neural Networks,CNN,RNN\",\n            \"entity_type\": \"Technology Field\",\n        }\n        print(f\"Inserting node 3: {node3_id}\")\n        await storage.upsert_node(node3_id, node3_data)\n\n        # Insert edge 1: Artificial Intelligence -> Machine Learning\n        edge1_data = {\n            \"relationship\": \"includes\",\n            \"weight\": 1.0,\n            \"description\": \"The field of artificial intelligence includes the subfield of machine learning.\",\n        }\n        print(f\"Inserting edge 1: {node1_id} -> {node2_id}\")\n        await storage.upsert_edge(node1_id, node2_id, edge1_data)\n\n        # Insert edge 2: Machine Learning -> Deep Learning\n        edge2_data = {\n            \"relationship\": \"includes\",\n            \"weight\": 1.0,\n            \"description\": \"The field of machine learning includes the subfield of deep learning.\",\n        }\n        print(f\"Inserting edge 2: {node2_id} -> {node3_id}\")\n        await storage.upsert_edge(node2_id, node3_id, edge2_data)\n\n        # 2. Test node_degree - get the degree of a node\n        print(f\"== Testing node_degree: {node1_id}\")\n        node1_degree = await storage.node_degree(node1_id)\n        print(f\"Degree of node {node1_id}: {node1_degree}\")\n        assert (\n            node1_degree == 1\n        ), f\"Degree of node {node1_id} should be 1, but got {node1_degree}\"\n\n        # 2.1 Test degrees of all nodes\n        print(\"== Testing degrees of all nodes\")\n        node2_degree = await storage.node_degree(node2_id)\n        node3_degree = await storage.node_degree(node3_id)\n        print(f\"Degree of node {node2_id}: {node2_degree}\")\n        print(f\"Degree of node {node3_id}: {node3_degree}\")\n        assert (\n            node2_degree == 2\n        ), f\"Degree of node {node2_id} should be 2, but got {node2_degree}\"\n        assert (\n            node3_degree == 1\n        ), f\"Degree of node {node3_id} should be 1, but got {node3_degree}\"\n\n        # 3. Test edge_degree - get the degree of an edge\n        print(f\"== Testing edge_degree: {node1_id} -> {node2_id}\")\n        edge_degree = await storage.edge_degree(node1_id, node2_id)\n        print(f\"Degree of edge {node1_id} -> {node2_id}: {edge_degree}\")\n        assert (\n            edge_degree == 3\n        ), f\"Degree of edge {node1_id} -> {node2_id} should be 3, but got {edge_degree}\"\n\n        # 3.1 Test reverse edge degree - verify undirected graph property\n        print(f\"== Testing reverse edge degree: {node2_id} -> {node1_id}\")\n        reverse_edge_degree = await storage.edge_degree(node2_id, node1_id)\n        print(f\"Degree of reverse edge {node2_id} -> {node1_id}: {reverse_edge_degree}\")\n        assert (\n            edge_degree == reverse_edge_degree\n        ), \"Degrees of forward and reverse edges are not consistent, undirected graph property verification failed\"\n        print(\n            \"Undirected graph property verification successful: degrees of forward and reverse edges are consistent\"\n        )\n\n        # 4. Test get_node_edges - get all edges of a node\n        print(f\"== Testing get_node_edges: {node2_id}\")\n        node2_edges = await storage.get_node_edges(node2_id)\n        print(f\"All edges of node {node2_id}: {node2_edges}\")\n        assert (\n            len(node2_edges) == 2\n        ), f\"Node {node2_id} should have 2 edges, but got {len(node2_edges)}\"\n\n        # 4.1 Verify undirected graph property of node edges\n        print(\"== Verifying undirected graph property of node edges\")\n        # Check if it includes connections with node1 and node3 (regardless of direction)\n        has_connection_with_node1 = False\n        has_connection_with_node3 = False\n        for edge in node2_edges:\n            # Check for connection with node1 (regardless of direction)\n            if (edge[0] == node1_id and edge[1] == node2_id) or (\n                edge[0] == node2_id and edge[1] == node1_id\n            ):\n                has_connection_with_node1 = True\n            # Check for connection with node3 (regardless of direction)\n            if (edge[0] == node2_id and edge[1] == node3_id) or (\n                edge[0] == node3_id and edge[1] == node2_id\n            ):\n                has_connection_with_node3 = True\n\n        assert (\n            has_connection_with_node1\n        ), f\"Edge list of node {node2_id} should include a connection with {node1_id}\"\n        assert (\n            has_connection_with_node3\n        ), f\"Edge list of node {node2_id} should include a connection with {node3_id}\"\n        print(\n            f\"Undirected graph property verification successful: edge list of node {node2_id} contains all relevant edges\"\n        )\n\n        # 5. Test get_all_labels - get all labels\n        print(\"== Testing get_all_labels\")\n        all_labels = await storage.get_all_labels()\n        print(f\"All labels: {all_labels}\")\n        assert len(all_labels) == 3, f\"Should have 3 labels, but got {len(all_labels)}\"\n        assert node1_id in all_labels, f\"{node1_id} should be in the label list\"\n        assert node2_id in all_labels, f\"{node2_id} should be in the label list\"\n        assert node3_id in all_labels, f\"{node3_id} should be in the label list\"\n\n        # 6. Test get_knowledge_graph - get a knowledge graph\n        print(\"== Testing get_knowledge_graph\")\n        kg = await storage.get_knowledge_graph(\"*\", max_depth=2, max_nodes=10)\n        print(f\"Number of nodes in knowledge graph: {len(kg.nodes)}\")\n        print(f\"Number of edges in knowledge graph: {len(kg.edges)}\")\n        assert isinstance(\n            kg, KnowledgeGraph\n        ), \"The returned result should be of type KnowledgeGraph\"\n        assert (\n            len(kg.nodes) == 3\n        ), f\"The knowledge graph should have 3 nodes, but got {len(kg.nodes)}\"\n        assert (\n            len(kg.edges) == 2\n        ), f\"The knowledge graph should have 2 edges, but got {len(kg.edges)}\"\n\n        # 7. Test delete_node - delete a node\n        print(f\"== Testing delete_node: {node3_id}\")\n        await storage.delete_node(node3_id)\n        node3_props = await storage.get_node(node3_id)\n        print(f\"Querying node properties after deletion {node3_id}: {node3_props}\")\n        assert node3_props is None, f\"Node {node3_id} should have been deleted\"\n\n        # Re-insert node 3 for subsequent tests\n        await storage.upsert_node(node3_id, node3_data)\n        await storage.upsert_edge(node2_id, node3_id, edge2_data)\n\n        # 8. Test remove_edges - delete edges\n        print(f\"== Testing remove_edges: {node2_id} -> {node3_id}\")\n        await storage.remove_edges([(node2_id, node3_id)])\n        edge_props = await storage.get_edge(node2_id, node3_id)\n        print(\n            f\"Querying edge properties after deletion {node2_id} -> {node3_id}: {edge_props}\"\n        )\n        assert (\n            edge_props is None\n        ), f\"Edge {node2_id} -> {node3_id} should have been deleted\"\n\n        # 8.1 Verify undirected graph property of edge deletion\n        print(\n            f\"== Verifying undirected graph property of edge deletion: {node3_id} -> {node2_id}\"\n        )\n        reverse_edge_props = await storage.get_edge(node3_id, node2_id)\n        print(\n            f\"Querying reverse edge properties after deletion {node3_id} -> {node2_id}: {reverse_edge_props}\"\n        )\n        assert (\n            reverse_edge_props is None\n        ), f\"Reverse edge {node3_id} -> {node2_id} should also be deleted, undirected graph property verification failed\"\n        print(\n            \"Undirected graph property verification successful: deleting an edge in one direction also deletes the reverse edge\"\n        )\n\n        # 9. Test remove_nodes - delete multiple nodes\n        print(f\"== Testing remove_nodes: [{node2_id}, {node3_id}]\")\n        await storage.remove_nodes([node2_id, node3_id])\n        node2_props = await storage.get_node(node2_id)\n        node3_props = await storage.get_node(node3_id)\n        print(f\"Querying node properties after deletion {node2_id}: {node2_props}\")\n        print(f\"Querying node properties after deletion {node3_id}: {node3_props}\")\n        assert node2_props is None, f\"Node {node2_id} should have been deleted\"\n        assert node3_props is None, f\"Node {node3_id} should have been deleted\"\n\n        print(\"\\nAdvanced tests completed.\")\n        return True\n\n    except Exception as e:\n        ASCIIColors.red(f\"An error occurred during the test: {str(e)}\")\n        return False\n\n\n@pytest.mark.integration\n@pytest.mark.requires_db\nasync def test_graph_batch_operations(storage):\n    \"\"\"\n    Test batch operations of the graph database:\n    1. Use get_nodes_batch to get properties of multiple nodes in batch.\n    2. Use node_degrees_batch to get degrees of multiple nodes in batch.\n    3. Use edge_degrees_batch to get degrees of multiple edges in batch.\n    4. Use get_edges_batch to get properties of multiple edges in batch.\n    5. Use get_nodes_edges_batch to get all edges of multiple nodes in batch.\n    \"\"\"\n    try:\n        chunk1_id = \"1\"\n        chunk2_id = \"2\"\n        chunk3_id = \"3\"\n        # 1. Insert test data\n        # Insert node 1: Artificial Intelligence\n        node1_id = \"Artificial Intelligence\"\n        node1_data = {\n            \"entity_id\": node1_id,\n            \"description\": \"Artificial intelligence is a branch of computer science that aims to understand the essence of intelligence and produce a new kind of intelligent machine that can react in a manner similar to human intelligence.\",\n            \"keywords\": \"AI,Machine Learning,Deep Learning\",\n            \"entity_type\": \"Technology Field\",\n            \"source_id\": GRAPH_FIELD_SEP.join([chunk1_id, chunk2_id]),\n        }\n        print(f\"Inserting node 1: {node1_id}\")\n        await storage.upsert_node(node1_id, node1_data)\n\n        # Insert node 2: Machine Learning\n        node2_id = \"Machine Learning\"\n        node2_data = {\n            \"entity_id\": node2_id,\n            \"description\": \"Machine learning is a branch of artificial intelligence that uses statistical methods to enable computer systems to learn without being explicitly programmed.\",\n            \"keywords\": \"Supervised Learning,Unsupervised Learning,Reinforcement Learning\",\n            \"entity_type\": \"Technology Field\",\n            \"source_id\": GRAPH_FIELD_SEP.join([chunk2_id, chunk3_id]),\n        }\n        print(f\"Inserting node 2: {node2_id}\")\n        await storage.upsert_node(node2_id, node2_data)\n\n        # Insert node 3: Deep Learning\n        node3_id = \"Deep Learning\"\n        node3_data = {\n            \"entity_id\": node3_id,\n            \"description\": \"Deep learning is a branch of machine learning that uses multi-layered neural networks to simulate the learning process of the human brain.\",\n            \"keywords\": \"Neural Networks,CNN,RNN\",\n            \"entity_type\": \"Technology Field\",\n            \"source_id\": GRAPH_FIELD_SEP.join([chunk3_id]),\n        }\n        print(f\"Inserting node 3: {node3_id}\")\n        await storage.upsert_node(node3_id, node3_data)\n\n        # Insert node 4: Natural Language Processing\n        node4_id = \"Natural Language Processing\"\n        node4_data = {\n            \"entity_id\": node4_id,\n            \"description\": \"Natural language processing is a branch of artificial intelligence that focuses on enabling computers to understand and process human language.\",\n            \"keywords\": \"NLP,Text Analysis,Language Models\",\n            \"entity_type\": \"Technology Field\",\n        }\n        print(f\"Inserting node 4: {node4_id}\")\n        await storage.upsert_node(node4_id, node4_data)\n\n        # Insert node 5: Computer Vision\n        node5_id = \"Computer Vision\"\n        node5_data = {\n            \"entity_id\": node5_id,\n            \"description\": \"Computer vision is a branch of artificial intelligence that focuses on enabling computers to gain information from images or videos.\",\n            \"keywords\": \"CV,Image Recognition,Object Detection\",\n            \"entity_type\": \"Technology Field\",\n        }\n        print(f\"Inserting node 5: {node5_id}\")\n        await storage.upsert_node(node5_id, node5_data)\n\n        # Insert edge 1: Artificial Intelligence -> Machine Learning\n        edge1_data = {\n            \"relationship\": \"includes\",\n            \"weight\": 1.0,\n            \"description\": \"The field of artificial intelligence includes the subfield of machine learning.\",\n            \"source_id\": GRAPH_FIELD_SEP.join([chunk1_id, chunk2_id]),\n        }\n        print(f\"Inserting edge 1: {node1_id} -> {node2_id}\")\n        await storage.upsert_edge(node1_id, node2_id, edge1_data)\n\n        # Insert edge 2: Machine Learning -> Deep Learning\n        edge2_data = {\n            \"relationship\": \"includes\",\n            \"weight\": 1.0,\n            \"description\": \"The field of machine learning includes the subfield of deep learning.\",\n            \"source_id\": GRAPH_FIELD_SEP.join([chunk2_id, chunk3_id]),\n        }\n        print(f\"Inserting edge 2: {node2_id} -> {node3_id}\")\n        await storage.upsert_edge(node2_id, node3_id, edge2_data)\n\n        # Insert edge 3: Artificial Intelligence -> Natural Language Processing\n        edge3_data = {\n            \"relationship\": \"includes\",\n            \"weight\": 1.0,\n            \"description\": \"The field of artificial intelligence includes the subfield of natural language processing.\",\n            \"source_id\": GRAPH_FIELD_SEP.join([chunk3_id]),\n        }\n        print(f\"Inserting edge 3: {node1_id} -> {node4_id}\")\n        await storage.upsert_edge(node1_id, node4_id, edge3_data)\n\n        # Insert edge 4: Artificial Intelligence -> Computer Vision\n        edge4_data = {\n            \"relationship\": \"includes\",\n            \"weight\": 1.0,\n            \"description\": \"The field of artificial intelligence includes the subfield of computer vision.\",\n        }\n        print(f\"Inserting edge 4: {node1_id} -> {node5_id}\")\n        await storage.upsert_edge(node1_id, node5_id, edge4_data)\n\n        # Insert edge 5: Deep Learning -> Natural Language Processing\n        edge5_data = {\n            \"relationship\": \"applied to\",\n            \"weight\": 0.8,\n            \"description\": \"Deep learning techniques are applied in the field of natural language processing.\",\n        }\n        print(f\"Inserting edge 5: {node3_id} -> {node4_id}\")\n        await storage.upsert_edge(node3_id, node4_id, edge5_data)\n\n        # Insert edge 6: Deep Learning -> Computer Vision\n        edge6_data = {\n            \"relationship\": \"applied to\",\n            \"weight\": 0.8,\n            \"description\": \"Deep learning techniques are applied in the field of computer vision.\",\n        }\n        print(f\"Inserting edge 6: {node3_id} -> {node5_id}\")\n        await storage.upsert_edge(node3_id, node5_id, edge6_data)\n\n        # 2. Test get_nodes_batch - batch get properties of multiple nodes\n        print(\"== Testing get_nodes_batch\")\n        node_ids = [node1_id, node2_id, node3_id]\n        nodes_dict = await storage.get_nodes_batch(node_ids)\n        print(f\"Batch get node properties result: {nodes_dict.keys()}\")\n        assert len(nodes_dict) == 3, f\"Should return 3 nodes, but got {len(nodes_dict)}\"\n        assert node1_id in nodes_dict, f\"{node1_id} should be in the result\"\n        assert node2_id in nodes_dict, f\"{node2_id} should be in the result\"\n        assert node3_id in nodes_dict, f\"{node3_id} should be in the result\"\n        assert (\n            nodes_dict[node1_id][\"description\"] == node1_data[\"description\"]\n        ), f\"{node1_id} description mismatch\"\n        assert (\n            nodes_dict[node2_id][\"description\"] == node2_data[\"description\"]\n        ), f\"{node2_id} description mismatch\"\n        assert (\n            nodes_dict[node3_id][\"description\"] == node3_data[\"description\"]\n        ), f\"{node3_id} description mismatch\"\n\n        # 3. Test node_degrees_batch - batch get degrees of multiple nodes\n        print(\"== Testing node_degrees_batch\")\n        node_degrees = await storage.node_degrees_batch(node_ids)\n        print(f\"Batch get node degrees result: {node_degrees}\")\n        assert (\n            len(node_degrees) == 3\n        ), f\"Should return degrees of 3 nodes, but got {len(node_degrees)}\"\n        assert node1_id in node_degrees, f\"{node1_id} should be in the result\"\n        assert node2_id in node_degrees, f\"{node2_id} should be in the result\"\n        assert node3_id in node_degrees, f\"{node3_id} should be in the result\"\n        assert (\n            node_degrees[node1_id] == 3\n        ), f\"Degree of {node1_id} should be 3, but got {node_degrees[node1_id]}\"\n        assert (\n            node_degrees[node2_id] == 2\n        ), f\"Degree of {node2_id} should be 2, but got {node_degrees[node2_id]}\"\n        assert (\n            node_degrees[node3_id] == 3\n        ), f\"Degree of {node3_id} should be 3, but got {node_degrees[node3_id]}\"\n\n        # 4. Test edge_degrees_batch - batch get degrees of multiple edges\n        print(\"== Testing edge_degrees_batch\")\n        edges = [(node1_id, node2_id), (node2_id, node3_id), (node3_id, node4_id)]\n        edge_degrees = await storage.edge_degrees_batch(edges)\n        print(f\"Batch get edge degrees result: {edge_degrees}\")\n        assert (\n            len(edge_degrees) == 3\n        ), f\"Should return degrees of 3 edges, but got {len(edge_degrees)}\"\n        assert (\n            node1_id,\n            node2_id,\n        ) in edge_degrees, f\"Edge {node1_id} -> {node2_id} should be in the result\"\n        assert (\n            node2_id,\n            node3_id,\n        ) in edge_degrees, f\"Edge {node2_id} -> {node3_id} should be in the result\"\n        assert (\n            node3_id,\n            node4_id,\n        ) in edge_degrees, f\"Edge {node3_id} -> {node4_id} should be in the result\"\n        # Verify edge degrees (sum of source and target node degrees)\n        assert (\n            edge_degrees[(node1_id, node2_id)] == 5\n        ), f\"Degree of edge {node1_id} -> {node2_id} should be 5, but got {edge_degrees[(node1_id, node2_id)]}\"\n        assert (\n            edge_degrees[(node2_id, node3_id)] == 5\n        ), f\"Degree of edge {node2_id} -> {node3_id} should be 5, but got {edge_degrees[(node2_id, node3_id)]}\"\n        assert (\n            edge_degrees[(node3_id, node4_id)] == 5\n        ), f\"Degree of edge {node3_id} -> {node4_id} should be 5, but got {edge_degrees[(node3_id, node4_id)]}\"\n\n        # 5. Test get_edges_batch - batch get properties of multiple edges\n        print(\"== Testing get_edges_batch\")\n        # Convert list of tuples to list of dicts for Neo4j style\n        edge_dicts = [{\"src\": src, \"tgt\": tgt} for src, tgt in edges]\n        edges_dict = await storage.get_edges_batch(edge_dicts)\n        print(f\"Batch get edge properties result: {edges_dict.keys()}\")\n        assert (\n            len(edges_dict) == 3\n        ), f\"Should return properties of 3 edges, but got {len(edges_dict)}\"\n        assert (\n            node1_id,\n            node2_id,\n        ) in edges_dict, f\"Edge {node1_id} -> {node2_id} should be in the result\"\n        assert (\n            node2_id,\n            node3_id,\n        ) in edges_dict, f\"Edge {node2_id} -> {node3_id} should be in the result\"\n        assert (\n            node3_id,\n            node4_id,\n        ) in edges_dict, f\"Edge {node3_id} -> {node4_id} should be in the result\"\n        assert (\n            edges_dict[(node1_id, node2_id)][\"relationship\"]\n            == edge1_data[\"relationship\"]\n        ), f\"Edge {node1_id} -> {node2_id} relationship mismatch\"\n        assert (\n            edges_dict[(node2_id, node3_id)][\"relationship\"]\n            == edge2_data[\"relationship\"]\n        ), f\"Edge {node2_id} -> {node3_id} relationship mismatch\"\n        assert (\n            edges_dict[(node3_id, node4_id)][\"relationship\"]\n            == edge5_data[\"relationship\"]\n        ), f\"Edge {node3_id} -> {node4_id} relationship mismatch\"\n\n        # 5.1 Test batch get of reverse edges - verify undirected property\n        print(\"== Testing batch get of reverse edges\")\n        # Create list of dicts for reverse edges\n        reverse_edge_dicts = [{\"src\": tgt, \"tgt\": src} for src, tgt in edges]\n        reverse_edges_dict = await storage.get_edges_batch(reverse_edge_dicts)\n        print(f\"Batch get reverse edge properties result: {reverse_edges_dict.keys()}\")\n        assert (\n            len(reverse_edges_dict) == 3\n        ), f\"Should return properties of 3 reverse edges, but got {len(reverse_edges_dict)}\"\n\n        # Verify that properties of forward and reverse edges are consistent\n        for (src, tgt), props in edges_dict.items():\n            assert (\n                (\n                    tgt,\n                    src,\n                )\n                in reverse_edges_dict\n            ), f\"Reverse edge {tgt} -> {src} should be in the result\"\n            assert (\n                props == reverse_edges_dict[(tgt, src)]\n            ), f\"Properties of edge {src} -> {tgt} and reverse edge {tgt} -> {src} are inconsistent\"\n\n        print(\n            \"Undirected graph property verification successful: properties of batch-retrieved forward and reverse edges are consistent\"\n        )\n\n        # 6. Test get_nodes_edges_batch - batch get all edges of multiple nodes\n        print(\"== Testing get_nodes_edges_batch\")\n        nodes_edges = await storage.get_nodes_edges_batch([node1_id, node3_id])\n        print(f\"Batch get node edges result: {nodes_edges.keys()}\")\n        assert (\n            len(nodes_edges) == 2\n        ), f\"Should return edges for 2 nodes, but got {len(nodes_edges)}\"\n        assert node1_id in nodes_edges, f\"{node1_id} should be in the result\"\n        assert node3_id in nodes_edges, f\"{node3_id} should be in the result\"\n        assert (\n            len(nodes_edges[node1_id]) == 3\n        ), f\"{node1_id} should have 3 edges, but has {len(nodes_edges[node1_id])}\"\n        assert (\n            len(nodes_edges[node3_id]) == 3\n        ), f\"{node3_id} should have 3 edges, but has {len(nodes_edges[node3_id])}\"\n\n        # 6.1 Verify undirected property of batch-retrieved node edges\n        print(\"== Verifying undirected property of batch-retrieved node edges\")\n\n        # Check if node 1's edges include all relevant edges (regardless of direction)\n        node1_outgoing_edges = [\n            (src, tgt) for src, tgt in nodes_edges[node1_id] if src == node1_id\n        ]\n        node1_incoming_edges = [\n            (src, tgt) for src, tgt in nodes_edges[node1_id] if tgt == node1_id\n        ]\n        print(f\"Outgoing edges of node {node1_id}: {node1_outgoing_edges}\")\n        print(f\"Incoming edges of node {node1_id}: {node1_incoming_edges}\")\n\n        # Check for edges to Machine Learning, Natural Language Processing, and Computer Vision\n        has_edge_to_node2 = any(tgt == node2_id for _, tgt in node1_outgoing_edges)\n        has_edge_to_node4 = any(tgt == node4_id for _, tgt in node1_outgoing_edges)\n        has_edge_to_node5 = any(tgt == node5_id for _, tgt in node1_outgoing_edges)\n\n        assert (\n            has_edge_to_node2\n        ), f\"Edge list of node {node1_id} should include an edge to {node2_id}\"\n        assert (\n            has_edge_to_node4\n        ), f\"Edge list of node {node1_id} should include an edge to {node4_id}\"\n        assert (\n            has_edge_to_node5\n        ), f\"Edge list of node {node1_id} should include an edge to {node5_id}\"\n\n        # Check if node 3's edges include all relevant edges (regardless of direction)\n        node3_outgoing_edges = [\n            (src, tgt) for src, tgt in nodes_edges[node3_id] if src == node3_id\n        ]\n        node3_incoming_edges = [\n            (src, tgt) for src, tgt in nodes_edges[node3_id] if tgt == node3_id\n        ]\n        print(f\"Outgoing edges of node {node3_id}: {node3_outgoing_edges}\")\n        print(f\"Incoming edges of node {node3_id}: {node3_incoming_edges}\")\n\n        # Check for connections with Machine Learning, Natural Language Processing, and Computer Vision (ignoring direction)\n        has_connection_with_node2 = any(\n            (src == node2_id and tgt == node3_id)\n            or (src == node3_id and tgt == node2_id)\n            for src, tgt in nodes_edges[node3_id]\n        )\n        has_connection_with_node4 = any(\n            (src == node3_id and tgt == node4_id)\n            or (src == node4_id and tgt == node3_id)\n            for src, tgt in nodes_edges[node3_id]\n        )\n        has_connection_with_node5 = any(\n            (src == node3_id and tgt == node5_id)\n            or (src == node5_id and tgt == node3_id)\n            for src, tgt in nodes_edges[node3_id]\n        )\n\n        assert (\n            has_connection_with_node2\n        ), f\"Edge list of node {node3_id} should include a connection with {node2_id}\"\n        assert (\n            has_connection_with_node4\n        ), f\"Edge list of node {node3_id} should include a connection with {node4_id}\"\n        assert (\n            has_connection_with_node5\n        ), f\"Edge list of node {node3_id} should include a connection with {node5_id}\"\n\n        print(\n            \"Undirected graph property verification successful: batch-retrieved node edges include all relevant edges (regardless of direction)\"\n        )\n\n        print(\"\\nBatch operations tests completed.\")\n        return True\n\n    except Exception as e:\n        ASCIIColors.red(f\"An error occurred during the test: {str(e)}\")\n        return False\n\n\n@pytest.mark.integration\n@pytest.mark.requires_db\nasync def test_graph_special_characters(storage):\n    \"\"\"\n    Test the graph database's handling of special characters:\n    1. Test node names and descriptions containing single quotes, double quotes, and backslashes.\n    2. Test edge descriptions containing single quotes, double quotes, and backslashes.\n    3. Verify that special characters are saved and retrieved correctly.\n    \"\"\"\n    try:\n        # 1. Test special characters in node name\n        node1_id = \"Node with 'single quotes'\"\n        node1_data = {\n            \"entity_id\": node1_id,\n            \"description\": \"This description contains 'single quotes', \\\"double quotes\\\", and \\\\backslashes\",\n            \"keywords\": \"special characters,quotes,escaping\",\n            \"entity_type\": \"Test Node\",\n        }\n        print(f\"Inserting node with special characters 1: {node1_id}\")\n        await storage.upsert_node(node1_id, node1_data)\n\n        # 2. Test double quotes in node name\n        node2_id = 'Node with \"double quotes\"'\n        node2_data = {\n            \"entity_id\": node2_id,\n            \"description\": \"This description contains both 'single quotes' and \\\"double quotes\\\" and \\\\a\\\\path\",\n            \"keywords\": \"special characters,quotes,JSON\",\n            \"entity_type\": \"Test Node\",\n        }\n        print(f\"Inserting node with special characters 2: {node2_id}\")\n        await storage.upsert_node(node2_id, node2_data)\n\n        # 3. Test backslashes in node name\n        node3_id = \"Node with \\\\backslashes\\\\\"\n        node3_data = {\n            \"entity_id\": node3_id,\n            \"description\": \"This description contains a Windows path C:\\\\Program Files\\\\ and escape characters \\\\n\\\\t\",\n            \"keywords\": \"backslashes,paths,escaping\",\n            \"entity_type\": \"Test Node\",\n        }\n        print(f\"Inserting node with special characters 3: {node3_id}\")\n        await storage.upsert_node(node3_id, node3_data)\n\n        # 4. Test special characters in edge description\n        edge1_data = {\n            \"relationship\": \"special 'relationship'\",\n            \"weight\": 1.0,\n            \"description\": \"This edge description contains 'single quotes', \\\"double quotes\\\", and \\\\backslashes\",\n        }\n        print(f\"Inserting edge with special characters: {node1_id} -> {node2_id}\")\n        await storage.upsert_edge(node1_id, node2_id, edge1_data)\n\n        # 5. Test more complex combination of special characters in edge description\n        edge2_data = {\n            \"relationship\": 'complex \"relationship\"\\\\type',\n            \"weight\": 0.8,\n            \"description\": \"Contains SQL injection attempt: SELECT * FROM users WHERE name='admin'--\",\n        }\n        print(\n            f\"Inserting edge with complex special characters: {node2_id} -> {node3_id}\"\n        )\n        await storage.upsert_edge(node2_id, node3_id, edge2_data)\n\n        # 6. Verify that node special characters are saved correctly\n        print(\"\\n== Verifying node special characters\")\n        for node_id, original_data in [\n            (node1_id, node1_data),\n            (node2_id, node2_data),\n            (node3_id, node3_data),\n        ]:\n            node_props = await storage.get_node(node_id)\n            if node_props:\n                print(f\"Successfully read node: {node_id}\")\n                print(\n                    f\"Node description: {node_props.get('description', 'No description')}\"\n                )\n\n                # Verify node ID is saved correctly\n                assert (\n                    node_props.get(\"entity_id\") == node_id\n                ), f\"Node ID mismatch: expected {node_id}, got {node_props.get('entity_id')}\"\n\n                # Verify description is saved correctly\n                assert (\n                    node_props.get(\"description\") == original_data[\"description\"]\n                ), f\"Node description mismatch: expected {original_data['description']}, got {node_props.get('description')}\"\n\n                print(f\"Node {node_id} special character verification successful\")\n            else:\n                print(f\"Failed to read node properties: {node_id}\")\n                assert False, f\"Failed to read node properties: {node_id}\"\n\n        # 7. Verify that edge special characters are saved correctly\n        print(\"\\n== Verifying edge special characters\")\n        edge1_props = await storage.get_edge(node1_id, node2_id)\n        if edge1_props:\n            print(f\"Successfully read edge: {node1_id} -> {node2_id}\")\n            print(\n                f\"Edge relationship: {edge1_props.get('relationship', 'No relationship')}\"\n            )\n            print(\n                f\"Edge description: {edge1_props.get('description', 'No description')}\"\n            )\n\n            # Verify edge relationship is saved correctly\n            assert (\n                edge1_props.get(\"relationship\") == edge1_data[\"relationship\"]\n            ), f\"Edge relationship mismatch: expected {edge1_data['relationship']}, got {edge1_props.get('relationship')}\"\n\n            # Verify edge description is saved correctly\n            assert (\n                edge1_props.get(\"description\") == edge1_data[\"description\"]\n            ), f\"Edge description mismatch: expected {edge1_data['description']}, got {edge1_props.get('description')}\"\n\n            print(\n                f\"Edge {node1_id} -> {node2_id} special character verification successful\"\n            )\n        else:\n            print(f\"Failed to read edge properties: {node1_id} -> {node2_id}\")\n            assert False, f\"Failed to read edge properties: {node1_id} -> {node2_id}\"\n\n        edge2_props = await storage.get_edge(node2_id, node3_id)\n        if edge2_props:\n            print(f\"Successfully read edge: {node2_id} -> {node3_id}\")\n            print(\n                f\"Edge relationship: {edge2_props.get('relationship', 'No relationship')}\"\n            )\n            print(\n                f\"Edge description: {edge2_props.get('description', 'No description')}\"\n            )\n\n            # Verify edge relationship is saved correctly\n            assert (\n                edge2_props.get(\"relationship\") == edge2_data[\"relationship\"]\n            ), f\"Edge relationship mismatch: expected {edge2_data['relationship']}, got {edge2_props.get('relationship')}\"\n\n            # Verify edge description is saved correctly\n            assert (\n                edge2_props.get(\"description\") == edge2_data[\"description\"]\n            ), f\"Edge description mismatch: expected {edge2_data['description']}, got {edge2_props.get('description')}\"\n\n            print(\n                f\"Edge {node2_id} -> {node3_id} special character verification successful\"\n            )\n        else:\n            print(f\"Failed to read edge properties: {node2_id} -> {node3_id}\")\n            assert False, f\"Failed to read edge properties: {node2_id} -> {node3_id}\"\n\n        print(\"\\nSpecial character tests completed, data is preserved in the database.\")\n        return True\n\n    except Exception as e:\n        ASCIIColors.red(f\"An error occurred during the test: {str(e)}\")\n        return False\n\n\n@pytest.mark.integration\n@pytest.mark.requires_db\nasync def test_graph_undirected_property(storage):\n    \"\"\"\n    Specifically test the undirected graph property of the storage:\n    1. Verify that after inserting an edge in one direction, a reverse query can retrieve the same result.\n    2. Verify that edge properties are consistent in forward and reverse queries.\n    3. Verify that after deleting an edge in one direction, the edge in the other direction is also deleted.\n    4. Verify the undirected property in batch operations.\n    \"\"\"\n    try:\n        # 1. Insert test data\n        # Insert node 1: Computer Science\n        node1_id = \"Computer Science\"\n        node1_data = {\n            \"entity_id\": node1_id,\n            \"description\": \"Computer science is the study of computers and their applications.\",\n            \"keywords\": \"computer,science,technology\",\n            \"entity_type\": \"Discipline\",\n        }\n        print(f\"Inserting node 1: {node1_id}\")\n        await storage.upsert_node(node1_id, node1_data)\n\n        # Insert node 2: Data Structures\n        node2_id = \"Data Structures\"\n        node2_data = {\n            \"entity_id\": node2_id,\n            \"description\": \"A data structure is a fundamental concept in computer science used to organize and store data.\",\n            \"keywords\": \"data,structure,organization\",\n            \"entity_type\": \"Concept\",\n        }\n        print(f\"Inserting node 2: {node2_id}\")\n        await storage.upsert_node(node2_id, node2_data)\n\n        # Insert node 3: Algorithms\n        node3_id = \"Algorithms\"\n        node3_data = {\n            \"entity_id\": node3_id,\n            \"description\": \"An algorithm is a set of steps and methods for solving problems.\",\n            \"keywords\": \"algorithm,steps,methods\",\n            \"entity_type\": \"Concept\",\n        }\n        print(f\"Inserting node 3: {node3_id}\")\n        await storage.upsert_node(node3_id, node3_data)\n\n        # 2. Test undirected property after edge insertion\n        print(\"\\n== Testing undirected property after edge insertion\")\n\n        # Insert edge 1: Computer Science -> Data Structures\n        edge1_data = {\n            \"relationship\": \"includes\",\n            \"weight\": 1.0,\n            \"description\": \"Computer science includes the concept of data structures.\",\n        }\n        print(f\"Inserting edge 1: {node1_id} -> {node2_id}\")\n        await storage.upsert_edge(node1_id, node2_id, edge1_data)\n\n        # Verify forward query\n        forward_edge = await storage.get_edge(node1_id, node2_id)\n        print(f\"Forward edge properties: {forward_edge}\")\n        assert (\n            forward_edge is not None\n        ), f\"Failed to read forward edge properties: {node1_id} -> {node2_id}\"\n\n        # Verify reverse query\n        reverse_edge = await storage.get_edge(node2_id, node1_id)\n        print(f\"Reverse edge properties: {reverse_edge}\")\n        assert (\n            reverse_edge is not None\n        ), f\"Failed to read reverse edge properties: {node2_id} -> {node1_id}\"\n\n        # Verify that forward and reverse edge properties are consistent\n        assert (\n            forward_edge == reverse_edge\n        ), \"Forward and reverse edge properties are inconsistent, undirected property verification failed\"\n        print(\n            \"Undirected property verification successful: forward and reverse edge properties are consistent\"\n        )\n\n        # 3. Test undirected property of edge degree\n        print(\"\\n== Testing undirected property of edge degree\")\n\n        # Insert edge 2: Computer Science -> Algorithms\n        edge2_data = {\n            \"relationship\": \"includes\",\n            \"weight\": 1.0,\n            \"description\": \"Computer science includes the concept of algorithms.\",\n        }\n        print(f\"Inserting edge 2: {node1_id} -> {node3_id}\")\n        await storage.upsert_edge(node1_id, node3_id, edge2_data)\n\n        # Verify degrees of forward and reverse edges\n        forward_degree = await storage.edge_degree(node1_id, node2_id)\n        reverse_degree = await storage.edge_degree(node2_id, node1_id)\n        print(f\"Degree of forward edge {node1_id} -> {node2_id}: {forward_degree}\")\n        print(f\"Degree of reverse edge {node2_id} -> {node1_id}: {reverse_degree}\")\n        assert (\n            forward_degree == reverse_degree\n        ), \"Degrees of forward and reverse edges are inconsistent, undirected property verification failed\"\n        print(\n            \"Undirected property verification successful: degrees of forward and reverse edges are consistent\"\n        )\n\n        # 4. Test undirected property of edge deletion\n        print(\"\\n== Testing undirected property of edge deletion\")\n\n        # Delete forward edge\n        print(f\"Deleting edge: {node1_id} -> {node2_id}\")\n        await storage.remove_edges([(node1_id, node2_id)])\n\n        # Verify forward edge is deleted\n        forward_edge = await storage.get_edge(node1_id, node2_id)\n        print(\n            f\"Querying forward edge properties after deletion {node1_id} -> {node2_id}: {forward_edge}\"\n        )\n        assert (\n            forward_edge is None\n        ), f\"Edge {node1_id} -> {node2_id} should have been deleted\"\n\n        # Verify reverse edge is also deleted\n        reverse_edge = await storage.get_edge(node2_id, node1_id)\n        print(\n            f\"Querying reverse edge properties after deletion {node2_id} -> {node1_id}: {reverse_edge}\"\n        )\n        assert (\n            reverse_edge is None\n        ), f\"Reverse edge {node2_id} -> {node1_id} should also be deleted, undirected property verification failed\"\n        print(\n            \"Undirected property verification successful: deleting an edge in one direction also deletes the reverse edge\"\n        )\n\n        # 5. Test undirected property in batch operations\n        print(\"\\n== Testing undirected property in batch operations\")\n\n        # Re-insert edge\n        await storage.upsert_edge(node1_id, node2_id, edge1_data)\n\n        # Batch get edge properties\n        edge_dicts = [\n            {\"src\": node1_id, \"tgt\": node2_id},\n            {\"src\": node1_id, \"tgt\": node3_id},\n        ]\n        reverse_edge_dicts = [\n            {\"src\": node2_id, \"tgt\": node1_id},\n            {\"src\": node3_id, \"tgt\": node1_id},\n        ]\n\n        edges_dict = await storage.get_edges_batch(edge_dicts)\n        reverse_edges_dict = await storage.get_edges_batch(reverse_edge_dicts)\n\n        print(f\"Batch get forward edge properties result: {edges_dict.keys()}\")\n        print(f\"Batch get reverse edge properties result: {reverse_edges_dict.keys()}\")\n\n        # Verify that properties of forward and reverse edges are consistent\n        for (src, tgt), props in edges_dict.items():\n            assert (\n                (\n                    tgt,\n                    src,\n                )\n                in reverse_edges_dict\n            ), f\"Reverse edge {tgt} -> {src} should be in the result\"\n            assert (\n                props == reverse_edges_dict[(tgt, src)]\n            ), f\"Properties of edge {src} -> {tgt} and reverse edge {tgt} -> {src} are inconsistent\"\n\n        print(\n            \"Undirected property verification successful: properties of batch-retrieved forward and reverse edges are consistent\"\n        )\n\n        # 6. Test undirected property of batch-retrieved node edges\n        print(\"\\n== Testing undirected property of batch-retrieved node edges\")\n\n        nodes_edges = await storage.get_nodes_edges_batch([node1_id, node2_id])\n        print(f\"Batch get node edges result: {nodes_edges.keys()}\")\n\n        # Check if node 1's edges include all relevant edges (regardless of direction)\n        node1_edges = nodes_edges[node1_id]\n        node2_edges = nodes_edges[node2_id]\n\n        # Check if node 1 has edges to node 2 and node 3\n        has_edge_to_node2 = any(\n            (src == node1_id and tgt == node2_id) for src, tgt in node1_edges\n        )\n        has_edge_to_node3 = any(\n            (src == node1_id and tgt == node3_id) for src, tgt in node1_edges\n        )\n\n        assert (\n            has_edge_to_node2\n        ), f\"Edge list of node {node1_id} should include an edge to {node2_id}\"\n        assert (\n            has_edge_to_node3\n        ), f\"Edge list of node {node1_id} should include an edge to {node3_id}\"\n\n        # Check if node 2 has a connection with node 1\n        has_edge_to_node1 = any(\n            (src == node2_id and tgt == node1_id)\n            or (src == node1_id and tgt == node2_id)\n            for src, tgt in node2_edges\n        )\n        assert (\n            has_edge_to_node1\n        ), f\"Edge list of node {node2_id} should include a connection with {node1_id}\"\n\n        print(\n            \"Undirected property verification successful: batch-retrieved node edges include all relevant edges (regardless of direction)\"\n        )\n\n        print(\"\\nUndirected property tests completed.\")\n        return True\n\n    except Exception as e:\n        ASCIIColors.red(f\"An error occurred during the test: {str(e)}\")\n        return False\n\n\nasync def main():\n    \"\"\"Main function\"\"\"\n    # Display program title\n    ASCIIColors.cyan(\"\"\"\n    ╔══════════════════════════════════════════════════════════════╗\n    ║            General Graph Storage Test Program                ║\n    ╚══════════════════════════════════════════════════════════════╝\n    \"\"\")\n\n    # Check for .env file\n    if not check_env_file():\n        return\n\n    # Load environment variables\n    load_dotenv(dotenv_path=\".env\", override=False)\n\n    # Get graph storage type\n    graph_storage_type = os.getenv(\"LIGHTRAG_GRAPH_STORAGE\", \"NetworkXStorage\")\n    ASCIIColors.magenta(\n        f\"\\nCurrently configured graph storage type: {graph_storage_type}\"\n    )\n    ASCIIColors.white(\n        f\"Supported graph storage types: {', '.join(STORAGE_IMPLEMENTATIONS['GRAPH_STORAGE']['implementations'])}\"\n    )\n\n    # Initialize storage instance\n    storage = await initialize_graph_storage()\n    if not storage:\n        ASCIIColors.red(\"Failed to initialize storage instance, exiting test program.\")\n        return\n\n    try:\n        # Display test options\n        ASCIIColors.yellow(\"\\nPlease select a test type:\")\n        ASCIIColors.white(\"1. Basic Test (Node and edge insertion, reading)\")\n        ASCIIColors.white(\n            \"2. Advanced Test (Degree, labels, knowledge graph, deletion, etc.)\"\n        )\n        ASCIIColors.white(\n            \"3. Batch Operations Test (Batch get node/edge properties, degrees, etc.)\"\n        )\n        ASCIIColors.white(\n            \"4. Undirected Property Test (Verify undirected properties of the storage)\"\n        )\n        ASCIIColors.white(\n            \"5. Special Characters Test (Verify handling of single/double quotes, backslashes, etc.)\"\n        )\n        ASCIIColors.white(\"6. All Tests\")\n\n        choice = input(\"\\nEnter your choice (1/2/3/4/5/6): \")\n\n        # Clean data before running tests\n        if choice in [\"1\", \"2\", \"3\", \"4\", \"5\", \"6\"]:\n            ASCIIColors.yellow(\"\\nCleaning data before running tests...\")\n            await storage.drop()\n            ASCIIColors.green(\"Data cleanup complete\\n\")\n\n        if choice == \"1\":\n            await test_graph_basic(storage)\n        elif choice == \"2\":\n            await test_graph_advanced(storage)\n        elif choice == \"3\":\n            await test_graph_batch_operations(storage)\n        elif choice == \"4\":\n            await test_graph_undirected_property(storage)\n        elif choice == \"5\":\n            await test_graph_special_characters(storage)\n        elif choice == \"6\":\n            ASCIIColors.cyan(\"\\n=== Starting Basic Test ===\")\n            basic_result = await test_graph_basic(storage)\n\n            if basic_result:\n                ASCIIColors.cyan(\"\\n=== Starting Advanced Test ===\")\n                advanced_result = await test_graph_advanced(storage)\n\n                if advanced_result:\n                    ASCIIColors.cyan(\"\\n=== Starting Batch Operations Test ===\")\n                    batch_result = await test_graph_batch_operations(storage)\n\n                    if batch_result:\n                        ASCIIColors.cyan(\"\\n=== Starting Undirected Property Test ===\")\n                        undirected_result = await test_graph_undirected_property(\n                            storage\n                        )\n\n                        if undirected_result:\n                            ASCIIColors.cyan(\n                                \"\\n=== Starting Special Characters Test ===\"\n                            )\n                            await test_graph_special_characters(storage)\n        else:\n            ASCIIColors.red(\"Invalid choice\")\n\n    finally:\n        # Close connection\n        if storage:\n            await storage.finalize()\n            ASCIIColors.green(\"\\nStorage connection closed.\")\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "tests/test_interactive_setup_outputs.py",
    "content": "\"\"\"Regression tests for interactive setup host vs. compose configuration.\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nimport subprocess\nfrom pathlib import Path\n\nimport pytest\n\npytestmark = pytest.mark.offline\n\nREPO_ROOT = Path(__file__).resolve().parents[1]\n\n\ndef run_bash_process(\n    script: str, cwd: Path | None = None, stdin: str | None = \"\"\n) -> subprocess.CompletedProcess[str]:\n    \"\"\"Run a bash snippet and return the completed process.\"\"\"\n\n    return subprocess.run(\n        [\"bash\", \"--norc\", \"--noprofile\", \"-c\", script],\n        cwd=cwd or REPO_ROOT,\n        input=stdin,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n\ndef run_bash(script: str, cwd: Path | None = None) -> str:\n    \"\"\"Run a bash snippet and return stdout.\"\"\"\n\n    result = run_bash_process(script, cwd=cwd)\n    if result.returncode != 0:\n        raise AssertionError(\n            f\"bash script failed with code {result.returncode}\\n\"\n            f\"stdout:\\n{result.stdout}\\n\"\n            f\"stderr:\\n{result.stderr}\"\n        )\n    return result.stdout\n\n\ndef parse_lines(output: str) -> dict[str, str]:\n    \"\"\"Parse KEY=value lines into a dictionary.\"\"\"\n\n    values: dict[str, str] = {}\n    for line in output.splitlines():\n        if \"=\" not in line:\n            continue\n        key, value = line.split(\"=\", 1)\n        values[key] = value\n    return values\n\n\ndef run_bash_lines(script: str, cwd: Path | None = None) -> dict[str, str]:\n    \"\"\"Run a bash snippet and parse KEY=value lines from stdout.\"\"\"\n\n    return parse_lines(run_bash(script, cwd=cwd))\n\n\ndef write_text_lines(path: Path, lines: list[str]) -> Path:\n    \"\"\"Write lines to a fixture file with a trailing newline.\"\"\"\n\n    path.write_text(\"\\n\".join(lines) + \"\\n\", encoding=\"utf-8\")\n    return path\n\n\ndef assert_single_compose_backup(tmp_path: Path, expected_content: str) -> Path:\n    \"\"\"Assert that a single compose backup exists with the expected content.\"\"\"\n\n    backups = sorted(tmp_path.glob(\"docker-compose.backup*.yml\"))\n    assert len(backups) == 1\n    assert re.fullmatch(r\"docker-compose\\.backup\\d{8}_\\d{6}\\.yml\", backups[0].name)\n    assert backups[0].read_text(encoding=\"utf-8\") == expected_content\n    return backups[0]\n\n\ndef test_collect_postgres_config_uses_fixed_bundled_port_and_compose_overrides() -> (\n    None\n):\n    \"\"\"Bundled PostgreSQL should use the fixed service port and compose overrides.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nconfirm_default_yes() {{ return 0; }}\nprompt_with_default() {{\n  case \"$1\" in\n    \"PostgreSQL host\") printf 'localhost' ;;\n    \"PostgreSQL user\") printf 'lightrag' ;;\n    \"PostgreSQL database\") printf 'lightrag' ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}}\nmask_sensitive_input() {{ printf 'supersecret'; }}\n\ncollect_postgres_config yes\n\nprintf 'POSTGRES_HOST=%s\\\\n' \"${{ENV_VALUES[POSTGRES_HOST]}}\"\nprintf 'POSTGRES_PORT=%s\\\\n' \"${{ENV_VALUES[POSTGRES_PORT]}}\"\nprintf 'COMPOSE_POSTGRES_HOST=%s\\\\n' \"${{COMPOSE_ENV_OVERRIDES[POSTGRES_HOST]}}\"\nprintf 'COMPOSE_POSTGRES_PORT=%s\\\\n' \"${{COMPOSE_ENV_OVERRIDES[POSTGRES_PORT]}}\"\nprintf 'DOCKER_SERVICE=%s\\\\n' \"${{DOCKER_SERVICES[0]}}\"\n\"\"\"\n    )\n\n    assert values[\"POSTGRES_HOST\"] == \"localhost\"\n    assert values[\"POSTGRES_PORT\"] == \"5432\"\n    assert values[\"COMPOSE_POSTGRES_HOST\"] == \"postgres\"\n    assert values[\"COMPOSE_POSTGRES_PORT\"] == \"5432\"\n    assert values[\"DOCKER_SERVICE\"] == \"postgres\"\n\n\ndef test_collect_postgres_config_uses_rag_defaults_without_prompt_for_empty_docker_credentials() -> (\n    None\n):\n    \"\"\"Docker PostgreSQL should auto-fill bundled credentials when old `.env` creds are empty.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nPROMPT_LOG_FILE=\"$(mktemp)\"\n: > \"$PROMPT_LOG_FILE\"\n\nconfirm_default_yes() {{ return 0; }}\nprompt_with_default() {{\n  printf '%s\\\\n' \"$1\" >> \"$PROMPT_LOG_FILE\"\n  case \"$1\" in\n    \"PostgreSQL host\") printf 'localhost' ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}}\nprompt_secret_with_default() {{\n  printf 'secret:%s\\\\n' \"$1\" >> \"$PROMPT_LOG_FILE\"\n  printf '%s' \"$2\"\n}}\n\nORIGINAL_ENV_VALUES[POSTGRES_USER]=\"\"\nORIGINAL_ENV_VALUES[POSTGRES_PASSWORD]=\"\"\nORIGINAL_ENV_VALUES[POSTGRES_DATABASE]=\"\"\n\ncollect_postgres_config yes\n\nprintf 'POSTGRES_USER=%s\\\\n' \"${{ENV_VALUES[POSTGRES_USER]}}\"\nprintf 'POSTGRES_PASSWORD=%s\\\\n' \"${{ENV_VALUES[POSTGRES_PASSWORD]}}\"\nprintf 'POSTGRES_DATABASE=%s\\\\n' \"${{ENV_VALUES[POSTGRES_DATABASE]}}\"\nprintf 'PROMPT_LOG=%s\\\\n' \"$(paste -sd '|' \"$PROMPT_LOG_FILE\")\"\n\"\"\"\n    )\n\n    assert values[\"POSTGRES_USER\"] == \"rag\"\n    assert values[\"POSTGRES_PASSWORD\"] == \"rag\"\n    assert values[\"POSTGRES_DATABASE\"] == \"rag\"\n    assert values[\"PROMPT_LOG\"] == \"PostgreSQL host\"\n\n\ndef test_collect_postgres_config_prompts_for_existing_docker_credentials() -> None:\n    \"\"\"Docker PostgreSQL should preserve editability when old `.env` creds already exist.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nPROMPT_LOG_FILE=\"$(mktemp)\"\n: > \"$PROMPT_LOG_FILE\"\n\nconfirm_default_yes() {{ return 0; }}\nprompt_with_default() {{\n  printf '%s[%s]\\\\n' \"$1\" \"$2\" >> \"$PROMPT_LOG_FILE\"\n  case \"$1\" in\n    \"PostgreSQL host\") printf 'localhost' ;;\n    \"PostgreSQL user\") printf 'updated-user' ;;\n    \"PostgreSQL database\") printf 'updated-db' ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}}\nprompt_secret_with_default() {{\n  printf '%s[%s]\\\\n' \"$1\" \"$2\" >> \"$PROMPT_LOG_FILE\"\n  printf 'updated-password'\n}}\n\nORIGINAL_ENV_VALUES[POSTGRES_USER]=\"existing-user\"\nORIGINAL_ENV_VALUES[POSTGRES_PASSWORD]=\"existing-password\"\nORIGINAL_ENV_VALUES[POSTGRES_DATABASE]=\"existing-db\"\n\ncollect_postgres_config yes\n\nprintf 'POSTGRES_USER=%s\\\\n' \"${{ENV_VALUES[POSTGRES_USER]}}\"\nprintf 'POSTGRES_PASSWORD=%s\\\\n' \"${{ENV_VALUES[POSTGRES_PASSWORD]}}\"\nprintf 'POSTGRES_DATABASE=%s\\\\n' \"${{ENV_VALUES[POSTGRES_DATABASE]}}\"\nprintf 'PROMPT_LOG=%s\\\\n' \"$(paste -sd '|' \"$PROMPT_LOG_FILE\")\"\n\"\"\"\n    )\n\n    assert values[\"POSTGRES_USER\"] == \"updated-user\"\n    assert values[\"POSTGRES_PASSWORD\"] == \"updated-password\"\n    assert values[\"POSTGRES_DATABASE\"] == \"updated-db\"\n    assert (\n        values[\"PROMPT_LOG\"] == \"PostgreSQL host[localhost]|\"\n        \"PostgreSQL user[existing-user]|PostgreSQL password: [existing-password]|\"\n        \"PostgreSQL database[existing-db]\"\n    )\n\n\ndef test_collect_postgres_config_still_prompts_for_host_credentials() -> None:\n    \"\"\"Host PostgreSQL should keep prompting even when saved creds are empty.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nPROMPT_LOG_FILE=\"$(mktemp)\"\n: > \"$PROMPT_LOG_FILE\"\n\nconfirm_default_no() {{ return 1; }}\nprompt_with_default() {{\n  printf '%s[%s]\\\\n' \"$1\" \"$2\" >> \"$PROMPT_LOG_FILE\"\n  case \"$1\" in\n    \"PostgreSQL host\") printf 'db.internal' ;;\n    \"PostgreSQL user\") printf 'host-user' ;;\n    \"PostgreSQL database\") printf 'host-db' ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}}\nprompt_until_valid() {{\n  printf '%s[%s]\\\\n' \"$1\" \"$2\" >> \"$PROMPT_LOG_FILE\"\n  if [[ \"$1\" == \"PostgreSQL port\" ]]; then\n    printf '6543'\n  else\n    printf '%s' \"$2\"\n  fi\n}}\nprompt_secret_with_default() {{\n  printf '%s[%s]\\\\n' \"$1\" \"$2\" >> \"$PROMPT_LOG_FILE\"\n  printf 'host-password'\n}}\n\nORIGINAL_ENV_VALUES[POSTGRES_USER]=\"\"\nORIGINAL_ENV_VALUES[POSTGRES_PASSWORD]=\"\"\n\ncollect_postgres_config no\n\nprintf 'POSTGRES_HOST=%s\\\\n' \"${{ENV_VALUES[POSTGRES_HOST]}}\"\nprintf 'POSTGRES_PORT=%s\\\\n' \"${{ENV_VALUES[POSTGRES_PORT]}}\"\nprintf 'POSTGRES_USER=%s\\\\n' \"${{ENV_VALUES[POSTGRES_USER]}}\"\nprintf 'POSTGRES_PASSWORD=%s\\\\n' \"${{ENV_VALUES[POSTGRES_PASSWORD]}}\"\nprintf 'PROMPT_LOG=%s\\\\n' \"$(paste -sd '|' \"$PROMPT_LOG_FILE\")\"\n\"\"\"\n    )\n\n    assert values[\"POSTGRES_HOST\"] == \"db.internal\"\n    assert values[\"POSTGRES_PORT\"] == \"6543\"\n    assert values[\"POSTGRES_USER\"] == \"host-user\"\n    assert values[\"POSTGRES_PASSWORD\"] == \"host-password\"\n    assert (\n        values[\"PROMPT_LOG\"] == \"PostgreSQL host[localhost]|PostgreSQL port[5432]|\"\n        \"PostgreSQL user[rag]|PostgreSQL password: [rag]|\"\n        \"PostgreSQL database[lightrag]\"\n    )\n\n\ndef test_collect_server_config_includes_summary_language_last() -> None:\n    \"\"\"Server config should prompt for summary language after the WebUI fields.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nPROMPT_LOG_FILE=\"$(mktemp)\"\n: > \"$PROMPT_LOG_FILE\"\n\nprompt_with_default() {{\n  printf '%s\\\\n' \"$1\" >> \"$PROMPT_LOG_FILE\"\n  case \"$1\" in\n    \"Server host\") printf '127.0.0.1' ;;\n    \"WebUI title\") printf 'Custom KB' ;;\n    \"WebUI description\") printf 'Custom description' ;;\n    \"Summary language\") printf 'Chinese' ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}}\nprompt_until_valid() {{\n  printf '%s\\\\n' \"$1\" >> \"$PROMPT_LOG_FILE\"\n  if [[ \"$1\" == \"Server port\" ]]; then\n    printf '9630'\n  else\n    printf '%s' \"$2\"\n  fi\n}}\n\ncollect_server_config\n\nprintf 'HOST=%s\\\\n' \"${{ENV_VALUES[HOST]}}\"\nprintf 'PORT=%s\\\\n' \"${{ENV_VALUES[PORT]}}\"\nprintf 'WEBUI_TITLE=%s\\\\n' \"${{ENV_VALUES[WEBUI_TITLE]}}\"\nprintf 'WEBUI_DESCRIPTION=%s\\\\n' \"${{ENV_VALUES[WEBUI_DESCRIPTION]}}\"\nprintf 'SUMMARY_LANGUAGE=%s\\\\n' \"${{ENV_VALUES[SUMMARY_LANGUAGE]}}\"\nprintf 'PROMPT_LOG=%s\\\\n' \"$(paste -sd '|' \"$PROMPT_LOG_FILE\")\"\n\"\"\"\n    )\n\n    assert values[\"HOST\"] == \"127.0.0.1\"\n    assert values[\"PORT\"] == \"9630\"\n    assert values[\"WEBUI_TITLE\"] == \"Custom KB\"\n    assert values[\"WEBUI_DESCRIPTION\"] == \"Custom description\"\n    assert values[\"SUMMARY_LANGUAGE\"] == \"Chinese\"\n    assert (\n        values[\"PROMPT_LOG\"]\n        == \"Server host|Server port|WebUI title|WebUI description|Summary language\"\n    )\n\n\n@pytest.mark.parametrize(\n    (\"setup_lines\", \"collector_call\", \"env_key\", \"expected_value\"),\n    [\n        (\n            [\n                'ENV_VALUES[POSTGRES_HOST]=\"db.example.com\"',\n                'ENV_VALUES[POSTGRES_PORT]=\"6543\"',\n            ],\n            \"collect_postgres_config yes\",\n            \"POSTGRES_HOST\",\n            \"localhost\",\n        ),\n        (\n            [\n                'ENV_VALUES[POSTGRES_HOST]=\"db.example.com\"',\n                'ENV_VALUES[POSTGRES_PORT]=\"6543\"',\n            ],\n            \"collect_postgres_config yes\",\n            \"POSTGRES_PORT\",\n            \"5432\",\n        ),\n        (\n            ['ENV_VALUES[NEO4J_URI]=\"neo4j+s://graph.example.com\"'],\n            \"collect_neo4j_config yes\",\n            \"NEO4J_URI\",\n            \"neo4j://localhost:7687\",\n        ),\n        (\n            ['ENV_VALUES[MONGO_URI]=\"mongodb://mongo.example.com:27018/\"'],\n            \"collect_mongodb_config yes\",\n            \"MONGO_URI\",\n            \"mongodb://localhost:27017/\",\n        ),\n        (\n            ['ENV_VALUES[REDIS_URI]=\"redis://cache.example.com:6380/1\"'],\n            \"collect_redis_config yes\",\n            \"REDIS_URI\",\n            \"redis://localhost:6379/\",\n        ),\n        (\n            ['ENV_VALUES[MILVUS_URI]=\"http://milvus.example.com:19530\"'],\n            \"collect_milvus_config yes\",\n            \"MILVUS_URI\",\n            \"http://localhost:19530\",\n        ),\n        (\n            ['ENV_VALUES[QDRANT_URL]=\"http://qdrant.example.com:6333\"'],\n            \"collect_qdrant_config yes\",\n            \"QDRANT_URL\",\n            \"http://localhost:6333\",\n        ),\n        (\n            ['ENV_VALUES[MEMGRAPH_URI]=\"bolt://memgraph.example.com:7687\"'],\n            \"collect_memgraph_config yes\",\n            \"MEMGRAPH_URI\",\n            \"bolt://localhost:7687\",\n        ),\n        (\n            ['ENV_VALUES[NEO4J_URI]=\"neo4j://localhost:7777\"'],\n            \"collect_neo4j_config yes\",\n            \"NEO4J_URI\",\n            \"neo4j://localhost:7687\",\n        ),\n        (\n            ['ENV_VALUES[MILVUS_URI]=\"http://localhost:29530\"'],\n            \"collect_milvus_config yes\",\n            \"MILVUS_URI\",\n            \"http://localhost:19530\",\n        ),\n        (\n            ['ENV_VALUES[QDRANT_URL]=\"http://localhost:16333\"'],\n            \"collect_qdrant_config yes\",\n            \"QDRANT_URL\",\n            \"http://localhost:6333\",\n        ),\n        (\n            ['ENV_VALUES[MEMGRAPH_URI]=\"bolt://localhost:17687\"'],\n            \"collect_memgraph_config yes\",\n            \"MEMGRAPH_URI\",\n            \"bolt://localhost:7687\",\n        ),\n    ],\n    ids=[\n        \"postgres-remote-host\",\n        \"postgres-port-reset-to-bundled-default\",\n        \"neo4j-remote-uri\",\n        \"mongodb-remote-uri\",\n        \"redis-remote-uri\",\n        \"milvus-remote-uri\",\n        \"qdrant-remote-uri\",\n        \"memgraph-remote-uri\",\n        \"neo4j-local-port\",\n        \"milvus-local-port\",\n        \"qdrant-local-port\",\n        \"memgraph-local-port\",\n    ],\n)\ndef test_collect_local_service_configs_normalize_stale_values(\n    setup_lines: list[str],\n    collector_call: str,\n    env_key: str,\n    expected_value: str,\n) -> None:\n    \"\"\"Bundled services should normalize stale remote or localhost endpoints on rerun.\"\"\"\n\n    setup_block = \"\\n\".join(setup_lines)\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\n{setup_block}\n\nconfirm_default_yes() {{ return 0; }}\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{\n  case \"$1\" in\n    \"PostgreSQL user\") printf 'lightrag' ;;\n    \"PostgreSQL database\") printf 'lightrag' ;;\n    \"Neo4j database\") printf 'neo4j' ;;\n    \"MongoDB database\") printf 'LightRAG' ;;\n    \"Milvus database name\") printf 'lightrag' ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\n\n{collector_call}\n\nprintf '{env_key}=%s\\\\n' \"${{ENV_VALUES[{env_key}]}}\"\n\"\"\"\n    )\n\n    assert values[env_key] == expected_value\n\n\ndef test_prepare_compose_runtime_overrides_keeps_env_unchanged() -> None:\n    \"\"\"Loopback endpoints should be rewritten only for compose overrides.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[LLM_BINDING_HOST]=\"http://localhost:11434\"\nENV_VALUES[EMBEDDING_BINDING_HOST]=\"http://127.0.0.1:11434\"\nENV_VALUES[RERANK_BINDING_HOST]=\"http://localhost:8000/rerank\"\n\nprepare_compose_runtime_overrides\n\nprintf 'ENV_LLM=%s\\\\n' \"${{ENV_VALUES[LLM_BINDING_HOST]}}\"\nprintf 'ENV_EMBEDDING=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_BINDING_HOST]}}\"\nprintf 'ENV_RERANK=%s\\\\n' \"${{ENV_VALUES[RERANK_BINDING_HOST]}}\"\nprintf 'COMPOSE_LLM=%s\\\\n' \"${{COMPOSE_ENV_OVERRIDES[LLM_BINDING_HOST]}}\"\nprintf 'COMPOSE_EMBEDDING=%s\\\\n' \"${{COMPOSE_ENV_OVERRIDES[EMBEDDING_BINDING_HOST]}}\"\nprintf 'COMPOSE_RERANK=%s\\\\n' \"${{COMPOSE_ENV_OVERRIDES[RERANK_BINDING_HOST]}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"ENV_LLM\"] == \"http://localhost:11434\"\n    assert values[\"ENV_EMBEDDING\"] == \"http://127.0.0.1:11434\"\n    assert values[\"ENV_RERANK\"] == \"http://localhost:8000/rerank\"\n    assert values[\"COMPOSE_LLM\"] == \"http://host.docker.internal:11434\"\n    assert values[\"COMPOSE_EMBEDDING\"] == \"http://host.docker.internal:11434\"\n    assert values[\"COMPOSE_RERANK\"] == \"http://host.docker.internal:8000/rerank\"\n\n\ndef test_generate_files_keep_host_env_values_and_inject_compose_overrides(\n    tmp_path: Path,\n) -> None:\n    \"\"\"This generation path keeps host-style values in `.env` and injects compose-only overrides separately.\"\"\"\n\n    env_example = tmp_path / \"env.example\"\n    env_example.write_text(\n        \"\\n\".join(\n            [\n                \"SSL_CERTFILE=/placeholder/cert.pem\",\n                \"SSL_KEYFILE=/placeholder/key.pem\",\n                \"LLM_BINDING_HOST=https://api.example.com/v1\",\n                \"EMBEDDING_BINDING_HOST=https://api.example.com/v1\",\n                \"RERANK_BINDING_HOST=https://api.example.com/v1\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    compose_file = tmp_path / \"docker-compose.yml\"\n    compose_file.write_text(\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"    env_file:\",\n                \"      - .env\",\n                \"    volumes:\",\n                \"      - ./.env:/app/.env\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    cert_path = tmp_path / \"cert.pem\"\n    cert_path.write_text(\"cert\", encoding=\"utf-8\")\n    key_path = tmp_path / \"key.pem\"\n    key_path.write_text(\"key\", encoding=\"utf-8\")\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nENV_VALUES[SSL_CERTFILE]=\"{cert_path}\"\nENV_VALUES[SSL_KEYFILE]=\"{key_path}\"\nENV_VALUES[LLM_BINDING_HOST]=\"http://localhost:11434\"\nENV_VALUES[EMBEDDING_BINDING_HOST]=\"http://127.0.0.1:11434\"\nENV_VALUES[RERANK_BINDING_HOST]=\"http://localhost:8000/rerank\"\nSSL_CERT_SOURCE_PATH=\"{cert_path}\"\nSSL_KEY_SOURCE_PATH=\"{key_path}\"\n\nprepare_compose_env_overrides\nstage_ssl_assets \"$SSL_CERT_SOURCE_PATH\" \"$SSL_KEY_SOURCE_PATH\"\ngenerate_env_file \"$REPO_ROOT/env.example\" \"$REPO_ROOT/.env\"\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.generated.yml\"\n\"\"\"\n    )\n\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    generated_compose = (tmp_path / \"docker-compose.generated.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert f\"SSL_CERTFILE={cert_path}\" in generated_env\n    assert f\"SSL_KEYFILE={key_path}\" in generated_env\n    assert \"LLM_BINDING_HOST=http://localhost:11434\" in generated_env\n    assert \"EMBEDDING_BINDING_HOST=http://127.0.0.1:11434\" in generated_env\n    assert \"RERANK_BINDING_HOST=http://localhost:8000/rerank\" in generated_env\n\n    assert 'SSL_CERTFILE: \"/app/data/certs/cert.pem\"' in generated_compose\n    assert 'SSL_KEYFILE: \"/app/data/certs/key.pem\"' in generated_compose\n    assert 'LLM_BINDING_HOST: \"http://host.docker.internal:11434\"' in generated_compose\n    assert (\n        'EMBEDDING_BINDING_HOST: \"http://host.docker.internal:11434\"'\n        in generated_compose\n    )\n    assert (\n        'RERANK_BINDING_HOST: \"http://host.docker.internal:8000/rerank\"'\n        in generated_compose\n    )\n    assert \"./data/certs/cert.pem:/app/data/certs/cert.pem:ro\" in generated_compose\n    assert \"./data/certs/key.pem:/app/data/certs/key.pem:ro\" in generated_compose\n    assert \"env_file:\" not in generated_compose\n\n\ndef test_generate_docker_compose_removes_lightrag_env_file_to_preserve_dollar_values(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Generated compose should remove `env_file` and skip empty environment blocks.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    container_name: lightrag\",\n            \"    image: example/lightrag:test\",\n            \"    env_file:\",\n            \"      - .env\",\n            \"    volumes:\",\n            \"      - ./.env:/app/.env\",\n        ],\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.generated.yml\"\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.generated.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert \"env_file:\" not in generated_compose\n    assert \"environment:\" not in generated_compose\n    assert \"container_name:\" not in generated_compose\n    assert \"- ./.env:/app/.env\" in generated_compose\n\n\ndef test_generate_docker_compose_removes_lightrag_container_name_from_existing_output(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Compose regeneration should strip fixed lightrag container names from prior output.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    container_name: lightrag\",\n            \"    image: example/lightrag:test\",\n        ],\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.final.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert \"container_name:\" not in generated_compose\n\n\ndef test_generate_docker_compose_preserves_list_style_lightrag_environment(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Compose regeneration should not mix mapping entries into list-style environments.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"    environment:\",\n            \"      - PORT=9621\",\n            \"      - FOO=bar\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nset_compose_override \"PORT\" \"1234\"\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.final.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert '      - \"PORT=1234\"' in generated_compose\n    assert \"      - FOO=bar\" in generated_compose\n    assert \"      PORT:\" not in generated_compose\n\n\ndef test_generate_docker_compose_injects_healthchecks_and_lightrag_depends_on(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Generated compose should gate LightRAG on all managed dependencies becoming healthy.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nadd_docker_service postgres\nadd_docker_service neo4j\nadd_docker_service mongodb\nadd_docker_service redis\nadd_docker_service milvus\nadd_docker_service qdrant\nadd_docker_service memgraph\nadd_docker_service vllm-embed\nadd_docker_service vllm-rerank\n\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.final.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    lightrag_start = generated_compose.index(\"  lightrag:\\n\")\n    embed_start = generated_compose.index(\"\\n  vllm-embed:\\n\")\n    lightrag_block = generated_compose[lightrag_start:embed_start]\n\n    assert \"    depends_on:\" in generated_compose\n    assert \"    depends_on:\" in lightrag_block\n    for service_name in (\n        \"postgres\",\n        \"neo4j\",\n        \"mongodb\",\n        \"redis\",\n        \"milvus\",\n        \"qdrant\",\n        \"memgraph\",\n        \"vllm-embed\",\n        \"vllm-rerank\",\n    ):\n        assert (\n            f\"      {service_name}:\\n        condition: service_healthy\"\n            in lightrag_block\n        )\n\n    assert generated_compose.count(\"    healthcheck:\") == 11\n    assert \"  milvus-etcd:\" in generated_compose\n    assert \"  milvus-minio:\" in generated_compose\n    assert \"      milvus-etcd:\\n        condition: service_healthy\" in generated_compose\n    assert (\n        \"      milvus-minio:\\n        condition: service_healthy\" in generated_compose\n    )\n\n\ndef test_generate_docker_compose_preserves_user_depends_on_and_removes_stale_managed_entries(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Compose regeneration should preserve user dependencies while refreshing wizard-managed ones.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"    depends_on:\",\n            \"      sidecar:\",\n            \"        condition: service_started\",\n            \"      postgres:\",\n            \"        condition: service_started\",\n            \"      vllm-embed:\",\n            \"        condition: service_healthy\",\n            \"  sidecar:\",\n            \"    image: busybox\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nadd_docker_service postgres\nadd_docker_service redis\n\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.final.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert \"      sidecar:\\n        condition: service_started\" in generated_compose\n    assert \"      postgres:\\n        condition: service_healthy\" in generated_compose\n    assert \"      redis:\\n        condition: service_healthy\" in generated_compose\n    assert (\n        \"      vllm-embed:\\n        condition: service_healthy\" not in generated_compose\n    )\n\n\ndef test_generate_docker_compose_repairs_misplaced_lightrag_depends_on_from_existing_output(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Regeneration should move stale lightrag depends_on content back onto the lightrag service.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"    environment:\",\n            \"  vllm-rerank:\",\n            \"    image: example/vllm:test\",\n            \"    restart: unless-stopped\",\n            \"    depends_on:\",\n            \"      my-service:\",\n            \"        condition: service_healthy\",\n            \"volumes:\",\n            \"  vllm_rerank_cache:\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nadd_docker_service vllm-rerank\n\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.final.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    lightrag_start = generated_compose.index(\"  lightrag:\\n\")\n    rerank_start = generated_compose.index(\"\\n  vllm-rerank:\\n\")\n    lightrag_block = generated_compose[lightrag_start:rerank_start]\n    rerank_block = generated_compose[rerank_start:]\n\n    assert \"    depends_on:\" in lightrag_block\n    assert \"      my-service:\\n        condition: service_healthy\" in lightrag_block\n    assert \"      vllm-rerank:\\n        condition: service_healthy\" in lightrag_block\n    assert \"    depends_on:\" not in rerank_block\n    assert generated_compose.count(\"\\n  vllm-rerank:\\n\") == 1\n\n\ndef test_generate_docker_compose_normalizes_lightrag_restart_policy_from_existing_output(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Regeneration should replace legacy lightrag restart with deploy.restart_policy.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"    restart: unless-stopped\",\n            \"    extra_hosts:\",\n            '      - \"host.docker.internal:host-gateway\"',\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.final.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    lightrag_start = generated_compose.index(\"  lightrag:\\n\")\n    lightrag_block = generated_compose[lightrag_start:]\n\n    assert \"    restart: unless-stopped\" not in lightrag_block\n    assert \"    deploy:\\n\" in lightrag_block\n    assert \"      restart_policy:\\n\" in lightrag_block\n    assert \"        condition: on-failure\\n\" in lightrag_block\n    assert \"        max_attempts: 10\\n\" in lightrag_block\n\n\ndef test_generate_docker_compose_normalizes_lightrag_restart_policy_without_blank_line_before_deploy(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Regeneration should move the separator blank line after deploy, not before it.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"    restart: unless-stopped\",\n            \"\",\n            \"  sidecar:\",\n            \"    image: busybox\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.final.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert \"    image: example/lightrag:test\\n\\n    deploy:\\n\" not in generated_compose\n    assert \"    image: example/lightrag:test\\n    deploy:\\n\" in generated_compose\n    assert \"        max_attempts: 10\\n\\n  sidecar:\\n\" in generated_compose\n\n\ndef test_existing_ssl_env_keeps_compose_mount_overrides(tmp_path: Path) -> None:\n    \"\"\"Compose regeneration should preserve working SSL mounts without implying `.env` is permanently dual-purpose.\"\"\"\n\n    compose_file = tmp_path / \"docker-compose.yml\"\n    compose_file.write_text(\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"    env_file:\",\n                \"      - .env\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    cert_path = tmp_path / \"cert.pem\"\n    cert_path.write_text(\"cert\", encoding=\"utf-8\")\n    key_path = tmp_path / \"key.pem\"\n    key_path.write_text(\"key\", encoding=\"utf-8\")\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"SSL=true\",\n                f\"SSL_CERTFILE={cert_path}\",\n                f\"SSL_KEYFILE={key_path}\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\nprepare_compose_env_overrides\nstage_ssl_assets \"$SSL_CERT_SOURCE_PATH\" \"$SSL_KEY_SOURCE_PATH\"\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.generated.yml\"\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.generated.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert 'SSL_CERTFILE: \"/app/data/certs/cert.pem\"' in generated_compose\n    assert 'SSL_KEYFILE: \"/app/data/certs/key.pem\"' in generated_compose\n    assert \"./data/certs/cert.pem:/app/data/certs/cert.pem:ro\" in generated_compose\n    assert \"./data/certs/key.pem:/app/data/certs/key.pem:ro\" in generated_compose\n\n\ndef test_finalize_base_setup_rewrites_ssl_env_to_preserved_compose_paths(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Compose-target reruns should rewrite broken SSL source paths to preserved staged compose paths.\"\"\"\n\n    staged_dir = tmp_path / \"data\" / \"certs\"\n    staged_dir.mkdir(parents=True)\n    (staged_dir / \"server.pem\").write_text(\"cert\", encoding=\"utf-8\")\n    (staged_dir / \"server.key\").write_text(\"key\", encoding=\"utf-8\")\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"SSL=true\",\n            \"SSL_CERTFILE=/missing/original-cert.pem\",\n            \"SSL_KEYFILE=/missing/original-key.pem\",\n            \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n            \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n            \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n            \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"    volumes:\",\n            \"      - ./.env:/app/.env\",\n            \"      - ./data/certs/server.pem:/app/data/certs/server.pem:ro\",\n            \"      - ./data/certs/server.key:/app/data/certs/server.key:ro\",\n            \"    environment:\",\n            \"      SSL_CERTFILE: /app/data/certs/server.pem\",\n            \"      SSL_KEYFILE: /app/data/certs/server.key\",\n        ],\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\ninitialize_default_storage_backends\n\nshow_summary() {{ :; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\") return 1 ;;\n    *) return 0 ;;\n  esac\n}}\nconfirm_required_yes_no() {{ return 0; }}\n\nfinalize_base_setup\n\nif validate_env_file; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\"\n    )\n    values = parse_lines(output)\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n\n    assert \"SSL_CERTFILE=/app/data/certs/server.pem\" in generated_env\n    assert \"SSL_KEYFILE=/app/data/certs/server.key\" in generated_env\n    assert values[\"VALID\"] == \"yes\"\n\n\ndef test_removing_ssl_strips_wizard_bind_mounts_from_compose(tmp_path: Path) -> None:\n    \"\"\"Re-running setup without SSL must remove only wizard-managed SSL mounts.\"\"\"\n\n    # A previously generated compose file that has SSL mounts.\n    compose_file = tmp_path / \"docker-compose.final.yml\"\n    compose_file.write_text(\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"    volumes:\",\n                '      - \"./data/certs/cert.pem:/app/data/certs/cert.pem:ro\"',\n                '      - \"./data/certs/key.pem:/app/data/certs/key.pem:ro\"',\n                '      - \"./data/rag_storage:/app/data/rag_storage\"',\n                '      - \"./data/inputs:/app/data/inputs\"',\n                '      - \"./custom-data:/app/data/custom\"',\n                \"    environment:\",\n                '      SSL_CERTFILE: \"/app/data/certs/cert.pem\"',\n                '      SSL_KEYFILE: \"/app/data/certs/key.pem\"',\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\"),\n        encoding=\"utf-8\",\n    )\n\n    # Re-run without SSL: COMPOSE_ENV_OVERRIDES has no SSL_CERTFILE/SSL_KEYFILE.\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\ngenerate_docker_compose \"{tmp_path}/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    result = compose_file.read_text(encoding=\"utf-8\")\n\n    # SSL bind mounts must be gone.\n    assert \"/app/data/certs/cert.pem\" not in result\n    assert \"/app/data/certs/key.pem\" not in result\n    # Default persistent mounts and user-added non-wizard mounts must be preserved.\n    assert \"./data/rag_storage:/app/data/rag_storage\" in result\n    assert \"./data/inputs:/app/data/inputs\" in result\n    assert \"./custom-data:/app/data/custom\" in result\n\n\ndef test_generate_docker_compose_preserves_non_managed_named_volumes(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Retained services should keep their referenced top-level named volumes.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"    volumes:\",\n            \"      - my_cache:/app/cache\",\n            \"  sidecar:\",\n            \"    image: busybox\",\n            '    command: [\"sleep\", \"infinity\"]',\n            \"    volumes:\",\n            \"      - sidecar_data:/data\",\n            \"  postgres:\",\n            \"    image: old/postgres:image\",\n            \"    volumes:\",\n            \"      - postgres_data:/var/lib/postgresql/data\",\n            \"volumes:\",\n            \"  my_cache:\",\n            \"    driver: local\",\n            \"  sidecar_data:\",\n            \"    driver: local\",\n            \"  postgres_data:\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n\n    assert \"  sidecar:\" in result\n    assert \"my_cache:/app/cache\" in result\n    assert \"sidecar_data:/data\" in result\n    assert \"  my_cache:\" in result\n    assert \"    driver: local\" in result\n    assert \"  sidecar_data:\" in result\n    assert \"postgres_data:\" not in result\n\n\ndef test_generate_docker_compose_inserts_managed_services_before_top_level_sections(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Managed services should stay inside services: even when custom top-level sections exist.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"    volumes:\",\n            \"      - ./.env:/app/.env\",\n            \"  worker:\",\n            \"    image: example/worker:test\",\n            \"    networks:\",\n            \"      - appnet\",\n            \"networks:\",\n            \"  appnet:\",\n            \"    driver: bridge\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nENV_VALUES[POSTGRES_USER]=\"lightrag\"\nENV_VALUES[POSTGRES_PASSWORD]=\"secret\"\nENV_VALUES[POSTGRES_DATABASE]=\"lightrag\"\nadd_docker_service \"postgres\"\n\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n\n    assert \"  postgres:\" in result\n    assert \"\\n\\nnetworks:\\n\" in result\n    assert result.index(\"\\n  postgres:\") < result.index(\"\\nnetworks:\\n\")\n    assert \"  appnet:\" in result\n\n\ndef test_generate_docker_compose_cleans_marker_and_blank_lines_when_only_lightrag_remains(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Regeneration should not leave a managed-services marker or stacked blank lines behind.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"    depends_on:\",\n            \"      vllm-embed:\",\n            \"        condition: service_healthy\",\n            \"      vllm-rerank:\",\n            \"        condition: service_healthy\",\n            \"\",\n            \"  vllm-embed:\",\n            \"    image: example/vllm:embed\",\n            \"\",\n            \"  vllm-rerank:\",\n            \"    image: example/vllm:rerank\",\n            \"\",\n            \"\",\n            \"\",\n            \"# __WIZARD_MANAGED_SERVICES__\",\n            \"networks:\",\n            \"  appnet:\",\n            \"    driver: bridge\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n\n    assert \"  vllm-embed:\" not in result\n    assert \"  vllm-rerank:\" not in result\n    assert \"__WIZARD_MANAGED_SERVICES__\" not in result\n    assert \"depends_on:\" not in result\n    assert \"        max_attempts: 10\\n\\nnetworks:\\n\" in result\n\n\ndef test_generate_docker_compose_keeps_blank_line_between_managed_service_and_top_level_sections(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Managed service blocks should stay visually separated from following top-level sections.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"networks:\",\n            \"  web_network:\",\n            \"    driver: bridge\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nadd_docker_service \"vllm-embed\"\n\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n\n    assert \"  vllm-embed:\" in result\n    assert \"        max_attempts: 10\\n    depends_on:\\n\" in result\n    assert \"    restart: unless-stopped\\n\\nnetworks:\\n\" in result\n\n\ndef test_generate_docker_compose_keeps_single_blank_line_before_generated_volumes(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Generated top-level volumes should be separated from prior sections by one blank line.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"networks:\",\n            \"  web_network:\",\n            \"    driver: bridge\",\n            \"\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nadd_docker_service \"vllm-embed\"\n\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n\n    assert \"\\n\\nvolumes:\\n\" in result\n    assert \"\\n\\n\\nvolumes:\\n\" not in result\n\n\ndef test_find_generated_compose_file_prefers_legacy_profile_match(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Legacy setup profile metadata should steer compose migration when available.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_SETUP_PROFILE=production\",\n            \"HOST=0.0.0.0\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"docker-compose.development.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: dev/lightrag\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"docker-compose.production.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: prod/lightrag\",\n        ],\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nprintf 'COMPOSE=%s\\\\n' \"$(find_generated_compose_file)\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"COMPOSE\"] == str(tmp_path / \"docker-compose.production.yml\")\n\n\ndef test_find_generated_compose_file_falls_back_to_order_without_profile(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Without legacy profile metadata, compose migration should use the default order.\"\"\"\n\n    write_text_lines(tmp_path / \".env\", [\"HOST=0.0.0.0\"])\n    write_text_lines(\n        tmp_path / \"docker-compose.development.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: dev/lightrag\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"docker-compose.production.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: prod/lightrag\",\n        ],\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nprintf 'COMPOSE=%s\\\\n' \"$(find_generated_compose_file)\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"COMPOSE\"] == str(tmp_path / \"docker-compose.development.yml\")\n\n\ndef test_collect_ssl_config_can_disable_loaded_ssl_values(tmp_path: Path) -> None:\n    \"\"\"Declining SSL should clear previously loaded cert paths and staged sources.\"\"\"\n\n    cert_path = tmp_path / \"cert.pem\"\n    cert_path.write_text(\"cert\", encoding=\"utf-8\")\n    key_path = tmp_path / \"key.pem\"\n    key_path.write_text(\"key\", encoding=\"utf-8\")\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"SSL=true\",\n                f\"SSL_CERTFILE={cert_path}\",\n                f\"SSL_KEYFILE={key_path}\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\n\nconfirm_default_yes() {{ return 1; }}\n\ncollect_ssl_config\n\nprintf 'SSL_IS_SET=%s\\\\n' \"${{ENV_VALUES[SSL]+set}}\"\nprintf 'SSL_CERTFILE_IS_SET=%s\\\\n' \"${{ENV_VALUES[SSL_CERTFILE]+set}}\"\nprintf 'SSL_KEYFILE_IS_SET=%s\\\\n' \"${{ENV_VALUES[SSL_KEYFILE]+set}}\"\nprintf 'SSL_CERT_SOURCE_PATH=%s\\\\n' \"$SSL_CERT_SOURCE_PATH\"\nprintf 'SSL_KEY_SOURCE_PATH=%s\\\\n' \"$SSL_KEY_SOURCE_PATH\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"SSL_IS_SET\"] == \"\"\n    assert values[\"SSL_CERTFILE_IS_SET\"] == \"\"\n    assert values[\"SSL_KEYFILE_IS_SET\"] == \"\"\n    assert values[\"SSL_CERT_SOURCE_PATH\"] == \"\"\n    assert values[\"SSL_KEY_SOURCE_PATH\"] == \"\"\n\n\ndef test_validate_env_file_rejects_missing_ssl_files(tmp_path: Path) -> None:\n    \"\"\"Validation should fail when SSL is enabled with missing cert/key paths.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"SSL=true\",\n                \"SSL_CERTFILE=/missing/cert.pem\",\n                \"SSL_KEYFILE=/missing/key.pem\",\n                \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"-lc\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nvalidate_env_file\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    assert result.returncode == 1\n    assert \"Invalid SSL_CERTFILE\" in result.stderr\n    assert \"Invalid SSL_KEYFILE\" in result.stderr\n\n\ndef test_validate_env_file_rejects_container_ssl_paths_for_host_target(\n    tmp_path: Path,\n) -> None:\n    \"\"\"host-target .env must not accept /app/data/certs/* even when the staged file exists.\"\"\"\n\n    (tmp_path / \"data\" / \"certs\").mkdir(parents=True)\n    (tmp_path / \"data\" / \"certs\" / \"cert.pem\").write_text(\"cert\", encoding=\"utf-8\")\n    (tmp_path / \"data\" / \"certs\" / \"key.pem\").write_text(\"key\", encoding=\"utf-8\")\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"SSL=true\",\n                \"SSL_CERTFILE=/app/data/certs/cert.pem\",\n                \"SSL_KEYFILE=/app/data/certs/key.pem\",\n                \"LIGHTRAG_RUNTIME_TARGET=host\",\n                \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"-lc\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nvalidate_env_file\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    assert result.returncode == 1\n    assert \"Invalid SSL_CERTFILE\" in result.stderr\n    assert \"Invalid SSL_KEYFILE\" in result.stderr\n\n\ndef test_validate_env_file_rejects_container_ssl_paths_for_default_host_target(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Omitting LIGHTRAG_RUNTIME_TARGET defaults to host; container paths must still be rejected.\"\"\"\n\n    (tmp_path / \"data\" / \"certs\").mkdir(parents=True)\n    (tmp_path / \"data\" / \"certs\" / \"cert.pem\").write_text(\"cert\", encoding=\"utf-8\")\n    (tmp_path / \"data\" / \"certs\" / \"key.pem\").write_text(\"key\", encoding=\"utf-8\")\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"SSL=true\",\n                \"SSL_CERTFILE=/app/data/certs/cert.pem\",\n                \"SSL_KEYFILE=/app/data/certs/key.pem\",\n                \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"-lc\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nvalidate_env_file\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    assert result.returncode == 1\n    assert \"Invalid SSL_CERTFILE\" in result.stderr\n    assert \"Invalid SSL_KEYFILE\" in result.stderr\n\n\ndef test_validate_env_file_accepts_container_ssl_paths_for_compose_target(\n    tmp_path: Path,\n) -> None:\n    \"\"\"compose-target .env may use /app/data/certs/* when the staged files exist.\"\"\"\n\n    (tmp_path / \"data\" / \"certs\").mkdir(parents=True)\n    (tmp_path / \"data\" / \"certs\" / \"cert.pem\").write_text(\"cert\", encoding=\"utf-8\")\n    (tmp_path / \"data\" / \"certs\" / \"key.pem\").write_text(\"key\", encoding=\"utf-8\")\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"SSL=true\",\n                \"SSL_CERTFILE=/app/data/certs/cert.pem\",\n                \"SSL_KEYFILE=/app/data/certs/key.pem\",\n                \"LIGHTRAG_RUNTIME_TARGET=compose\",\n                \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"-lc\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nvalidate_env_file\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    assert result.returncode == 0\n\n\ndef test_generate_env_file_comments_out_later_duplicate_active_keys(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Commented example keys should not be overridden by later active defaults.\"\"\"\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nENV_VALUES[EMBEDDING_BINDING]=\"ollama\"\nENV_VALUES[EMBEDDING_MODEL]=\"bge-m3:latest\"\nENV_VALUES[EMBEDDING_DIM]=\"1024\"\nENV_VALUES[EMBEDDING_BINDING_HOST]=\"http://localhost:11434\"\n\ngenerate_env_file \"{REPO_ROOT}/env.example\" \"$REPO_ROOT/.env\"\n\"\"\"\n    )\n\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\").splitlines()\n    active_embedding_lines = [\n        line for line in generated_env if line.startswith(\"EMBEDDING_BINDING=\")\n    ]\n    active_model_lines = [\n        line for line in generated_env if line.startswith(\"EMBEDDING_MODEL=\")\n    ]\n    active_host_lines = [\n        line for line in generated_env if line.startswith(\"EMBEDDING_BINDING_HOST=\")\n    ]\n\n    assert active_embedding_lines == [\"EMBEDDING_BINDING=ollama\"]\n    assert active_model_lines == [\"EMBEDDING_MODEL=bge-m3:latest\"]\n    assert active_host_lines == [\"EMBEDDING_BINDING_HOST=http://localhost:11434\"]\n    assert \"# EMBEDDING_BINDING=openai\" in generated_env\n\n\ndef test_generate_env_file_round_trips_dollar_signs_in_quoted_values(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Quoted values containing `$` should survive generate/load cycles unchanged.\"\"\"\n\n    env_example = tmp_path / \"env.example\"\n    env_example.write_text(\n        \"\\n\".join(\n            [\n                \"TOKEN_SECRET=placeholder\",\n                \"LIGHTRAG_API_KEY=placeholder\",\n                \"WEBUI_DESCRIPTION=placeholder\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nENV_VALUES[TOKEN_SECRET]='abc$HOME'\nENV_VALUES[LIGHTRAG_API_KEY]='plain$token'\nENV_VALUES[WEBUI_DESCRIPTION]='value with \"$PATH\" and $HOME'\n\ngenerate_env_file \"$REPO_ROOT/env.example\" \"$REPO_ROOT/.env\"\nreset_state\nload_env_file \"$REPO_ROOT/.env\"\n\nprintf 'TOKEN_SECRET=%s\\\\n' \"${{ENV_VALUES[TOKEN_SECRET]}}\"\nprintf 'LIGHTRAG_API_KEY=%s\\\\n' \"${{ENV_VALUES[LIGHTRAG_API_KEY]}}\"\nprintf 'WEBUI_DESCRIPTION=%s\\\\n' \"${{ENV_VALUES[WEBUI_DESCRIPTION]}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n\n    assert 'TOKEN_SECRET=\"abc$HOME\"' in generated_env\n    assert 'LIGHTRAG_API_KEY=\"plain$token\"' in generated_env\n    assert 'WEBUI_DESCRIPTION=\"value with \\\\\"$PATH\\\\\" and $HOME\"' in generated_env\n    assert values[\"TOKEN_SECRET\"] == \"abc$HOME\"\n    assert values[\"LIGHTRAG_API_KEY\"] == \"plain$token\"\n    assert values[\"WEBUI_DESCRIPTION\"] == 'value with \"$PATH\" and $HOME'\n\n\ndef test_validate_sensitive_env_literals_rejects_interpolation_syntax() -> None:\n    \"\"\"Sensitive values should reject `${...}` so default dotenv interpolation stays safe.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[TOKEN_SECRET]='${{JWT_SECRET}}'\nENV_VALUES[LIGHTRAG_API_KEY]='plain$token'\nENV_VALUES[WEBUI_DESCRIPTION]='${{ALLOWED_MACRO}}'\n\nif validate_sensitive_env_literals; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"VALID\"] == \"no\"\n\n\n@pytest.mark.parametrize(\n    (\"collector_name\", \"binding_prefix\", \"env_lines\"),\n    [\n        (\n            \"collect_llm_config\",\n            \"LLM\",\n            [\n                \"LLM_BINDING=openai\",\n                \"LLM_MODEL=gpt-4o\",\n                \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n                \"LLM_BINDING_API_KEY=${OPENAI_API_KEY}\",\n            ],\n        ),\n        (\n            \"collect_embedding_config\",\n            \"EMBEDDING\",\n            [\n                \"EMBEDDING_BINDING=openai\",\n                \"EMBEDDING_MODEL=text-embedding-3-large\",\n                \"EMBEDDING_DIM=3072\",\n                \"EMBEDDING_BINDING_HOST=https://api.openai.com/v1\",\n                \"EMBEDDING_BINDING_API_KEY=${OPENAI_API_KEY}\",\n            ],\n        ),\n    ],\n    ids=[\"llm-bedrock-clears-api-key\", \"embedding-bedrock-clears-api-key\"],\n)\ndef test_collect_provider_config_clears_stale_api_key_for_bedrock(\n    tmp_path: Path,\n    collector_name: str,\n    binding_prefix: str,\n    env_lines: list[str],\n) -> None:\n    \"\"\"Switching a provider to Bedrock should remove stale API-key settings.\"\"\"\n\n    write_text_lines(tmp_path / \".env\", env_lines)\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\n\nprompt_choice() {{ printf 'aws_bedrock'; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_required_secret() {{ printf 'dummy-secret'; }}\nmask_sensitive_input() {{ printf ''; }}\nconfirm_default_yes() {{ return 0; }}\n\n{collector_name}\n\nprintf 'BINDING=%s\\\\n' \"${{ENV_VALUES[{binding_prefix}_BINDING]}}\"\nprintf 'API_KEY_SET=%s\\\\n' \"${{ENV_VALUES[{binding_prefix}_BINDING_API_KEY]+set}}\"\nif validate_sensitive_env_literals; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"BINDING\"] == \"aws_bedrock\"\n    assert values[\"API_KEY_SET\"] == \"\"\n    assert values[\"VALID\"] == \"yes\"\n\n\n@pytest.mark.parametrize(\n    (\n        \"collector_name\",\n        \"binding_prefix\",\n        \"provider_choice\",\n        \"secret_stub\",\n        \"expected_binding\",\n        \"expected_model\",\n        \"expected_host\",\n        \"expected_dim\",\n        \"expected_api_key_set\",\n    ),\n    [\n        (\n            \"collect_llm_config\",\n            \"LLM\",\n            \"ollama\",\n            \"\",\n            \"ollama\",\n            \"mistral-nemo:latest\",\n            \"http://localhost:11434\",\n            \"\",\n            \"\",\n        ),\n        (\n            \"collect_embedding_config\",\n            \"EMBEDDING\",\n            \"jina\",\n            \"prompt_secret_until_valid_with_default() { printf 'jina-secret-key'; }\",\n            \"jina\",\n            \"jina-embeddings-v4\",\n            \"https://api.jina.ai/v1/embeddings\",\n            \"2048\",\n            \"set\",\n        ),\n    ],\n    ids=[\"llm-provider-defaults\", \"embedding-provider-defaults\"],\n)\ndef test_collect_provider_config_uses_provider_specific_defaults(\n    collector_name: str,\n    binding_prefix: str,\n    provider_choice: str,\n    secret_stub: str,\n    expected_binding: str,\n    expected_model: str,\n    expected_host: str,\n    expected_dim: str,\n    expected_api_key_set: str,\n) -> None:\n    \"\"\"Fresh provider selection should pick provider-specific defaults.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nprompt_choice() {{ printf '{provider_choice}'; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\n{secret_stub}\n\n{collector_name}\n\nprintf 'BINDING=%s\\\\n' \"${{ENV_VALUES[{binding_prefix}_BINDING]}}\"\nprintf 'MODEL=%s\\\\n' \"${{ENV_VALUES[{binding_prefix}_MODEL]}}\"\nprintf 'HOST=%s\\\\n' \"${{ENV_VALUES[{binding_prefix}_BINDING_HOST]}}\"\nprintf 'DIM=%s\\\\n' \"${{ENV_VALUES[{binding_prefix}_DIM]:-}}\"\nprintf 'API_KEY_SET=%s\\\\n' \"${{ENV_VALUES[{binding_prefix}_BINDING_API_KEY]+set}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"BINDING\"] == expected_binding\n    assert values[\"MODEL\"] == expected_model\n    assert values[\"HOST\"] == expected_host\n    assert values[\"DIM\"] == expected_dim\n    assert values[\"API_KEY_SET\"] == expected_api_key_set\n\n\n@pytest.mark.parametrize(\n    (\n        \"collector_name\",\n        \"binding_prefix\",\n        \"env_lines\",\n        \"prompt_stubs\",\n        \"expected_binding\",\n        \"expected_model\",\n        \"expected_host\",\n        \"expected_dim\",\n        \"expected_api_key\",\n    ),\n    [\n        (\n            \"collect_llm_config\",\n            \"LLM\",\n            [\n                \"LLM_BINDING=openai-ollama\",\n                \"LLM_MODEL=llama3.1:8b\",\n                \"LLM_BINDING_HOST=http://localhost:11434/v1\",\n                \"LLM_BINDING_API_KEY=sk-local-test-key\",\n            ],\n            \"\"\"\nprompt_with_default() { printf '%s' \"$2\"; }\nprompt_secret_until_valid_with_default() { printf '%s' \"$2\"; }\n\"\"\",\n            \"openai-ollama\",\n            \"llama3.1:8b\",\n            \"http://localhost:11434/v1\",\n            \"\",\n            \"sk-local-test-key\",\n        ),\n        (\n            \"collect_embedding_config\",\n            \"EMBEDDING\",\n            [\n                \"EMBEDDING_BINDING=lollms\",\n                \"EMBEDDING_MODEL=lollms_embedding_model\",\n                \"EMBEDDING_DIM=1024\",\n                \"EMBEDDING_BINDING_HOST=http://localhost:9600\",\n            ],\n            \"\"\"prompt_with_default() { printf '%s' \"$2\"; }\"\"\",\n            \"lollms\",\n            \"lollms_embedding_model\",\n            \"http://localhost:9600\",\n            \"1024\",\n            \"\",\n        ),\n    ],\n    ids=[\"llm-rerun-preserves-openai-ollama\", \"embedding-rerun-preserves-lollms\"],\n)\ndef test_collect_provider_config_preserves_supported_binding_on_rerun(\n    tmp_path: Path,\n    collector_name: str,\n    binding_prefix: str,\n    env_lines: list[str],\n    prompt_stubs: str,\n    expected_binding: str,\n    expected_model: str,\n    expected_host: str,\n    expected_dim: str,\n    expected_api_key: str,\n) -> None:\n    \"\"\"Reruns should preserve supported provider bindings and their saved settings.\"\"\"\n\n    write_text_lines(tmp_path / \".env\", env_lines)\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\n\n{prompt_stubs}\n\n{collector_name}\n\nprintf 'BINDING=%s\\\\n' \"${{ENV_VALUES[{binding_prefix}_BINDING]}}\"\nprintf 'MODEL=%s\\\\n' \"${{ENV_VALUES[{binding_prefix}_MODEL]}}\"\nprintf 'HOST=%s\\\\n' \"${{ENV_VALUES[{binding_prefix}_BINDING_HOST]}}\"\nprintf 'DIM=%s\\\\n' \"${{ENV_VALUES[{binding_prefix}_DIM]:-}}\"\nprintf 'API_KEY=%s\\\\n' \"${{ENV_VALUES[{binding_prefix}_BINDING_API_KEY]:-}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"BINDING\"] == expected_binding\n    assert values[\"MODEL\"] == expected_model\n    assert values[\"HOST\"] == expected_host\n    assert values[\"DIM\"] == expected_dim\n    assert values[\"API_KEY\"] == expected_api_key\n\n\ndef test_collect_embedding_config_forces_ollama_for_openai_ollama_llm(\n    tmp_path: Path,\n) -> None:\n    \"\"\"`openai-ollama` should not preserve a conflicting embedding provider.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=openai-ollama\",\n            \"EMBEDDING_BINDING=openai\",\n            \"EMBEDDING_MODEL=text-embedding-3-large\",\n            \"EMBEDDING_DIM=3072\",\n            \"EMBEDDING_BINDING_HOST=https://api.openai.com/v1\",\n            \"EMBEDDING_BINDING_API_KEY=local-key\",\n        ],\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\n\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\n\ncollect_embedding_config\n\nprintf 'EMBEDDING_BINDING=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_BINDING]}}\"\nprintf 'EMBEDDING_MODEL=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_MODEL]}}\"\nprintf 'EMBEDDING_DIM=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_DIM]}}\"\nprintf 'EMBEDDING_BINDING_HOST=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_BINDING_HOST]}}\"\nprintf 'EMBEDDING_BINDING_API_KEY_SET=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_BINDING_API_KEY]+set}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"EMBEDDING_BINDING\"] == \"ollama\"\n    assert values[\"EMBEDDING_MODEL\"] == \"bge-m3:latest\"\n    assert values[\"EMBEDDING_DIM\"] == \"1024\"\n    assert values[\"EMBEDDING_BINDING_HOST\"] == \"http://localhost:11434\"\n    assert values[\"EMBEDDING_BINDING_API_KEY_SET\"] == \"\"\n\n\ndef test_collect_llm_config_allows_bedrock_ambient_credential_chain() -> None:\n    \"\"\"Bedrock setup should allow IAM roles, AWS profiles, or SSO without saved keys.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nprompt_choice() {{ printf 'aws_bedrock'; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_clearable_with_default() {{ printf ''; }}\nprompt_required_secret() {{ return 1; }}\nconfirm_default_yes() {{ return 1; }}\n\ncollect_llm_config\n\nprintf 'LLM_BINDING=%s\\\\n' \"${{ENV_VALUES[LLM_BINDING]}}\"\nprintf 'AWS_ACCESS_KEY_ID_SET=%s\\\\n' \"${{ENV_VALUES[AWS_ACCESS_KEY_ID]+set}}\"\nprintf 'AWS_SECRET_ACCESS_KEY_SET=%s\\\\n' \"${{ENV_VALUES[AWS_SECRET_ACCESS_KEY]+set}}\"\nprintf 'AWS_SESSION_TOKEN_SET=%s\\\\n' \"${{ENV_VALUES[AWS_SESSION_TOKEN]+set}}\"\nprintf 'AWS_REGION_SET=%s\\\\n' \"${{ENV_VALUES[AWS_REGION]+set}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"LLM_BINDING\"] == \"aws_bedrock\"\n    assert values[\"AWS_ACCESS_KEY_ID_SET\"] == \"\"\n    assert values[\"AWS_SECRET_ACCESS_KEY_SET\"] == \"\"\n    assert values[\"AWS_SESSION_TOKEN_SET\"] == \"\"\n    assert values[\"AWS_REGION_SET\"] == \"\"\n\n\ndef test_switching_both_providers_off_bedrock_clears_saved_aws_credentials(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Reruns should not keep stale AWS Bedrock secrets in regenerated `.env` files.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=aws_bedrock\",\n            \"LLM_MODEL=anthropic.claude-3-5-sonnet-20241022-v2:0\",\n            \"LLM_BINDING_HOST=https://bedrock.amazonaws.com\",\n            \"EMBEDDING_BINDING=aws_bedrock\",\n            \"EMBEDDING_MODEL=amazon.titan-embed-text-v2:0\",\n            \"EMBEDDING_DIM=1024\",\n            \"EMBEDDING_BINDING_HOST=https://bedrock.amazonaws.com\",\n            \"AWS_ACCESS_KEY_ID=AKIAOLDKEY\",\n            \"AWS_SECRET_ACCESS_KEY=oldsecretvalue\",\n            \"AWS_SESSION_TOKEN=oldsess\",\n            \"AWS_REGION=us-east-1\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        [\n            \"# AWS_ACCESS_KEY_ID=your_aws_access_key_id\",\n            \"# AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key\",\n            \"# AWS_SESSION_TOKEN=your_optional_aws_session_token\",\n            \"# AWS_REGION=us-east-1\",\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=your_api_key\",\n            \"EMBEDDING_BINDING=openai\",\n            \"EMBEDDING_MODEL=text-embedding-3-large\",\n            \"EMBEDDING_DIM=3072\",\n            \"EMBEDDING_BINDING_HOST=https://api.openai.com/v1\",\n            \"EMBEDDING_BINDING_API_KEY=your_api_key\",\n        ],\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\n\nprompt_choice() {{ printf 'openai'; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf 'fresh-key'; }}\n\ncollect_llm_config\ncollect_embedding_config\ngenerate_env_file \"$REPO_ROOT/env.example\" \"$REPO_ROOT/.env.generated\"\n\nprintf 'AWS_ACCESS_KEY_ID_SET=%s\\\\n' \"${{ENV_VALUES[AWS_ACCESS_KEY_ID]+set}}\"\nprintf 'AWS_SECRET_ACCESS_KEY_SET=%s\\\\n' \"${{ENV_VALUES[AWS_SECRET_ACCESS_KEY]+set}}\"\nprintf 'AWS_SESSION_TOKEN_SET=%s\\\\n' \"${{ENV_VALUES[AWS_SESSION_TOKEN]+set}}\"\nprintf 'AWS_REGION_SET=%s\\\\n' \"${{ENV_VALUES[AWS_REGION]+set}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n    generated_lines = (\n        (tmp_path / \".env.generated\").read_text(encoding=\"utf-8\").splitlines()\n    )\n\n    assert values[\"AWS_ACCESS_KEY_ID_SET\"] == \"\"\n    assert values[\"AWS_SECRET_ACCESS_KEY_SET\"] == \"\"\n    assert values[\"AWS_SESSION_TOKEN_SET\"] == \"\"\n    assert values[\"AWS_REGION_SET\"] == \"\"\n    assert not any(line.startswith(\"AWS_ACCESS_KEY_ID=\") for line in generated_lines)\n    assert not any(\n        line.startswith(\"AWS_SECRET_ACCESS_KEY=\") for line in generated_lines\n    )\n    assert not any(line.startswith(\"AWS_SESSION_TOKEN=\") for line in generated_lines)\n    assert not any(line.startswith(\"AWS_REGION=\") for line in generated_lines)\n\n\ndef test_collect_rerank_config_preserves_api_key_when_disabled(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Disabling reranking should preserve credentials so they survive re-enable.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"RERANK_BINDING=cohere\",\n            \"RERANK_MODEL=rerank-v3.5\",\n            \"RERANK_BINDING_HOST=https://api.cohere.com/v1/rerank\",\n            \"RERANK_BINDING_API_KEY=test-api-key-literal\",\n        ],\n    )\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\n\nconfirm_default_yes() {{ return 1; }}\n\ncollect_rerank_config\n\nprintf 'RERANK_BINDING=%s\\\\n' \"${{ENV_VALUES[RERANK_BINDING]}}\"\nprintf 'RERANK_BINDING_API_KEY_SET=%s\\\\n' \"${{ENV_VALUES[RERANK_BINDING_API_KEY]+set}}\"\nif validate_sensitive_env_literals; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\"\n    )\n\n    assert values[\"RERANK_BINDING\"] == \"null\"\n    assert values[\"RERANK_BINDING_API_KEY_SET\"] == \"set\"\n    assert values[\"VALID\"] == \"yes\"\n\n\ndef test_load_existing_env_forces_cohere_binding_for_vllm_rerank(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Loading a Docker-managed vLLM rerank config should normalize the binding to cohere.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"RERANK_BINDING=jina\",\n            \"LIGHTRAG_SETUP_RERANK_PROVIDER=vllm\",\n            \"RERANK_BINDING_HOST=http://localhost:8000/rerank\",\n        ],\n    )\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\n\nprintf 'RERANK_BINDING=%s\\\\n' \"${{ENV_VALUES[RERANK_BINDING]}}\"\nprintf 'LIGHTRAG_SETUP_RERANK_PROVIDER=%s\\\\n' \"${{ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]}}\"\n\"\"\"\n    )\n\n    assert values[\"RERANK_BINDING\"] == \"cohere\"\n    assert values[\"LIGHTRAG_SETUP_RERANK_PROVIDER\"] == \"vllm\"\n\n\ndef test_collect_rerank_config_does_not_offer_vllm_provider_option() -> None:\n    \"\"\"The generic rerank provider prompt should only expose valid RERANK_BINDING values.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[RERANK_BINDING]=\"cohere\"\n\nconfirm_default_no() {{ return 0; }}\nprompt_choice() {{\n  case \"$1\" in\n    \"Rerank provider\")\n      shift 2\n      for option in \"$@\"; do\n        if [[ \"$option\" == \"vllm\" ]]; then\n          echo \"unexpected vllm option\" >&2\n          return 91\n        fi\n      done\n      printf 'cohere'\n      ;;\n    *)\n      printf '%s' \"$2\"\n      ;;\n  esac\n}}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf 'cohere-secret-123'; }}\n\ncollect_rerank_config\n\nprintf 'RERANK_BINDING=%s\\\\n' \"${{ENV_VALUES[RERANK_BINDING]}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"RERANK_BINDING\"] == \"cohere\"\n\n\ndef test_collect_rerank_config_switching_from_vllm_clears_local_defaults() -> None:\n    \"\"\"Switching from local vLLM to hosted rerank should replace stale vLLM values with provider defaults.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]=\"vllm\"\nENV_VALUES[RERANK_BINDING]=\"cohere\"\nENV_VALUES[RERANK_MODEL]=\"BAAI/bge-reranker-v2-m3\"\nENV_VALUES[RERANK_BINDING_HOST]=\"http://localhost:8000/rerank\"\n\nconfirm_default_no() {{ return 0; }}\nprompt_choice() {{\n  case \"$1\" in\n    \"Rerank provider\") printf 'cohere' ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf 'cohere-secret-123'; }}\n\ncollect_rerank_config\n\nprintf 'RERANK_BINDING=%s\\\\n' \"${{ENV_VALUES[RERANK_BINDING]}}\"\nprintf 'LIGHTRAG_SETUP_RERANK_PROVIDER=%s\\\\n' \"${{ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]:-}}\"\nprintf 'RERANK_MODEL=%s\\\\n' \"${{ENV_VALUES[RERANK_MODEL]:-}}\"\nprintf 'RERANK_BINDING_HOST=%s\\\\n' \"${{ENV_VALUES[RERANK_BINDING_HOST]:-}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"RERANK_BINDING\"] == \"cohere\"\n    assert values[\"LIGHTRAG_SETUP_RERANK_PROVIDER\"] == \"\"\n    # Stale vLLM model should be replaced by the cohere provider default\n    assert values[\"RERANK_MODEL\"] != \"BAAI/bge-reranker-v2-m3\"\n    assert values[\"RERANK_MODEL\"] == \"rerank-v3.5\"\n    # Stale vLLM localhost endpoint should be replaced by the cohere provider default\n    assert \"localhost:8000\" not in values[\"RERANK_BINDING_HOST\"]\n    assert \"cohere\" in values[\"RERANK_BINDING_HOST\"]\n\n\ndef test_collect_rerank_config_ignores_vllm_marker_when_docker_is_predeclined() -> None:\n    \"\"\"A predeclined Docker path should default the provider prompt to the binding, not the setup marker.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]=\"vllm\"\nENV_VALUES[RERANK_BINDING]=\"cohere\"\n\nprompt_choice() {{\n  case \"$1\" in\n    \"Rerank provider\")\n      if [[ \"$2\" != \"cohere\" ]]; then\n        echo \"unexpected rerank provider default: $2\" >&2\n        return 91\n      fi\n      printf 'cohere'\n      ;;\n    *)\n      printf '%s' \"$2\"\n      ;;\n  esac\n}}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf 'cohere-secret-123'; }}\n\ncollect_rerank_config \"yes\" \"no\"\n\nprintf 'RERANK_BINDING=%s\\\\n' \"${{ENV_VALUES[RERANK_BINDING]}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"RERANK_BINDING\"] == \"cohere\"\n\n\ndef test_generate_docker_compose_escapes_dollar_signs_in_overrides_and_service_secrets(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Compose generation should keep `$` literals in runtime overrides and bundled secrets.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"    env_file:\",\n            \"      - .env\",\n        ],\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nENV_VALUES[MONGO_URI]='mongodb://user:p$HOME@localhost:27017/'\nENV_VALUES[POSTGRES_USER]='user$ID'\nENV_VALUES[POSTGRES_PASSWORD]='pass$HOME'\nENV_VALUES[POSTGRES_DATABASE]='db$NAME'\nENV_VALUES[NEO4J_PASSWORD]='neo$PASS'\nENV_VALUES[NEO4J_DATABASE]='graph$DB'\nENV_VALUES[MINIO_ACCESS_KEY_ID]='minio$USER'\nENV_VALUES[MINIO_SECRET_ACCESS_KEY]='minio$SECRET'\n\nprepare_compose_runtime_overrides\nadd_docker_service postgres\nadd_docker_service neo4j\nadd_docker_service milvus\n\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.generated.yml\"\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.generated.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert (\n        'MONGO_URI: \"mongodb://user:p$$HOME@host.docker.internal:27017/\"'\n        in generated_compose\n    )\n    assert 'POSTGRES_USER: \"user$$ID\"' in generated_compose\n    assert 'POSTGRES_PASSWORD: \"pass$$HOME\"' in generated_compose\n    assert 'POSTGRES_DB: \"db$$NAME\"' in generated_compose\n    assert (\n        \"NEO4J_AUTH: ${NEO4J_USERNAME:?missing}/${NEO4J_PASSWORD:?missing}\"\n        in generated_compose\n    )\n    assert 'NEO4J_dbms_default__database: \"graph$$DB\"' in generated_compose\n    assert 'MINIO_ACCESS_KEY_ID: \"${MINIO_ACCESS_KEY_ID:?missing}\"' in generated_compose\n    assert (\n        'MINIO_SECRET_ACCESS_KEY: \"${MINIO_SECRET_ACCESS_KEY:?missing}\"'\n        in generated_compose\n    )\n    assert 'MINIO_ROOT_USER: \"${MINIO_ACCESS_KEY_ID:?missing}\"' in generated_compose\n    assert (\n        'MINIO_ROOT_PASSWORD: \"${MINIO_SECRET_ACCESS_KEY:?missing}\"'\n        in generated_compose\n    )\n    assert \"milvus-etcd\" in generated_compose\n    assert \"milvus-minio\" in generated_compose\n\n\ndef test_env_base_flow_preserves_non_inference_env_values(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-base wizard should leave server, security, and observability values untouched.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"HOST=127.0.0.1\",\n                \"PORT=9999\",\n                \"WEBUI_TITLE=Existing Title\",\n                \"WEBUI_DESCRIPTION=Existing Description\",\n                \"SSL=true\",\n                \"SSL_CERTFILE=/some/cert.pem\",\n                \"SSL_KEYFILE=/some/key.pem\",\n                \"AUTH_ACCOUNTS=admin:secret\",\n                \"TOKEN_SECRET=jwt-secret\",\n                \"LIGHTRAG_API_KEY=api-key\",\n                \"LANGFUSE_ENABLE_TRACE=true\",\n                \"LANGFUSE_SECRET_KEY=langfuse-secret\",\n                \"LLM_BINDING_API_KEY=sk-existing\",\n                \"EMBEDDING_BINDING_API_KEY=sk-existing\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{ return 1; }}\nconfirm_default_yes() {{ return 1; }}\n\nfinalize_base_setup() {{\n  printf 'HOST=%s\\\\n' \"${{ENV_VALUES[HOST]}}\"\n  printf 'PORT=%s\\\\n' \"${{ENV_VALUES[PORT]}}\"\n  printf 'WEBUI_TITLE=%s\\\\n' \"${{ENV_VALUES[WEBUI_TITLE]}}\"\n  printf 'WEBUI_DESCRIPTION=%s\\\\n' \"${{ENV_VALUES[WEBUI_DESCRIPTION]}}\"\n  printf 'LLM_BINDING=%s\\\\n' \"${{ENV_VALUES[LLM_BINDING]}}\"\n  printf 'LLM_BINDING_API_KEY=%s\\\\n' \"${{ENV_VALUES[LLM_BINDING_API_KEY]}}\"\n  printf 'EMBEDDING_BINDING_API_KEY=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_BINDING_API_KEY]}}\"\n  printf 'SSL_SET=%s\\\\n' \"${{ENV_VALUES[SSL]+set}}\"\n  printf 'AUTH_ACCOUNTS_SET=%s\\\\n' \"${{ENV_VALUES[AUTH_ACCOUNTS]+set}}\"\n  printf 'TOKEN_SECRET_SET=%s\\\\n' \"${{ENV_VALUES[TOKEN_SECRET]+set}}\"\n  printf 'LIGHTRAG_API_KEY_SET=%s\\\\n' \"${{ENV_VALUES[LIGHTRAG_API_KEY]+set}}\"\n  printf 'LANGFUSE_ENABLE_TRACE_SET=%s\\\\n' \"${{ENV_VALUES[LANGFUSE_ENABLE_TRACE]+set}}\"\n  printf 'LANGFUSE_SECRET_KEY_SET=%s\\\\n' \"${{ENV_VALUES[LANGFUSE_SECRET_KEY]+set}}\"\n}}\n\nenv_base_flow\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"HOST\"] == \"127.0.0.1\"\n    assert values[\"PORT\"] == \"9999\"\n    assert values[\"WEBUI_TITLE\"] == \"Existing Title\"\n    assert values[\"WEBUI_DESCRIPTION\"] == \"Existing Description\"\n    assert values[\"LLM_BINDING\"] == \"openai\"\n    assert values[\"LLM_BINDING_API_KEY\"] == \"sk-existing\"\n    assert values[\"EMBEDDING_BINDING_API_KEY\"] == \"sk-existing\"\n    # env-base does not touch server / security / observability values\n    assert values[\"SSL_SET\"] == \"set\"\n    assert values[\"AUTH_ACCOUNTS_SET\"] == \"set\"\n    assert values[\"TOKEN_SECRET_SET\"] == \"set\"\n    assert values[\"LIGHTRAG_API_KEY_SET\"] == \"set\"\n    assert values[\"LANGFUSE_ENABLE_TRACE_SET\"] == \"set\"\n    assert values[\"LANGFUSE_SECRET_KEY_SET\"] == \"set\"\n\n\ndef test_env_base_flow_preserves_existing_provider_bindings_on_rerun(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Rerunning env-base should keep prior LLM and embedding provider settings.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LLM_BINDING=ollama\",\n                \"LLM_MODEL=llama3.2:latest\",\n                \"LLM_BINDING_HOST=http://localhost:11434\",\n                \"EMBEDDING_BINDING=ollama\",\n                \"EMBEDDING_MODEL=nomic-embed-text:latest\",\n                \"EMBEDDING_DIM=768\",\n                \"EMBEDDING_BINDING_HOST=http://localhost:11434\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{ return 1; }}\nconfirm_default_yes() {{ return 1; }}\n\nfinalize_base_setup() {{\n  printf 'LLM_BINDING=%s\\\\n' \"${{ENV_VALUES[LLM_BINDING]}}\"\n  printf 'LLM_MODEL=%s\\\\n' \"${{ENV_VALUES[LLM_MODEL]}}\"\n  printf 'LLM_BINDING_HOST=%s\\\\n' \"${{ENV_VALUES[LLM_BINDING_HOST]}}\"\n  printf 'EMBEDDING_BINDING=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_BINDING]}}\"\n  printf 'EMBEDDING_MODEL=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_MODEL]}}\"\n  printf 'EMBEDDING_DIM=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_DIM]}}\"\n  printf 'EMBEDDING_BINDING_HOST=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_BINDING_HOST]}}\"\n}}\n\nenv_base_flow\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"LLM_BINDING\"] == \"ollama\"\n    assert values[\"LLM_MODEL\"] == \"llama3.2:latest\"\n    assert values[\"LLM_BINDING_HOST\"] == \"http://localhost:11434\"\n    assert values[\"EMBEDDING_BINDING\"] == \"ollama\"\n    assert values[\"EMBEDDING_MODEL\"] == \"nomic-embed-text:latest\"\n    assert values[\"EMBEDDING_DIM\"] == \"768\"\n    assert values[\"EMBEDDING_BINDING_HOST\"] == \"http://localhost:11434\"\n\n\ndef test_env_base_flow_preserves_existing_vllm_embedding_settings_on_rerun(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Rerunning env-base should keep saved local vLLM embedding settings.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o-mini\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"EMBEDDING_BINDING=openai\",\n            \"EMBEDDING_MODEL=BAAI/custom-embed\",\n            \"EMBEDDING_DIM=768\",\n            \"EMBEDDING_BINDING_HOST=http://localhost:9101/v1\",\n            \"EMBEDDING_BINDING_API_KEY=embed-key\",\n            \"LIGHTRAG_SETUP_EMBEDDING_PROVIDER=vllm\",\n            \"VLLM_EMBED_MODEL=BAAI/custom-embed\",\n            \"VLLM_EMBED_PORT=9101\",\n            \"VLLM_EMBED_DEVICE=cpu\",\n        ],\n    )\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{ return 1; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"Run embedding model locally via Docker (vLLM)?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\n\nfinalize_base_setup() {{\n  printf 'EMBEDDING_MODEL=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_MODEL]}}\"\n  printf 'EMBEDDING_DIM=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_DIM]}}\"\n  printf 'EMBEDDING_BINDING_HOST=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_BINDING_HOST]}}\"\n  printf 'VLLM_EMBED_MODEL=%s\\\\n' \"${{ENV_VALUES[VLLM_EMBED_MODEL]}}\"\n  printf 'VLLM_EMBED_PORT=%s\\\\n' \"${{ENV_VALUES[VLLM_EMBED_PORT]}}\"\n}}\n\nenv_base_flow\n\"\"\"\n    )\n\n    assert values[\"EMBEDDING_MODEL\"] == \"BAAI/custom-embed\"\n    assert values[\"EMBEDDING_DIM\"] == \"768\"\n    assert values[\"EMBEDDING_BINDING_HOST\"] == \"http://localhost:9101/v1\"\n    assert values[\"VLLM_EMBED_MODEL\"] == \"BAAI/custom-embed\"\n    assert values[\"VLLM_EMBED_PORT\"] == \"9101\"\n\n\ndef test_env_base_flow_resets_remote_embedding_host_when_switching_to_vllm(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Switching a remote embedding provider to local vLLM should restore localhost.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o-mini\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"EMBEDDING_BINDING=jina\",\n            \"EMBEDDING_MODEL=jina-embeddings-v4\",\n            \"EMBEDDING_DIM=2048\",\n            \"EMBEDDING_BINDING_HOST=https://api.jina.ai/v1/embeddings\",\n            \"EMBEDDING_BINDING_API_KEY=jina-key\",\n            \"VLLM_EMBED_PORT=9101\",\n        ],\n    )\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{\n  case \"$1\" in\n    \"Run embedding model locally via Docker (vLLM)?\") return 0 ;;\n    \"Enable reranking?\") return 1 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_default_yes() {{ return 1; }}\n\nfinalize_base_setup() {{\n  printf 'EMBEDDING_BINDING_HOST=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_BINDING_HOST]}}\"\n  printf 'LIGHTRAG_SETUP_EMBEDDING_PROVIDER=%s\\\\n' \"${{ENV_VALUES[LIGHTRAG_SETUP_EMBEDDING_PROVIDER]}}\"\n}}\n\nenv_base_flow\n\"\"\"\n    )\n\n    assert values[\"EMBEDDING_BINDING_HOST\"] == \"http://localhost:9101/v1\"\n    assert values[\"LIGHTRAG_SETUP_EMBEDDING_PROVIDER\"] == \"vllm\"\n\n\ndef test_env_base_flow_preserves_existing_vllm_embedding_device_on_gpu_host(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Saved vLLM embedding CPU/GPU mode should win over auto-detected GPU defaults.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o-mini\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"EMBEDDING_BINDING=openai\",\n            \"EMBEDDING_MODEL=BAAI/custom-embed\",\n            \"EMBEDDING_DIM=1024\",\n            \"EMBEDDING_BINDING_HOST=http://localhost:9101/v1\",\n            \"EMBEDDING_BINDING_API_KEY=embed-key\",\n            \"LIGHTRAG_SETUP_EMBEDDING_PROVIDER=vllm\",\n            \"VLLM_EMBED_MODEL=BAAI/custom-embed\",\n            \"VLLM_EMBED_PORT=9101\",\n            \"VLLM_EMBED_DEVICE=cpu\",\n        ],\n    )\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nnvidia-smi() {{ return 0; }}\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{ return 1; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"Run embedding model locally via Docker (vLLM)?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\n\nfinalize_base_setup() {{\n  printf 'VLLM_EMBED_DEVICE=%s\\\\n' \"${{ENV_VALUES[VLLM_EMBED_DEVICE]}}\"\n}}\n\nenv_base_flow\n\"\"\"\n    )\n\n    assert values[\"VLLM_EMBED_DEVICE\"] == \"cpu\"\n\n\ndef test_env_base_flow_preserves_existing_vllm_embedding_cuda_device_on_rerun(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Saved vLLM embedding CUDA mode should survive env-base reruns.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o-mini\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"EMBEDDING_BINDING=openai\",\n            \"EMBEDDING_MODEL=BAAI/custom-embed\",\n            \"EMBEDDING_DIM=1024\",\n            \"EMBEDDING_BINDING_HOST=http://localhost:9101/v1\",\n            \"EMBEDDING_BINDING_API_KEY=embed-key\",\n            \"LIGHTRAG_SETUP_EMBEDDING_PROVIDER=vllm\",\n            \"VLLM_EMBED_MODEL=BAAI/custom-embed\",\n            \"VLLM_EMBED_PORT=9101\",\n            \"VLLM_EMBED_DEVICE=cuda\",\n        ],\n    )\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nnvidia-smi() {{ return 0; }}\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{ return 1; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"Run embedding model locally via Docker (vLLM)?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\n\nfinalize_base_setup() {{\n  printf 'VLLM_EMBED_DEVICE=%s\\\\n' \"${{ENV_VALUES[VLLM_EMBED_DEVICE]}}\"\n}}\n\nenv_base_flow\n\"\"\"\n    )\n\n    assert values[\"VLLM_EMBED_DEVICE\"] == \"cuda\"\n\n\ndef test_env_base_flow_defaults_new_vllm_embedding_to_cuda_on_gpu_host(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Fresh local vLLM embedding setup should honor GPU auto-detection.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nnvidia-smi() {{ return 0; }}\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{\n  case \"$1\" in\n    \"Run embedding model locally via Docker (vLLM)?\") return 0 ;;\n    \"Enable reranking?\") return 1 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_default_yes() {{\n  return 1\n}}\n\nfinalize_base_setup() {{\n  printf 'VLLM_EMBED_DEVICE=%s\\\\n' \"${{ENV_VALUES[VLLM_EMBED_DEVICE]}}\"\n}}\n\nenv_base_flow\n\"\"\"\n    )\n\n    assert values[\"VLLM_EMBED_DEVICE\"] == \"cuda\"\n\n\ndef test_env_base_flow_preserves_ssl_config_on_rerun(tmp_path: Path) -> None:\n    \"\"\"env-base should preserve SSL config on rerun, even when old paths are stale.\"\"\"\n\n    cases = {\n        \"stale-paths\": [\n            \"SSL=true\",\n            \"SSL_CERTFILE=/missing/cert.pem\",\n            \"SSL_KEYFILE=/missing/key.pem\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"EMBEDDING_BINDING_API_KEY=sk-existing\",\n        ],\n        \"existing-paths\": [\n            \"SSL=true\",\n            \"SSL_CERTFILE=/some/cert.pem\",\n            \"SSL_KEYFILE=/some/key.pem\",\n        ],\n    }\n\n    for case_name, env_lines in cases.items():\n        case_dir = tmp_path / case_name\n        case_dir.mkdir()\n        write_text_lines(\n            case_dir / \"env.example\",\n            (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n        )\n        write_text_lines(case_dir / \".env\", env_lines)\n\n        run_bash(\n            f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{case_dir}\"\n\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{ return 1; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    *) return 1 ;;\n  esac\n}}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_base_flow\n\"\"\"\n        )\n\n        generated_lines = (case_dir / \".env\").read_text(encoding=\"utf-8\").splitlines()\n        for line in env_lines:\n            assert line in generated_lines\n\n\ndef test_env_base_flow_preserves_existing_compose_ssl_when_env_paths_are_stale(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-base should keep compose SSL wiring when inherited source paths no longer exist.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"SSL=true\",\n            \"SSL_CERTFILE=/missing/cert.pem\",\n            \"SSL_KEYFILE=/missing/key.pem\",\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o-mini\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"EMBEDDING_BINDING=openai\",\n            \"EMBEDDING_MODEL=text-embedding-3-small\",\n            \"EMBEDDING_DIM=1536\",\n            \"EMBEDDING_BINDING_HOST=https://api.openai.com/v1\",\n            \"EMBEDDING_BINDING_API_KEY=sk-existing\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"    environment:\",\n            '      SSL_CERTFILE: \"/app/data/certs/cert.pem\"',\n            '      SSL_KEYFILE: \"/app/data/certs/key.pem\"',\n            \"    volumes:\",\n            '      - \"./data/certs/cert.pem:/app/data/certs/cert.pem:ro\"',\n            '      - \"./data/certs/key.pem:/app/data/certs/key.pem:ro\"',\n        ],\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{ return 1; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"Run LightRAG Server via Docker?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_base_flow\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.final.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert 'SSL_CERTFILE: \"/app/data/certs/cert.pem\"' in generated_compose\n    assert 'SSL_KEYFILE: \"/app/data/certs/key.pem\"' in generated_compose\n    assert \"./data/certs/cert.pem:/app/data/certs/cert.pem:ro\" in generated_compose\n    assert \"./data/certs/key.pem:/app/data/certs/key.pem:ro\" in generated_compose\n\n\ndef test_finalize_base_setup_uses_compose_native_storage_endpoints_on_rerun(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Preserved managed storage services should inject compose-native endpoints on base reruns.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_RUNTIME_TARGET=compose\",\n            \"LIGHTRAG_SETUP_NEO4J_DEPLOYMENT=docker\",\n            \"LIGHTRAG_SETUP_MILVUS_DEPLOYMENT=docker\",\n            \"NEO4J_URI=neo4j://localhost:7687\",\n            \"MILVUS_URI=http://localhost:19530\",\n            \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n            \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n            \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n            \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"  neo4j:\",\n            \"    image: neo4j:latest\",\n            \"  milvus:\",\n            \"    image: milvusdb/milvus:v2.6.11\",\n            \"  milvus-etcd:\",\n            \"    image: quay.io/coreos/etcd:v3.5.16\",\n            \"  milvus-minio:\",\n            \"    image: minio/minio:latest\",\n            \"volumes:\",\n            \"  neo4j_data:\",\n            \"  milvus_data:\",\n            \"  milvus-etcd_data:\",\n            \"  milvus-minio_data:\",\n        ],\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\nshow_summary() {{ :; }}\nconfirm_required_yes_no() {{ return 0; }}\nconfirm_default_yes() {{ return 0; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nfinalize_base_setup\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n\n    assert 'NEO4J_URI: \"neo4j://neo4j:7687\"' in result\n    assert 'MILVUS_URI: \"http://milvus:19530\"' in result\n    assert 'NEO4J_URI: \"neo4j://host.docker.internal:7687\"' not in result\n    assert 'MILVUS_URI: \"http://host.docker.internal:19530\"' not in result\n    assert \"      milvus:\\n        condition: service_healthy\" in result\n    assert \"      milvus-etcd:\\n        condition: service_healthy\" not in result\n    assert \"      milvus-minio:\\n        condition: service_healthy\" not in result\n\n\ndef test_finalize_base_setup_drops_stale_storage_services_missing_from_env_markers(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-base should treat storage Docker state in `.env` as authoritative.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_RUNTIME_TARGET=compose\",\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o-mini\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"EMBEDDING_BINDING=openai\",\n            \"EMBEDDING_MODEL=text-embedding-3-small\",\n            \"EMBEDDING_DIM=1536\",\n            \"EMBEDDING_BINDING_HOST=https://api.openai.com/v1\",\n            \"EMBEDDING_BINDING_API_KEY=sk-existing\",\n            \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n            \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n            \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n            \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"  redis:\",\n            \"    image: redis:latest\",\n            \"  qdrant:\",\n            \"    image: qdrant/qdrant:latest\",\n            \"volumes:\",\n            \"  redis_data:\",\n            \"  qdrant_data:\",\n        ],\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\nshow_summary() {{ :; }}\nconfirm_required_yes_no() {{ return 0; }}\nconfirm_default_yes() {{ return 1; }}\nconfirm_default_no() {{ return 1; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nfinalize_base_setup\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n\n    assert \"  lightrag:\" in result\n    assert \"  redis:\" not in result\n    assert \"  qdrant:\" not in result\n    assert \"redis_data:\" not in result\n    assert \"qdrant_data:\" not in result\n    assert \"LIGHTRAG_RUNTIME_TARGET=compose\" in generated_env\n\n\ndef test_env_base_flow_backs_up_legacy_generated_compose_before_rewrite(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-base should back up the active legacy compose file before regenerating final output.\"\"\"\n\n    legacy_compose = (\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: prod/lightrag\",\n            ]\n        )\n        + \"\\n\"\n    )\n\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_SETUP_PROFILE=production\",\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o-mini\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"EMBEDDING_BINDING=openai\",\n            \"EMBEDDING_MODEL=text-embedding-3-small\",\n            \"EMBEDDING_DIM=1536\",\n            \"EMBEDDING_BINDING_HOST=https://api.openai.com/v1\",\n            \"EMBEDDING_BINDING_API_KEY=sk-existing\",\n        ],\n    )\n    (tmp_path / \"docker-compose.production.yml\").write_text(\n        legacy_compose,\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{\n  case \"$1\" in\n    \"LLM API key: \"|\"Embedding API key: \") printf 'sk-test-key' ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}}\nconfirm_default_no() {{ return 1; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"Run LightRAG Server via Docker?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_base_flow\n\"\"\"\n    )\n\n    assert_single_compose_backup(tmp_path, legacy_compose)\n    assert (tmp_path / \"docker-compose.final.yml\").exists()\n    assert (tmp_path / \"docker-compose.production.yml\").read_text(encoding=\"utf-8\") == (\n        legacy_compose\n    )\n\n\ndef test_env_base_flow_deletes_compose_when_switching_lightrag_to_host(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-base should back up and delete compose when no Docker services remain.\"\"\"\n\n    existing_compose = (\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"  redis:\",\n                \"    image: redis:latest\",\n            ]\n        )\n        + \"\\n\"\n    )\n\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_RUNTIME_TARGET=compose\",\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o-mini\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"EMBEDDING_BINDING=openai\",\n            \"EMBEDDING_MODEL=text-embedding-3-small\",\n            \"EMBEDDING_DIM=1536\",\n            \"EMBEDDING_BINDING_HOST=https://api.openai.com/v1\",\n            \"EMBEDDING_BINDING_API_KEY=sk-existing\",\n        ],\n    )\n    (tmp_path / \"docker-compose.final.yml\").write_text(\n        existing_compose,\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{\n  case \"$1\" in\n    \"LLM API key: \"|\"Embedding API key: \") printf 'sk-test-key' ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}}\nconfirm_default_no() {{\n  case \"$1\" in\n    \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_default_yes() {{ return 1; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_base_flow\n\"\"\"\n    )\n\n    assert_single_compose_backup(tmp_path, existing_compose)\n    assert not (tmp_path / \"docker-compose.final.yml\").exists()\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    assert \"LIGHTRAG_RUNTIME_TARGET=host\" in generated_env\n\n\ndef test_env_base_flow_generates_env_and_compose_files(tmp_path: Path) -> None:\n    \"\"\"env-base should generate `.env` and docker-compose output for hosted and local providers.\"\"\"\n\n    cases = {\n        \"openai\": {\n            \"prompt_choice\": \"prompt_choice() { printf '%s' \\\"$2\\\"; }\",\n            \"prompt_secret\": \"\"\"\nprompt_secret_until_valid_with_default() {\n  case \"$1\" in\n    \"LLM API key: \"|\"Embedding API key: \") printf 'sk-test-key' ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}\n\"\"\",\n            \"env_assertions\": [\n                \"LLM_BINDING=openai\",\n                \"LLM_BINDING_API_KEY=sk-test-key\",\n                \"EMBEDDING_BINDING_API_KEY=sk-test-key\",\n            ],\n        },\n        \"ollama\": {\n            \"prompt_choice\": \"\"\"\nprompt_choice() {\n  case \"$1\" in\n    \"LLM provider\") printf 'ollama' ;;\n    \"Embedding provider\") printf 'ollama' ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}\n\"\"\",\n            \"prompt_secret\": \"prompt_secret_until_valid_with_default() { printf '%s' \\\"$2\\\"; }\",\n            \"env_assertions\": [\n                \"LLM_BINDING=ollama\",\n                \"EMBEDDING_BINDING=ollama\",\n            ],\n        },\n    }\n\n    for case_name, case in cases.items():\n        case_dir = tmp_path / case_name\n        case_dir.mkdir()\n        write_text_lines(\n            case_dir / \"env.example\",\n            (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n        )\n        write_text_lines(\n            case_dir / \"docker-compose.yml\",\n            (REPO_ROOT / \"docker-compose.yml\").read_text(encoding=\"utf-8\").splitlines(),\n        )\n\n        run_bash(\n            f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{case_dir}\"\n\n{case[\"prompt_choice\"]}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\n{case[\"prompt_secret\"]}\nconfirm_default_no() {{\n  case \"$1\" in\n    \"Run embedding model locally via Docker (vLLM)?\") return 1 ;;\n    \"Enable reranking?\") return 1 ;;\n    \"Run LightRAG Server via Docker?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_default_yes() {{\n  case \"$1\" in\n    *) return 1 ;;\n  esac\n}}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_base_flow\n\"\"\"\n        )\n\n        generated_env = (case_dir / \".env\").read_text(encoding=\"utf-8\")\n        generated_compose = (case_dir / \"docker-compose.final.yml\").read_text(\n            encoding=\"utf-8\"\n        )\n\n        assert \"LIGHTRAG_RUNTIME_TARGET=compose\" in generated_env\n        assert \"LIGHTRAG_KV_STORAGE=JsonKVStorage\" in generated_env\n        assert \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\" in generated_env\n        assert \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\" in generated_env\n        assert \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\" in generated_env\n        for expected_line in case[\"env_assertions\"]:\n            assert expected_line in generated_env\n        assert \"services:\" in generated_compose\n        assert \"  lightrag:\" in generated_compose\n        assert \"env_file:\" not in generated_compose\n\n\ndef test_env_base_flow_generates_validatable_env_on_clean_checkout(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Fresh env-base output should include default storage selections and pass validation.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{\n  case \"$1\" in\n    \"LLM API key: \"|\"Embedding API key: \") printf 'sk-test-key' ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}}\nconfirm_default_no() {{ return 1; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    *) return 1 ;;\n  esac\n}}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_base_flow\nvalidate_env_file\n\"\"\"\n    )\n\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    assert \"LIGHTRAG_KV_STORAGE=JsonKVStorage\" in generated_env\n    assert \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\" in generated_env\n    assert \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\" in generated_env\n    assert \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\" in generated_env\n    assert \"LIGHTRAG_RUNTIME_TARGET=host\" in generated_env\n    assert \"LIGHTRAG_SETUP_PROFILE=\" not in generated_env\n\n\ndef test_env_storage_flow_drops_legacy_setup_profile_on_write(tmp_path: Path) -> None:\n    \"\"\"Modular flows should not persist LIGHTRAG_SETUP_PROFILE into regenerated .env files.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_SETUP_PROFILE=production\",\n            \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n            \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n            \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n            \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"JsonKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"NanoVectorDBStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"NetworkXStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"JsonDocStatusStorage\"\n}}\ncollect_database_config() {{ :; }}\nvalidate_required_variables() {{ return 0; }}\nconfirm_default_yes() {{ return 0; }}\nconfirm_default_no() {{ return 1; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    assert \"LIGHTRAG_RUNTIME_TARGET=host\" in generated_env\n    assert \"LIGHTRAG_SETUP_PROFILE=\" not in generated_env\n\n\ndef test_env_base_flow_registers_vllm_rerank_service_for_docker_deployment(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Choosing docker rerank in env-base should add vllm-rerank to DOCKER_SERVICE_SET.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\ncollect_llm_config() {{ :; }}\ncollect_embedding_config() {{ :; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{\n  case \"$1\" in\n    \"Run embedding model locally via Docker (vLLM)?\") return 1 ;;\n    \"Enable reranking?\") return 0 ;;\n    \"Run rerank service locally via Docker?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_default_yes() {{ return 1; }}\n\nfinalize_base_setup() {{\n  if [[ -n \"${{DOCKER_SERVICE_SET[vllm-rerank]+set}}\" ]]; then\n    printf 'HAS_VLLM_SERVICE=yes\\\\n'\n  else\n    printf 'HAS_VLLM_SERVICE=no\\\\n'\n  fi\n}}\n\nenv_base_flow\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"HAS_VLLM_SERVICE\"] == \"yes\"\n\n\ndef test_env_base_flow_preserves_existing_vllm_rerank_settings_on_rerun(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Rerunning env-base should keep saved local vLLM rerank model and port.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o-mini\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"RERANK_BINDING=cohere\",\n            \"RERANK_MODEL=BAAI/custom-rerank\",\n            \"RERANK_BINDING_HOST=http://localhost:9200/rerank\",\n            \"RERANK_BINDING_API_KEY=rerank-key\",\n            \"LIGHTRAG_SETUP_RERANK_PROVIDER=vllm\",\n            \"VLLM_RERANK_MODEL=BAAI/custom-rerank\",\n            \"VLLM_RERANK_PORT=9200\",\n            \"VLLM_RERANK_DEVICE=cpu\",\n        ],\n    )\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{\n  case \"$1\" in\n    \"Enable reranking?\") return 0 ;;\n    \"Run rerank service locally via Docker?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_default_yes() {{ return 1; }}\ncollect_embedding_config() {{ :; }}\n\nfinalize_base_setup() {{\n  printf 'RERANK_MODEL=%s\\\\n' \"${{ENV_VALUES[RERANK_MODEL]}}\"\n  printf 'RERANK_BINDING_HOST=%s\\\\n' \"${{ENV_VALUES[RERANK_BINDING_HOST]}}\"\n  printf 'VLLM_RERANK_MODEL=%s\\\\n' \"${{ENV_VALUES[VLLM_RERANK_MODEL]}}\"\n  printf 'VLLM_RERANK_PORT=%s\\\\n' \"${{ENV_VALUES[VLLM_RERANK_PORT]}}\"\n}}\n\nenv_base_flow\n\"\"\"\n    )\n\n    assert values[\"RERANK_MODEL\"] == \"BAAI/custom-rerank\"\n    assert values[\"RERANK_BINDING_HOST\"] == \"http://localhost:9200/rerank\"\n    assert values[\"VLLM_RERANK_MODEL\"] == \"BAAI/custom-rerank\"\n    assert values[\"VLLM_RERANK_PORT\"] == \"9200\"\n\n\ndef test_env_base_flow_does_not_repeat_rerank_docker_prompt_when_declined(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Declining rerank Docker at the outer prompt should switch to endpoint-based config.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o-mini\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"RERANK_BINDING=cohere\",\n            \"RERANK_MODEL=BAAI/custom-rerank\",\n            \"RERANK_BINDING_HOST=http://localhost:9200/rerank\",\n            \"RERANK_BINDING_API_KEY=rerank-key\",\n            \"LIGHTRAG_SETUP_RERANK_PROVIDER=vllm\",\n            \"VLLM_RERANK_MODEL=BAAI/custom-rerank\",\n            \"VLLM_RERANK_PORT=9200\",\n            \"VLLM_RERANK_DEVICE=cpu\",\n        ],\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nDOCKER_PROMPT_COUNT=0\nRERANK_MODEL_PROMPT_LOG=\"$REPO_ROOT/rerank-model-prompts.log\"\n: > \"$RERANK_MODEL_PROMPT_LOG\"\n\nprompt_choice() {{\n  case \"$1\" in\n    \"vLLM device\")\n      echo \"unexpected vLLM device prompt\" >&2\n      return 91\n      ;;\n    *)\n      printf '%s' \"$2\"\n      ;;\n  esac\n}}\nprompt_with_default() {{\n  case \"$1\" in\n    \"vLLM rerank model\")\n      echo \"unexpected vLLM rerank model prompt\" >&2\n      return 93\n      ;;\n    \"Rerank model\")\n      printf 'hit\\n' >> \"$RERANK_MODEL_PROMPT_LOG\"\n      printf '%s' \"$2\"\n      return 0\n      ;;\n    \"Rerank endpoint\")\n      printf '%s' \"https://rerank.example.internal/rerank\"\n      return 0\n      ;;\n  esac\n  printf '%s' \"$2\"\n}}\nprompt_until_valid() {{\n  case \"$1\" in\n    \"vLLM rerank port\")\n      echo \"unexpected vLLM rerank port prompt\" >&2\n      return 92\n      ;;\n  esac\n  printf '%s' \"$2\"\n}}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{ return 1; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"Enable reranking?\") return 0 ;;\n    \"Run rerank service locally via Docker?\")\n      DOCKER_PROMPT_COUNT=$((DOCKER_PROMPT_COUNT + 1))\n      return 1\n      ;;\n    *) return 1 ;;\n  esac\n}}\ncollect_embedding_config() {{ :; }}\n\nfinalize_base_setup() {{\n  local rerank_model_prompt_count\n  rerank_model_prompt_count=\"$(wc -l < \"$RERANK_MODEL_PROMPT_LOG\" | tr -d '[:space:]')\"\n  printf 'DOCKER_PROMPT_COUNT=%s\\\\n' \"$DOCKER_PROMPT_COUNT\"\n  printf 'RERANK_MODEL_PROMPT_COUNT=%s\\\\n' \"$rerank_model_prompt_count\"\n  printf 'RERANK_BINDING_HOST=%s\\\\n' \"${{ENV_VALUES[RERANK_BINDING_HOST]}}\"\n  printf 'LIGHTRAG_SETUP_RERANK_PROVIDER=%s\\\\n' \"${{ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]:-}}\"\n}}\n\nenv_base_flow\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"DOCKER_PROMPT_COUNT\"] == \"1\"\n    assert values[\"RERANK_MODEL_PROMPT_COUNT\"] == \"1\"\n    assert values[\"RERANK_BINDING_HOST\"] == \"https://rerank.example.internal/rerank\"\n    assert values[\"LIGHTRAG_SETUP_RERANK_PROVIDER\"] == \"\"\n    assert \"vLLM uses the Cohere-compatible rerank API.\" not in output\n\n\ndef test_env_base_flow_comments_rerank_setup_marker_when_switching_off_docker(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Switching rerank from Docker to a non-Docker provider should drop the setup marker.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o-mini\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"RERANK_BINDING=cohere\",\n            \"RERANK_MODEL=BAAI/custom-rerank\",\n            \"RERANK_BINDING_HOST=http://localhost:9200/rerank\",\n            \"RERANK_BINDING_API_KEY=rerank-key\",\n            \"LIGHTRAG_SETUP_RERANK_PROVIDER=vllm\",\n            \"VLLM_RERANK_MODEL=BAAI/custom-rerank\",\n            \"VLLM_RERANK_PORT=9200\",\n            \"VLLM_RERANK_DEVICE=cpu\",\n        ],\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nprompt_choice() {{\n  case \"$1\" in\n    \"Rerank provider\") printf 'cohere' ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}}\nprompt_with_default() {{\n  case \"$1\" in\n    \"Rerank endpoint\") printf '%s' \"https://api.cohere.com/v2/rerank\" ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{\n  case \"$1\" in\n    \"Run embedding model locally via Docker (vLLM)?\") return 1 ;;\n    \"Run rerank service locally via Docker?\") return 1 ;;\n    \"Run LightRAG Server via Docker?\") return 1 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"Enable reranking?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_base_flow\n\"\"\"\n    )\n\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    active_marker_lines = [\n        line\n        for line in generated_env.splitlines()\n        if line.startswith(\"LIGHTRAG_SETUP_RERANK_PROVIDER=\")\n    ]\n\n    assert \"RERANK_BINDING=cohere\" in generated_env\n    assert active_marker_lines == []\n\n\ndef test_env_base_flow_resets_remote_rerank_host_when_switching_to_vllm(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Switching a remote reranker to local vLLM should restore localhost.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o-mini\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"RERANK_BINDING=jina\",\n            \"RERANK_MODEL=jina-reranker-v2-base-multilingual\",\n            \"RERANK_BINDING_HOST=https://api.jina.ai/v1/rerank\",\n            \"RERANK_BINDING_API_KEY=jina-key\",\n            \"VLLM_RERANK_PORT=9200\",\n        ],\n    )\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{\n  case \"$1\" in\n    \"Run rerank service locally via Docker?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"Enable reranking?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\ncollect_embedding_config() {{ :; }}\n\nfinalize_base_setup() {{\n  printf 'RERANK_BINDING=%s\\\\n' \"${{ENV_VALUES[RERANK_BINDING]}}\"\n  printf 'RERANK_BINDING_HOST=%s\\\\n' \"${{ENV_VALUES[RERANK_BINDING_HOST]}}\"\n  printf 'LIGHTRAG_SETUP_RERANK_PROVIDER=%s\\\\n' \"${{ENV_VALUES[LIGHTRAG_SETUP_RERANK_PROVIDER]}}\"\n}}\n\nenv_base_flow\n\"\"\"\n    )\n\n    assert values[\"RERANK_BINDING\"] == \"cohere\"\n    assert values[\"RERANK_BINDING_HOST\"] == \"http://localhost:9200/rerank\"\n    assert values[\"LIGHTRAG_SETUP_RERANK_PROVIDER\"] == \"vllm\"\n\n\ndef test_env_base_flow_preserves_existing_vllm_rerank_device_on_gpu_host(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Saved vLLM rerank CPU/GPU mode should win over auto-detected GPU defaults.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o-mini\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"RERANK_BINDING=cohere\",\n            \"RERANK_MODEL=BAAI/custom-rerank\",\n            \"RERANK_BINDING_HOST=http://localhost:9200/rerank\",\n            \"RERANK_BINDING_API_KEY=rerank-key\",\n            \"LIGHTRAG_SETUP_RERANK_PROVIDER=vllm\",\n            \"VLLM_RERANK_MODEL=BAAI/custom-rerank\",\n            \"VLLM_RERANK_PORT=9200\",\n            \"VLLM_RERANK_DEVICE=cpu\",\n        ],\n    )\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nnvidia-smi() {{ return 0; }}\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{\n  case \"$1\" in\n    \"Enable reranking?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"Run rerank service locally via Docker?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\ncollect_embedding_config() {{ :; }}\n\nfinalize_base_setup() {{\n  printf 'VLLM_RERANK_DEVICE=%s\\\\n' \"${{ENV_VALUES[VLLM_RERANK_DEVICE]}}\"\n}}\n\nenv_base_flow\n\"\"\"\n    )\n\n    assert values[\"VLLM_RERANK_DEVICE\"] == \"cpu\"\n\n\ndef test_env_base_flow_preserves_existing_vllm_rerank_cuda_device_on_rerun(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Saved vLLM rerank CUDA mode should survive env-base reruns.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=openai\",\n            \"LLM_MODEL=gpt-4o-mini\",\n            \"LLM_BINDING_HOST=https://api.openai.com/v1\",\n            \"LLM_BINDING_API_KEY=sk-existing\",\n            \"RERANK_BINDING=cohere\",\n            \"RERANK_MODEL=BAAI/custom-rerank\",\n            \"RERANK_BINDING_HOST=http://localhost:9200/rerank\",\n            \"RERANK_BINDING_API_KEY=rerank-key\",\n            \"LIGHTRAG_SETUP_RERANK_PROVIDER=vllm\",\n            \"VLLM_RERANK_MODEL=BAAI/custom-rerank\",\n            \"VLLM_RERANK_PORT=9200\",\n            \"VLLM_RERANK_DEVICE=cuda\",\n        ],\n    )\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nnvidia-smi() {{ return 0; }}\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_no() {{\n  case \"$1\" in\n    \"Enable reranking?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"Run rerank service locally via Docker?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\ncollect_embedding_config() {{ :; }}\n\nfinalize_base_setup() {{\n  printf 'VLLM_RERANK_DEVICE=%s\\\\n' \"${{ENV_VALUES[VLLM_RERANK_DEVICE]}}\"\n}}\n\nenv_base_flow\n\"\"\"\n    )\n\n    assert values[\"VLLM_RERANK_DEVICE\"] == \"cuda\"\n\n\ndef test_env_storage_flow_applies_selected_storage_backends(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-storage should honor the selected backends without auto-applying a preset.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n            \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n            \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n            \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n            \"LLM_BINDING=ollama\",\n            \"LLM_MODEL=llama3.2:latest\",\n            \"LLM_BINDING_HOST=http://localhost:11434\",\n            \"EMBEDDING_BINDING=ollama\",\n            \"EMBEDDING_MODEL=nomic-embed-text:latest\",\n            \"EMBEDDING_DIM=768\",\n            \"EMBEDDING_BINDING_HOST=http://localhost:11434\",\n        ],\n    )\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"RedisKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"MilvusVectorDBStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"Neo4JStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"RedisDocStatusStorage\"\n}}\ncollect_database_config() {{ :; }}\ncollect_docker_image_tags() {{ :; }}\nfinalize_storage_setup() {{\n  printf 'LIGHTRAG_KV_STORAGE=%s\\\\n' \"${{ENV_VALUES[LIGHTRAG_KV_STORAGE]}}\"\n  printf 'LIGHTRAG_VECTOR_STORAGE=%s\\\\n' \"${{ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]}}\"\n  printf 'LIGHTRAG_GRAPH_STORAGE=%s\\\\n' \"${{ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]}}\"\n  printf 'LIGHTRAG_DOC_STATUS_STORAGE=%s\\\\n' \"${{ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]}}\"\n  printf 'LLM_BINDING=%s\\\\n' \"${{ENV_VALUES[LLM_BINDING]}}\"\n  printf 'EMBEDDING_BINDING=%s\\\\n' \"${{ENV_VALUES[EMBEDDING_BINDING]}}\"\n}}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    assert values[\"LIGHTRAG_KV_STORAGE\"] == \"RedisKVStorage\"\n    assert values[\"LIGHTRAG_VECTOR_STORAGE\"] == \"MilvusVectorDBStorage\"\n    assert values[\"LIGHTRAG_GRAPH_STORAGE\"] == \"Neo4JStorage\"\n    assert values[\"LIGHTRAG_DOC_STATUS_STORAGE\"] == \"RedisDocStatusStorage\"\n    # LLM and embedding settings from existing .env are preserved\n    assert values[\"LLM_BINDING\"] == \"ollama\"\n    assert values[\"EMBEDDING_BINDING\"] == \"ollama\"\n\n\ndef test_env_storage_flow_reuses_saved_storage_docker_default(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Saved storage deployment metadata should drive the next Docker prompt default.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_SETUP_POSTGRES_DEPLOYMENT=docker\",\n            \"LIGHTRAG_KV_STORAGE=PGKVStorage\",\n            \"LIGHTRAG_VECTOR_STORAGE=PGVectorStorage\",\n            \"LIGHTRAG_GRAPH_STORAGE=PGGraphStorage\",\n            \"LIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage\",\n        ],\n    )\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"PGKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"PGVectorStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"PGGraphStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"PGDocStatusStorage\"\n  REQUIRED_DB_TYPES[postgresql]=1\n}}\ncollect_postgres_config() {{\n  printf 'POSTGRES_DEFAULT_DOCKER=%s\\\\n' \"$1\"\n}}\nfinalize_storage_setup() {{ :; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    assert values[\"POSTGRES_DEFAULT_DOCKER\"] == \"yes\"\n\n\ndef test_env_storage_flow_writes_storage_docker_marker_for_selected_service(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Choosing a bundled storage service should persist its deployment marker in `.env`.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=ollama\",\n            \"EMBEDDING_BINDING=ollama\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    (tmp_path / \"docker-compose.yml\").write_text(\n        (REPO_ROOT / \"docker-compose.yml\").read_text(encoding=\"utf-8\"),\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"PGKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"PGVectorStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"PGGraphStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"PGDocStatusStorage\"\n  REQUIRED_DB_TYPES[postgresql]=1\n}}\ncollect_postgres_config() {{\n  add_docker_service \"postgres\"\n  ENV_VALUES[POSTGRES_HOST]=\"localhost\"\n  ENV_VALUES[POSTGRES_PORT]=\"5432\"\n  ENV_VALUES[POSTGRES_USER]=\"lightrag\"\n  ENV_VALUES[POSTGRES_PASSWORD]=\"secret\"\n  ENV_VALUES[POSTGRES_DATABASE]=\"lightrag\"\n}}\nvalidate_required_variables() {{ return 0; }}\nvalidate_mongo_vector_storage_config() {{ return 0; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\") return 1 ;;\n    *) return 0 ;;\n  esac\n}}\nconfirm_default_no() {{ return 1; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    assert any(\n        line == \"LIGHTRAG_SETUP_POSTGRES_DEPLOYMENT=docker\"\n        for line in generated_env.splitlines()\n    )\n    assert \"LIGHTRAG_RUNTIME_TARGET=compose\" in generated_env\n\n\ndef test_env_storage_flow_writes_opensearch_docker_marker_for_selected_service(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Choosing bundled OpenSearch should persist its deployment marker in `.env`.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=ollama\",\n            \"EMBEDDING_BINDING=ollama\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    (tmp_path / \"docker-compose.yml\").write_text(\n        (REPO_ROOT / \"docker-compose.yml\").read_text(encoding=\"utf-8\"),\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"OpenSearchKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"OpenSearchVectorDBStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"OpenSearchGraphStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"OpenSearchDocStatusStorage\"\n  REQUIRED_DB_TYPES[opensearch]=1\n}}\ncollect_opensearch_config() {{\n  add_docker_service \"opensearch\"\n  ENV_VALUES[OPENSEARCH_HOSTS]=\"localhost:9200\"\n  ENV_VALUES[OPENSEARCH_USER]=\"admin\"\n  ENV_VALUES[OPENSEARCH_PASSWORD]=\"secret\"\n  ENV_VALUES[OPENSEARCH_USE_SSL]=\"true\"\n  ENV_VALUES[OPENSEARCH_VERIFY_CERTS]=\"false\"\n}}\nvalidate_required_variables() {{ return 0; }}\nvalidate_mongo_vector_storage_config() {{ return 0; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\") return 1 ;;\n    *) return 0 ;;\n  esac\n}}\nconfirm_default_no() {{ return 1; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    assert any(\n        line == \"LIGHTRAG_SETUP_OPENSEARCH_DEPLOYMENT=docker\"\n        for line in generated_env.splitlines()\n    )\n    assert \"LIGHTRAG_RUNTIME_TARGET=compose\" in generated_env\n\n\ndef test_env_storage_flow_removes_storage_docker_marker_when_switching_to_host(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Choosing a host-managed storage backend should clear a previously saved Docker marker.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_SETUP_POSTGRES_DEPLOYMENT=docker\",\n            \"LIGHTRAG_KV_STORAGE=PGKVStorage\",\n            \"LIGHTRAG_VECTOR_STORAGE=PGVectorStorage\",\n            \"LIGHTRAG_GRAPH_STORAGE=PGGraphStorage\",\n            \"LIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"PGKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"PGVectorStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"PGGraphStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"PGDocStatusStorage\"\n  REQUIRED_DB_TYPES[postgresql]=1\n}}\ncollect_postgres_config() {{\n  ENV_VALUES[POSTGRES_HOST]=\"localhost\"\n  ENV_VALUES[POSTGRES_PORT]=\"5432\"\n  ENV_VALUES[POSTGRES_USER]=\"lightrag\"\n  ENV_VALUES[POSTGRES_PASSWORD]=\"secret\"\n  ENV_VALUES[POSTGRES_DATABASE]=\"lightrag\"\n}}\nvalidate_required_variables() {{ return 0; }}\nvalidate_mongo_vector_storage_config() {{ return 0; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\") return 1 ;;\n    *) return 0 ;;\n  esac\n}}\nconfirm_default_no() {{ return 1; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    assert not any(\n        line.startswith(\"LIGHTRAG_SETUP_POSTGRES_DEPLOYMENT=\")\n        for line in generated_env.splitlines()\n    )\n    assert \"LIGHTRAG_RUNTIME_TARGET=host\" in generated_env\n\n\ndef test_env_storage_flow_clears_unused_storage_docker_markers(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Markers for databases no longer required by the selected backends should be removed.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_SETUP_POSTGRES_DEPLOYMENT=docker\",\n            \"LIGHTRAG_KV_STORAGE=PGKVStorage\",\n            \"LIGHTRAG_VECTOR_STORAGE=PGVectorStorage\",\n            \"LIGHTRAG_GRAPH_STORAGE=PGGraphStorage\",\n            \"LIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"JsonKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"NanoVectorDBStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"NetworkXStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"JsonDocStatusStorage\"\n}}\ncollect_database_config() {{ :; }}\nvalidate_required_variables() {{ return 0; }}\nvalidate_mongo_vector_storage_config() {{ return 0; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\") return 1 ;;\n    *) return 0 ;;\n  esac\n}}\nconfirm_default_no() {{ return 1; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    assert not any(\n        line.startswith(\"LIGHTRAG_SETUP_POSTGRES_DEPLOYMENT=\")\n        for line in generated_env.splitlines()\n    )\n    assert \"LIGHTRAG_KV_STORAGE=JsonKVStorage\" in generated_env\n\n\ndef test_env_storage_flow_generates_env_and_compose_files(tmp_path: Path) -> None:\n    \"\"\"env-storage should write updated .env and a docker-compose.final.yml.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LLM_BINDING=ollama\",\n                \"EMBEDDING_BINDING=ollama\",\n                \"AUTH_ACCOUNTS=admin:secret\",\n                \"TOKEN_SECRET=jwt-secret\",\n                \"WHITELIST_PATHS=/health\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\"),\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"docker-compose.yml\").write_text(\n        (REPO_ROOT / \"docker-compose.yml\").read_text(encoding=\"utf-8\"),\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"PGKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"MilvusVectorDBStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"Neo4JStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"PGDocStatusStorage\"\n  add_docker_service \"postgres\"\n  add_docker_service \"neo4j\"\n}}\ncollect_database_config() {{ :; }}\ncollect_docker_image_tags() {{ :; }}\nvalidate_required_variables() {{ return 0; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    *) return 1 ;;\n  esac\n}}\nconfirm_default_no() {{ return 1; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    generated_compose = (tmp_path / \"docker-compose.final.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert \"LIGHTRAG_KV_STORAGE=PGKVStorage\" in generated_env\n    assert \"LIGHTRAG_GRAPH_STORAGE=Neo4JStorage\" in generated_env\n    assert \"LLM_BINDING=ollama\" in generated_env\n    assert \"services:\" in generated_compose\n    assert \"  lightrag:\" in generated_compose\n    assert \"env_file:\" not in generated_compose\n\n\ndef test_env_storage_flow_uses_rag_defaults_for_empty_postgres_docker_credentials(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-storage should write bundled postgres credentials when old `.env` creds are empty.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LLM_BINDING=ollama\",\n                \"EMBEDDING_BINDING=ollama\",\n                \"AUTH_ACCOUNTS=admin:secret\",\n                \"TOKEN_SECRET=jwt-secret\",\n                \"WHITELIST_PATHS=/health\",\n                \"POSTGRES_USER=\",\n                \"POSTGRES_PASSWORD=\",\n                \"POSTGRES_DATABASE=\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\"),\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"docker-compose.yml\").write_text(\n        (REPO_ROOT / \"docker-compose.yml\").read_text(encoding=\"utf-8\"),\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nPROMPT_LOG_FILE=\"$(mktemp)\"\n: > \"$PROMPT_LOG_FILE\"\n\nselect_storage_backends() {{\n  REQUIRED_DB_TYPES[postgresql]=1\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"PGKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"PGVectorStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"PGGraphStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"PGDocStatusStorage\"\n}}\nconfirm_default_no() {{\n  if [[ \"$1\" == \"Run PostgreSQL locally via Docker?\" ]]; then\n    return 0\n  fi\n  return 1\n}}\nconfirm_default_yes() {{ return 0; }}\nconfirm_required_yes_no() {{ return 0; }}\nprompt_with_default() {{\n  printf '%s\\\\n' \"$1\" >> \"$PROMPT_LOG_FILE\"\n  case \"$1\" in\n    \"PostgreSQL host\") printf 'localhost' ;;\n    *) printf '%s' \"$2\" ;;\n  esac\n}}\nprompt_secret_with_default() {{\n  printf 'secret:%s\\\\n' \"$1\" >> \"$PROMPT_LOG_FILE\"\n  printf '%s' \"$2\"\n}}\n\nenv_storage_flow\n\nprintf 'PROMPT_LOG=%s\\\\n' \"$(paste -sd '|' \"$PROMPT_LOG_FILE\")\"\n\"\"\",\n        cwd=tmp_path,\n    )\n\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    generated_compose = (tmp_path / \"docker-compose.final.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert \"POSTGRES_USER=rag\" in generated_env\n    assert \"POSTGRES_PASSWORD=rag\" in generated_env\n    assert \"POSTGRES_DATABASE=rag\" in generated_env\n    assert 'POSTGRES_USER: \"rag\"' in generated_compose\n    assert 'POSTGRES_PASSWORD: \"rag\"' in generated_compose\n    assert 'POSTGRES_DB: \"rag\"' in generated_compose\n\n\n@pytest.mark.parametrize(\n    (\"changed_key\", \"changed_value\", \"expected_rewrite\"),\n    [\n        (\"NEO4J_PASSWORD\", \"updated-password\", \"no\"),\n        (\"NEO4J_DATABASE\", \"updated-database\", \"yes\"),\n    ],\n    ids=[\"neo4j-password-does-not-rewrite\", \"neo4j-database-rewrites\"],\n)\ndef test_configure_storage_compose_rewrites_only_rewrites_neo4j_on_database_change(\n    changed_key: str,\n    changed_value: str,\n    expected_rewrite: str,\n) -> None:\n    \"\"\"Neo4j service rewrites should be driven by database changes, not credentials.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nEXISTING_MANAGED_ROOT_SERVICE_SET[neo4j]=1\nDOCKER_SERVICE_SET[neo4j]=1\nORIGINAL_ENV_VALUES[NEO4J_PASSWORD]=\"original-password\"\nORIGINAL_ENV_VALUES[NEO4J_DATABASE]=\"neo4j\"\nENV_VALUES[NEO4J_PASSWORD]=\"original-password\"\nENV_VALUES[NEO4J_DATABASE]=\"neo4j\"\nENV_VALUES[{changed_key}]=\"{changed_value}\"\n\nconfigure_storage_compose_rewrites\n\nif [[ -n \"${{COMPOSE_REWRITE_SERVICE_SET[neo4j]+set}}\" ]]; then\n  printf 'REWRITE=yes\\\\n'\nelse\n  printf 'REWRITE=no\\\\n'\nfi\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"REWRITE\"] == expected_rewrite\n\n\n@pytest.mark.parametrize(\n    (\"changed_key\", \"changed_value\", \"expected_rewrite\"),\n    [\n        (\"POSTGRES_HOST\", \"db.example.com\", \"no\"),\n        (\"POSTGRES_PORT\", \"6543\", \"no\"),\n        (\"POSTGRES_USER\", \"updated-user\", \"yes\"),\n        (\"POSTGRES_PASSWORD\", \"updated-password\", \"yes\"),\n        (\"POSTGRES_DATABASE\", \"updated-database\", \"yes\"),\n    ],\n    ids=[\n        \"postgres-host-does-not-rewrite\",\n        \"postgres-port-does-not-rewrite\",\n        \"postgres-user-rewrites\",\n        \"postgres-password-rewrites\",\n        \"postgres-database-rewrites\",\n    ],\n)\ndef test_configure_storage_compose_rewrites_only_rewrites_postgres_for_service_env_changes(\n    changed_key: str,\n    changed_value: str,\n    expected_rewrite: str,\n) -> None:\n    \"\"\"Postgres service rewrites should only follow changes emitted into the postgres block.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nEXISTING_MANAGED_ROOT_SERVICE_SET[postgres]=1\nDOCKER_SERVICE_SET[postgres]=1\nORIGINAL_ENV_VALUES[POSTGRES_HOST]=\"localhost\"\nORIGINAL_ENV_VALUES[POSTGRES_PORT]=\"5432\"\nORIGINAL_ENV_VALUES[POSTGRES_USER]=\"rag\"\nORIGINAL_ENV_VALUES[POSTGRES_PASSWORD]=\"rag\"\nORIGINAL_ENV_VALUES[POSTGRES_DATABASE]=\"lightrag\"\nENV_VALUES[POSTGRES_HOST]=\"localhost\"\nENV_VALUES[POSTGRES_PORT]=\"5432\"\nENV_VALUES[POSTGRES_USER]=\"rag\"\nENV_VALUES[POSTGRES_PASSWORD]=\"rag\"\nENV_VALUES[POSTGRES_DATABASE]=\"lightrag\"\nENV_VALUES[{changed_key}]=\"{changed_value}\"\n\nconfigure_storage_compose_rewrites\n\nif [[ -n \"${{COMPOSE_REWRITE_SERVICE_SET[postgres]+set}}\" ]]; then\n  printf 'REWRITE=yes\\\\n'\nelse\n  printf 'REWRITE=no\\\\n'\nfi\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"REWRITE\"] == expected_rewrite\n\n\ndef test_env_storage_flow_backs_up_existing_compose_before_rewrite(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-storage should back up the current compose file before rewriting it.\"\"\"\n\n    existing_compose = (\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"    environment:\",\n                '      LEGACY_SETTING: \"1\"',\n                \"  postgres:\",\n                \"    image: gzdaniel/postgres-for-rag:16.6\",\n            ]\n        )\n        + \"\\n\"\n    )\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=openai\",\n            \"EMBEDDING_BINDING=openai\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    (tmp_path / \"docker-compose.final.yml\").write_text(\n        existing_compose,\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"JsonKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"NanoVectorDBStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"NetworkXStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"JsonDocStatusStorage\"\n}}\ncollect_database_config() {{ :; }}\nvalidate_required_variables() {{ return 0; }}\nvalidate_mongo_vector_storage_config() {{ return 0; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\") return 1 ;;\n    *) return 0 ;;\n  esac\n}}\nconfirm_default_no() {{ return 1; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    assert_single_compose_backup(tmp_path, existing_compose)\n    assert (tmp_path / \"docker-compose.final.yml\").exists()\n\n\ndef test_env_storage_flow_keeps_compose_mode_for_user_sidecars(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-storage should keep LightRAG in Docker when user sidecars are present.\"\"\"\n\n    existing_compose = (\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"    environment:\",\n                '      LEGACY_SETTING: \"1\"',\n                \"  sidecar:\",\n                \"    image: busybox\",\n            ]\n        )\n        + \"\\n\"\n    )\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=openai\",\n            \"EMBEDDING_BINDING=openai\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    (tmp_path / \"docker-compose.final.yml\").write_text(\n        existing_compose,\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"JsonKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"NanoVectorDBStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"NetworkXStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"JsonDocStatusStorage\"\n}}\ncollect_database_config() {{ :; }}\nvalidate_required_variables() {{ return 0; }}\nvalidate_mongo_vector_storage_config() {{ return 0; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nconfirm_default_yes() {{ return 0; }}\nconfirm_default_no() {{ return 1; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n\n    assert_single_compose_backup(tmp_path, existing_compose)\n    assert \"  lightrag:\" in result\n    assert \"  sidecar:\" in result\n    assert \"LIGHTRAG_RUNTIME_TARGET=compose\" in generated_env\n\n\ndef test_env_storage_flow_clears_mongodb_docker_marker_for_atlas_vector_storage(\n    tmp_path: Path,\n) -> None:\n    \"\"\"MongoDB Atlas-only vector storage should not preserve a local Docker deployment marker.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_SETUP_MONGODB_DEPLOYMENT=docker\",\n            \"LIGHTRAG_KV_STORAGE=MongoKVStorage\",\n            \"LIGHTRAG_VECTOR_STORAGE=MongoVectorDBStorage\",\n            \"LIGHTRAG_GRAPH_STORAGE=MongoGraphStorage\",\n            \"LIGHTRAG_DOC_STATUS_STORAGE=MongoDocStatusStorage\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"MongoKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"MongoVectorDBStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"MongoGraphStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"MongoDocStatusStorage\"\n  REQUIRED_DB_TYPES[mongodb]=1\n}}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nvalidate_required_variables() {{ return 0; }}\nvalidate_mongo_vector_storage_config() {{ return 0; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nconfirm_default_yes() {{ return 0; }}\nconfirm_default_no() {{ return 1; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    assert not any(\n        line.startswith(\"LIGHTRAG_SETUP_MONGODB_DEPLOYMENT=\")\n        for line in generated_env.splitlines()\n    )\n    assert \"MONGO_URI=mongodb+srv://cluster.example.mongodb.net/\" in generated_env\n\n\ndef test_env_storage_flow_preserves_existing_compose_ssl_when_env_paths_are_stale(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-storage should keep compose SSL wiring when inherited source paths no longer exist.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"SSL=true\",\n            \"SSL_CERTFILE=/missing/cert.pem\",\n            \"SSL_KEYFILE=/missing/key.pem\",\n            \"LLM_BINDING=openai\",\n            \"EMBEDDING_BINDING=openai\",\n            \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n            \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n            \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n            \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"    environment:\",\n            '      SSL_CERTFILE: \"/app/data/certs/cert.pem\"',\n            '      SSL_KEYFILE: \"/app/data/certs/key.pem\"',\n            \"    volumes:\",\n            '      - \"./data/certs/cert.pem:/app/data/certs/cert.pem:ro\"',\n            '      - \"./data/certs/key.pem:/app/data/certs/key.pem:ro\"',\n        ],\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"JsonKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"NanoVectorDBStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"NetworkXStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"JsonDocStatusStorage\"\n}}\ncollect_database_config() {{ :; }}\nvalidate_required_variables() {{ return 0; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\") return 1 ;;\n    *) return 0 ;;\n  esac\n}}\nconfirm_default_no() {{ return 1; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.final.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert 'SSL_CERTFILE: \"/app/data/certs/cert.pem\"' in generated_compose\n    assert 'SSL_KEYFILE: \"/app/data/certs/key.pem\"' in generated_compose\n    assert \"./data/certs/cert.pem:/app/data/certs/cert.pem:ro\" in generated_compose\n    assert \"./data/certs/key.pem:/app/data/certs/key.pem:ro\" in generated_compose\n\n\ndef test_env_server_flow_preserves_existing_compose_ssl_when_env_paths_are_stale(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-server should keep compose SSL wiring and variable-based port publishing.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"SSL=true\",\n            \"SSL_CERTFILE=/missing/cert.pem\",\n            \"SSL_KEYFILE=/missing/key.pem\",\n            \"HOST=0.0.0.0\",\n            \"PORT=9621\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"    environment:\",\n            '      SSL_CERTFILE: \"/app/data/certs/cert.pem\"',\n            '      SSL_KEYFILE: \"/app/data/certs/key.pem\"',\n            \"    volumes:\",\n            '      - \"./data/certs/cert.pem:/app/data/certs/cert.pem:ro\"',\n            '      - \"./data/certs/key.pem:/app/data/certs/key.pem:ro\"',\n        ],\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\ncollect_server_config() {{\n  ENV_VALUES[HOST]=\"0.0.0.0\"\n  ENV_VALUES[PORT]=\"8080\"\n}}\ncollect_security_config() {{ :; }}\ncollect_ssl_config() {{ :; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\") return 1 ;;\n    *) return 0 ;;\n  esac\n}}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_server_flow\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.final.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert 'SSL_CERTFILE: \"/app/data/certs/cert.pem\"' in generated_compose\n    assert 'SSL_KEYFILE: \"/app/data/certs/key.pem\"' in generated_compose\n    assert \"./data/certs/cert.pem:/app/data/certs/cert.pem:ro\" in generated_compose\n    assert \"./data/certs/key.pem:/app/data/certs/key.pem:ro\" in generated_compose\n    assert 'PORT: \"9621\"' in generated_compose\n    assert '      - \"${HOST:-0.0.0.0}:${PORT:-9621}:9621\"' in generated_compose\n\n\ndef test_env_server_flow_backs_up_existing_compose_before_rewrite(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-server should back up the current compose file before rewriting it.\"\"\"\n\n    existing_compose = (\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"    environment:\",\n                '      PORT: \"9621\"',\n            ]\n        )\n        + \"\\n\"\n    )\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"HOST=0.0.0.0\",\n            \"PORT=9621\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    (tmp_path / \"docker-compose.final.yml\").write_text(\n        existing_compose,\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\ncollect_server_config() {{\n  ENV_VALUES[HOST]=\"0.0.0.0\"\n  ENV_VALUES[PORT]=\"8080\"\n}}\ncollect_security_config() {{ :; }}\ncollect_ssl_config() {{ :; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nvalidate_security_config() {{ return 0; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\") return 1 ;;\n    *) return 0 ;;\n  esac\n}}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_server_flow\n\"\"\"\n    )\n\n    assert_single_compose_backup(tmp_path, existing_compose)\n    assert (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\") != (\n        existing_compose\n    )\n\n\ndef test_switching_to_non_docker_storage_removes_stale_services_from_compose(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-storage must strip managed storage services while preserving user sidecars.\"\"\"\n\n    # Existing compose with postgres and neo4j Docker services.\n    compose_file = tmp_path / \"docker-compose.final.yml\"\n    compose_file.write_text(\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"  postgres:\",\n                \"    image: gzdaniel/postgres-for-rag:16.6\",\n                \"  neo4j:\",\n                \"    image: neo4j:5.26.21-community\",\n                \"  sidecar:\",\n                \"    image: busybox\",\n                '    command: [\"sleep\", \"infinity\"]',\n                \"    volumes:\",\n                \"      - sidecar_data:/data\",\n                \"volumes:\",\n                \"  postgres_data:\",\n                \"  neo4j_data:\",\n                \"  sidecar_data:\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\"LLM_BINDING=openai\\n\", encoding=\"utf-8\")\n    (tmp_path / \"env.example\").write_text(\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\"),\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"docker-compose.yml\").write_text(\n        (REPO_ROOT / \"docker-compose.yml\").read_text(encoding=\"utf-8\"),\n        encoding=\"utf-8\",\n    )\n\n    # User switches to non-Docker backends: DOCKER_SERVICES stays empty.\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"JsonKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"NanoVectorDBStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"NetworkXStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"JsonDocStatusStorage\"\n}}\ncollect_database_config() {{ :; }}\ncollect_docker_image_tags() {{ :; }}\nvalidate_required_variables() {{ return 0; }}\nconfirm_default_yes() {{ return 0; }}\nconfirm_default_no() {{ return 1; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    result = compose_file.read_text(encoding=\"utf-8\")\n    # Stale storage services must be gone.\n    assert \"postgres:\" not in result\n    assert \"neo4j:\" not in result\n    assert \"postgres_data:\" not in result\n    assert \"neo4j_data:\" not in result\n    # lightrag and user services must be preserved.\n    assert \"  lightrag:\" in result\n    assert \"  sidecar:\" in result\n    assert \"sidecar_data:\" in result\n\n\ndef test_env_storage_flow_drops_stale_vllm_services_missing_from_env_markers(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-storage should remove stale vLLM services unless `.env` still marks them as Docker-managed.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_RUNTIME_TARGET=compose\",\n            \"LLM_BINDING=openai\",\n            \"EMBEDDING_BINDING=openai\",\n            \"RERANK_BINDING=cohere\",\n            \"LIGHTRAG_SETUP_RERANK_PROVIDER=cohere\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    (tmp_path / \"docker-compose.final.yml\").write_text(\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"  vllm-embed:\",\n                \"    image: vllm/vllm-openai:latest\",\n                \"  vllm-rerank:\",\n                \"    image: vllm/vllm-openai:latest\",\n                \"volumes:\",\n                \"  vllm_embed_cache:\",\n                \"  vllm_rerank_cache:\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"JsonKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"NanoVectorDBStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"NetworkXStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"JsonDocStatusStorage\"\n}}\ncollect_database_config() {{ :; }}\nvalidate_required_variables() {{ return 0; }}\nvalidate_mongo_vector_storage_config() {{ return 0; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nconfirm_default_yes() {{ return 1; }}\nconfirm_default_no() {{ return 1; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n\n    assert \"  vllm-embed:\" not in result\n    assert \"  vllm-rerank:\" not in result\n    assert \"vllm_embed_cache:\" not in result\n    assert \"vllm_rerank_cache:\" not in result\n    assert \"LIGHTRAG_RUNTIME_TARGET=compose\" in generated_env\n\n\ndef test_env_storage_flow_preserves_vllm_services_marked_in_env(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-storage should restore vLLM services from `.env` markers even without old compose entries.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_RUNTIME_TARGET=compose\",\n            \"LLM_BINDING=openai\",\n            \"EMBEDDING_BINDING=openai\",\n            \"EMBEDDING_BINDING_HOST=http://localhost:8001/v1\",\n            \"LIGHTRAG_SETUP_EMBEDDING_PROVIDER=vllm\",\n            \"VLLM_EMBED_MODEL=BAAI/bge-m3\",\n            \"VLLM_EMBED_PORT=8001\",\n            \"VLLM_EMBED_DEVICE=cpu\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n        ],\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"JsonKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"NanoVectorDBStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"NetworkXStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"JsonDocStatusStorage\"\n}}\ncollect_database_config() {{ :; }}\nvalidate_required_variables() {{ return 0; }}\nvalidate_mongo_vector_storage_config() {{ return 0; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nconfirm_default_yes() {{ return 1; }}\nconfirm_default_no() {{ return 1; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n\n    assert \"  vllm-embed:\" in result\n    assert \"LIGHTRAG_RUNTIME_TARGET=compose\" in generated_env\n\n\ndef test_env_storage_flow_deletes_compose_when_switching_lightrag_to_host(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-storage should back up and delete compose when no Docker services remain.\"\"\"\n\n    existing_compose = (\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"  redis:\",\n                \"    image: redis:latest\",\n            ]\n        )\n        + \"\\n\"\n    )\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_RUNTIME_TARGET=compose\",\n            \"LLM_BINDING=openai\",\n            \"EMBEDDING_BINDING=openai\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    (tmp_path / \"docker-compose.final.yml\").write_text(\n        existing_compose,\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nselect_storage_backends() {{\n  ENV_VALUES[LIGHTRAG_KV_STORAGE]=\"JsonKVStorage\"\n  ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"NanoVectorDBStorage\"\n  ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"NetworkXStorage\"\n  ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"JsonDocStatusStorage\"\n}}\ncollect_database_config() {{ :; }}\nvalidate_required_variables() {{ return 0; }}\nvalidate_mongo_vector_storage_config() {{ return 0; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nconfirm_default_yes() {{ return 1; }}\nconfirm_default_no() {{\n  case \"$1\" in\n    \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_storage_flow\n\"\"\"\n    )\n\n    assert_single_compose_backup(tmp_path, existing_compose)\n    assert not (tmp_path / \"docker-compose.final.yml\").exists()\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    assert \"LIGHTRAG_RUNTIME_TARGET=host\" in generated_env\n\n\ndef test_generate_docker_compose_uses_template_images_even_with_old_env_overrides(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Managed services should be regenerated from templates instead of legacy image overrides.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"POSTGRES_IMAGE=registry.example.com/postgres-for-rag:patched\",\n            \"VLLM_EMBED_IMAGE_TAG=patched\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\nadd_docker_service postgres\nadd_docker_service vllm-embed\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n\n    assert \"image: gzdaniel/postgres-for-rag:16.6\" in result\n    assert \"image: vllm/vllm-openai-cpu:latest\" in result\n    assert \"registry.example.com/postgres-for-rag:patched\" not in result\n    assert \"vllm/vllm-openai-cpu:patched\" not in result\n\n\ndef test_generate_docker_compose_preserves_long_form_named_sidecar_volumes(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Managed-service regeneration must not misparse preserved long-form named volumes.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LLM_BINDING=openai\",\n            \"EMBEDDING_BINDING=openai\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"  sidecar:\",\n            \"    image: busybox\",\n            '    command: [\"sleep\", \"infinity\"]',\n            \"    volumes:\",\n            \"      - source: sidecar_data\",\n            \"        target: /data\",\n            \"        type: volume\",\n            \"volumes:\",\n            \"  sidecar_data:\",\n        ],\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\nadd_docker_service postgres\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n\n    assert \"  sidecar_data:\" in result\n    assert \"\\n  source:\\n\" not in result\n\n\ndef test_collect_milvus_config_defaults_to_existing_database_name() -> None:\n    \"\"\"Milvus database prompt should preserve the documented default database.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nconfirm_default_yes() {{ return 1; }}\nprompt_with_default() {{\n  printf '%s' \"$2\"\n}}\nprompt_until_valid() {{\n  printf '%s' \"$2\"\n}}\n\ncollect_milvus_config no\n\nprintf 'MILVUS_DB_NAME=%s\\\\n' \"${{ENV_VALUES[MILVUS_DB_NAME]}}\"\n\"\"\"\n    )\n\n    assert values[\"MILVUS_DB_NAME\"] == \"lightrag\"\n\n\ndef test_collect_milvus_config_initializes_minio_credentials_for_local_docker(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Local Docker Milvus should write default MinIO credentials when none exist yet.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_example = tmp_path / \"env.example\"\n    env_example.write_text((REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\"))\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nconfirm_default_yes() {{ return 0; }}\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{\n  printf '%s' \"$2\"\n}}\nprompt_until_valid() {{\n  printf '%s' \"$2\"\n}}\n\ncollect_milvus_config yes\ngenerate_env_file \"$REPO_ROOT/env.example\" \"$REPO_ROOT/.env\"\n\"\"\",\n        cwd=tmp_path,\n    )\n\n    env_text = env_file.read_text(encoding=\"utf-8\")\n    assert \"MINIO_ACCESS_KEY_ID=minioadmin\" in env_text\n    assert \"MINIO_SECRET_ACCESS_KEY=minioadmin\" in env_text\n\n\n@pytest.mark.parametrize(\n    (\"setup_lines\", \"nvidia_impl\", \"expected_device\"),\n    [\n        (\n            ['ENV_VALUES[MILVUS_DEVICE]=\"cpu\"'],\n            \"nvidia-smi() { return 0; }\",\n            \"cpu\",\n        ),\n        (\n            ['ENV_VALUES[MILVUS_DEVICE]=\"cuda\"'],\n            \"nvidia-smi() { return 1; }\",\n            \"cuda\",\n        ),\n        (\n            [],\n            \"nvidia-smi() { return 0; }\",\n            \"cuda\",\n        ),\n    ],\n    ids=[\"saved-cpu-wins\", \"saved-cuda-wins\", \"gpu-host-defaults-to-cuda\"],\n)\ndef test_collect_milvus_config_resolves_device_default_for_local_docker(\n    setup_lines: list[str],\n    nvidia_impl: str,\n    expected_device: str,\n) -> None:\n    \"\"\"Milvus device defaults should prefer saved state and otherwise use host CUDA detection.\"\"\"\n\n    setup_block = \"\\n\".join(setup_lines)\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\n{setup_block}\n{nvidia_impl}\n\nconfirm_default_yes() {{ return 0; }}\nprompt_choice() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\n\ncollect_milvus_config yes\n\nprintf 'MILVUS_DEVICE=%s\\\\n' \"${{ENV_VALUES[MILVUS_DEVICE]}}\"\n\"\"\"\n    )\n\n    assert values[\"MILVUS_DEVICE\"] == expected_device\n\n\n@pytest.mark.parametrize(\n    (\"env_key\", \"env_value\", \"expected_value\"),\n    [\n        (\"POSTGRES_HOST\", \"127.0.0.1\", \"host.docker.internal\"),\n        (\"REDIS_URI\", \"redis://localhost:6379\", \"redis://host.docker.internal:6379\"),\n        (\n            \"MONGO_URI\",\n            \"mongodb://127.0.0.1:27017/\",\n            \"mongodb://host.docker.internal:27017/\",\n        ),\n        (\n            \"MONGO_URI\",\n            \"mongodb://root:root@localhost:27017/\",\n            \"mongodb://root:root@host.docker.internal:27017/\",\n        ),\n        (\"NEO4J_URI\", \"neo4j://localhost:7687\", \"neo4j://host.docker.internal:7687\"),\n        (\"MILVUS_URI\", \"http://localhost:19530\", \"http://host.docker.internal:19530\"),\n        (\"QDRANT_URL\", \"http://127.0.0.1:6333\", \"http://host.docker.internal:6333\"),\n        (\"MEMGRAPH_URI\", \"bolt://localhost:7687\", \"bolt://host.docker.internal:7687\"),\n        (\"POSTGRES_HOST\", \"0.0.0.0\", \"host.docker.internal\"),\n        (\n            \"LLM_BINDING_HOST\",\n            \"http://0.0.0.0:11434\",\n            \"http://host.docker.internal:11434\",\n        ),\n        (\n            \"RERANK_BINDING_HOST\",\n            \"http://0.0.0.0:8000/rerank\",\n            \"http://host.docker.internal:8000/rerank\",\n        ),\n    ],\n    ids=[\n        \"postgres-loopback-host\",\n        \"redis-loopback-uri\",\n        \"mongo-loopback-uri\",\n        \"mongo-authenticated-loopback-uri\",\n        \"neo4j-loopback-uri\",\n        \"milvus-loopback-uri\",\n        \"qdrant-loopback-uri\",\n        \"memgraph-loopback-uri\",\n        \"postgres-zero-host\",\n        \"llm-zero-host\",\n        \"rerank-zero-host\",\n    ],\n)\ndef test_prepare_compose_runtime_overrides_rewrites_container_endpoints(\n    env_key: str, env_value: str, expected_value: str\n) -> None:\n    \"\"\"Loopback and 0.0.0.0 endpoints should be rewritten for container reachability.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[{env_key}]=\"{env_value}\"\n\nprepare_compose_runtime_overrides\n\nprintf '{env_key}=%s\\\\n' \"${{COMPOSE_ENV_OVERRIDES[{env_key}]}}\"\n\"\"\"\n    )\n\n    assert values[env_key] == expected_value\n\n\ndef test_collect_mongodb_config_local_service_strips_stale_credentials_on_rerun() -> (\n    None\n):\n    \"\"\"Bundled MongoDB should keep host `.env` aligned with the unauthenticated template.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[MONGO_URI]=\"mongodb://root:secret@localhost:27018/\"\n\nconfirm_default_yes() {{ return 0; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{\n  if [[ \"$1\" == \"MongoDB database\" ]]; then\n    printf 'LightRAG'\n  else\n    printf '%s' \"$2\"\n  fi\n}}\n\ncollect_mongodb_config yes\n\nprintf 'MONGO_URI=%s\\\\n' \"${{ENV_VALUES[MONGO_URI]}}\"\nprintf 'COMPOSE_MONGO_URI=%s\\\\n' \"${{COMPOSE_ENV_OVERRIDES[MONGO_URI]}}\"\nprintf 'DOCKER_SERVICE=%s\\\\n' \"${{DOCKER_SERVICES[0]}}\"\n\"\"\"\n    )\n\n    assert values[\"MONGO_URI\"] == \"mongodb://localhost:27017/\"\n    assert values[\"COMPOSE_MONGO_URI\"] == \"mongodb://mongodb:27017/\"\n    assert values[\"DOCKER_SERVICE\"] == \"mongodb\"\n\n\ndef test_collect_redis_config_local_service_normalizes_custom_host_port() -> None:\n    \"\"\"Bundled Redis should keep host `.env` aligned with the published local port.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[REDIS_URI]=\"redis://localhost:6380/1\"\n\nconfirm_default_yes() {{ return 0; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\n\ncollect_redis_config yes\n\nprintf 'REDIS_URI=%s\\\\n' \"${{ENV_VALUES[REDIS_URI]}}\"\nprintf 'COMPOSE_REDIS_URI=%s\\\\n' \"${{COMPOSE_ENV_OVERRIDES[REDIS_URI]}}\"\nprintf 'DOCKER_SERVICE=%s\\\\n' \"${{DOCKER_SERVICES[0]}}\"\n\"\"\"\n    )\n\n    assert values[\"REDIS_URI\"] == \"redis://localhost:6379/1\"\n    assert values[\"COMPOSE_REDIS_URI\"] == \"redis://redis:6379\"\n    assert values[\"DOCKER_SERVICE\"] == \"redis\"\n\n\n@pytest.mark.parametrize(\n    (\"host_value\", \"expected_port_mapping\"),\n    [\n        (\"127.0.0.1\", \"${HOST:-0.0.0.0}:${PORT:-9621}:9621\"),\n        (\"192.168.1.10\", \"${HOST:-0.0.0.0}:${PORT:-9621}:9621\"),\n    ],\n    ids=[\"loopback-bind\", \"lan-bind\"],\n)\ndef test_prepare_compose_runtime_overrides_normalizes_server_binding(\n    host_value: str, expected_port_mapping: str\n) -> None:\n    \"\"\"Compose runtime should keep variable-based publishing while fixing container bind values.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[HOST]=\"{host_value}\"\nENV_VALUES[PORT]=\"8080\"\n\nprepare_compose_runtime_overrides\n\nprintf 'HOST=%s\\\\n' \"${{COMPOSE_ENV_OVERRIDES[HOST]}}\"\nprintf 'PORT=%s\\\\n' \"${{COMPOSE_ENV_OVERRIDES[PORT]}}\"\nprintf 'PORT_MAPPING=%s\\\\n' \"${{LIGHTRAG_COMPOSE_SERVER_PORT_MAPPING}}\"\n\"\"\"\n    )\n\n    assert values[\"HOST\"] == \"0.0.0.0\"\n    assert values[\"PORT\"] == \"9621\"\n    assert values[\"PORT_MAPPING\"] == expected_port_mapping\n\n\ndef test_generate_docker_compose_injects_server_host_and_port_overrides(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Generated compose should preserve variable-based host publishing and fix container bind values.\"\"\"\n\n    compose_file = tmp_path / \"docker-compose.yml\"\n    compose_file.write_text(\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"    env_file:\",\n                \"      - .env\",\n                \"    ports:\",\n                '      - \"${PORT:-9621}:9621\"',\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nENV_VALUES[HOST]=\"localhost\"\nENV_VALUES[PORT]=\"8080\"\n\nprepare_compose_runtime_overrides\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.generated.yml\"\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.generated.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert 'HOST: \"0.0.0.0\"' in generated_compose\n    assert 'PORT: \"9621\"' in generated_compose\n    assert '      - \"${HOST:-0.0.0.0}:${PORT:-9621}:9621\"' in generated_compose\n\n\ndef test_generate_docker_compose_injects_env_overrides_into_lightrag_not_after_managed_services(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Env overrides must appear inside the lightrag environment block, not after managed services.\n\n    When the base compose has a top-level volumes: section, the strip pass inserts a\n    __WIZARD_MANAGED_SERVICES__ marker at the point where volumes: begins.  Before the\n    fix the environment injector would miss that marker (column-0 comment) as an\n    end-of-environment boundary and append overrides after it — which placed them outside\n    the lightrag service once postgres/neo4j were merged in.\n    \"\"\"\n\n    compose_file = tmp_path / \"docker-compose.yml\"\n    compose_file.write_text(\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"    environment:\",\n                \"      EXISTING_KEY: existing_value\",\n                \"    volumes:\",\n                \"      - ./.env:/app/.env\",\n                \"volumes:\",\n                \"  some_volume:\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nENV_VALUES[POSTGRES_USER]=\"lightrag\"\nENV_VALUES[POSTGRES_PASSWORD]=\"secret\"\nENV_VALUES[POSTGRES_DATABASE]=\"lightrag\"\nadd_docker_service \"postgres\"\nset_compose_override \"LLM_BINDING_HOST\" \"http://host.docker.internal:11434\"\n\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.generated.yml\"\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.generated.yml\").read_text(encoding=\"utf-8\")\n\n    lightrag_pos = result.index(\"  lightrag:\")\n    postgres_pos = result.index(\"  postgres:\")\n    override_pos = result.index('LLM_BINDING_HOST: \"http://host.docker.internal:11434\"')\n\n    # Override must appear inside lightrag's block, before the postgres service.\n    assert lightrag_pos < override_pos < postgres_pos\n\n\ndef test_finalize_server_setup_skips_embedded_milvus_sub_services(\n    tmp_path: Path,\n) -> None:\n    \"\"\"finalize_server_setup must keep prefixed Milvus child services on rerun.\"\"\"\n\n    compose_file = tmp_path / \"docker-compose.final.yml\"\n    compose_file.write_text(\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"  milvus:\",\n                \"    image: milvusdb/milvus:v2.6.11\",\n                \"  milvus-etcd:\",\n                \"    image: quay.io/coreos/etcd:v3.5.16\",\n                \"  milvus-minio:\",\n                \"    image: minio/minio:RELEASE.2024-12-13T22-19-12Z\",\n                \"volumes:\",\n                \"  milvus_data:\",\n                \"  milvus-etcd_data:\",\n                \"  milvus-minio_data:\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\"),\n        encoding=\"utf-8\",\n    )\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_SETUP_MILVUS_DEPLOYMENT=docker\",\n        ],\n    )\n\n    # Should complete without error; Milvus child services are managed via the\n    # Milvus template, not as independent root services.\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\ncollect_server_config() {{ :; }}\ncollect_security_config() {{ :; }}\ncollect_ssl_config() {{ :; }}\nconfirm_required_yes_no() {{ return 0; }}\nfinalize_server_setup\n\"\"\"\n    )\n\n    result = compose_file.read_text(encoding=\"utf-8\")\n    # The Milvus template and its prefixed child services must still be present.\n    assert \"milvus\" in result\n    assert \"milvus-etcd\" in result\n    assert \"milvus-minio\" in result\n    assert \"      milvus:\\n        condition: service_healthy\" in result\n    assert \"      milvus-etcd:\\n        condition: service_healthy\" not in result\n    assert \"      milvus-minio:\\n        condition: service_healthy\" not in result\n\n\ndef test_finalize_server_setup_uses_compose_native_neo4j_endpoint_on_rerun(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Preserved managed services should inject compose-native endpoints on server reruns.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_SETUP_NEO4J_DEPLOYMENT=docker\",\n            \"NEO4J_URI=neo4j://localhost:7687\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"  neo4j:\",\n            \"    image: neo4j:latest\",\n        ],\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\nshow_summary() {{ :; }}\nconfirm_required_yes_no() {{ return 0; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nvalidate_security_config() {{ return 0; }}\nfinalize_server_setup\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n\n    assert 'NEO4J_URI: \"neo4j://neo4j:7687\"' in result\n    assert 'NEO4J_URI: \"neo4j://host.docker.internal:7687\"' not in result\n\n\ndef test_finalize_server_setup_drops_stale_managed_services_missing_from_env_markers(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-server should remove stale wizard-managed services not marked in `.env`.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"HOST=0.0.0.0\",\n            \"PORT=9621\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"  redis:\",\n            \"    image: redis:latest\",\n            \"  vllm-embed:\",\n            \"    image: vllm/vllm-openai:latest\",\n            \"volumes:\",\n            \"  redis_data:\",\n            \"  vllm_embed_cache:\",\n        ],\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\nshow_summary() {{ :; }}\ncollect_server_config() {{ :; }}\ncollect_security_config() {{ :; }}\ncollect_ssl_config() {{ :; }}\nconfirm_required_yes_no() {{ return 0; }}\nconfirm_default_yes() {{\n  case \"$1\" in\n    \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\") return 1 ;;\n    *) return 0 ;;\n  esac\n}}\nvalidate_sensitive_env_literals() {{ return 0; }}\nvalidate_security_config() {{ return 0; }}\nfinalize_server_setup\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n\n    assert \"  redis:\" not in result\n    assert \"  vllm-embed:\" not in result\n    assert \"redis_data:\" not in result\n    assert \"vllm_embed_cache:\" not in result\n    assert \"  lightrag:\" in result\n    assert \"LIGHTRAG_RUNTIME_TARGET=compose\" in generated_env\n\n\ndef test_env_server_flow_deletes_compose_when_switching_lightrag_to_host(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-server should back up and delete compose when no managed or sidecar services remain.\"\"\"\n\n    existing_compose = (\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"  redis:\",\n                \"    image: redis:latest\",\n            ]\n        )\n        + \"\\n\"\n    )\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_RUNTIME_TARGET=compose\",\n            \"HOST=0.0.0.0\",\n            \"PORT=9621\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    (tmp_path / \"docker-compose.final.yml\").write_text(\n        existing_compose,\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\ncollect_server_config() {{\n  ENV_VALUES[HOST]=\"0.0.0.0\"\n  ENV_VALUES[PORT]=\"8080\"\n}}\ncollect_security_config() {{ :; }}\ncollect_ssl_config() {{ :; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nvalidate_security_config() {{ return 0; }}\nconfirm_default_yes() {{ return 1; }}\nconfirm_default_no() {{\n  case \"$1\" in\n    \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_server_flow\n\"\"\"\n    )\n\n    assert_single_compose_backup(tmp_path, existing_compose)\n    assert not (tmp_path / \"docker-compose.final.yml\").exists()\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    assert \"LIGHTRAG_RUNTIME_TARGET=host\" in generated_env\n\n\ndef test_env_server_flow_keeps_compose_mode_for_user_sidecars(\n    tmp_path: Path,\n) -> None:\n    \"\"\"env-server should keep LightRAG in Docker when compose still carries user sidecars.\"\"\"\n\n    existing_compose = (\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"  sidecar:\",\n                \"    image: busybox\",\n            ]\n        )\n        + \"\\n\"\n    )\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_RUNTIME_TARGET=compose\",\n            \"HOST=0.0.0.0\",\n            \"PORT=9621\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    (tmp_path / \"docker-compose.final.yml\").write_text(\n        existing_compose,\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\ncollect_server_config() {{\n  ENV_VALUES[HOST]=\"0.0.0.0\"\n  ENV_VALUES[PORT]=\"8080\"\n}}\ncollect_security_config() {{ :; }}\ncollect_ssl_config() {{ :; }}\nvalidate_sensitive_env_literals() {{ return 0; }}\nvalidate_security_config() {{ return 0; }}\nconfirm_default_yes() {{ return 0; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_server_flow\n\"\"\"\n    )\n\n    result = (tmp_path / \"docker-compose.final.yml\").read_text(encoding=\"utf-8\")\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n\n    assert \"  sidecar:\" in result\n    assert \"  lightrag:\" in result\n    assert \"LIGHTRAG_RUNTIME_TARGET=compose\" in generated_env\n\n\ndef test_env_server_flow_rejects_invalid_ssl_cert_when_switching_to_host(\n    tmp_path: Path,\n) -> None:\n    \"\"\"finalize_server_setup should reject a missing SSL cert even when switching to host mode.\"\"\"\n\n    existing_compose = (\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"  redis:\",\n                \"    image: redis:latest\",\n            ]\n        )\n        + \"\\n\"\n    )\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_RUNTIME_TARGET=compose\",\n            \"HOST=0.0.0.0\",\n            \"PORT=9621\",\n            \"SSL=true\",\n            \"SSL_CERTFILE=/nonexistent/cert.pem\",\n            \"SSL_KEYFILE=/nonexistent/key.pem\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    (tmp_path / \"docker-compose.final.yml\").write_text(\n        existing_compose,\n        encoding=\"utf-8\",\n    )\n\n    result = run_bash_process(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\ncollect_server_config() {{ :; }}\ncollect_security_config() {{ :; }}\ncollect_ssl_config() {{\n  ENV_VALUES[SSL]=\"true\"\n  SSL_CERT_SOURCE_PATH=\"/nonexistent/cert.pem\"\n  SSL_KEY_SOURCE_PATH=\"/nonexistent/key.pem\"\n}}\nvalidate_sensitive_env_literals() {{ return 0; }}\nvalidate_security_config() {{ return 0; }}\nconfirm_default_yes() {{ return 1; }}\nconfirm_default_no() {{\n  case \"$1\" in\n    \"All wizard-managed services have been removed. Remove LightRAG from Docker and switch to host mode?\") return 0 ;;\n    *) return 1 ;;\n  esac\n}}\nconfirm_required_yes_no() {{ return 0; }}\n\nenv_server_flow\n\"\"\",\n    )\n\n    assert result.returncode != 0\n    assert (\n        \"Invalid SSL_CERTFILE\" in result.stderr\n        or \"Invalid SSL_CERTFILE\" in result.stdout\n    )\n    # compose and .env must not have been modified\n    assert (tmp_path / \"docker-compose.final.yml\").exists()\n    assert \"LIGHTRAG_RUNTIME_TARGET=compose\" in (tmp_path / \".env\").read_text(\n        encoding=\"utf-8\"\n    )\n\n\ndef test_detect_managed_root_services_deduplicates_embedded_milvus_children(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Managed service discovery should collapse Milvus child services to the root service.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"docker-compose.final.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n            \"  milvus:\",\n            \"    image: milvusdb/milvus:v2.6.11\",\n            \"  milvus-etcd:\",\n            \"    image: quay.io/coreos/etcd:v3.5.16\",\n            \"  milvus-minio:\",\n            \"    image: minio/minio:latest\",\n            \"  neo4j:\",\n            \"    image: neo4j:latest\",\n        ],\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\ndetect_managed_root_services \"{tmp_path}/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    assert output.splitlines() == [\"milvus\", \"neo4j\"]\n\n\ndef test_finalize_server_setup_allows_risky_security_config_and_security_check_reports_it(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Wizard writes `.env` without blocking, while security-check reports risky settings.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"AUTH_ACCOUNTS=admin:secret\",\n            \"TOKEN_SECRET=jwt-secret\",\n            \"WHITELIST_PATHS=/health,/api/*\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\n\nshow_summary() {{ :; }}\nconfirm_default_yes() {{ return 0; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nif finalize_server_setup; then\n  printf 'RESULT=success\\\\n'\nelse\n  printf 'RESULT=failure\\\\n'\nfi\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"RESULT\"] == \"success\"\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nsecurity_check_env_file\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    assert result.returncode == 1\n    assert \"WHITELIST_PATHS exposes /api routes\" in result.stdout\n\n\ndef test_finalize_server_setup_rejects_malformed_auth_accounts(tmp_path: Path) -> None:\n    \"\"\"Server setup should fail fast instead of persisting invalid AUTH_ACCOUNTS syntax.\"\"\"\n\n    write_text_lines(tmp_path / \".env\", [\"HOST=0.0.0.0\"])\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\n\ncollect_server_config() {{ :; }}\ncollect_ssl_config() {{ :; }}\nENV_VALUES[AUTH_ACCOUNTS]=\"admin\"\nENV_VALUES[TOKEN_SECRET]=\"jwt-secret\"\nshow_summary() {{ :; }}\nconfirm_default_yes() {{ return 0; }}\nconfirm_required_yes_no() {{ return 0; }}\n\nif finalize_server_setup; then\n  printf 'RESULT=success\\\\n'\nelse\n  printf 'RESULT=failure\\\\n'\nfi\nprintf 'ENV=%s\\\\n' \"$(cat \"$REPO_ROOT/.env\")\"\n\"\"\",\n        cwd=tmp_path,\n    )\n    values = parse_lines(output)\n\n    assert values[\"RESULT\"] == \"failure\"\n    assert values[\"ENV\"] == \"HOST=0.0.0.0\"\n\n\ndef test_validate_uri_accepts_neo4j_self_signed_tls_scheme() -> None:\n    \"\"\"Neo4j self-signed TLS URIs should pass validation.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\n\nif validate_uri \"neo4j+ssc://db.example.com:7687\" neo4j; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"VALID\"] == \"yes\"\n\n\ndef test_ssl_staging_uses_distinct_names_for_same_basename_inputs(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Cert/key files with the same basename should stage to distinct paths.\"\"\"\n\n    env_example = tmp_path / \"env.example\"\n    env_example.write_text(\n        \"\\n\".join(\n            [\n                \"SSL_CERTFILE=/placeholder/cert.pem\",\n                \"SSL_KEYFILE=/placeholder/key.pem\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    compose_file = tmp_path / \"docker-compose.yml\"\n    compose_file.write_text(\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"    env_file:\",\n                \"      - .env\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    cert_dir = tmp_path / \"certs\"\n    key_dir = tmp_path / \"keys\"\n    cert_dir.mkdir()\n    key_dir.mkdir()\n    cert_path = cert_dir / \"server.pem\"\n    cert_path.write_text(\"cert\", encoding=\"utf-8\")\n    key_path = key_dir / \"server.pem\"\n    key_path.write_text(\"key\", encoding=\"utf-8\")\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nENV_VALUES[SSL_CERTFILE]=\"{cert_path}\"\nENV_VALUES[SSL_KEYFILE]=\"{key_path}\"\nSSL_CERT_SOURCE_PATH=\"{cert_path}\"\nSSL_KEY_SOURCE_PATH=\"{key_path}\"\n\nprepare_compose_env_overrides\nstage_ssl_assets \"$SSL_CERT_SOURCE_PATH\" \"$SSL_KEY_SOURCE_PATH\"\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.generated.yml\"\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.generated.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n    staged_cert = tmp_path / \"data\" / \"certs\" / \"cert-server.pem\"\n    staged_key = tmp_path / \"data\" / \"certs\" / \"key-server.pem\"\n\n    assert staged_cert.read_text(encoding=\"utf-8\") == \"cert\"\n    assert staged_key.read_text(encoding=\"utf-8\") == \"key\"\n    assert 'SSL_CERTFILE: \"/app/data/certs/cert-server.pem\"' in generated_compose\n    assert 'SSL_KEYFILE: \"/app/data/certs/key-server.pem\"' in generated_compose\n    assert (\n        \"./data/certs/cert-server.pem:/app/data/certs/cert-server.pem:ro\"\n        in generated_compose\n    )\n    assert (\n        \"./data/certs/key-server.pem:/app/data/certs/key-server.pem:ro\"\n        in generated_compose\n    )\n\n\ndef test_ssl_staging_skips_copy_for_already_staged_relative_paths(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Re-running setup with already-staged certs should not fail on identical copies.\"\"\"\n\n    staged_dir = tmp_path / \"data\" / \"certs\"\n    staged_dir.mkdir(parents=True)\n    cert_path = staged_dir / \"server.pem\"\n    key_path = staged_dir / \"server.key\"\n    cert_path.write_text(\"cert\", encoding=\"utf-8\")\n    key_path.write_text(\"key\", encoding=\"utf-8\")\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\ncd \"{tmp_path}\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nstage_ssl_assets \"./data/certs/server.pem\" \"./data/certs/server.key\"\n\"\"\"\n    )\n\n    assert cert_path.read_text(encoding=\"utf-8\") == \"cert\"\n    assert key_path.read_text(encoding=\"utf-8\") == \"key\"\n\n\n@pytest.mark.parametrize(\n    (\"name\", \"env_lines\", \"setup_snippet\", \"finalize_call\"),\n    [\n        (\n            \"base\",\n            [],\n            \"\\n\".join(\n                [\n                    'ENV_VALUES[VLLM_EMBED_DEVICE]=\"cpu\"',\n                    'ENV_VALUES[VLLM_EMBED_MODEL]=\"BAAI/bge-m3\"',\n                    'ENV_VALUES[VLLM_EMBED_PORT]=\"8001\"',\n                    'ENV_VALUES[VLLM_EMBED_API_KEY]=\"local-key\"',\n                    'add_docker_service \"vllm-embed\"',\n                    \"confirm_default_no() { return 1; }\",\n                ]\n            ),\n            \"finalize_base_setup\",\n        ),\n        (\n            \"storage\",\n            [\n                \"LIGHTRAG_KV_STORAGE=PGKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=PGDocStatusStorage\",\n                \"POSTGRES_USER=lightrag\",\n                \"POSTGRES_PASSWORD=secret\",\n                \"POSTGRES_DATABASE=lightrag\",\n            ],\n            'add_docker_service \"postgres\"',\n            \"finalize_storage_setup\",\n        ),\n    ],\n    ids=[\"base\", \"storage\"],\n)\ndef test_finalize_flows_stage_inherited_ssl_assets_for_compose(\n    tmp_path: Path,\n    name: str,\n    env_lines: list[str],\n    setup_snippet: str,\n    finalize_call: str,\n) -> None:\n    \"\"\"Compose-writing finalize flows should stage inherited SSL assets before mounting them.\"\"\"\n\n    cert_path = tmp_path / f\"{name}-source-cert.pem\"\n    key_path = tmp_path / f\"{name}-source-key.pem\"\n    cert_path.write_text(\"cert\", encoding=\"utf-8\")\n    key_path.write_text(\"key\", encoding=\"utf-8\")\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            *env_lines,\n            \"SSL=true\",\n            f\"SSL_CERTFILE={cert_path}\",\n            f\"SSL_KEYFILE={key_path}\",\n        ],\n    )\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    (tmp_path / \"docker-compose.yml\").write_text(\n        (REPO_ROOT / \"docker-compose.yml\").read_text(encoding=\"utf-8\"),\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\n\n{setup_snippet}\n\nshow_summary() {{ :; }}\nconfirm_default_yes() {{ return 0; }}\nconfirm_required_yes_no() {{ return 0; }}\n\n{finalize_call}\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.final.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    staged_cert = tmp_path / \"data\" / \"certs\" / f\"{name}-source-cert.pem\"\n    staged_key = tmp_path / \"data\" / \"certs\" / f\"{name}-source-key.pem\"\n\n    assert staged_cert.read_text(encoding=\"utf-8\") == \"cert\"\n    assert staged_key.read_text(encoding=\"utf-8\") == \"key\"\n    assert (\n        f\"./data/certs/{name}-source-cert.pem:/app/data/certs/{name}-source-cert.pem:ro\"\n        in generated_compose\n    )\n    assert (\n        f\"./data/certs/{name}-source-key.pem:/app/data/certs/{name}-source-key.pem:ro\"\n        in generated_compose\n    )\n\n\ndef test_generate_docker_compose_vllm_gpu_honors_documented_gpu_selector(\n    tmp_path: Path,\n) -> None:\n    \"\"\"GPU vLLM compose should honor the documented CUDA selector variables.\"\"\"\n\n    env_example = tmp_path / \"env.example\"\n    env_example.write_text(\n        \"\\n\".join(\n            [\n                \"# VLLM_RERANK_DEVICE=cuda\",\n                \"# CUDA_VISIBLE_DEVICES=-1\",\n                \"# NVIDIA_VISIBLE_DEVICES=all\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    compose_file = tmp_path / \"docker-compose.yml\"\n    compose_file.write_text(\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"    env_file:\",\n                \"      - .env\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nENV_VALUES[VLLM_RERANK_DEVICE]=\"cuda\"\nENV_VALUES[CUDA_VISIBLE_DEVICES]=\"0\"\nadd_docker_service \"vllm-rerank\"\n\ngenerate_env_file \"$REPO_ROOT/env.example\" \"$REPO_ROOT/.env\"\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.generated.yml\"\n\"\"\"\n    )\n\n    generated_env = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n    generated_compose = (tmp_path / \"docker-compose.generated.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert \"CUDA_VISIBLE_DEVICES=0\" in generated_env\n    assert \"NVIDIA_VISIBLE_DEVICES: ${NVIDIA_VISIBLE_DEVICES:-all}\" in generated_compose\n    assert \"      vllm-rerank:\\n        condition: service_healthy\" in generated_compose\n    assert \"    healthcheck:\" in generated_compose\n    assert \"VLLM_RERANK_PORT:-8000\" in generated_compose\n    assert 'grep -q \":$${PORT_HEX} \"' in generated_compose\n\n\n@pytest.mark.parametrize(\n    (\"device\", \"expected_image\"),\n    [\n        (\"cpu\", \"image: milvusdb/milvus:v2.6.11\"),\n        (\"cuda\", \"image: milvusdb/milvus:v2.6.11-gpu\"),\n    ],\n)\ndef test_generate_docker_compose_selects_milvus_template_from_device(\n    tmp_path: Path,\n    device: str,\n    expected_image: str,\n) -> None:\n    \"\"\"Milvus compose generation should switch templates based on MILVUS_DEVICE.\"\"\"\n\n    write_text_lines(\n        tmp_path / \"env.example\",\n        (REPO_ROOT / \"env.example\").read_text(encoding=\"utf-8\").splitlines(),\n    )\n    write_text_lines(\n        tmp_path / \"docker-compose.yml\",\n        [\n            \"services:\",\n            \"  lightrag:\",\n            \"    image: example/lightrag:test\",\n        ],\n    )\n\n    run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nENV_VALUES[MILVUS_DEVICE]=\"{device}\"\nadd_docker_service milvus\n\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.final.yml\"\n\"\"\"\n    )\n\n    generated_compose = (tmp_path / \"docker-compose.final.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert expected_image in generated_compose\n\n\ndef test_collect_security_config_can_clear_existing_values_on_rerun(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Rerunning security setup should be able to remove previously saved values.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"AUTH_ACCOUNTS=admin:secret\",\n                \"TOKEN_SECRET=jwt-secret\",\n                \"TOKEN_EXPIRE_HOURS=72\",\n                \"LIGHTRAG_API_KEY=api-key\",\n                \"WHITELIST_PATHS=/health,/api/*,/docs\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\n\nconfirm_default_no() {{ return 0; }}\nprompt_clearable_with_default() {{ printf '%s' \"$CLEAR_INPUT_SENTINEL\"; }}\nprompt_clearable_secret_with_default() {{ printf '%s' \"$CLEAR_INPUT_SENTINEL\"; }}\n\ncollect_security_config yes no\ngenerate_env_file \"{REPO_ROOT}/env.example\" \"$REPO_ROOT/.env.generated\"\n\nprintf 'AUTH_ACCOUNTS_SET=%s\\\\n' \"${{ENV_VALUES[AUTH_ACCOUNTS]+set}}\"\nprintf 'TOKEN_SECRET_SET=%s\\\\n' \"${{ENV_VALUES[TOKEN_SECRET]+set}}\"\nprintf 'TOKEN_EXPIRE_HOURS_SET=%s\\\\n' \"${{ENV_VALUES[TOKEN_EXPIRE_HOURS]+set}}\"\nprintf 'LIGHTRAG_API_KEY_SET=%s\\\\n' \"${{ENV_VALUES[LIGHTRAG_API_KEY]+set}}\"\nprintf 'WHITELIST_PATHS_SET=%s\\\\n' \"${{ENV_VALUES[WHITELIST_PATHS]+set}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n    generated_lines = (\n        (tmp_path / \".env.generated\").read_text(encoding=\"utf-8\").splitlines()\n    )\n\n    assert values[\"AUTH_ACCOUNTS_SET\"] == \"\"\n    assert values[\"TOKEN_SECRET_SET\"] == \"\"\n    assert values[\"TOKEN_EXPIRE_HOURS_SET\"] == \"\"\n    assert values[\"LIGHTRAG_API_KEY_SET\"] == \"\"\n    assert values[\"WHITELIST_PATHS_SET\"] == \"set\"\n    assert not any(line.startswith(\"AUTH_ACCOUNTS=\") for line in generated_lines)\n    assert not any(line.startswith(\"TOKEN_SECRET=\") for line in generated_lines)\n    assert not any(line.startswith(\"TOKEN_EXPIRE_HOURS=\") for line in generated_lines)\n    assert not any(line.startswith(\"LIGHTRAG_API_KEY=\") for line in generated_lines)\n    assert \"WHITELIST_PATHS=\" in generated_lines\n\n\ndef test_collect_security_config_preserves_explicit_empty_whitelist_on_rerun(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Rerunning security setup should keep an explicitly empty whitelist unchanged.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\"WHITELIST_PATHS=\\n\", encoding=\"utf-8\")\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\n\nprompt_clearable_with_default() {{ printf '%s' \"$2\"; }}\nprompt_clearable_secret_with_default() {{ printf '%s' \"$2\"; }}\n\ncollect_security_config no no\n\nprintf 'WHITELIST_PATHS_SET=%s\\\\n' \"${{ENV_VALUES[WHITELIST_PATHS]+set}}\"\nprintf 'WHITELIST_PATHS=%s\\\\n' \"${{ENV_VALUES[WHITELIST_PATHS]}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"WHITELIST_PATHS_SET\"] == \"set\"\n    assert values[\"WHITELIST_PATHS\"] == \"\"\n\n\ndef test_collect_observability_config_clears_existing_values_on_rerun(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Rerunning setup should remove saved Langfuse settings when observability is declined.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LANGFUSE_ENABLE_TRACE=true\",\n                \"LANGFUSE_SECRET_KEY=old-secret\",\n                \"LANGFUSE_PUBLIC_KEY=old-public\",\n                \"LANGFUSE_HOST=https://langfuse.example\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nload_existing_env_if_present\n\n\ncollect_observability_config\ngenerate_env_file \"{REPO_ROOT}/env.example\" \"$REPO_ROOT/.env.generated\"\n\nprintf 'LANGFUSE_ENABLE_TRACE_SET=%s\\\\n' \"${{ENV_VALUES[LANGFUSE_ENABLE_TRACE]+set}}\"\nprintf 'LANGFUSE_SECRET_KEY_SET=%s\\\\n' \"${{ENV_VALUES[LANGFUSE_SECRET_KEY]+set}}\"\nprintf 'LANGFUSE_PUBLIC_KEY_SET=%s\\\\n' \"${{ENV_VALUES[LANGFUSE_PUBLIC_KEY]+set}}\"\nprintf 'LANGFUSE_HOST_SET=%s\\\\n' \"${{ENV_VALUES[LANGFUSE_HOST]+set}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n    generated_lines = (\n        (tmp_path / \".env.generated\").read_text(encoding=\"utf-8\").splitlines()\n    )\n\n    assert values[\"LANGFUSE_ENABLE_TRACE_SET\"] == \"\"\n    assert values[\"LANGFUSE_SECRET_KEY_SET\"] == \"\"\n    assert values[\"LANGFUSE_PUBLIC_KEY_SET\"] == \"\"\n    assert values[\"LANGFUSE_HOST_SET\"] == \"\"\n    assert not any(\n        line.startswith(\"LANGFUSE_ENABLE_TRACE=\") for line in generated_lines\n    )\n    assert not any(line.startswith(\"LANGFUSE_SECRET_KEY=\") for line in generated_lines)\n    assert not any(line.startswith(\"LANGFUSE_PUBLIC_KEY=\") for line in generated_lines)\n    assert not any(line.startswith(\"LANGFUSE_HOST=\") for line in generated_lines)\n\n\ndef test_collect_neo4j_config_bundled_service_keeps_username_editable(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Bundled Neo4j should preserve editable credentials and existing database overrides.\"\"\"\n\n    compose_file = tmp_path / \"docker-compose.yml\"\n    compose_file.write_text(\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n                \"    env_file:\",\n                \"      - .env\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\n\nENV_VALUES[NEO4J_USERNAME]=\"custom-user\"\nENV_VALUES[NEO4J_PASSWORD]=\"existing-password\"\nENV_VALUES[NEO4J_DATABASE]=\"custom-db\"\n\nconfirm_default_yes() {{ return 0; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_log_file=\"$(mktemp)\"\ntrap 'rm -f \"$prompt_log_file\"' EXIT\nprompt_with_default() {{\n  printf '%s\\\\n' \"$1\" >> \"$prompt_log_file\"\n  if [[ \"$1\" == \"Neo4j database\" ]]; then\n    printf 'custom-db-2'\n  else\n    printf '%s' \"$2\"\n  fi\n}}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\n\ncollect_neo4j_config yes\ngenerate_docker_compose \"$REPO_ROOT/docker-compose.generated.yml\"\n\nprintf 'NEO4J_USERNAME=%s\\\\n' \"${{ENV_VALUES[NEO4J_USERNAME]}}\"\nprintf 'NEO4J_PASSWORD=%s\\\\n' \"${{ENV_VALUES[NEO4J_PASSWORD]}}\"\nprintf 'NEO4J_DATABASE=%s\\\\n' \"${{ENV_VALUES[NEO4J_DATABASE]}}\"\nprintf 'DOCKER_SERVICE=%s\\\\n' \"${{DOCKER_SERVICES[0]}}\"\nprintf 'DATABASE_PROMPTS=%s\\\\n' \"$(grep -c '^Neo4j database$' \"$prompt_log_file\" || true)\"\n\"\"\"\n    )\n    values = parse_lines(output)\n    generated_compose = (tmp_path / \"docker-compose.generated.yml\").read_text(\n        encoding=\"utf-8\"\n    )\n\n    assert values[\"NEO4J_USERNAME\"] == \"custom-user\"\n    assert values[\"NEO4J_PASSWORD\"] == \"existing-password\"\n    assert values[\"NEO4J_DATABASE\"] == \"custom-db-2\"\n    assert values[\"DOCKER_SERVICE\"] == \"neo4j\"\n    assert values[\"DATABASE_PROMPTS\"] == \"1\"\n    assert (\n        \"NEO4J_AUTH: ${NEO4J_USERNAME:?missing}/${NEO4J_PASSWORD:?missing}\"\n        in generated_compose\n    )\n    assert 'NEO4J_dbms_default__database: \"custom-db-2\"' in generated_compose\n\n\ndef test_collect_neo4j_config_bundled_service_defaults_database_when_unset() -> None:\n    \"\"\"Bundled Neo4j should pin the community default database when no prior value exists.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nprompt_log_file=\"$(mktemp)\"\ntrap 'rm -f \"$prompt_log_file\"' EXIT\n\nconfirm_default_yes() {{ return 0; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{\n  printf '%s\\\\n' \"$1\" >> \"$prompt_log_file\"\n  printf '%s' \"$2\"\n}}\nprompt_secret_until_valid_with_default() {{ printf 'secure-password'; }}\n\ncollect_neo4j_config yes\n\nprintf 'DATABASE=%s\\\\n' \"${{ENV_VALUES[NEO4J_DATABASE]}}\"\nprintf 'DATABASE_PROMPTS=%s\\\\n' \"$(grep -c '^Neo4j database$' \"$prompt_log_file\" || true)\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"DATABASE\"] == \"neo4j\"\n    assert values[\"DATABASE_PROMPTS\"] == \"0\"\n\n\ndef test_collect_neo4j_config_uses_existing_password_as_default_in_docker_mode() -> (\n    None\n):\n    \"\"\"Bundled Neo4j should preserve the existing password when the default is accepted.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[NEO4J_PASSWORD]=\"from-env-password\"\n\nconfirm_default_yes() {{ return 0; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\n\ncollect_neo4j_config yes\n\nprintf 'PASSWORD=%s\\\\n' \"${{ENV_VALUES[NEO4J_PASSWORD]}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"PASSWORD\"] == \"from-env-password\"\n\n\ndef test_collect_neo4j_config_uses_existing_password_as_default_in_external_mode() -> (\n    None\n):\n    \"\"\"External Neo4j should preserve the existing password when the default is accepted.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[NEO4J_PASSWORD]=\"from-env-password\"\n\nconfirm_default_no() {{ return 1; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_with_default() {{ printf '%s' \"$2\"; }}\n\ncollect_neo4j_config no\n\nprintf 'PASSWORD=%s\\\\n' \"${{ENV_VALUES[NEO4J_PASSWORD]}}\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"PASSWORD\"] == \"from-env-password\"\n\n\ndef test_collect_neo4j_config_bundled_service_reprompts_for_empty_credentials() -> None:\n    \"\"\"Bundled Neo4j should reject empty username and password values.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nprompt_log_file=\"$(mktemp)\"\ntrap 'rm -f \"$prompt_log_file\"' EXIT\n\nconfirm_default_yes() {{ return 0; }}\nprompt_until_valid() {{\n  local prompt=\"$1\"\n  local default=\"$2\"\n  local validator=\"$3\"\n  shift 3\n  local value=\"\"\n\n  while true; do\n    if [[ \"$prompt\" == \"Neo4j URI\" ]]; then\n      value=\"$default\"\n    else\n      printf 'username\\\\n' >> \"$prompt_log_file\"\n      if [[ \"$(grep -c '^username$' \"$prompt_log_file\")\" -eq 1 ]]; then\n        value=\"\"\n      else\n        value=\"neo4j-user\"\n      fi\n    fi\n\n    if \"$validator\" \"$value\" \"$@\"; then\n      printf '%s' \"$value\"\n      return 0\n    fi\n  done\n}}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{\n  local prompt=\"$1\"\n  local default=\"$2\"\n  local validator=\"$3\"\n  shift 3\n  local value=\"\"\n\n  while true; do\n    printf 'password\\\\n' >> \"$prompt_log_file\"\n    if [[ \"$(grep -c '^password$' \"$prompt_log_file\")\" -eq 1 ]]; then\n      value=\"\"\n    else\n      value=\"secure-password\"\n    fi\n\n    if \"$validator\" \"$value\" \"$@\"; then\n      printf '%s' \"$value\"\n      return 0\n    fi\n  done\n}}\n\ncollect_neo4j_config yes\n\nprintf 'USERNAME=%s\\\\n' \"${{ENV_VALUES[NEO4J_USERNAME]}}\"\nprintf 'PASSWORD=%s\\\\n' \"${{ENV_VALUES[NEO4J_PASSWORD]}}\"\nprintf 'USERNAME_CALLS=%s\\\\n' \"$(grep -c '^username$' \"$prompt_log_file\")\"\nprintf 'PASSWORD_CALLS=%s\\\\n' \"$(grep -c '^password$' \"$prompt_log_file\")\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"USERNAME\"] == \"neo4j-user\"\n    assert values[\"PASSWORD\"] == \"secure-password\"\n    assert values[\"USERNAME_CALLS\"] == \"2\"\n    assert values[\"PASSWORD_CALLS\"] == \"2\"\n\n\ndef test_collect_neo4j_config_external_service_still_uses_standard_prompts() -> None:\n    \"\"\"External Neo4j setup should keep the non-Docker prompt behavior unchanged.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nprompt_log_file=\"$(mktemp)\"\ntrap 'rm -f \"$prompt_log_file\"' EXIT\n\nconfirm_default_no() {{ return 1; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{\n  printf 'with_default\\\\n' >> \"$prompt_log_file\"\n  if [[ \"$1\" == \"Neo4j username\" ]]; then\n    printf 'external-user'\n  elif [[ \"$1\" == \"Neo4j database\" ]]; then\n    printf 'external-db'\n  else\n    printf '%s' \"$2\"\n  fi\n}}\nprompt_secret_with_default() {{\n  printf 'secret_with_default\\\\n' >> \"$prompt_log_file\"\n  printf 'external-password'\n}}\n\ncollect_neo4j_config no\n\nprintf 'USERNAME=%s\\\\n' \"${{ENV_VALUES[NEO4J_USERNAME]}}\"\nprintf 'PASSWORD=%s\\\\n' \"${{ENV_VALUES[NEO4J_PASSWORD]}}\"\nprintf 'DATABASE=%s\\\\n' \"${{ENV_VALUES[NEO4J_DATABASE]}}\"\nprintf 'USERNAME_PROMPTS=%s\\\\n' \"$(grep -c '^with_default$' \"$prompt_log_file\")\"\nprintf 'PASSWORD_PROMPTS=%s\\\\n' \"$(grep -c '^secret_with_default$' \"$prompt_log_file\")\"\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"USERNAME\"] == \"external-user\"\n    assert values[\"PASSWORD\"] == \"external-password\"\n    assert values[\"DATABASE\"] == \"external-db\"\n    assert values[\"USERNAME_PROMPTS\"] == \"2\"\n    assert values[\"PASSWORD_PROMPTS\"] == \"1\"\n\n\ndef test_validate_security_config_rejects_malformed_auth_accounts() -> None:\n    \"\"\"Security validation should reject auth entries the API cannot parse.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nif validate_security_config \"admin\" \"token-secret\" \"\" no \"/health\"; then\n  printf 'MISSING_COLON=yes\\\\n'\nelse\n  printf 'MISSING_COLON=no\\\\n'\nfi\n\nif validate_security_config \"admin:secret,\" \"token-secret\" \"\" no \"/health\"; then\n  printf 'TRAILING_COMMA=yes\\\\n'\nelse\n  printf 'TRAILING_COMMA=no\\\\n'\nfi\n\nif validate_security_config \"admin:secret,reader:hunter2\" \"token-secret\" \"\" no \"/health\"; then\n  printf 'VALID_FORMAT=yes\\\\n'\nelse\n  printf 'VALID_FORMAT=no\\\\n'\nfi\n\"\"\"\n    )\n    values = parse_lines(output)\n\n    assert values[\"MISSING_COLON\"] == \"no\"\n    assert values[\"TRAILING_COMMA\"] == \"no\"\n    assert values[\"VALID_FORMAT\"] == \"yes\"\n\n\ndef test_security_check_reports_missing_authentication(tmp_path: Path) -> None:\n    \"\"\"Security audit should flag unauthenticated API exposure.\"\"\"\n\n    write_text_lines(tmp_path / \".env\", [\"HOST=0.0.0.0\"])\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nsecurity_check_env_file\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    assert result.returncode == 1\n    assert \"No API protection is configured.\" in result.stdout\n\n\ndef test_security_check_passes_for_authenticated_minimal_config(tmp_path: Path) -> None:\n    \"\"\"Security audit should pass for a minimally hardened config.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"AUTH_ACCOUNTS=admin:secret\",\n            \"TOKEN_SECRET=jwt-secret\",\n            \"WHITELIST_PATHS=/health\",\n        ],\n    )\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nsecurity_check_env_file\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    assert result.returncode == 0\n    assert \"No obvious security issues found\" in result.stdout\n\n\ndef test_security_check_reports_api_key_only_with_default_whitelist(\n    tmp_path: Path,\n) -> None:\n    \"\"\"API-key-only deployment with unset WHITELIST_PATHS inherits /api/* and must be flagged.\"\"\"\n\n    write_text_lines(tmp_path / \".env\", [\"LIGHTRAG_API_KEY=my-secret-key\"])\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nsecurity_check_env_file\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    assert result.returncode == 1\n    assert \"WHITELIST_PATHS exposes /api routes\" in result.stdout\n\n\ndef test_security_check_reports_api_key_only_with_explicit_api_wildcard_whitelist(\n    tmp_path: Path,\n) -> None:\n    \"\"\"API-key-only deployment with WHITELIST_PATHS=/health,/api/* must be flagged.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\"LIGHTRAG_API_KEY=my-secret-key\", \"WHITELIST_PATHS=/health,/api/*\"],\n    )\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nsecurity_check_env_file\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    assert result.returncode == 1\n    assert \"WHITELIST_PATHS exposes /api routes\" in result.stdout\n\n\ndef test_security_check_passes_for_api_key_only_with_safe_whitelist(\n    tmp_path: Path,\n) -> None:\n    \"\"\"API-key-only deployment with a safe WHITELIST_PATHS should pass the security check.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\"LIGHTRAG_API_KEY=my-secret-key\", \"WHITELIST_PATHS=/health\"],\n    )\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nsecurity_check_env_file\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    assert result.returncode == 0\n    assert \"No obvious security issues found\" in result.stdout\n\n\ndef test_security_check_ignores_default_opensearch_password_when_opensearch_unused(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Security audit should ignore OpenSearch defaults when no OpenSearch storage is selected.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"AUTH_ACCOUNTS=admin:secret\",\n            \"TOKEN_SECRET=jwt-secret\",\n            \"WHITELIST_PATHS=/health\",\n            \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n            \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n            \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n            \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n            \"OPENSEARCH_PASSWORD=LightRAG2026_!@\",\n        ],\n    )\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nsecurity_check_env_file\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    assert result.returncode == 0\n    assert \"OPENSEARCH_PASSWORD uses a well-known default value.\" not in result.stdout\n\n\ndef test_security_check_reports_default_opensearch_password_when_opensearch_selected(\n    tmp_path: Path,\n) -> None:\n    \"\"\"Security audit should flag the default OpenSearch password when OpenSearch is selected.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"AUTH_ACCOUNTS=admin:secret\",\n            \"TOKEN_SECRET=jwt-secret\",\n            \"WHITELIST_PATHS=/health\",\n            \"LIGHTRAG_KV_STORAGE=OpenSearchKVStorage\",\n            \"LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage\",\n            \"LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage\",\n            \"LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage\",\n            \"OPENSEARCH_HOSTS=localhost:9200\",\n            \"OPENSEARCH_USER=admin\",\n            \"OPENSEARCH_PASSWORD=LightRAG2026_!@\",\n        ],\n    )\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nsecurity_check_env_file\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    assert result.returncode == 1\n    assert \"OPENSEARCH_PASSWORD uses a well-known default value.\" in result.stdout\n\n\ndef test_show_summary_masks_auth_accounts() -> None:\n    \"\"\"Configuration summaries should not print auth account passwords.\"\"\"\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[AUTH_ACCOUNTS]=\"admin:secret,reader:hunter2\"\nENV_VALUES[TOKEN_SECRET]=\"jwt-secret\"\nENV_VALUES[HOST]=\"0.0.0.0\"\n\nshow_summary\n\"\"\"\n    )\n\n    assert \"AUTH_ACCOUNTS=***\" in output\n    assert \"TOKEN_SECRET=***\" in output\n    assert \"admin:secret\" not in output\n    assert \"reader:hunter2\" not in output\n\n\ndef test_validate_env_file_handles_supported_and_unsupported_uri_schemes(\n    tmp_path: Path,\n) -> None:\n    \"\"\"validate_env_file should reject malformed schemes and allow supported TLS variants.\"\"\"\n\n    cases = {\n        \"invalid-neo4j-scheme\": (\n            [\n                \"LIGHTRAG_GRAPH_STORAGE=Neo4JStorage\",\n                \"NEO4J_URI=http://localhost:7687\",\n                \"NEO4J_USERNAME=neo4j\",\n                \"NEO4J_PASSWORD=secret\",\n            ],\n            \"no\",\n            \"Invalid NEO4J_URI\",\n        ),\n        \"invalid-redis-scheme\": (\n            [\n                \"LIGHTRAG_KV_STORAGE=RedisKVStorage\",\n                \"REDIS_URI=tcp://localhost:6379\",\n            ],\n            \"no\",\n            \"Invalid REDIS_URI\",\n        ),\n        \"valid-rediss-scheme\": (\n            [\n                \"LIGHTRAG_KV_STORAGE=RedisKVStorage\",\n                \"REDIS_URI=rediss://localhost:6380\",\n            ],\n            \"yes\",\n            \"\",\n        ),\n    }\n\n    for case_name, (extra_lines, expected_valid, expected_stderr) in cases.items():\n        case_dir = tmp_path / case_name\n        case_dir.mkdir()\n        write_text_lines(\n            case_dir / \".env\",\n            [\n                \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n                *extra_lines,\n            ],\n        )\n        write_text_lines(case_dir / \"env.example\", [\"LLM_BINDING=openai\"])\n\n        result = subprocess.run(\n            [\n                \"bash\",\n                \"--norc\",\n                \"--noprofile\",\n                \"-c\",\n                f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{case_dir}\"\nreset_state\nif validate_env_file; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\",\n            ],\n            cwd=REPO_ROOT,\n            capture_output=True,\n            text=True,\n            check=False,\n        )\n\n        values = parse_lines(result.stdout)\n        assert values[\"VALID\"] == expected_valid\n        if expected_stderr:\n            assert expected_stderr in result.stderr\n\n\ndef test_validate_env_file_rejects_invalid_runtime_target(tmp_path: Path) -> None:\n    \"\"\"validate_env_file should reject unsupported LIGHTRAG_RUNTIME_TARGET values.\"\"\"\n\n    write_text_lines(\n        tmp_path / \".env\",\n        [\n            \"LIGHTRAG_RUNTIME_TARGET=laptop\",\n            \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n            \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n            \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n            \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n        ],\n    )\n    write_text_lines(tmp_path / \"env.example\", [\"LLM_BINDING=openai\"])\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nif validate_env_file; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    values = parse_lines(result.stdout)\n    assert values[\"VALID\"] == \"no\"\n    assert \"Invalid LIGHTRAG_RUNTIME_TARGET\" in result.stderr\n\n\ndef test_validate_required_variables_requires_opensearch_basic_auth() -> None:\n    \"\"\"OpenSearch storages should require both OPENSEARCH_USER and OPENSEARCH_PASSWORD.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[LIGHTRAG_KV_STORAGE]=\"OpenSearchKVStorage\"\nENV_VALUES[LIGHTRAG_VECTOR_STORAGE]=\"OpenSearchVectorDBStorage\"\nENV_VALUES[LIGHTRAG_GRAPH_STORAGE]=\"OpenSearchGraphStorage\"\nENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]=\"OpenSearchDocStatusStorage\"\nENV_VALUES[OPENSEARCH_HOSTS]=\"localhost:9200\"\n\nif validate_required_variables \\\n  \"${{ENV_VALUES[LIGHTRAG_KV_STORAGE]}}\" \\\n  \"${{ENV_VALUES[LIGHTRAG_VECTOR_STORAGE]}}\" \\\n  \"${{ENV_VALUES[LIGHTRAG_GRAPH_STORAGE]}}\" \\\n  \"${{ENV_VALUES[LIGHTRAG_DOC_STATUS_STORAGE]}}\"; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\"\n    )\n\n    assert values[\"VALID\"] == \"no\"\n\n\ndef test_collect_opensearch_config_preserves_graphlookup_auto_detection() -> None:\n    \"\"\"collect_opensearch_config should leave PPL graphlookup unset unless explicitly configured.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nconfirm_default_yes() {{ return 0; }}\nconfirm_default_no() {{ return 1; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\n\ncollect_opensearch_config \"yes\"\n\nif [[ -v 'ENV_VALUES[OPENSEARCH_USE_PPL_GRAPHLOOKUP]' ]]; then\n  printf 'GRAPHLOOKUP_SET=yes\\\\n'\nelse\n  printf 'GRAPHLOOKUP_SET=no\\\\n'\nfi\n\"\"\"\n    )\n\n    assert values[\"GRAPHLOOKUP_SET\"] == \"no\"\n\n\ndef test_collect_opensearch_config_preserves_explicit_graphlookup_override() -> None:\n    \"\"\"collect_opensearch_config should keep an existing PPL graphlookup override.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[OPENSEARCH_USE_PPL_GRAPHLOOKUP]=\"true\"\n\nconfirm_default_yes() {{ return 0; }}\nconfirm_default_no() {{ return 1; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\n\ncollect_opensearch_config \"yes\"\nprintf 'GRAPHLOOKUP=%s\\\\n' \"${{ENV_VALUES[OPENSEARCH_USE_PPL_GRAPHLOOKUP]}}\"\n\"\"\"\n    )\n\n    assert values[\"GRAPHLOOKUP\"] == \"true\"\n\n\ndef test_collect_opensearch_config_forces_docker_verify_certs_false() -> None:\n    \"\"\"collect_opensearch_config should force OPENSEARCH_VERIFY_CERTS=false for Docker.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nENV_VALUES[OPENSEARCH_USE_SSL]=\"false\"\nENV_VALUES[OPENSEARCH_VERIFY_CERTS]=\"true\"\n\nconfirm_default_yes() {{ return 0; }}\nconfirm_default_no() {{ return 1; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\n\ncollect_opensearch_config \"yes\"\nprintf 'USE_SSL=%s\\\\n' \"${{ENV_VALUES[OPENSEARCH_USE_SSL]}}\"\nprintf 'VERIFY_CERTS=%s\\\\n' \"${{ENV_VALUES[OPENSEARCH_VERIFY_CERTS]}}\"\n\"\"\"\n    )\n\n    assert values[\"USE_SSL\"] == \"false\"\n    assert values[\"VERIFY_CERTS\"] == \"false\"\n\n\ndef test_collect_opensearch_config_defaults_docker_tls_flags_when_unset() -> None:\n    \"\"\"collect_opensearch_config should supply Docker TLS defaults when .env has no values.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nconfirm_default_yes() {{ return 0; }}\nconfirm_default_no() {{ return 1; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\n\ncollect_opensearch_config \"yes\"\nprintf 'USE_SSL=%s\\\\n' \"${{ENV_VALUES[OPENSEARCH_USE_SSL]}}\"\nprintf 'VERIFY_CERTS=%s\\\\n' \"${{ENV_VALUES[OPENSEARCH_VERIFY_CERTS]}}\"\n\"\"\"\n    )\n\n    assert values[\"USE_SSL\"] == \"true\"\n    assert values[\"VERIFY_CERTS\"] == \"false\"\n\n\ndef test_collect_opensearch_config_validates_hosts_during_prompt() -> None:\n    \"\"\"collect_opensearch_config should validate OPENSEARCH_HOSTS at prompt time.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nvalidator_file=\"$(mktemp)\"\n\nconfirm_default_yes() {{ return 0; }}\nconfirm_default_no() {{ return 1; }}\nprompt_until_valid() {{\n  printf '%s' \"$3\" > \"$validator_file\"\n  printf '%s' \"$2\"\n}}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{ printf '%s' \"$2\"; }}\n\ncollect_opensearch_config \"yes\"\nprintf 'HOST_VALIDATOR=%s\\\\n' \"$(cat \"$validator_file\")\"\n\"\"\"\n    )\n\n    assert values[\"HOST_VALIDATOR\"] == \"validate_opensearch_hosts_format\"\n\n\ndef test_collect_opensearch_config_validates_password_during_prompt() -> None:\n    \"\"\"collect_opensearch_config should validate OPENSEARCH_PASSWORD at prompt time.\"\"\"\n\n    values = run_bash_lines(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nreset_state\n\nvalidator_file=\"$(mktemp)\"\n\nconfirm_default_yes() {{ return 0; }}\nconfirm_default_no() {{ return 1; }}\nprompt_until_valid() {{ printf '%s' \"$2\"; }}\nprompt_with_default() {{ printf '%s' \"$2\"; }}\nprompt_secret_until_valid_with_default() {{\n  printf '%s' \"$3\" > \"$validator_file\"\n  printf '%s' \"$2\"\n}}\n\ncollect_opensearch_config \"yes\"\nprintf 'PASSWORD_VALIDATOR=%s\\\\n' \"$(cat \"$validator_file\")\"\n\"\"\"\n    )\n\n    assert values[\"PASSWORD_VALIDATOR\"] == \"validate_opensearch_password_strength\"\n\n\ndef test_validate_env_file_rejects_mongo_vector_storage_without_atlas_uri(\n    tmp_path: Path,\n) -> None:\n    \"\"\"validate_env_file must reject MongoVectorDBStorage when MONGO_URI is not Atlas (mongodb+srv://).\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=MongoVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n                \"MONGO_URI=mongodb://localhost:27017\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\"LLM_BINDING=openai\\n\", encoding=\"utf-8\")\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nif validate_env_file; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    values = parse_lines(result.stdout)\n    assert values[\"VALID\"] == \"no\"\n    assert \"MongoVectorDBStorage requires a MongoDB Atlas URI\" in result.stderr\n\n\ndef test_validate_env_file_rejects_empty_opensearch_hosts(tmp_path: Path) -> None:\n    \"\"\"validate_env_file should reject an explicitly empty OPENSEARCH_HOSTS setting.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LIGHTRAG_KV_STORAGE=OpenSearchKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage\",\n                \"OPENSEARCH_HOSTS=\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\"LLM_BINDING=openai\\n\", encoding=\"utf-8\")\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nif validate_env_file; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    values = parse_lines(result.stdout)\n    assert values[\"VALID\"] == \"no\"\n    assert \"Empty OPENSEARCH_HOSTS\" in result.stderr\n\n\ndef test_validate_env_file_rejects_whitespace_only_opensearch_hosts(\n    tmp_path: Path,\n) -> None:\n    \"\"\"validate_env_file should reject OpenSearch host lists with only blank entries.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LIGHTRAG_KV_STORAGE=OpenSearchKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage\",\n                \"OPENSEARCH_HOSTS=   ,   \",\n                \"OPENSEARCH_USER=admin\",\n                \"OPENSEARCH_PASSWORD=StrongPass1!\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\"LLM_BINDING=openai\\n\", encoding=\"utf-8\")\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nif validate_env_file; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    values = parse_lines(result.stdout)\n    assert values[\"VALID\"] == \"no\"\n    assert \"OPENSEARCH_HOSTS must not contain empty host entries.\" in result.stderr\n\n\ndef test_validate_env_file_rejects_docker_opensearch_without_password(\n    tmp_path: Path,\n) -> None:\n    \"\"\"validate_env_file should reject bundled OpenSearch when auth is incomplete.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LIGHTRAG_KV_STORAGE=OpenSearchKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage\",\n                \"LIGHTRAG_SETUP_OPENSEARCH_DEPLOYMENT=docker\",\n                \"OPENSEARCH_HOSTS=localhost:9200\",\n                \"OPENSEARCH_USER=admin\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\"LLM_BINDING=openai\\n\", encoding=\"utf-8\")\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nif validate_env_file; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    values = parse_lines(result.stdout)\n    assert values[\"VALID\"] == \"no\"\n    assert (\n        \"Bundled OpenSearch requires OPENSEARCH_USER and OPENSEARCH_PASSWORD\"\n        in result.stderr\n    )\n\n\ndef test_validate_env_file_rejects_weak_docker_opensearch_password(\n    tmp_path: Path,\n) -> None:\n    \"\"\"validate_env_file should reject bundled OpenSearch passwords the image will refuse.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LIGHTRAG_KV_STORAGE=OpenSearchKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage\",\n                \"LIGHTRAG_SETUP_OPENSEARCH_DEPLOYMENT=docker\",\n                \"OPENSEARCH_HOSTS=localhost:9200\",\n                \"OPENSEARCH_USER=admin\",\n                \"OPENSEARCH_PASSWORD=weakpass\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\"LLM_BINDING=openai\\n\", encoding=\"utf-8\")\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nif validate_env_file; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    values = parse_lines(result.stdout)\n    assert values[\"VALID\"] == \"no\"\n    assert \"OpenSearch requires a strong OPENSEARCH_PASSWORD\" in result.stderr\n\n\ndef test_validate_env_file_rejects_weak_host_opensearch_password(\n    tmp_path: Path,\n) -> None:\n    \"\"\"validate_env_file should reject weak OpenSearch passwords even for host deployments.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LIGHTRAG_KV_STORAGE=OpenSearchKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage\",\n                \"OPENSEARCH_HOSTS=localhost:9200\",\n                \"OPENSEARCH_USER=admin\",\n                \"OPENSEARCH_PASSWORD=weakpass\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\"LLM_BINDING=openai\\n\", encoding=\"utf-8\")\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nif validate_env_file; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    values = parse_lines(result.stdout)\n    assert values[\"VALID\"] == \"no\"\n    assert \"OpenSearch requires a strong OPENSEARCH_PASSWORD\" in result.stderr\n\n\ndef test_validate_env_file_rejects_unauthenticated_host_opensearch(\n    tmp_path: Path,\n) -> None:\n    \"\"\"validate_env_file should reject host-mode OpenSearch with no auth fields.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LIGHTRAG_KV_STORAGE=OpenSearchKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage\",\n                \"OPENSEARCH_HOSTS=localhost:9200\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\"LLM_BINDING=openai\\n\", encoding=\"utf-8\")\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nif validate_env_file; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    values = parse_lines(result.stdout)\n    assert values[\"VALID\"] == \"no\"\n    assert \"OPENSEARCH_USER\" in result.stderr\n    assert \"OPENSEARCH_PASSWORD\" in result.stderr\n\n\ndef test_validate_env_file_rejects_partial_host_opensearch_auth(\n    tmp_path: Path,\n) -> None:\n    \"\"\"validate_env_file should reject host-mode OpenSearch when only one auth field is set.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LIGHTRAG_KV_STORAGE=OpenSearchKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage\",\n                \"OPENSEARCH_HOSTS=localhost:9200\",\n                \"OPENSEARCH_USER=admin\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\"LLM_BINDING=openai\\n\", encoding=\"utf-8\")\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nif validate_env_file; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    values = parse_lines(result.stdout)\n    assert values[\"VALID\"] == \"no\"\n    assert \"OPENSEARCH_PASSWORD\" in result.stderr\n\n\ndef test_validate_env_file_rejects_opensearch_hosts_with_uri_scheme(\n    tmp_path: Path,\n) -> None:\n    \"\"\"validate_env_file should require OPENSEARCH_HOSTS to stay as host:port entries.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LIGHTRAG_KV_STORAGE=OpenSearchKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=OpenSearchVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=OpenSearchGraphStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=OpenSearchDocStatusStorage\",\n                \"OPENSEARCH_HOSTS=https://localhost:9200\",\n                \"OPENSEARCH_USER=admin\",\n                \"OPENSEARCH_PASSWORD=StrongPass1!\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\"LLM_BINDING=openai\\n\", encoding=\"utf-8\")\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nif validate_env_file; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    values = parse_lines(result.stdout)\n    assert values[\"VALID\"] == \"no\"\n    assert (\n        \"OPENSEARCH_HOSTS must use bare host:port entries, not URLs.\" in result.stderr\n    )\n\n\ndef test_validate_env_file_ignores_invalid_unused_storage_settings(\n    tmp_path: Path,\n) -> None:\n    \"\"\"validate_env_file should ignore malformed settings for backends not selected by storage.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n                \"NEO4J_URI=http://localhost:7687\",\n                \"MONGO_URI=not-a-mongo-uri\",\n                \"REDIS_URI=tcp://localhost:6379\",\n                \"MILVUS_URI=tcp://localhost:19530\",\n                \"QDRANT_URL=tcp://localhost:6333\",\n                \"MEMGRAPH_URI=http://localhost:7687\",\n                \"POSTGRES_PORT=99999\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\"LLM_BINDING=openai\\n\", encoding=\"utf-8\")\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nif validate_env_file; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    values = parse_lines(result.stdout)\n    assert values[\"VALID\"] == \"yes\"\n    assert \"Invalid NEO4J_URI\" not in result.stderr\n    assert \"Invalid MONGO_URI\" not in result.stderr\n    assert \"Invalid REDIS_URI\" not in result.stderr\n    assert \"Invalid MILVUS_URI\" not in result.stderr\n    assert \"Invalid QDRANT_URL\" not in result.stderr\n    assert \"Invalid MEMGRAPH_URI\" not in result.stderr\n    assert \"Invalid POSTGRES_PORT\" not in result.stderr\n\n\ndef test_validate_env_file_allows_empty_opensearch_hosts_when_unused(\n    tmp_path: Path,\n) -> None:\n    \"\"\"validate_env_file should ignore blank OpenSearch hosts when no OpenSearch storage is selected.\"\"\"\n\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\n        \"\\n\".join(\n            [\n                \"LIGHTRAG_KV_STORAGE=JsonKVStorage\",\n                \"LIGHTRAG_VECTOR_STORAGE=NanoVectorDBStorage\",\n                \"LIGHTRAG_GRAPH_STORAGE=NetworkXStorage\",\n                \"LIGHTRAG_DOC_STATUS_STORAGE=JsonDocStatusStorage\",\n                \"OPENSEARCH_HOSTS=\",\n            ]\n        )\n        + \"\\n\",\n        encoding=\"utf-8\",\n    )\n    (tmp_path / \"env.example\").write_text(\"LLM_BINDING=openai\\n\", encoding=\"utf-8\")\n\n    result = subprocess.run(\n        [\n            \"bash\",\n            \"--norc\",\n            \"--noprofile\",\n            \"-c\",\n            f\"\"\"\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\nreset_state\nif validate_env_file; then\n  printf 'VALID=yes\\\\n'\nelse\n  printf 'VALID=no\\\\n'\nfi\n\"\"\",\n        ],\n        cwd=REPO_ROOT,\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n\n    values = parse_lines(result.stdout)\n    assert values[\"VALID\"] == \"yes\"\n    assert \"Empty OPENSEARCH_HOSTS\" not in result.stderr\n\n\ndef test_backup_only_backs_up_env_and_generated_compose(tmp_path: Path) -> None:\n    \"\"\"backup_only should back up both .env and the active generated compose file.\"\"\"\n\n    compose_content = (\n        \"\\n\".join(\n            [\n                \"services:\",\n                \"  lightrag:\",\n                \"    image: example/lightrag:test\",\n            ]\n        )\n        + \"\\n\"\n    )\n\n    write_text_lines(tmp_path / \".env\", [\"HOST=0.0.0.0\"])\n    (tmp_path / \"docker-compose.final.yml\").write_text(\n        compose_content,\n        encoding=\"utf-8\",\n    )\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nbackup_only\n\"\"\"\n    )\n\n    env_backups = sorted(tmp_path.glob(\".env.backup.*\"))\n    assert len(env_backups) == 1\n    assert env_backups[0].read_text(encoding=\"utf-8\") == \"HOST=0.0.0.0\\n\"\n    assert \"Backed up .env to\" in output\n    assert \"Backed up compose file to\" in output\n    assert_single_compose_backup(tmp_path, compose_content)\n\n\ndef test_backup_only_skips_compose_backup_when_no_generated_compose_exists(\n    tmp_path: Path,\n) -> None:\n    \"\"\"backup_only should still succeed when only .env exists.\"\"\"\n\n    write_text_lines(tmp_path / \".env\", [\"HOST=0.0.0.0\"])\n\n    output = run_bash(\n        f\"\"\"\nset -euo pipefail\nsource \"{REPO_ROOT}/scripts/setup/setup.sh\"\nREPO_ROOT=\"{tmp_path}\"\n\nbackup_only\n\"\"\"\n    )\n\n    env_backups = sorted(tmp_path.glob(\".env.backup.*\"))\n    assert len(env_backups) == 1\n    assert \"Backed up .env to\" in output\n    assert \"Backed up compose file to\" not in output\n    assert list(tmp_path.glob(\"docker-compose.backup*.yml\")) == []\n"
  },
  {
    "path": "tests/test_lightrag_ollama_chat.py",
    "content": "\"\"\"\nLightRAG Ollama Compatibility Interface Test Script\n\nThis script tests the LightRAG's Ollama compatibility interface, including:\n1. Basic functionality tests (streaming and non-streaming responses)\n2. Query mode tests (local, global, naive, hybrid)\n3. Error handling tests (including streaming and non-streaming scenarios)\n\nAll responses use the JSON Lines format, complying with the Ollama API specification.\n\"\"\"\n\nimport pytest\nimport requests\nimport json\nimport argparse\nimport time\nfrom typing import Dict, Any, Optional, List, Callable\nfrom dataclasses import dataclass, asdict\nfrom datetime import datetime\nfrom pathlib import Path\nfrom enum import Enum, auto\n\n\nclass ErrorCode(Enum):\n    \"\"\"Error codes for MCP errors\"\"\"\n\n    InvalidRequest = auto()\n    InternalError = auto()\n\n\nclass McpError(Exception):\n    \"\"\"Base exception class for MCP errors\"\"\"\n\n    def __init__(self, code: ErrorCode, message: str):\n        self.code = code\n        self.message = message\n        super().__init__(message)\n\n\nDEFAULT_CONFIG = {\n    \"server\": {\n        \"host\": \"localhost\",\n        \"port\": 9621,\n        \"model\": \"lightrag:latest\",\n        \"timeout\": 300,\n        \"max_retries\": 1,\n        \"retry_delay\": 1,\n    },\n    \"test_cases\": {\n        \"basic\": {\"query\": \"唐僧有几个徒弟\"},\n        \"generate\": {\"query\": \"电视剧西游记导演是谁\"},\n    },\n}\n\n# Example conversation history for testing\nEXAMPLE_CONVERSATION = [\n    {\"role\": \"user\", \"content\": \"你好\"},\n    {\"role\": \"assistant\", \"content\": \"你好!我是一个AI助手,很高兴为你服务。\"},\n    {\"role\": \"user\", \"content\": \"Who are you?\"},\n    {\"role\": \"assistant\", \"content\": \"I'm a Knowledge base query assistant.\"},\n]\n\n\nclass OutputControl:\n    \"\"\"Output control class, manages the verbosity of test output\"\"\"\n\n    _verbose: bool = False\n\n    @classmethod\n    def set_verbose(cls, verbose: bool) -> None:\n        cls._verbose = verbose\n\n    @classmethod\n    def is_verbose(cls) -> bool:\n        return cls._verbose\n\n\n@dataclass\nclass ExecutionResult:\n    \"\"\"Test execution result data class\"\"\"\n\n    name: str\n    success: bool\n    duration: float\n    error: Optional[str] = None\n    timestamp: str = \"\"\n\n    def __post_init__(self):\n        if not self.timestamp:\n            self.timestamp = datetime.now().isoformat()\n\n\nclass ExecutionStats:\n    \"\"\"Test execution statistics\"\"\"\n\n    def __init__(self):\n        self.results: List[ExecutionResult] = []\n        self.start_time = datetime.now()\n\n    def add_result(self, result: ExecutionResult):\n        self.results.append(result)\n\n    def export_results(self, path: str = \"test_results.json\"):\n        \"\"\"Export test results to a JSON file\n        Args:\n            path: Output file path\n        \"\"\"\n        results_data = {\n            \"start_time\": self.start_time.isoformat(),\n            \"end_time\": datetime.now().isoformat(),\n            \"results\": [asdict(r) for r in self.results],\n            \"summary\": {\n                \"total\": len(self.results),\n                \"passed\": sum(1 for r in self.results if r.success),\n                \"failed\": sum(1 for r in self.results if not r.success),\n                \"total_duration\": sum(r.duration for r in self.results),\n            },\n        }\n\n        with open(path, \"w\", encoding=\"utf-8\") as f:\n            json.dump(results_data, f, ensure_ascii=False, indent=2)\n        print(f\"\\nTest results saved to: {path}\")\n\n    def print_summary(self):\n        total = len(self.results)\n        passed = sum(1 for r in self.results if r.success)\n        failed = total - passed\n        duration = sum(r.duration for r in self.results)\n\n        print(\"\\n=== Test Summary ===\")\n        print(f\"Start time: {self.start_time.strftime('%Y-%m-%d %H:%M:%S')}\")\n        print(f\"Total duration: {duration:.2f} seconds\")\n        print(f\"Total tests: {total}\")\n        print(f\"Passed: {passed}\")\n        print(f\"Failed: {failed}\")\n\n        if failed > 0:\n            print(\"\\nFailed tests:\")\n            for result in self.results:\n                if not result.success:\n                    print(f\"- {result.name}: {result.error}\")\n\n\ndef make_request(\n    url: str, data: Dict[str, Any], stream: bool = False, check_status: bool = True\n) -> requests.Response:\n    \"\"\"Send an HTTP request with retry mechanism\n    Args:\n        url: Request URL\n        data: Request data\n        stream: Whether to use streaming response\n        check_status: Whether to check HTTP status code (default: True)\n    Returns:\n        requests.Response: Response object\n\n    Raises:\n        requests.exceptions.RequestException: Request failed after all retries\n        requests.exceptions.HTTPError: HTTP status code is not 200 (when check_status is True)\n    \"\"\"\n    server_config = CONFIG[\"server\"]\n    max_retries = server_config[\"max_retries\"]\n    retry_delay = server_config[\"retry_delay\"]\n    timeout = server_config[\"timeout\"]\n\n    for attempt in range(max_retries):\n        try:\n            response = requests.post(url, json=data, stream=stream, timeout=timeout)\n            if check_status and response.status_code != 200:\n                response.raise_for_status()\n            return response\n        except requests.exceptions.RequestException as e:\n            if attempt == max_retries - 1:  # Last retry\n                raise\n            print(f\"\\nRequest failed, retrying in {retry_delay} seconds: {str(e)}\")\n            time.sleep(retry_delay)\n\n\ndef load_config() -> Dict[str, Any]:\n    \"\"\"Load configuration file\n\n    First try to load from config.json in the current directory,\n    if it doesn't exist, use the default configuration\n    Returns:\n        Configuration dictionary\n    \"\"\"\n    config_path = Path(\"config.json\")\n    if config_path.exists():\n        with open(config_path, \"r\", encoding=\"utf-8\") as f:\n            return json.load(f)\n    return DEFAULT_CONFIG\n\n\ndef print_json_response(data: Dict[str, Any], title: str = \"\", indent: int = 2) -> None:\n    \"\"\"Format and print JSON response data\n    Args:\n        data: Data dictionary to print\n        title: Title to print\n        indent: Number of spaces for JSON indentation\n    \"\"\"\n    if OutputControl.is_verbose():\n        if title:\n            print(f\"\\n=== {title} ===\")\n        print(json.dumps(data, ensure_ascii=False, indent=indent))\n\n\n# Global configuration\nCONFIG = load_config()\n\n\ndef get_base_url(endpoint: str = \"chat\") -> str:\n    \"\"\"Return the base URL for specified endpoint\n    Args:\n        endpoint: API endpoint name (chat or generate)\n    Returns:\n        Complete URL for the endpoint\n    \"\"\"\n    server = CONFIG[\"server\"]\n    return f\"http://{server['host']}:{server['port']}/api/{endpoint}\"\n\n\ndef create_chat_request_data(\n    content: str,\n    stream: bool = False,\n    model: str = None,\n    conversation_history: List[Dict[str, str]] = None,\n) -> Dict[str, Any]:\n    \"\"\"Create chat request data\n    Args:\n        content: User message content\n        stream: Whether to use streaming response\n        model: Model name\n        conversation_history: List of previous conversation messages\n        history_turns: Number of history turns to include\n    Returns:\n        Dictionary containing complete chat request data\n    \"\"\"\n    messages = conversation_history or []\n    messages.append({\"role\": \"user\", \"content\": content})\n\n    return {\n        \"model\": model or CONFIG[\"server\"][\"model\"],\n        \"messages\": messages,\n        \"stream\": stream,\n    }\n\n\ndef create_generate_request_data(\n    prompt: str,\n    system: str = None,\n    stream: bool = False,\n    model: str = None,\n    options: Dict[str, Any] = None,\n) -> Dict[str, Any]:\n    \"\"\"Create generate request data\n    Args:\n        prompt: Generation prompt\n        system: System prompt\n        stream: Whether to use streaming response\n        model: Model name\n        options: Additional options\n    Returns:\n        Dictionary containing complete generate request data\n    \"\"\"\n    data = {\n        \"model\": model or CONFIG[\"server\"][\"model\"],\n        \"prompt\": prompt,\n        \"stream\": stream,\n    }\n    if system:\n        data[\"system\"] = system\n    if options:\n        data[\"options\"] = options\n    return data\n\n\n# Global test statistics\nSTATS = ExecutionStats()\n\n\ndef run_test(func: Callable, name: str) -> None:\n    \"\"\"Run a test and record the results\n    Args:\n        func: Test function\n        name: Test name\n    \"\"\"\n    start_time = time.time()\n    try:\n        func()\n        duration = time.time() - start_time\n        STATS.add_result(ExecutionResult(name, True, duration))\n    except Exception as e:\n        duration = time.time() - start_time\n        STATS.add_result(ExecutionResult(name, False, duration, str(e)))\n        raise\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef test_non_stream_chat() -> None:\n    \"\"\"Test non-streaming call to /api/chat endpoint\"\"\"\n    url = get_base_url()\n\n    # Send request with conversation history\n    data = create_chat_request_data(\n        CONFIG[\"test_cases\"][\"basic\"][\"query\"],\n        stream=False,\n        conversation_history=EXAMPLE_CONVERSATION,\n    )\n    response = make_request(url, data)\n\n    # Print response\n    if OutputControl.is_verbose():\n        print(\"\\n=== Non-streaming call response ===\")\n    response_json = response.json()\n\n    # Print response content\n    print_json_response(\n        {\"model\": response_json[\"model\"], \"message\": response_json[\"message\"]},\n        \"Response content\",\n    )\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef test_stream_chat() -> None:\n    \"\"\"Test streaming call to /api/chat endpoint\n\n    Use JSON Lines format to process streaming responses, each line is a complete JSON object.\n    Response format:\n    {\n        \"model\": \"lightrag:latest\",\n        \"created_at\": \"2024-01-15T00:00:00Z\",\n        \"message\": {\n            \"role\": \"assistant\",\n            \"content\": \"Partial response content\",\n            \"images\": null\n        },\n        \"done\": false\n    }\n\n    The last message will contain performance statistics, with done set to true.\n    \"\"\"\n    url = get_base_url()\n\n    # Send request with conversation history\n    data = create_chat_request_data(\n        CONFIG[\"test_cases\"][\"basic\"][\"query\"],\n        stream=True,\n        conversation_history=EXAMPLE_CONVERSATION,\n    )\n    response = make_request(url, data, stream=True)\n\n    if OutputControl.is_verbose():\n        print(\"\\n=== Streaming call response ===\")\n    output_buffer = []\n    try:\n        for line in response.iter_lines():\n            if line:  # Skip empty lines\n                try:\n                    # Decode and parse JSON\n                    data = json.loads(line.decode(\"utf-8\"))\n                    if data.get(\"done\", True):  # If it's the completion marker\n                        if (\n                            \"total_duration\" in data\n                        ):  # Final performance statistics message\n                            # print_json_response(data, \"Performance statistics\")\n                            break\n                    else:  # Normal content message\n                        message = data.get(\"message\", {})\n                        content = message.get(\"content\", \"\")\n                        if content:  # Only collect non-empty content\n                            output_buffer.append(content)\n                            print(\n                                content, end=\"\", flush=True\n                            )  # Print content in real-time\n                except json.JSONDecodeError:\n                    print(\"Error decoding JSON from response line\")\n    finally:\n        response.close()  # Ensure the response connection is closed\n\n    # Print a newline\n    print()\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef test_query_modes() -> None:\n    \"\"\"Test different query mode prefixes\n\n    Supported query modes:\n    - /local: Local retrieval mode, searches only in highly relevant documents\n    - /global: Global retrieval mode, searches across all documents\n    - /naive: Naive mode, does not use any optimization strategies\n    - /hybrid: Hybrid mode (default), combines multiple strategies\n    - /mix: Mix mode\n\n    Each mode will return responses in the same format, but with different retrieval strategies.\n    \"\"\"\n    url = get_base_url()\n    modes = [\"local\", \"global\", \"naive\", \"hybrid\", \"mix\"]\n\n    for mode in modes:\n        if OutputControl.is_verbose():\n            print(f\"\\n=== Testing /{mode} mode ===\")\n        data = create_chat_request_data(\n            f\"/{mode} {CONFIG['test_cases']['basic']['query']}\", stream=False\n        )\n\n        # Send request\n        response = make_request(url, data)\n        response_json = response.json()\n\n        # Print response content\n        print_json_response(\n            {\"model\": response_json[\"model\"], \"message\": response_json[\"message\"]}\n        )\n\n\ndef create_error_test_data(error_type: str) -> Dict[str, Any]:\n    \"\"\"Create request data for error testing\n    Args:\n        error_type: Error type, supported:\n            - empty_messages: Empty message list\n            - invalid_role: Invalid role field\n            - missing_content: Missing content field\n\n    Returns:\n        Request dictionary containing error data\n    \"\"\"\n    error_data = {\n        \"empty_messages\": {\"model\": \"lightrag:latest\", \"messages\": [], \"stream\": True},\n        \"invalid_role\": {\n            \"model\": \"lightrag:latest\",\n            \"messages\": [{\"invalid_role\": \"user\", \"content\": \"Test message\"}],\n            \"stream\": True,\n        },\n        \"missing_content\": {\n            \"model\": \"lightrag:latest\",\n            \"messages\": [{\"role\": \"user\"}],\n            \"stream\": True,\n        },\n    }\n    return error_data.get(error_type, error_data[\"empty_messages\"])\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef test_stream_error_handling() -> None:\n    \"\"\"Test error handling for streaming responses\n\n    Test scenarios:\n    1. Empty message list\n    2. Message format error (missing required fields)\n\n    Error responses should be returned immediately without establishing a streaming connection.\n    The status code should be 4xx, and detailed error information should be returned.\n    \"\"\"\n    url = get_base_url()\n\n    if OutputControl.is_verbose():\n        print(\"\\n=== Testing streaming response error handling ===\")\n\n    # Test empty message list\n    if OutputControl.is_verbose():\n        print(\"\\n--- Testing empty message list (streaming) ---\")\n    data = create_error_test_data(\"empty_messages\")\n    response = make_request(url, data, stream=True, check_status=False)\n    print(f\"Status code: {response.status_code}\")\n    if response.status_code != 200:\n        print_json_response(response.json(), \"Error message\")\n    response.close()\n\n    # Test invalid role field\n    if OutputControl.is_verbose():\n        print(\"\\n--- Testing invalid role field (streaming) ---\")\n    data = create_error_test_data(\"invalid_role\")\n    response = make_request(url, data, stream=True, check_status=False)\n    print(f\"Status code: {response.status_code}\")\n    if response.status_code != 200:\n        print_json_response(response.json(), \"Error message\")\n    response.close()\n\n    # Test missing content field\n    if OutputControl.is_verbose():\n        print(\"\\n--- Testing missing content field (streaming) ---\")\n    data = create_error_test_data(\"missing_content\")\n    response = make_request(url, data, stream=True, check_status=False)\n    print(f\"Status code: {response.status_code}\")\n    if response.status_code != 200:\n        print_json_response(response.json(), \"Error message\")\n    response.close()\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef test_error_handling() -> None:\n    \"\"\"Test error handling for non-streaming responses\n\n    Test scenarios:\n    1. Empty message list\n    2. Message format error (missing required fields)\n\n    Error response format:\n    {\n        \"detail\": \"Error description\"\n    }\n\n    All errors should return appropriate HTTP status codes and clear error messages.\n    \"\"\"\n    url = get_base_url()\n\n    if OutputControl.is_verbose():\n        print(\"\\n=== Testing error handling ===\")\n\n    # Test empty message list\n    if OutputControl.is_verbose():\n        print(\"\\n--- Testing empty message list ---\")\n    data = create_error_test_data(\"empty_messages\")\n    data[\"stream\"] = False  # Change to non-streaming mode\n    response = make_request(url, data, check_status=False)\n    print(f\"Status code: {response.status_code}\")\n    print_json_response(response.json(), \"Error message\")\n\n    # Test invalid role field\n    if OutputControl.is_verbose():\n        print(\"\\n--- Testing invalid role field ---\")\n    data = create_error_test_data(\"invalid_role\")\n    data[\"stream\"] = False  # Change to non-streaming mode\n    response = make_request(url, data, check_status=False)\n    print(f\"Status code: {response.status_code}\")\n    print_json_response(response.json(), \"Error message\")\n\n    # Test missing content field\n    if OutputControl.is_verbose():\n        print(\"\\n--- Testing missing content field ---\")\n    data = create_error_test_data(\"missing_content\")\n    data[\"stream\"] = False  # Change to non-streaming mode\n    response = make_request(url, data, check_status=False)\n    print(f\"Status code: {response.status_code}\")\n    print_json_response(response.json(), \"Error message\")\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef test_non_stream_generate() -> None:\n    \"\"\"Test non-streaming call to /api/generate endpoint\"\"\"\n    url = get_base_url(\"generate\")\n    data = create_generate_request_data(\n        CONFIG[\"test_cases\"][\"generate\"][\"query\"], stream=False\n    )\n\n    # Send request\n    response = make_request(url, data)\n\n    # Print response\n    if OutputControl.is_verbose():\n        print(\"\\n=== Non-streaming generate response ===\")\n    response_json = response.json()\n\n    # Print response content\n    print(json.dumps(response_json, ensure_ascii=False, indent=2))\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef test_stream_generate() -> None:\n    \"\"\"Test streaming call to /api/generate endpoint\"\"\"\n    url = get_base_url(\"generate\")\n    data = create_generate_request_data(\n        CONFIG[\"test_cases\"][\"generate\"][\"query\"], stream=True\n    )\n\n    # Send request and get streaming response\n    response = make_request(url, data, stream=True)\n\n    if OutputControl.is_verbose():\n        print(\"\\n=== Streaming generate response ===\")\n    output_buffer = []\n    try:\n        for line in response.iter_lines():\n            if line:  # Skip empty lines\n                try:\n                    # Decode and parse JSON\n                    data = json.loads(line.decode(\"utf-8\"))\n                    if data.get(\"done\", True):  # If it's the completion marker\n                        if (\n                            \"total_duration\" in data\n                        ):  # Final performance statistics message\n                            break\n                    else:  # Normal content message\n                        content = data.get(\"response\", \"\")\n                        if content:  # Only collect non-empty content\n                            output_buffer.append(content)\n                            print(\n                                content, end=\"\", flush=True\n                            )  # Print content in real-time\n                except json.JSONDecodeError:\n                    print(\"Error decoding JSON from response line\")\n    finally:\n        response.close()  # Ensure the response connection is closed\n\n    # Print a newline\n    print()\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef test_generate_with_system() -> None:\n    \"\"\"Test generate with system prompt\"\"\"\n    url = get_base_url(\"generate\")\n    data = create_generate_request_data(\n        CONFIG[\"test_cases\"][\"generate\"][\"query\"],\n        system=\"你是一个知识渊博的助手\",\n        stream=False,\n    )\n\n    # Send request\n    response = make_request(url, data)\n\n    # Print response\n    if OutputControl.is_verbose():\n        print(\"\\n=== Generate with system prompt response ===\")\n    response_json = response.json()\n\n    # Print response content\n    print_json_response(\n        {\n            \"model\": response_json[\"model\"],\n            \"response\": response_json[\"response\"],\n            \"done\": response_json[\"done\"],\n        },\n        \"Response content\",\n    )\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef test_generate_error_handling() -> None:\n    \"\"\"Test error handling for generate endpoint\"\"\"\n    url = get_base_url(\"generate\")\n\n    # Test empty prompt\n    if OutputControl.is_verbose():\n        print(\"\\n=== Testing empty prompt ===\")\n    data = create_generate_request_data(\"\", stream=False)\n    response = make_request(url, data, check_status=False)\n    print(f\"Status code: {response.status_code}\")\n    print_json_response(response.json(), \"Error message\")\n\n    # Test invalid options\n    if OutputControl.is_verbose():\n        print(\"\\n=== Testing invalid options ===\")\n    data = create_generate_request_data(\n        CONFIG[\"test_cases\"][\"basic\"][\"query\"],\n        options={\"invalid_option\": \"value\"},\n        stream=False,\n    )\n    response = make_request(url, data, check_status=False)\n    print(f\"Status code: {response.status_code}\")\n    print_json_response(response.json(), \"Error message\")\n\n\n@pytest.mark.integration\n@pytest.mark.requires_api\ndef test_generate_concurrent() -> None:\n    \"\"\"Test concurrent generate requests\"\"\"\n    import asyncio\n    import aiohttp\n    from contextlib import asynccontextmanager\n\n    @asynccontextmanager\n    async def get_session():\n        async with aiohttp.ClientSession() as session:\n            yield session\n\n    async def make_request(session, prompt: str, request_id: int):\n        url = get_base_url(\"generate\")\n        data = create_generate_request_data(prompt, stream=False)\n        try:\n            async with session.post(url, json=data) as response:\n                if response.status != 200:\n                    error_msg = (\n                        f\"Request {request_id} failed with status {response.status}\"\n                    )\n                    if OutputControl.is_verbose():\n                        print(f\"\\n{error_msg}\")\n                    raise McpError(ErrorCode.InternalError, error_msg)\n                result = await response.json()\n                if \"error\" in result:\n                    error_msg = (\n                        f\"Request {request_id} returned error: {result['error']}\"\n                    )\n                    if OutputControl.is_verbose():\n                        print(f\"\\n{error_msg}\")\n                    raise McpError(ErrorCode.InternalError, error_msg)\n                return result\n        except Exception as e:\n            error_msg = f\"Request {request_id} failed: {str(e)}\"\n            if OutputControl.is_verbose():\n                print(f\"\\n{error_msg}\")\n            raise McpError(ErrorCode.InternalError, error_msg)\n\n    async def run_concurrent_requests():\n        prompts = [\"第一个问题\", \"第二个问题\", \"第三个问题\", \"第四个问题\", \"第五个问题\"]\n\n        async with get_session() as session:\n            tasks = [\n                make_request(session, prompt, i + 1) for i, prompt in enumerate(prompts)\n            ]\n            results = await asyncio.gather(*tasks, return_exceptions=True)\n\n            success_results = []\n            error_messages = []\n\n            for i, result in enumerate(results):\n                if isinstance(result, Exception):\n                    error_messages.append(f\"Request {i + 1} failed: {str(result)}\")\n                else:\n                    success_results.append((i + 1, result))\n\n            if error_messages:\n                for req_id, result in success_results:\n                    if OutputControl.is_verbose():\n                        print(f\"\\nRequest {req_id} succeeded:\")\n                        print_json_response(result)\n\n                error_summary = \"\\n\".join(error_messages)\n                raise McpError(\n                    ErrorCode.InternalError,\n                    f\"Some concurrent requests failed:\\n{error_summary}\",\n                )\n\n            return results\n\n    if OutputControl.is_verbose():\n        print(\"\\n=== Testing concurrent generate requests ===\")\n\n    # Run concurrent requests\n    try:\n        results = asyncio.run(run_concurrent_requests())\n        # all success, print out results\n        for i, result in enumerate(results, 1):\n            print(f\"\\nRequest {i} result:\")\n            print_json_response(result)\n    except McpError:\n        # error message already printed\n        raise\n\n\ndef get_test_cases() -> Dict[str, Callable]:\n    \"\"\"Get all available test cases\n    Returns:\n        A dictionary mapping test names to test functions\n    \"\"\"\n    return {\n        \"non_stream\": test_non_stream_chat,\n        \"stream\": test_stream_chat,\n        \"modes\": test_query_modes,\n        \"errors\": test_error_handling,\n        \"stream_errors\": test_stream_error_handling,\n        \"non_stream_generate\": test_non_stream_generate,\n        \"stream_generate\": test_stream_generate,\n        \"generate_with_system\": test_generate_with_system,\n        \"generate_errors\": test_generate_error_handling,\n        \"generate_concurrent\": test_generate_concurrent,\n    }\n\n\ndef create_default_config():\n    \"\"\"Create a default configuration file\"\"\"\n    config_path = Path(\"config.json\")\n    if not config_path.exists():\n        with open(config_path, \"w\", encoding=\"utf-8\") as f:\n            json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=2)\n        print(f\"Default configuration file created: {config_path}\")\n\n\ndef parse_args() -> argparse.Namespace:\n    \"\"\"Parse command line arguments\"\"\"\n    parser = argparse.ArgumentParser(\n        description=\"LightRAG Ollama Compatibility Interface Testing\",\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n        epilog=\"\"\"\nConfiguration file (config.json):\n  {\n    \"server\": {\n      \"host\": \"localhost\",      # Server address\n      \"port\": 9621,            # Server port\n      \"model\": \"lightrag:latest\" # Default model name\n    },\n    \"test_cases\": {\n      \"basic\": {\n        \"query\": \"Test query\",      # Basic query text\n        \"stream_query\": \"Stream query\" # Stream query text\n      }\n    }\n  }\n\"\"\",\n    )\n    parser.add_argument(\n        \"-q\",\n        \"--quiet\",\n        action=\"store_true\",\n        help=\"Silent mode, only display test result summary\",\n    )\n    parser.add_argument(\n        \"-a\",\n        \"--ask\",\n        type=str,\n        help=\"Specify query content, which will override the query settings in the configuration file\",\n    )\n    parser.add_argument(\n        \"--init-config\", action=\"store_true\", help=\"Create default configuration file\"\n    )\n    parser.add_argument(\n        \"--output\",\n        type=str,\n        default=\"\",\n        help=\"Test result output file path, default is not to output to a file\",\n    )\n    parser.add_argument(\n        \"--tests\",\n        nargs=\"+\",\n        choices=list(get_test_cases().keys()) + [\"all\"],\n        default=[\"all\"],\n        help=\"Test cases to run, options: %(choices)s. Use 'all' to run all tests （except error tests)\",\n    )\n    return parser.parse_args()\n\n\nif __name__ == \"__main__\":\n    args = parse_args()\n\n    # Set output mode\n    OutputControl.set_verbose(not args.quiet)\n\n    # If query content is specified, update the configuration\n    if args.ask:\n        CONFIG[\"test_cases\"][\"basic\"][\"query\"] = args.ask\n\n    # If specified to create a configuration file\n    if args.init_config:\n        create_default_config()\n        exit(0)\n\n    test_cases = get_test_cases()\n\n    try:\n        if \"all\" in args.tests:\n            # Run all tests except error handling tests\n            if OutputControl.is_verbose():\n                print(\"\\n【Chat API Tests】\")\n            run_test(test_non_stream_chat, \"Non-streaming Chat Test\")\n            run_test(test_stream_chat, \"Streaming Chat Test\")\n            run_test(test_query_modes, \"Chat Query Mode Test\")\n\n            if OutputControl.is_verbose():\n                print(\"\\n【Generate API Tests】\")\n            run_test(test_non_stream_generate, \"Non-streaming Generate Test\")\n            run_test(test_stream_generate, \"Streaming Generate Test\")\n            run_test(test_generate_with_system, \"Generate with System Prompt Test\")\n            run_test(test_generate_concurrent, \"Generate Concurrent Test\")\n        else:\n            # Run specified tests\n            for test_name in args.tests:\n                if OutputControl.is_verbose():\n                    print(f\"\\n【Running Test: {test_name}】\")\n                run_test(test_cases[test_name], test_name)\n    except Exception as e:\n        print(f\"\\nAn error occurred: {str(e)}\")\n    finally:\n        # Print test statistics\n        STATS.print_summary()\n        # If an output file path is specified, export the results\n        if args.output:\n            STATS.export_results(args.output)\n"
  },
  {
    "path": "tests/test_llm_cache_tools_opensearch.py",
    "content": "\"\"\"\nOffline tests for OpenSearch support in LLM cache tools.\n\"\"\"\n\nfrom types import SimpleNamespace\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\n\npytest.importorskip(\n    \"opensearchpy\",\n    reason=\"opensearchpy is required for OpenSearch tool tests\",\n)\n\nfrom lightrag.tools.clean_llm_query_cache import CleanupStats, CleanupTool\nfrom lightrag.tools.migrate_llm_cache import MigrationTool\n\npytestmark = pytest.mark.offline\n\n\nclass FakeOpenSearchStorage:\n    def __init__(self, batches, workspace=\"test-workspace\"):\n        self._batches = batches\n        self.workspace = workspace\n        self.deleted_batches = []\n\n    async def _iter_raw_docs(self, batch_size=1000):\n        for batch in self._batches:\n            yield batch\n\n    async def delete(self, ids):\n        self.deleted_batches.append(list(ids))\n\n\ndef _flatten(batches):\n    return [item for batch in batches for item in batch]\n\n\nclass TestCleanupToolOpenSearch:\n    @pytest.mark.asyncio\n    async def test_count_query_caches_opensearch(self):\n        tool = CleanupTool()\n        storage = FakeOpenSearchStorage(\n            [\n                [\n                    {\"_id\": \"mix:query:1\", \"_source\": {}},\n                    {\"_id\": \"mix:keywords:1\", \"_source\": {}},\n                    {\"_id\": \"default:extract:1\", \"_source\": {}},\n                ],\n                [\n                    {\"_id\": \"hybrid:query:1\", \"_source\": {}},\n                    {\"_id\": \"local:keywords:1\", \"_source\": {}},\n                    {\"_id\": \"other:key:1\", \"_source\": {}},\n                ],\n            ]\n        )\n\n        counts = await tool.count_query_caches(storage, \"OpenSearchKVStorage\")\n\n        assert counts[\"mix\"] == {\"query\": 1, \"keywords\": 1}\n        assert counts[\"hybrid\"] == {\"query\": 1, \"keywords\": 0}\n        assert counts[\"local\"] == {\"query\": 0, \"keywords\": 1}\n        assert counts[\"global\"] == {\"query\": 0, \"keywords\": 0}\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        (\"cleanup_type\", \"expected_ids\"),\n        [\n            (\n                \"all\",\n                [\n                    \"mix:query:1\",\n                    \"mix:keywords:1\",\n                    \"global:query:1\",\n                    \"local:keywords:1\",\n                ],\n            ),\n            (\"query\", [\"mix:query:1\", \"global:query:1\"]),\n            (\"keywords\", [\"mix:keywords:1\", \"local:keywords:1\"]),\n        ],\n    )\n    async def test_delete_query_caches_opensearch(self, cleanup_type, expected_ids):\n        tool = CleanupTool()\n        tool.batch_size = 2\n        storage = FakeOpenSearchStorage(\n            [\n                [\n                    {\"_id\": \"mix:query:1\", \"_source\": {}},\n                    {\"_id\": \"mix:keywords:1\", \"_source\": {}},\n                ],\n                [\n                    {\"_id\": \"global:query:1\", \"_source\": {}},\n                    {\"_id\": \"local:keywords:1\", \"_source\": {}},\n                    {\"_id\": \"default:extract:1\", \"_source\": {}},\n                ],\n            ]\n        )\n        stats = CleanupStats()\n\n        await tool.delete_query_caches(\n            storage, \"OpenSearchKVStorage\", cleanup_type, stats\n        )\n\n        assert _flatten(storage.deleted_batches) == expected_ids\n        assert all(len(batch) <= 2 for batch in storage.deleted_batches)\n        assert stats.successfully_deleted == len(expected_ids)\n        assert stats.successful_batches == len(storage.deleted_batches)\n\n    def test_check_config_ini_for_storage_opensearch(self, tmp_path, monkeypatch):\n        monkeypatch.chdir(tmp_path)\n        (tmp_path / \"config.ini\").write_text(\"[opensearch]\\nhosts = localhost:9200\\n\")\n\n        assert CleanupTool().check_config_ini_for_storage(\"OpenSearchKVStorage\")\n\n    def test_get_storage_class_opensearch(self):\n        cleanup_cls = CleanupTool().get_storage_class(\"OpenSearchKVStorage\")\n        migrate_cls = MigrationTool().get_storage_class(\"OpenSearchKVStorage\")\n\n        assert cleanup_cls.__name__ == \"OpenSearchKVStorage\"\n        assert migrate_cls.__name__ == \"OpenSearchKVStorage\"\n\n\nclass TestMigrationToolOpenSearch:\n    @pytest.mark.asyncio\n    async def test_count_and_stream_default_caches_opensearch(self):\n        tool = MigrationTool()\n        storage = FakeOpenSearchStorage(\n            [\n                [\n                    {\"_id\": \"default:extract:1\", \"_source\": {\"return\": \"a\"}},\n                    {\"_id\": \"mix:query:1\", \"_source\": {\"return\": \"ignored\"}},\n                ],\n                [\n                    {\"_id\": \"default:summary:1\", \"_source\": {\"return\": \"b\"}},\n                    {\"_id\": \"default:extract:2\", \"_source\": {\"return\": \"c\"}},\n                ],\n            ]\n        )\n\n        count = await tool.count_default_caches(storage, \"OpenSearchKVStorage\")\n        streamed = [\n            batch\n            async for batch in tool.stream_default_caches(\n                storage, \"OpenSearchKVStorage\", batch_size=2\n            )\n        ]\n\n        assert count == 3\n        assert streamed == [\n            {\n                \"default:extract:1\": {\"return\": \"a\"},\n                \"default:summary:1\": {\"return\": \"b\"},\n            },\n            {\"default:extract:2\": {\"return\": \"c\"}},\n        ]\n\n    def test_count_available_storage_types_includes_opensearch(\n        self, tmp_path, monkeypatch\n    ):\n        monkeypatch.chdir(tmp_path)\n        (tmp_path / \"config.ini\").write_text(\"[opensearch]\\nhosts = localhost:9200\\n\")\n\n        with patch.dict(\"os.environ\", {}, clear=True):\n            assert MigrationTool().count_available_storage_types() == 2\n\n    @pytest.mark.asyncio\n    async def test_setup_storage_returns_effective_workspace(self, monkeypatch):\n        tool = MigrationTool()\n        fake_storage = SimpleNamespace(workspace=\"forced-workspace\")\n\n        monkeypatch.setattr(tool, \"check_env_vars\", lambda _: True)\n        monkeypatch.setattr(\n            tool, \"initialize_storage\", AsyncMock(return_value=fake_storage)\n        )\n        monkeypatch.setattr(tool, \"count_default_caches\", AsyncMock(return_value=3))\n\n        with patch(\"builtins.input\", return_value=\"5\"):\n            storage, storage_name, workspace, total_count = await tool.setup_storage(\n                \"Source\", use_streaming=True\n            )\n\n        assert storage is fake_storage\n        assert storage_name == \"OpenSearchKVStorage\"\n        assert workspace == \"forced-workspace\"\n        assert total_count == 3\n"
  },
  {
    "path": "tests/test_milvus_index_config.py",
    "content": "\"\"\"\nTests for Milvus index configuration\n\nThis test suite validates the MilvusIndexConfig class and its integration\nwith MilvusVectorDBStorage.\n\"\"\"\n\nimport pytest\nimport os\nfrom unittest.mock import patch, MagicMock\nfrom lightrag.kg.milvus_impl import (\n    MilvusIndexConfig,\n    SUPPORTED_INDEX_TYPES,\n    SUPPORTED_METRIC_TYPES,\n    SUPPORTED_SQ_TYPES,\n    SUPPORTED_REFINE_TYPES,\n)\n\n\nclass TestMilvusIndexConfig:\n    \"\"\"MilvusIndexConfig unit tests\"\"\"\n\n    def test_default_values(self):\n        \"\"\"Test default configuration\"\"\"\n        config = MilvusIndexConfig()\n        assert config.index_type == \"AUTOINDEX\"\n        assert config.metric_type == \"COSINE\"\n        assert config.hnsw_m == 16\n        assert config.hnsw_ef_construction == 360\n        assert config.hnsw_ef == 200\n        assert config.sq_type == \"SQ8\"\n        assert not config.sq_refine\n        assert config.sq_refine_type == \"FP32\"\n        assert config.sq_refine_k == 10\n        assert config.ivf_nlist == 1024\n        assert config.ivf_nprobe == 16\n\n    def test_env_override(self):\n        \"\"\"Test environment variable override\"\"\"\n        with patch.dict(\n            os.environ,\n            {\n                \"MILVUS_INDEX_TYPE\": \"HNSW\",\n                \"MILVUS_METRIC_TYPE\": \"L2\",\n                \"MILVUS_HNSW_M\": \"64\",\n            },\n        ):\n            config = MilvusIndexConfig()\n            assert config.index_type == \"HNSW\"\n            assert config.metric_type == \"L2\"\n            assert config.hnsw_m == 64\n\n    def test_init_param_priority(self):\n        \"\"\"Test initialization parameter priority over environment variables\"\"\"\n        with patch.dict(os.environ, {\"MILVUS_INDEX_TYPE\": \"IVF_FLAT\"}):\n            config = MilvusIndexConfig(index_type=\"HNSW\")\n            assert config.index_type == \"HNSW\"  # Init param takes precedence\n\n    def test_case_insensitive_index_type(self):\n        \"\"\"Test that index type is case insensitive\"\"\"\n        config = MilvusIndexConfig(index_type=\"hnsw\")\n        assert config.index_type == \"HNSW\"\n\n    def test_case_insensitive_metric_type(self):\n        \"\"\"Test that metric type is case insensitive\"\"\"\n        config = MilvusIndexConfig(metric_type=\"cosine\")\n        assert config.metric_type == \"COSINE\"\n\n    def test_invalid_index_type(self):\n        \"\"\"Test invalid index type raises exception\"\"\"\n        with pytest.raises(ValueError, match=\"Unsupported index type\"):\n            MilvusIndexConfig(index_type=\"INVALID_INDEX\")\n\n    def test_invalid_metric_type(self):\n        \"\"\"Test invalid metric type raises exception\"\"\"\n        with pytest.raises(ValueError, match=\"Unsupported metric type\"):\n            MilvusIndexConfig(metric_type=\"INVALID_METRIC\")\n\n    def test_invalid_hnsw_m_range_low(self):\n        \"\"\"Test HNSW M parameter range validation (too low)\"\"\"\n        with pytest.raises(ValueError, match=\"hnsw_m must be in\"):\n            MilvusIndexConfig(hnsw_m=1)  # Less than 2\n\n    def test_invalid_hnsw_m_range_high(self):\n        \"\"\"Test HNSW M parameter range validation (too high)\"\"\"\n        with pytest.raises(ValueError, match=\"hnsw_m must be in\"):\n            MilvusIndexConfig(hnsw_m=3000)  # Greater than 2048\n\n    def test_valid_hnsw_m_boundary(self):\n        \"\"\"Test HNSW M parameter boundary values\"\"\"\n        config_low = MilvusIndexConfig(hnsw_m=2)\n        assert config_low.hnsw_m == 2\n\n        config_high = MilvusIndexConfig(hnsw_m=2048)\n        assert config_high.hnsw_m == 2048\n\n    def test_invalid_hnsw_ef_construction(self):\n        \"\"\"Test HNSW efConstruction validation\"\"\"\n        with pytest.raises(ValueError, match=\"hnsw_ef_construction must be\"):\n            MilvusIndexConfig(hnsw_ef_construction=0)\n\n    def test_invalid_ivf_nlist_low(self):\n        \"\"\"Test IVF nlist parameter range validation (too low)\"\"\"\n        with pytest.raises(ValueError, match=\"ivf_nlist must be in\"):\n            MilvusIndexConfig(ivf_nlist=0)\n\n    def test_invalid_ivf_nlist_high(self):\n        \"\"\"Test IVF nlist parameter range validation (too high)\"\"\"\n        with pytest.raises(ValueError, match=\"ivf_nlist must be in\"):\n            MilvusIndexConfig(ivf_nlist=70000)\n\n    def test_invalid_sq_type(self):\n        \"\"\"Test invalid sq_type\"\"\"\n        with pytest.raises(ValueError, match=\"Unsupported sq_type\"):\n            MilvusIndexConfig(index_type=\"HNSW_SQ\", sq_type=\"INVALID\")\n\n    def test_invalid_refine_type(self):\n        \"\"\"Test invalid refine_type\"\"\"\n        with pytest.raises(ValueError, match=\"Unsupported refine_type\"):\n            MilvusIndexConfig(\n                index_type=\"HNSW_SQ\", sq_refine=True, sq_refine_type=\"INVALID\"\n            )\n\n    def test_version_validation_hnsw_sq_pass(self):\n        \"\"\"Test HNSW_SQ version validation passes with valid versions\"\"\"\n        config = MilvusIndexConfig(index_type=\"HNSW_SQ\")\n\n        # Version meets requirement\n        config.validate_milvus_version(\"2.6.8\")  # Exactly required\n        config.validate_milvus_version(\"2.6.9\")  # Above requirement\n        config.validate_milvus_version(\"2.7.0\")  # Higher version\n\n    def test_version_validation_hnsw_sq_fail(self):\n        \"\"\"Test HNSW_SQ version validation fails with invalid versions\"\"\"\n        config = MilvusIndexConfig(index_type=\"HNSW_SQ\")\n\n        # Version does not meet requirement\n        with pytest.raises(ValueError, match=\"HNSW_SQ requires Milvus 2.6.8\"):\n            config.validate_milvus_version(\"2.6.7\")  # Below 2.6.8\n\n        with pytest.raises(ValueError, match=\"HNSW_SQ requires Milvus 2.6.8\"):\n            config.validate_milvus_version(\"2.5.0\")  # Much lower\n\n    def test_version_validation_hnsw_sq_with_sq4u(self):\n        \"\"\"Test HNSW_SQ + SQ4U version validation\"\"\"\n        config = MilvusIndexConfig(index_type=\"HNSW_SQ\", sq_type=\"SQ4U\")\n\n        # Passes with valid version\n        config.validate_milvus_version(\"2.6.9\")\n\n        # Fails with invalid version\n        with pytest.raises(ValueError, match=\"HNSW_SQ requires Milvus 2.6.8\"):\n            config.validate_milvus_version(\"2.6.0\")\n\n    def test_version_validation_hnsw_no_requirement(self):\n        \"\"\"Test normal HNSW has no version restriction\"\"\"\n        config = MilvusIndexConfig(index_type=\"HNSW\")\n\n        # No version restriction\n        config.validate_milvus_version(\"2.4.0\")  # Lower version OK\n        config.validate_milvus_version(\"2.6.9\")  # Higher version OK\n\n    def test_version_validation_with_dev_suffix(self):\n        \"\"\"Test version validation handles dev suffixes\"\"\"\n        config = MilvusIndexConfig(index_type=\"HNSW_SQ\")\n\n        # Should handle \"2.6.9-dev\" format\n        config.validate_milvus_version(\"2.6.9-dev\")\n\n    def test_build_index_params_autoindex(self):\n        \"\"\"Test AUTOINDEX generates explicit index parameters\"\"\"\n        config = MilvusIndexConfig(index_type=\"AUTOINDEX\")\n        mock_index_params = MagicMock()\n\n        result = config.build_index_params(mock_index_params)\n        assert result is mock_index_params\n        mock_index_params.add_index.assert_called_once_with(\n            field_name=\"vector\",\n            index_type=\"AUTOINDEX\",\n            metric_type=\"COSINE\",\n            params={},\n        )\n\n    def test_build_index_params_hnsw(self):\n        \"\"\"Test HNSW index parameters construction\"\"\"\n        config = MilvusIndexConfig(\n            index_type=\"HNSW\",\n            metric_type=\"COSINE\",\n            hnsw_m=32,\n            hnsw_ef_construction=256,\n        )\n\n        mock_index_params = MagicMock()\n\n        config.build_index_params(mock_index_params)\n\n        mock_index_params.add_index.assert_called_once()\n        call_kwargs = mock_index_params.add_index.call_args[1]\n        assert call_kwargs[\"index_type\"] == \"HNSW\"\n        assert call_kwargs[\"metric_type\"] == \"COSINE\"\n        assert call_kwargs[\"params\"][\"M\"] == 32\n        assert call_kwargs[\"params\"][\"efConstruction\"] == 256\n\n    def test_build_index_params_hnsw_sq(self):\n        \"\"\"Test HNSW_SQ index parameters construction\"\"\"\n        config = MilvusIndexConfig(\n            index_type=\"HNSW_SQ\",\n            sq_type=\"SQ8\",\n            sq_refine=True,\n            sq_refine_type=\"FP32\",\n        )\n\n        mock_index_params = MagicMock()\n\n        config.build_index_params(mock_index_params)\n\n        call_kwargs = mock_index_params.add_index.call_args[1]\n        assert call_kwargs[\"index_type\"] == \"HNSW_SQ\"\n        assert call_kwargs[\"params\"][\"sq_type\"] == \"SQ8\"\n        assert call_kwargs[\"params\"][\"refine\"] is True\n        assert call_kwargs[\"params\"][\"refine_type\"] == \"FP32\"\n\n    def test_build_index_params_hnsw_sq_no_refine(self):\n        \"\"\"Test HNSW_SQ without refinement\"\"\"\n        config = MilvusIndexConfig(index_type=\"HNSW_SQ\", sq_type=\"SQ8\", sq_refine=False)\n\n        mock_index_params = MagicMock()\n\n        config.build_index_params(mock_index_params)\n\n        call_kwargs = mock_index_params.add_index.call_args[1]\n        assert call_kwargs[\"index_type\"] == \"HNSW_SQ\"\n        assert call_kwargs[\"params\"][\"sq_type\"] == \"SQ8\"\n        assert \"refine\" not in call_kwargs[\"params\"]\n        assert \"refine_type\" not in call_kwargs[\"params\"]\n\n    def test_build_index_params_ivf_flat(self):\n        \"\"\"Test IVF_FLAT index parameters construction\"\"\"\n        config = MilvusIndexConfig(index_type=\"IVF_FLAT\", ivf_nlist=2048)\n\n        mock_index_params = MagicMock()\n\n        config.build_index_params(mock_index_params)\n\n        call_kwargs = mock_index_params.add_index.call_args[1]\n        assert call_kwargs[\"index_type\"] == \"IVF_FLAT\"\n        assert call_kwargs[\"params\"][\"nlist\"] == 2048\n\n    def test_build_index_params_with_none(self):\n        \"\"\"Test that RuntimeError is raised when index_params is None for custom types\"\"\"\n        config = MilvusIndexConfig(index_type=\"HNSW\")\n\n        # Pass None to simulate when compatibility helper returns None\n        with pytest.raises(RuntimeError, match=\"IndexParams not available\"):\n            config.build_index_params(None)\n\n    def test_build_search_params_hnsw(self):\n        \"\"\"Test HNSW search parameters construction\"\"\"\n        config = MilvusIndexConfig(index_type=\"HNSW\", hnsw_ef=150)\n        params = config.build_search_params()\n        assert params[\"params\"][\"ef\"] == 150\n\n    def test_build_search_params_hnsw_sq_with_refine(self):\n        \"\"\"Test HNSW_SQ with refinement search parameters\"\"\"\n        config = MilvusIndexConfig(\n            index_type=\"HNSW_SQ\", hnsw_ef=200, sq_refine=True, sq_refine_k=20\n        )\n        params = config.build_search_params()\n        assert params[\"params\"][\"ef\"] == 200\n        assert params[\"params\"][\"refine_k\"] == 20\n\n    def test_build_search_params_hnsw_sq_no_refine(self):\n        \"\"\"Test HNSW_SQ without refinement search parameters\"\"\"\n        config = MilvusIndexConfig(index_type=\"HNSW_SQ\", hnsw_ef=200, sq_refine=False)\n        params = config.build_search_params()\n        assert params[\"params\"][\"ef\"] == 200\n        assert \"refine_k\" not in params[\"params\"]\n\n    def test_build_search_params_ivf(self):\n        \"\"\"Test IVF search parameters construction\"\"\"\n        config = MilvusIndexConfig(index_type=\"IVF_FLAT\", ivf_nprobe=32)\n        params = config.build_search_params()\n        assert params[\"params\"][\"nprobe\"] == 32\n\n    def test_build_search_params_autoindex(self):\n        \"\"\"Test AUTOINDEX search parameters (empty)\"\"\"\n        config = MilvusIndexConfig(index_type=\"AUTOINDEX\")\n        params = config.build_search_params()\n        assert params == {}\n\n    def test_to_dict_hnsw(self):\n        \"\"\"Test configuration export for HNSW\"\"\"\n        config = MilvusIndexConfig(index_type=\"HNSW\")\n        d = config.to_dict()\n        assert d[\"index_type\"] == \"HNSW\"\n        assert d[\"hnsw_m\"] == 16\n        assert d[\"sq_type\"] is None  # Not HNSW_SQ\n        assert d[\"ivf_nlist\"] is None  # Not IVF\n\n    def test_to_dict_hnsw_sq(self):\n        \"\"\"Test configuration export for HNSW_SQ\"\"\"\n        config = MilvusIndexConfig(index_type=\"HNSW_SQ\", sq_type=\"SQ8\")\n        d = config.to_dict()\n        assert d[\"index_type\"] == \"HNSW_SQ\"\n        assert d[\"sq_type\"] == \"SQ8\"\n        assert d[\"ivf_nlist\"] is None\n\n    def test_to_dict_ivf(self):\n        \"\"\"Test configuration export for IVF\"\"\"\n        config = MilvusIndexConfig(index_type=\"IVF_FLAT\")\n        d = config.to_dict()\n        assert d[\"index_type\"] == \"IVF_FLAT\"\n        assert d[\"ivf_nlist\"] == 1024\n        assert d[\"sq_type\"] is None\n\n    def test_env_bool_parsing(self):\n        \"\"\"Test boolean environment variable parsing\"\"\"\n        with patch.dict(os.environ, {\"MILVUS_HNSW_SQ_REFINE\": \"true\"}):\n            config = MilvusIndexConfig(index_type=\"HNSW_SQ\")\n            assert config.sq_refine is True\n\n        with patch.dict(os.environ, {\"MILVUS_HNSW_SQ_REFINE\": \"false\"}):\n            config = MilvusIndexConfig(index_type=\"HNSW_SQ\")\n            assert not config.sq_refine\n\n        with patch.dict(os.environ, {\"MILVUS_HNSW_SQ_REFINE\": \"1\"}):\n            config = MilvusIndexConfig(index_type=\"HNSW_SQ\")\n            assert config.sq_refine is True\n\n        with patch.dict(os.environ, {\"MILVUS_HNSW_SQ_REFINE\": \"0\"}):\n            config = MilvusIndexConfig(index_type=\"HNSW_SQ\")\n            assert not config.sq_refine\n\n    def test_env_int_parsing_invalid(self):\n        \"\"\"Test integer environment variable parsing with invalid value\"\"\"\n        with patch.dict(os.environ, {\"MILVUS_HNSW_M\": \"invalid\"}):\n            config = MilvusIndexConfig()\n            assert config.hnsw_m == 16  # Falls back to default (Milvus 2.4+)\n\n    def test_all_index_types_supported(self):\n        \"\"\"Test all supported index types can be configured\"\"\"\n        for index_type in SUPPORTED_INDEX_TYPES:\n            if index_type == \"HNSW_SQ\":\n                # HNSW_SQ requires special parameters\n                config = MilvusIndexConfig(index_type=index_type, sq_type=\"SQ8\")\n            else:\n                config = MilvusIndexConfig(index_type=index_type)\n            assert config.index_type == index_type\n\n    def test_all_metric_types_supported(self):\n        \"\"\"Test all supported metric types can be configured\"\"\"\n        for metric_type in SUPPORTED_METRIC_TYPES:\n            config = MilvusIndexConfig(metric_type=metric_type)\n            assert config.metric_type == metric_type\n\n    def test_all_sq_types_supported(self):\n        \"\"\"Test all supported sq_types can be configured\"\"\"\n        for sq_type in SUPPORTED_SQ_TYPES:\n            config = MilvusIndexConfig(index_type=\"HNSW_SQ\", sq_type=sq_type)\n            assert config.sq_type == sq_type\n\n    def test_all_refine_types_supported(self):\n        \"\"\"Test all supported refine_types can be configured\"\"\"\n        for refine_type in SUPPORTED_REFINE_TYPES:\n            config = MilvusIndexConfig(\n                index_type=\"HNSW_SQ\", sq_refine=True, sq_refine_type=refine_type\n            )\n            assert config.sq_refine_type == refine_type\n\n    def test_get_config_field_names(self):\n        \"\"\"Test get_config_field_names() returns all dataclass fields\"\"\"\n        field_names = MilvusIndexConfig.get_config_field_names()\n\n        # Check that it's a set\n        assert isinstance(field_names, set)\n\n        # Check that all expected fields are present\n        expected_fields = {\n            \"index_type\",\n            \"metric_type\",\n            \"hnsw_m\",\n            \"hnsw_ef_construction\",\n            \"hnsw_ef\",\n            \"sq_type\",\n            \"sq_refine\",\n            \"sq_refine_type\",\n            \"sq_refine_k\",\n            \"ivf_nlist\",\n            \"ivf_nprobe\",\n        }\n        assert field_names == expected_fields\n\n    def test_get_config_field_names_single_source_of_truth(self):\n        \"\"\"Test that get_config_field_names() provides single source of truth for configuration parameters\"\"\"\n        # This test ensures that when we add new fields to MilvusIndexConfig,\n        # they are automatically included in get_config_field_names()\n        # without needing to update hardcoded lists elsewhere\n\n        from dataclasses import fields as dataclass_fields\n\n        # Get fields directly from dataclass\n        direct_fields = {f.name for f in dataclass_fields(MilvusIndexConfig)}\n\n        # Get fields via the method\n        method_fields = MilvusIndexConfig.get_config_field_names()\n\n        # They should be identical\n        assert direct_fields == method_fields, (\n            f\"Method should return same fields as dataclass. \"\n            f\"Difference: {direct_fields.symmetric_difference(method_fields)}\"\n        )\n\n\nif __name__ == \"__main__\":\n    pytest.main([__file__, \"-v\"])\n"
  },
  {
    "path": "tests/test_milvus_index_creation.py",
    "content": "\"\"\"\nTests for Milvus index creation behavior\n\nThis test suite validates:\n1. P1: build_index_params uses compatibility helper\n2. P2: Vector index creation failures are surfaced to callers\n\"\"\"\n\nimport asyncio\nimport pytest\nfrom unittest.mock import MagicMock, patch\nfrom lightrag.kg.milvus_impl import MilvusVectorDBStorage, MilvusIndexConfig\n\n\n@pytest.mark.offline\nclass TestMilvusIndexCreation:\n    \"\"\"Test index creation behavior and error handling\"\"\"\n\n    def test_vector_index_creation_failure_is_raised(self):\n        \"\"\"Test that vector index creation failures are raised to the caller (P2 fix)\"\"\"\n        # Setup storage instance\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        storage = MilvusVectorDBStorage(\n            namespace=\"test_entities\",\n            workspace=\"test_workspace\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                    \"index_type\": \"HNSW\",\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n\n        # Mock the client and _get_index_params\n        mock_client = MagicMock()\n        mock_index_params = MagicMock()\n\n        storage._client = mock_client\n        storage.final_namespace = \"test_entities\"\n\n        # Mock _get_index_params to return a valid IndexParams\n        with patch.object(storage, \"_get_index_params\", return_value=mock_index_params):\n            # Mock build_index_params to return the mock_index_params\n            with patch.object(\n                storage.index_config,\n                \"build_index_params\",\n                return_value=mock_index_params,\n            ):\n                # Mock create_index to raise an exception (simulating index creation failure)\n                mock_client.create_index.side_effect = Exception(\n                    \"Index creation failed\"\n                )\n\n                # Verify that the exception is raised (not caught and logged)\n                with pytest.raises(Exception, match=\"Index creation failed\"):\n                    storage._create_indexes_after_collection()\n\n    def test_scalar_index_creation_failure_is_logged_not_raised(self):\n        \"\"\"Test that scalar index creation failures are logged but not raised (existing behavior)\"\"\"\n        # Setup storage instance\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        storage = MilvusVectorDBStorage(\n            namespace=\"test_entities\",\n            workspace=\"test_workspace\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                    \"index_type\": \"AUTOINDEX\",  # No custom vector index\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n\n        # Mock the client and _get_index_params\n        mock_client = MagicMock()\n        mock_index_params = MagicMock()\n\n        storage._client = mock_client\n        storage.final_namespace = \"test_entities\"\n\n        # Mock _get_index_params to return a valid IndexParams for scalar indexes\n        with patch.object(storage, \"_get_index_params\", return_value=mock_index_params):\n            # Let vector AUTOINDEX creation succeed, then fail on scalar index creation\n            mock_client.create_index.side_effect = [\n                None,\n                Exception(\"Scalar index creation failed\"),\n            ]\n\n            # Verify that the function completes without raising (scalar index failures are logged)\n            # This should not raise an exception\n            storage._create_indexes_after_collection()\n\n            # The function should complete successfully even though scalar index creation failed\n\n    def test_build_index_params_uses_passed_index_params(self):\n        \"\"\"Test that build_index_params uses the passed index_params parameter (P1 fix)\"\"\"\n        config = MilvusIndexConfig(\n            index_type=\"HNSW\",\n            metric_type=\"COSINE\",\n            hnsw_m=32,\n            hnsw_ef_construction=256,\n        )\n\n        mock_index_params = MagicMock()\n\n        # Call build_index_params with the mock_index_params\n        result = config.build_index_params(mock_index_params)\n\n        # Verify that it used the passed index_params\n        assert result == mock_index_params\n        mock_index_params.add_index.assert_called_once()\n\n    def test_build_index_params_raises_when_index_params_is_none_for_custom_type(self):\n        \"\"\"Test that build_index_params raises RuntimeError when index_params is None for custom types (P1 fix)\"\"\"\n        config = MilvusIndexConfig(\n            index_type=\"HNSW\",\n            metric_type=\"COSINE\",\n        )\n\n        # Call with None (simulating compatibility helper returning None)\n        # Should raise RuntimeError for non-AUTOINDEX types\n        with pytest.raises(RuntimeError, match=\"IndexParams not available\"):\n            config.build_index_params(None)\n\n    def test_build_index_params_returns_none_for_autoindex_when_index_params_is_none(\n        self,\n    ):\n        \"\"\"Test AUTOINDEX falls back to direct API parameters when IndexParams is unavailable.\"\"\"\n        config = MilvusIndexConfig(\n            index_type=\"AUTOINDEX\",\n            metric_type=\"COSINE\",\n        )\n\n        # AUTOINDEX should still produce direct API parameters\n        result = config.build_index_params(None)\n        assert result == {\n            \"field_name\": \"vector\",\n            \"index_type\": \"AUTOINDEX\",\n            \"metric_type\": \"COSINE\",\n            \"params\": {},\n        }\n\n    def test_build_index_params_autoindex_uses_index_params_object(self):\n        \"\"\"Test AUTOINDEX still creates an explicit vector index when IndexParams is available.\"\"\"\n        config = MilvusIndexConfig(\n            index_type=\"AUTOINDEX\",\n            metric_type=\"COSINE\",\n        )\n\n        mock_index_params = MagicMock()\n\n        result = config.build_index_params(mock_index_params)\n\n        assert result == mock_index_params\n        mock_index_params.add_index.assert_called_once_with(\n            field_name=\"vector\",\n            index_type=\"AUTOINDEX\",\n            metric_type=\"COSINE\",\n            params={},\n        )\n\n    def test_create_indexes_uses_compatibility_helper(self):\n        \"\"\"Test that _create_indexes_after_collection uses _get_index_params (P1 fix)\"\"\"\n        # Setup storage instance\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        storage = MilvusVectorDBStorage(\n            namespace=\"test_entities\",\n            workspace=\"test_workspace\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                    \"index_type\": \"HNSW\",\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n\n        # Mock the client\n        mock_client = MagicMock()\n        mock_index_params = MagicMock()\n\n        storage._client = mock_client\n        storage.final_namespace = \"test_entities\"\n\n        # Spy on _get_index_params to verify it's called\n        with patch.object(\n            storage, \"_get_index_params\", return_value=mock_index_params\n        ) as mock_get_index_params:\n            # Call the method\n            storage._create_indexes_after_collection()\n\n            # Verify that _get_index_params was called at least once\n            assert mock_get_index_params.call_count >= 1\n\n    def test_version_probing_only_for_hnsw_sq(self):\n        \"\"\"Test that get_server_version is only called when index type requires it (P2 fix)\"\"\"\n        from unittest.mock import AsyncMock\n\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        # Test with HNSW (no version requirement) - should NOT call get_server_version\n        storage = MilvusVectorDBStorage(\n            namespace=\"test_entities\",\n            workspace=\"test_workspace\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                    \"index_type\": \"HNSW\",\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n\n        mock_client = MagicMock()\n        storage._client = mock_client\n\n        # Mock the init lock as an async context manager\n        mock_lock = AsyncMock()\n\n        with patch(\n            \"lightrag.kg.milvus_impl.get_data_init_lock\", return_value=mock_lock\n        ):\n            with patch.object(storage, \"_create_collection_if_not_exist\"):\n                asyncio.run(storage.initialize())\n\n        # get_server_version should NOT be called for HNSW\n        mock_client.get_server_version.assert_not_called()\n\n    def test_version_probing_called_for_hnsw_sq(self):\n        \"\"\"Test that get_server_version IS called when HNSW_SQ is configured (P2 fix)\"\"\"\n        from unittest.mock import AsyncMock\n\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        storage = MilvusVectorDBStorage(\n            namespace=\"test_entities\",\n            workspace=\"test_workspace\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                    \"index_type\": \"HNSW_SQ\",\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n\n        mock_client = MagicMock()\n        mock_client.get_server_version.return_value = \"2.6.9\"\n        storage._client = mock_client\n\n        # Mock the init lock as an async context manager\n        mock_lock = AsyncMock()\n\n        with patch(\n            \"lightrag.kg.milvus_impl.get_data_init_lock\", return_value=mock_lock\n        ):\n            with patch.object(storage, \"_create_collection_if_not_exist\"):\n                asyncio.run(storage.initialize())\n\n        # get_server_version SHOULD be called for HNSW_SQ\n        mock_client.get_server_version.assert_called_once()\n\n    def test_initialize_creates_missing_database_before_collection_setup(self):\n        \"\"\"Test that initialize bootstraps a missing configured Milvus database.\"\"\"\n        from unittest.mock import AsyncMock\n\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        storage = MilvusVectorDBStorage(\n            namespace=\"test_entities\",\n            workspace=\"space1\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"working_dir\": \"/tmp/lightrag\",\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n\n        bootstrap_client = MagicMock()\n        bootstrap_client.list_databases.return_value = [\"default\"]\n        mock_lock = AsyncMock()\n\n        with patch.dict(\n            \"os.environ\",\n            {\n                \"MILVUS_URI\": \"http://milvus:19530\",\n                \"MILVUS_DB_NAME\": \"lightrag\",\n            },\n            clear=False,\n        ):\n            with patch(\n                \"lightrag.kg.milvus_impl.MilvusClient\", return_value=bootstrap_client\n            ) as mock_client_cls:\n                with patch(\n                    \"lightrag.kg.milvus_impl.get_data_init_lock\",\n                    return_value=mock_lock,\n                ):\n                    with patch.object(storage, \"_create_collection_if_not_exist\"):\n                        asyncio.run(storage.initialize())\n\n        mock_client_cls.assert_called_once_with(\n            uri=\"http://milvus:19530\",\n            user=None,\n            password=None,\n            token=None,\n        )\n        bootstrap_client.list_databases.assert_called_once_with()\n        bootstrap_client.create_database.assert_called_once_with(\"lightrag\")\n        bootstrap_client.use_database.assert_called_once_with(\"lightrag\")\n\n    def test_initialize_uses_existing_database_without_recreating_it(self):\n        \"\"\"Test that initialize switches to an existing configured Milvus database.\"\"\"\n        from unittest.mock import AsyncMock\n\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        storage = MilvusVectorDBStorage(\n            namespace=\"test_entities\",\n            workspace=\"space1\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"working_dir\": \"/tmp/lightrag\",\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n\n        bootstrap_client = MagicMock()\n        bootstrap_client.list_databases.return_value = [\"default\", \"lightrag\"]\n        mock_lock = AsyncMock()\n\n        with patch.dict(\n            \"os.environ\",\n            {\n                \"MILVUS_URI\": \"http://milvus:19530\",\n                \"MILVUS_DB_NAME\": \"lightrag\",\n            },\n            clear=False,\n        ):\n            with patch(\n                \"lightrag.kg.milvus_impl.MilvusClient\", return_value=bootstrap_client\n            ):\n                with patch(\n                    \"lightrag.kg.milvus_impl.get_data_init_lock\",\n                    return_value=mock_lock,\n                ):\n                    with patch.object(storage, \"_create_collection_if_not_exist\"):\n                        asyncio.run(storage.initialize())\n\n        bootstrap_client.list_databases.assert_called_once_with()\n        bootstrap_client.create_database.assert_not_called()\n        bootstrap_client.use_database.assert_called_once_with(\"lightrag\")\n\n    def test_existing_collection_missing_vector_index_is_repaired(self):\n        \"\"\"Existing collections missing vector indexes should be repaired automatically.\"\"\"\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        storage = MilvusVectorDBStorage(\n            namespace=\"entities\",\n            workspace=\"space1\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"working_dir\": \"/tmp/lightrag\",\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n        storage.final_namespace = \"space1_entities\"\n        storage._client = MagicMock()\n        storage._client.has_collection.return_value = True\n\n        load_error = RuntimeError(\n            \"there is no vector index on field: [vector], please create index firstly\"\n        )\n\n        with patch.object(storage._client, \"describe_collection\", return_value={}):\n            with patch.object(storage, \"_validate_collection_compatibility\"):\n                with patch.object(\n                    storage,\n                    \"_ensure_collection_loaded\",\n                    side_effect=[load_error, None],\n                ) as mock_load:\n                    with patch.object(\n                        storage, \"_repair_missing_vector_index\"\n                    ) as mock_repair:\n                        storage._create_collection_if_not_exist()\n\n        assert mock_load.call_count == 2\n        mock_repair.assert_called_once_with()\n\n    def test_existing_collection_index_repair_failure_has_precise_error(self):\n        \"\"\"Index repair failures should not be reported as collection validation failures.\"\"\"\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        storage = MilvusVectorDBStorage(\n            namespace=\"entities\",\n            workspace=\"space1\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"working_dir\": \"/tmp/lightrag\",\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n        storage.final_namespace = \"space1_entities\"\n        storage._client = MagicMock()\n        storage._client.has_collection.return_value = True\n\n        load_error = RuntimeError(\n            \"there is no vector index on field: [vector], please create index firstly\"\n        )\n\n        with patch.object(storage._client, \"describe_collection\", return_value={}):\n            with patch.object(storage, \"_validate_collection_compatibility\"):\n                with patch.object(\n                    storage, \"_ensure_collection_loaded\", side_effect=load_error\n                ):\n                    with patch.object(\n                        storage,\n                        \"_repair_missing_vector_index\",\n                        side_effect=RuntimeError(\"create index failed\"),\n                    ):\n                        with pytest.raises(\n                            RuntimeError,\n                            match=\"Index repair failed for collection 'space1_entities'\",\n                        ):\n                            storage._create_collection_if_not_exist()\n\n    def test_existing_collection_non_index_validation_failure_still_raises(self):\n        \"\"\"Non-index validation failures should still stop initialization.\"\"\"\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        storage = MilvusVectorDBStorage(\n            namespace=\"entities\",\n            workspace=\"space1\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"working_dir\": \"/tmp/lightrag\",\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n        storage.final_namespace = \"space1_entities\"\n        storage._client = MagicMock()\n        storage._client.has_collection.return_value = True\n\n        with patch.object(storage._client, \"describe_collection\", return_value={}):\n            with patch.object(\n                storage,\n                \"_validate_collection_compatibility\",\n                side_effect=RuntimeError(\"dimension mismatch\"),\n            ):\n                with pytest.raises(\n                    RuntimeError,\n                    match=\"Collection validation failed for 'space1_entities'\",\n                ):\n                    storage._create_collection_if_not_exist()\n\n\nif __name__ == \"__main__\":\n    pytest.main([__file__, \"-v\"])\n"
  },
  {
    "path": "tests/test_milvus_kwargs_bridge.py",
    "content": "\"\"\"\nTests for bridging vector_db_storage_cls_kwargs to MilvusIndexConfig\n\nThis test suite validates that MilvusIndexConfig parameters can be passed\nthrough vector_db_storage_cls_kwargs and that backward compatibility is maintained.\n\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock\nfrom lightrag.kg.milvus_impl import MilvusVectorDBStorage\n\n\n@pytest.mark.offline\nclass TestMilvusKwargsParameterBridge:\n    \"\"\"Test parameter bridging from vector_db_storage_cls_kwargs to MilvusIndexConfig\"\"\"\n\n    def test_kwargs_to_index_config_basic(self):\n        \"\"\"Test that basic HNSW parameters are passed from kwargs to MilvusIndexConfig\"\"\"\n        # Mock the embedding function\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        # Create storage instance with custom index config parameters in kwargs\n        storage = MilvusVectorDBStorage(\n            namespace=\"test_entities\",\n            workspace=\"test_workspace\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                    \"hnsw_m\": 32,\n                    \"hnsw_ef\": 256,\n                    \"hnsw_ef_construction\": 300,\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n\n        # Verify that parameters were passed to index_config\n        assert storage.index_config.hnsw_m == 32\n        assert storage.index_config.hnsw_ef == 256\n        assert storage.index_config.hnsw_ef_construction == 300\n\n    def test_kwargs_to_index_config_index_and_metric_types(self):\n        \"\"\"Test that index_type and metric_type are passed from kwargs\"\"\"\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        storage = MilvusVectorDBStorage(\n            namespace=\"test_entities\",\n            workspace=\"test_workspace\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                    \"index_type\": \"IVF_FLAT\",\n                    \"metric_type\": \"L2\",\n                    \"ivf_nlist\": 2048,\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n\n        # Verify that parameters were passed to index_config\n        assert storage.index_config.index_type == \"IVF_FLAT\"\n        assert storage.index_config.metric_type == \"L2\"\n        assert storage.index_config.ivf_nlist == 2048\n\n    def test_kwargs_to_index_config_sq_parameters(self):\n        \"\"\"Test that HNSW_SQ parameters are passed from kwargs\"\"\"\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        storage = MilvusVectorDBStorage(\n            namespace=\"test_entities\",\n            workspace=\"test_workspace\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                    \"index_type\": \"HNSW_SQ\",\n                    \"sq_type\": \"SQ8\",\n                    \"sq_refine\": True,\n                    \"sq_refine_type\": \"FP16\",\n                    \"sq_refine_k\": 20,\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n\n        # Verify that parameters were passed to index_config\n        assert storage.index_config.index_type == \"HNSW_SQ\"\n        assert storage.index_config.sq_type == \"SQ8\"\n        assert storage.index_config.sq_refine is True\n        assert storage.index_config.sq_refine_type == \"FP16\"\n        assert storage.index_config.sq_refine_k == 20\n\n    def test_backward_compatibility_no_index_params(self):\n        \"\"\"Test backward compatibility when no index parameters are provided in kwargs\"\"\"\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        # Create storage without any index config parameters in kwargs\n        storage = MilvusVectorDBStorage(\n            namespace=\"test_entities\",\n            workspace=\"test_workspace\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n\n        # Verify that default values are used (from environment variables or defaults)\n        # Defaults aligned with Milvus 2.4+ official documentation\n        assert storage.index_config.index_type == \"AUTOINDEX\"  # Default\n        assert storage.index_config.metric_type == \"COSINE\"  # Default\n        assert storage.index_config.hnsw_m == 16  # Default (Milvus 2.4+)\n        assert storage.index_config.hnsw_ef_construction == 360  # Default (Milvus 2.4+)\n\n    def test_kwargs_params_override_environment_variables(self):\n        \"\"\"Test that kwargs parameters take precedence over environment variables\"\"\"\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        # Set environment variables\n        with patch.dict(\n            \"os.environ\",\n            {\n                \"MILVUS_INDEX_TYPE\": \"IVF_FLAT\",\n                \"MILVUS_HNSW_M\": \"16\",\n            },\n        ):\n            # Create storage with kwargs parameters that should override env vars\n            storage = MilvusVectorDBStorage(\n                namespace=\"test_entities\",\n                workspace=\"test_workspace\",\n                global_config={\n                    \"embedding_batch_num\": 100,\n                    \"vector_db_storage_cls_kwargs\": {\n                        \"cosine_better_than_threshold\": 0.3,\n                        \"index_type\": \"HNSW\",\n                        \"hnsw_m\": 64,\n                    },\n                },\n                embedding_func=mock_embedding_func,\n                meta_fields=set(),\n            )\n\n            # Verify that kwargs parameters override environment variables\n            assert (\n                storage.index_config.index_type == \"HNSW\"\n            )  # From kwargs, not IVF_FLAT\n            assert storage.index_config.hnsw_m == 64  # From kwargs, not 16\n\n    def test_non_index_params_ignored(self):\n        \"\"\"Test that non-index-config parameters in kwargs are ignored\"\"\"\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        storage = MilvusVectorDBStorage(\n            namespace=\"test_entities\",\n            workspace=\"test_workspace\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                    \"hnsw_m\": 32,\n                    \"some_other_param\": \"ignored\",  # Should be ignored\n                    \"another_param\": 123,  # Should be ignored\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n\n        # Verify that valid parameter was passed\n        assert storage.index_config.hnsw_m == 32\n        # Verify that invalid parameters were ignored (no AttributeError)\n        assert not hasattr(storage.index_config, \"some_other_param\")\n        assert not hasattr(storage.index_config, \"another_param\")\n\n    def test_raganything_framework_integration_scenario(self):\n        \"\"\"Test configuration passing through frameworks like RAGAnything\n\n        This test validates the use case where a framework (like RAGAnything)\n        sits on top of LightRAG and needs to pass Milvus index configuration\n        through to LightRAG without modifying environment variables.\n\n        The framework can pass all index config parameters via\n        vector_db_storage_cls_kwargs, and they will be properly extracted\n        and applied to MilvusIndexConfig.\n        \"\"\"\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        # Simulate RAGAnything framework passing configuration to LightRAG\n        # All index configuration parameters are passed through kwargs\n        framework_config = {\n            \"embedding_batch_num\": 100,\n            \"vector_db_storage_cls_kwargs\": {\n                # Required for vector storage\n                \"cosine_better_than_threshold\": 0.2,\n                # Milvus index configuration - all parameters supported\n                \"index_type\": \"HNSW\",\n                \"metric_type\": \"L2\",\n                \"hnsw_m\": 48,\n                \"hnsw_ef_construction\": 400,\n                \"hnsw_ef\": 200,\n                # Framework-specific parameters (should be ignored by Milvus)\n                \"framework_version\": \"1.0.0\",\n                \"custom_setting\": \"value\",\n            },\n        }\n\n        # Create storage instance with framework configuration\n        storage = MilvusVectorDBStorage(\n            namespace=\"test_entities\",\n            workspace=\"raganything_workspace\",\n            global_config=framework_config,\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n\n        # Verify all Milvus parameters were correctly extracted and applied\n        assert storage.index_config.index_type == \"HNSW\"\n        assert storage.index_config.metric_type == \"L2\"\n        assert storage.index_config.hnsw_m == 48\n        assert storage.index_config.hnsw_ef_construction == 400\n        assert storage.index_config.hnsw_ef == 200\n\n        # Verify framework-specific parameters were ignored\n        assert not hasattr(storage.index_config, \"framework_version\")\n        assert not hasattr(storage.index_config, \"custom_setting\")\n\n        # Verify workspace isolation is maintained\n        assert storage.workspace == \"raganything_workspace\"\n\n    def test_all_milvus_parameters_supported_via_kwargs(self):\n        \"\"\"Test that all 11 MilvusIndexConfig parameters can be configured via kwargs\n\n        This comprehensive test ensures that every single index configuration\n        parameter defined in MilvusIndexConfig can be passed through\n        vector_db_storage_cls_kwargs, which is critical for framework integration.\n        \"\"\"\n        mock_embedding_func = MagicMock()\n        mock_embedding_func.embedding_dim = 128\n\n        # Pass ALL 11 MilvusIndexConfig parameters via kwargs\n        storage = MilvusVectorDBStorage(\n            namespace=\"test_entities\",\n            workspace=\"test_workspace\",\n            global_config={\n                \"embedding_batch_num\": 100,\n                \"vector_db_storage_cls_kwargs\": {\n                    \"cosine_better_than_threshold\": 0.3,\n                    # All 11 MilvusIndexConfig parameters\n                    \"index_type\": \"HNSW_SQ\",\n                    \"metric_type\": \"IP\",\n                    \"hnsw_m\": 64,\n                    \"hnsw_ef_construction\": 512,\n                    \"hnsw_ef\": 256,\n                    \"sq_type\": \"SQ8\",\n                    \"sq_refine\": True,\n                    \"sq_refine_type\": \"FP16\",\n                    \"sq_refine_k\": 30,\n                    \"ivf_nlist\": 4096,\n                    \"ivf_nprobe\": 64,\n                },\n            },\n            embedding_func=mock_embedding_func,\n            meta_fields=set(),\n        )\n\n        # Verify EVERY parameter was correctly applied\n        assert storage.index_config.index_type == \"HNSW_SQ\"\n        assert storage.index_config.metric_type == \"IP\"\n        assert storage.index_config.hnsw_m == 64\n        assert storage.index_config.hnsw_ef_construction == 512\n        assert storage.index_config.hnsw_ef == 256\n        assert storage.index_config.sq_type == \"SQ8\"\n        assert storage.index_config.sq_refine is True\n        assert storage.index_config.sq_refine_type == \"FP16\"\n        assert storage.index_config.sq_refine_k == 30\n        assert storage.index_config.ivf_nlist == 4096\n        assert storage.index_config.ivf_nprobe == 64\n\n\nif __name__ == \"__main__\":\n    pytest.main([__file__, \"-v\"])\n"
  },
  {
    "path": "tests/test_neo4j_fulltext_index.py",
    "content": "#!/usr/bin/env python\n\"\"\"\nTest Neo4j full-text index functionality, specifically:\n1. Workspace-specific index naming\n2. Legacy index migration\n3. search_labels functionality with workspace-specific indexes\n\"\"\"\n\nimport asyncio\nimport os\nimport sys\nimport pytest\nimport numpy as np\n\n# Add the project root directory to the Python path\nsys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\n\nfrom lightrag.kg.shared_storage import initialize_share_data\n\n\n# Mock embedding function that returns random vectors\nasync def mock_embedding_func(texts):\n    return np.random.rand(len(texts), 10)  # Return 10-dimensional random vectors\n\n\n@pytest.fixture\nasync def neo4j_storage():\n    \"\"\"\n    Initialize Neo4j storage for testing.\n    Requires Neo4j to be running and configured via environment variables.\n    \"\"\"\n    # Check if Neo4j is configured\n    if not os.getenv(\"NEO4J_URI\"):\n        pytest.skip(\"Neo4j not configured (NEO4J_URI not set)\")\n\n    from lightrag.kg.neo4j_impl import Neo4JStorage\n\n    # Initialize shared_storage for locks\n    initialize_share_data()\n\n    global_config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.5},\n        \"working_dir\": os.environ.get(\"WORKING_DIR\", \"./rag_storage\"),\n    }\n\n    storage = Neo4JStorage(\n        namespace=\"test_fulltext_index\",\n        workspace=\"test_workspace\",\n        global_config=global_config,\n        embedding_func=mock_embedding_func,\n    )\n\n    # Initialize the connection\n    await storage.initialize()\n\n    # Clean up any existing data\n    await storage.drop()\n\n    yield storage\n\n    # Cleanup\n    await storage.drop()\n    await storage.finalize()\n\n\n@pytest.mark.integration\n@pytest.mark.requires_db\nasync def test_fulltext_index_creation(neo4j_storage):\n    \"\"\"\n    Test that the full-text index is created with the workspace-specific name.\n    \"\"\"\n    storage = neo4j_storage\n    workspace_label = storage._get_workspace_label()\n    expected_index_name = f\"entity_id_fulltext_idx_{workspace_label}\"\n\n    # Query Neo4j to check if the index exists\n    async with storage._driver.session(database=storage._DATABASE) as session:\n        result = await session.run(\"SHOW FULLTEXT INDEXES\")\n        indexes = await result.data()\n        await result.consume()\n\n        # Check if the workspace-specific index exists\n        index_names = [idx[\"name\"] for idx in indexes]\n        assert (\n            expected_index_name in index_names\n        ), f\"Expected index '{expected_index_name}' not found. Found indexes: {index_names}\"\n\n        # Check if the legacy index doesn't exist (should be migrated if it was there)\n        legacy_index_name = \"entity_id_fulltext_idx\"\n        if legacy_index_name in index_names:\n            # If legacy index exists, it should be for a different workspace\n            # or it means migration didn't happen\n            print(\n                f\"Warning: Legacy index '{legacy_index_name}' still exists alongside '{expected_index_name}'\"\n            )\n\n\n@pytest.mark.integration\n@pytest.mark.requires_db\nasync def test_search_labels_with_workspace_index(neo4j_storage):\n    \"\"\"\n    Test that search_labels uses the workspace-specific index and returns results.\n    \"\"\"\n    storage = neo4j_storage\n\n    # Insert test nodes\n    test_nodes = [\n        {\n            \"entity_id\": \"Artificial Intelligence\",\n            \"description\": \"AI field\",\n            \"keywords\": \"AI,ML,DL\",\n            \"entity_type\": \"Technology\",\n        },\n        {\n            \"entity_id\": \"Machine Learning\",\n            \"description\": \"ML subfield\",\n            \"keywords\": \"supervised,unsupervised\",\n            \"entity_type\": \"Technology\",\n        },\n        {\n            \"entity_id\": \"Deep Learning\",\n            \"description\": \"DL subfield\",\n            \"keywords\": \"neural networks\",\n            \"entity_type\": \"Technology\",\n        },\n        {\n            \"entity_id\": \"Natural Language Processing\",\n            \"description\": \"NLP field\",\n            \"keywords\": \"text,language\",\n            \"entity_type\": \"Technology\",\n        },\n    ]\n\n    for node_data in test_nodes:\n        await storage.upsert_node(node_data[\"entity_id\"], node_data)\n\n    # Give the index time to become consistent (eventually consistent index)\n    await asyncio.sleep(2)\n\n    # Test search_labels\n    results = await storage.search_labels(\"Learning\", limit=10)\n\n    # Should find nodes with \"Learning\" in them\n    assert len(results) > 0, \"search_labels should return results for 'Learning'\"\n    assert any(\n        \"Learning\" in result for result in results\n    ), \"Results should contain 'Learning'\"\n\n    # Test case-insensitive search\n    results_lower = await storage.search_labels(\"learning\", limit=10)\n    assert len(results_lower) > 0, \"search_labels should be case-insensitive\"\n\n    # Test partial match\n    results_partial = await storage.search_labels(\"Intelli\", limit=10)\n    assert (\n        len(results_partial) > 0\n    ), \"search_labels should support partial matching with wildcard\"\n    assert any(\n        \"Intelligence\" in result for result in results_partial\n    ), \"Should find 'Artificial Intelligence'\"\n\n\n@pytest.mark.integration\n@pytest.mark.requires_db\nasync def test_search_labels_chinese_text(neo4j_storage):\n    \"\"\"\n    Test that search_labels works with Chinese text using the CJK analyzer.\n    \"\"\"\n    storage = neo4j_storage\n\n    # Insert Chinese test nodes\n    chinese_nodes = [\n        {\n            \"entity_id\": \"人工智能\",\n            \"description\": \"人工智能领域\",\n            \"keywords\": \"AI,机器学习\",\n            \"entity_type\": \"技术\",\n        },\n        {\n            \"entity_id\": \"机器学习\",\n            \"description\": \"机器学习子领域\",\n            \"keywords\": \"监督学习,无监督学习\",\n            \"entity_type\": \"技术\",\n        },\n        {\n            \"entity_id\": \"深度学习\",\n            \"description\": \"深度学习子领域\",\n            \"keywords\": \"神经网络\",\n            \"entity_type\": \"技术\",\n        },\n    ]\n\n    for node_data in chinese_nodes:\n        await storage.upsert_node(node_data[\"entity_id\"], node_data)\n\n    # Give the index time to become consistent\n    await asyncio.sleep(2)\n\n    # Test Chinese text search\n    results = await storage.search_labels(\"学习\", limit=10)\n\n    # Should find nodes with \"学习\" in them\n    assert len(results) > 0, \"search_labels should return results for Chinese text\"\n    assert any(\n        \"学习\" in result for result in results\n    ), \"Results should contain Chinese characters '学习'\"\n\n\n@pytest.mark.integration\n@pytest.mark.requires_db\nasync def test_search_labels_fallback_to_contains(neo4j_storage):\n    \"\"\"\n    Test that search_labels falls back to CONTAINS search if the index fails.\n    This can happen with older Neo4j versions or if the index is not yet available.\n    \"\"\"\n    storage = neo4j_storage\n\n    # Insert test nodes\n    test_nodes = [\n        {\n            \"entity_id\": \"Test Node Alpha\",\n            \"description\": \"Test node\",\n            \"keywords\": \"test\",\n            \"entity_type\": \"Test\",\n        },\n        {\n            \"entity_id\": \"Test Node Beta\",\n            \"description\": \"Test node\",\n            \"keywords\": \"test\",\n            \"entity_type\": \"Test\",\n        },\n    ]\n\n    for node_data in test_nodes:\n        await storage.upsert_node(node_data[\"entity_id\"], node_data)\n\n    # Even if the full-text index is not available, CONTAINS should work\n    results = await storage.search_labels(\"Alpha\", limit=10)\n\n    # Should find the node using fallback CONTAINS search\n    assert len(results) > 0, \"Fallback CONTAINS search should return results\"\n    assert \"Test Node Alpha\" in results, \"Should find 'Test Node Alpha'\"\n\n\n@pytest.mark.integration\n@pytest.mark.requires_db\nasync def test_multiple_workspaces_have_separate_indexes(neo4j_storage):\n    \"\"\"\n    Test that different workspaces have their own separate indexes.\n    \"\"\"\n    from lightrag.kg.neo4j_impl import Neo4JStorage\n\n    # Create storage for workspace 1\n    storage1 = neo4j_storage\n\n    # Create storage for workspace 2\n    global_config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.5},\n        \"working_dir\": os.environ.get(\"WORKING_DIR\", \"./rag_storage\"),\n    }\n\n    storage2 = Neo4JStorage(\n        namespace=\"test_fulltext_index\",\n        workspace=\"test_workspace_2\",\n        global_config=global_config,\n        embedding_func=mock_embedding_func,\n    )\n\n    await storage2.initialize()\n    await storage2.drop()\n\n    try:\n        # Check that both workspaces have their own indexes\n        async with storage1._driver.session(database=storage1._DATABASE) as session:\n            result = await session.run(\"SHOW FULLTEXT INDEXES\")\n            indexes = await result.data()\n            await result.consume()\n\n            index_names = [idx[\"name\"] for idx in indexes]\n            workspace1_index = (\n                f\"entity_id_fulltext_idx_{storage1._get_workspace_label()}\"\n            )\n            workspace2_index = (\n                f\"entity_id_fulltext_idx_{storage2._get_workspace_label()}\"\n            )\n\n            assert (\n                workspace1_index in index_names\n            ), f\"Workspace 1 index '{workspace1_index}' should exist\"\n            assert (\n                workspace2_index in index_names\n            ), f\"Workspace 2 index '{workspace2_index}' should exist\"\n\n    finally:\n        # Clean up: drop the fulltext index created for workspace 2 to prevent accumulation\n        try:\n            async with storage2._driver.session(database=storage2._DATABASE) as session:\n                index_name = storage2._get_fulltext_index_name(\n                    storage2._get_workspace_label()\n                )\n                drop_query = f\"DROP INDEX {index_name} IF EXISTS\"\n                result = await session.run(drop_query)\n                await result.consume()\n        except Exception:\n            pass  # Ignore errors during cleanup\n        await storage2.drop()\n        await storage2.finalize()\n\n\nif __name__ == \"__main__\":\n    # Run tests with pytest\n    pytest.main([__file__, \"-v\", \"--run-integration\"])\n"
  },
  {
    "path": "tests/test_no_model_suffix_safety.py",
    "content": "\"\"\"\nTests for safety when model suffix is absent (no model_name provided).\n\nThis test module verifies that the system correctly handles the case when\nno model_name is provided, preventing accidental deletion of the only table/collection\non restart.\n\nCritical Bug: When model_suffix is empty, table_name == legacy_table_name.\nOn second startup, Case 1 logic would delete the only table/collection thinking\nit's \"legacy\", causing all subsequent operations to fail.\n\"\"\"\n\nfrom unittest.mock import MagicMock, AsyncMock, patch\n\nfrom lightrag.kg.qdrant_impl import QdrantVectorDBStorage\nfrom lightrag.kg.postgres_impl import PGVectorStorage\n\n\nclass TestNoModelSuffixSafety:\n    \"\"\"Test suite for preventing data loss when model_suffix is absent.\"\"\"\n\n    def test_qdrant_no_suffix_second_startup(self):\n        \"\"\"\n        Test Qdrant doesn't delete collection on second startup when no model_name.\n\n        Scenario:\n        1. First startup: Creates collection without suffix\n        2. Collection is empty\n        3. Second startup: Should NOT delete the collection\n\n        Bug: Without fix, Case 1 would delete the only collection.\n        \"\"\"\n        from qdrant_client import models\n\n        client = MagicMock()\n\n        # Simulate second startup: collection already exists and is empty\n        # IMPORTANT: Without suffix, collection_name == legacy collection name\n        collection_name = \"lightrag_vdb_chunks\"  # No suffix, same as legacy\n\n        # Both exist (they're the same collection)\n        client.collection_exists.return_value = True\n\n        # Collection is empty\n        client.count.return_value.count = 0\n\n        # Patch _find_legacy_collection to return the SAME collection name\n        # This simulates the scenario where new collection == legacy collection\n        with patch(\n            \"lightrag.kg.qdrant_impl._find_legacy_collection\",\n            return_value=\"lightrag_vdb_chunks\",  # Same as collection_name\n        ):\n            # Call setup_collection\n            # This should detect that new == legacy and skip deletion\n            QdrantVectorDBStorage.setup_collection(\n                client,\n                collection_name,\n                namespace=\"chunks\",\n                workspace=\"_\",\n                vectors_config=models.VectorParams(\n                    size=1536, distance=models.Distance.COSINE\n                ),\n                hnsw_config=models.HnswConfigDiff(\n                    payload_m=16,\n                    m=0,\n                ),\n                model_suffix=\"\",  # Empty suffix to simulate no model_name provided\n            )\n\n        # CRITICAL: Collection should NOT be deleted\n        client.delete_collection.assert_not_called()\n\n        # Verify we returned early (skipped Case 1 cleanup)\n        # The collection_exists was checked, but we didn't proceed to count\n        # because we detected same name\n        assert client.collection_exists.call_count >= 1\n\n    async def test_postgres_no_suffix_second_startup(self):\n        \"\"\"\n        Test PostgreSQL doesn't delete table on second startup when no model_name.\n\n        Scenario:\n        1. First startup: Creates table without suffix\n        2. Table is empty\n        3. Second startup: Should NOT delete the table\n\n        Bug: Without fix, Case 1 would delete the only table.\n        \"\"\"\n        db = AsyncMock()\n\n        # Configure mock return values to avoid unawaited coroutine warnings\n        db.query.return_value = {\"count\": 0}\n        db._create_vector_index.return_value = None\n\n        # Simulate second startup: table already exists and is empty\n        # IMPORTANT: table_name and legacy_table_name are THE SAME\n        table_name = \"LIGHTRAG_VDB_CHUNKS\"  # No suffix\n        legacy_table_name = \"LIGHTRAG_VDB_CHUNKS\"  # Same as new\n\n        # Setup mock responses using check_table_exists on db\n        async def check_table_exists_side_effect(name):\n            # Both tables exist (they're the same)\n            return True\n\n        db.check_table_exists = AsyncMock(side_effect=check_table_exists_side_effect)\n\n        # Call setup_table\n        # This should detect that new == legacy and skip deletion\n        await PGVectorStorage.setup_table(\n            db,\n            table_name,\n            workspace=\"test_workspace\",\n            embedding_dim=1536,\n            legacy_table_name=legacy_table_name,\n            base_table=\"LIGHTRAG_VDB_CHUNKS\",\n        )\n\n        # CRITICAL: Table should NOT be deleted (no DROP TABLE)\n        drop_calls = [\n            call\n            for call in db.execute.call_args_list\n            if call[0][0] and \"DROP TABLE\" in call[0][0]\n        ]\n        assert (\n            len(drop_calls) == 0\n        ), \"Should not drop table when new and legacy are the same\"\n\n        # Note: COUNT queries for workspace data are expected behavior in Case 1\n        # (for logging/warning purposes when workspace data is empty).\n        # The critical safety check is that DROP TABLE is not called.\n\n    def test_qdrant_with_suffix_case1_still_works(self):\n        \"\"\"\n        Test that Case 1 cleanup still works when there IS a suffix.\n\n        This ensures our fix doesn't break the normal Case 1 scenario.\n        \"\"\"\n        from qdrant_client import models\n\n        client = MagicMock()\n\n        # Different names (normal case)\n        collection_name = \"lightrag_vdb_chunks_ada_002_1536d\"  # With suffix\n        legacy_collection = \"lightrag_vdb_chunks\"  # Without suffix\n\n        # Setup: both exist\n        def collection_exists_side_effect(name):\n            return name in [collection_name, legacy_collection]\n\n        client.collection_exists.side_effect = collection_exists_side_effect\n\n        # Legacy is empty\n        client.count.return_value.count = 0\n\n        # Call setup_collection\n        QdrantVectorDBStorage.setup_collection(\n            client,\n            collection_name,\n            namespace=\"chunks\",\n            workspace=\"_\",\n            vectors_config=models.VectorParams(\n                size=1536, distance=models.Distance.COSINE\n            ),\n            hnsw_config=models.HnswConfigDiff(\n                payload_m=16,\n                m=0,\n            ),\n            model_suffix=\"ada_002_1536d\",\n        )\n\n        # SHOULD delete legacy (normal Case 1 behavior)\n        client.delete_collection.assert_called_once_with(\n            collection_name=legacy_collection\n        )\n\n    async def test_postgres_with_suffix_case1_still_works(self):\n        \"\"\"\n        Test that Case 1 cleanup still works when there IS a suffix.\n\n        This ensures our fix doesn't break the normal Case 1 scenario.\n        \"\"\"\n        db = AsyncMock()\n\n        # Different names (normal case)\n        table_name = \"LIGHTRAG_VDB_CHUNKS_ADA_002_1536D\"  # With suffix\n        legacy_table_name = \"LIGHTRAG_VDB_CHUNKS\"  # Without suffix\n\n        # Setup mock responses using check_table_exists on db\n        async def check_table_exists_side_effect(name):\n            # Both tables exist\n            return True\n\n        db.check_table_exists = AsyncMock(side_effect=check_table_exists_side_effect)\n\n        # Mock empty table\n        async def query_side_effect(sql, params, **kwargs):\n            if \"COUNT(*)\" in sql:\n                return {\"count\": 0}\n            return {}\n\n        db.query.side_effect = query_side_effect\n\n        # Call setup_table\n        await PGVectorStorage.setup_table(\n            db,\n            table_name,\n            workspace=\"test_workspace\",\n            embedding_dim=1536,\n            legacy_table_name=legacy_table_name,\n            base_table=\"LIGHTRAG_VDB_CHUNKS\",\n        )\n\n        # SHOULD delete legacy (normal Case 1 behavior)\n        drop_calls = [\n            call\n            for call in db.execute.call_args_list\n            if call[0][0] and \"DROP TABLE\" in call[0][0]\n        ]\n        assert len(drop_calls) == 1, \"Should drop legacy table in normal Case 1\"\n        assert legacy_table_name in drop_calls[0][0][0]\n"
  },
  {
    "path": "tests/test_opensearch_storage.py",
    "content": "\"\"\"\nUnit tests for OpenSearch storage implementations.\n\nAll tests use mocks — no running OpenSearch instance required.\nRun with: pytest tests/test_opensearch_storage.py -v\n\"\"\"\n\nimport pytest\nfrom contextlib import asynccontextmanager\nfrom unittest.mock import AsyncMock, patch\nimport numpy as np\n\npytest.importorskip(\n    \"opensearchpy\",\n    reason=\"opensearchpy is required for OpenSearch storage tests\",\n)\n\nfrom opensearchpy.exceptions import NotFoundError, OpenSearchException  # type: ignore\nfrom lightrag.kg.opensearch_impl import (\n    OpenSearchKVStorage,\n    OpenSearchDocStatusStorage,\n    OpenSearchGraphStorage,\n    OpenSearchVectorDBStorage,\n    ClientManager,\n    _build_index_name,\n    _resolve_workspace,\n    _sanitize_index_name,\n)\nfrom lightrag.base import DocStatus, DocProcessingStatus\n\npytestmark = pytest.mark.offline\n\n\n# ---------------------------------------------------------------------------\n# Mock the shared storage lock so tests don't need full LightRAG init\n# ---------------------------------------------------------------------------\n\n\n@asynccontextmanager\nasync def _mock_lock():\n    yield\n\n\ndef _mock_lock_factory():\n    return _mock_lock()\n\n\ndef _missing_index_error() -> NotFoundError:\n    return NotFoundError(404, \"index_not_found_exception\", \"no such index\")\n\n\n@pytest.fixture(autouse=True)\ndef patch_data_init_lock():\n    \"\"\"Patch get_data_init_lock globally so initialize() works without shared storage.\"\"\"\n    with patch(\n        \"lightrag.kg.opensearch_impl.get_data_init_lock\", side_effect=_mock_lock_factory\n    ):\n        yield\n\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\nclass MockEmbeddingFunc:\n    \"\"\"Mock embedding function that returns random vectors.\"\"\"\n\n    def __init__(self, dim=128):\n        self.embedding_dim = dim\n        self.max_token_size = 512\n        self.model_name = \"mock-embed\"\n\n    async def __call__(self, texts, **kwargs):\n        return np.random.rand(len(texts), self.embedding_dim).astype(np.float32)\n\n\n@pytest.fixture\ndef global_config():\n    \"\"\"Standard global config fixture for all storage tests.\"\"\"\n    return {\n        \"embedding_batch_num\": 10,\n        \"max_graph_nodes\": 1000,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.2},\n    }\n\n\n@pytest.fixture\ndef embed_func():\n    \"\"\"Mock embedding function fixture.\"\"\"\n    return MockEmbeddingFunc()\n\n\ndef _make_client():\n    \"\"\"Create a fully-mocked AsyncOpenSearch client with spec validation.\"\"\"\n    from opensearchpy import AsyncOpenSearch\n\n    client = AsyncMock(spec=AsyncOpenSearch)\n    # indices sub-client\n    client.indices = AsyncMock()\n    client.indices.exists = AsyncMock(return_value=False)\n    client.indices.create = AsyncMock()\n    client.indices.delete = AsyncMock()\n    client.indices.refresh = AsyncMock()\n    client.indices.get_mapping = AsyncMock(return_value={})\n    # transport for PPL\n    client.transport = AsyncMock()\n    client.transport.perform_request = AsyncMock(\n        side_effect=Exception(\"PPL not available\")\n    )\n    # document operations\n    client.exists = AsyncMock(return_value=False)\n    client.index = AsyncMock()\n    client.delete = AsyncMock()\n    client.delete_by_query = AsyncMock()\n    client.get = AsyncMock(\n        return_value={\n            \"_id\": \"doc1\",\n            \"_source\": {\"content\": \"hello\", \"create_time\": 0, \"update_time\": 0},\n        }\n    )\n    client.mget = AsyncMock(\n        return_value={\n            \"docs\": [\n                {\"_id\": \"id1\", \"found\": True, \"_source\": {\"content\": \"c1\"}},\n                {\"_id\": \"id2\", \"found\": True, \"_source\": {\"content\": \"c2\"}},\n            ]\n        }\n    )\n    client.count = AsyncMock(return_value={\"count\": 5})\n    client.search = AsyncMock(\n        return_value={\n            \"hits\": {\"hits\": [], \"total\": {\"value\": 0}},\n            \"aggregations\": {\n                \"status_counts\": {\"buckets\": []},\n                \"src\": {\"buckets\": []},\n                \"tgt\": {\"buckets\": []},\n                \"source_degrees\": {\"buckets\": []},\n                \"target_degrees\": {\"buckets\": []},\n            },\n        }\n    )\n    # PIT operations\n    client.create_pit = AsyncMock(return_value={\"pit_id\": \"mock_pit_id_123\"})\n    client.delete_pit = AsyncMock()\n    return client\n\n\n@pytest.fixture\ndef mock_client():\n    \"\"\"Fully-mocked AsyncOpenSearch client fixture.\"\"\"\n    return _make_client()\n\n\n# ---------------------------------------------------------------------------\n# Helper utilities\n# ---------------------------------------------------------------------------\n\n\nclass TestHelpers:\n    \"\"\"Tests for module-level helper functions (_build_index_name, _resolve_workspace, _sanitize_index_name).\"\"\"\n\n    def test_build_index_name_with_workspace(self):\n        ws, ns, idx = _build_index_name(\"myws\", \"text_chunks\")\n        assert ws == \"myws\"\n        assert ns == \"myws_text_chunks\"\n        assert idx == _sanitize_index_name(\"myws_text_chunks\")\n\n    def test_build_index_name_no_workspace(self):\n        ws, ns, idx = _build_index_name(\"\", \"chunks\")\n        assert ws == \"\"\n        assert idx == _sanitize_index_name(\"chunks\")\n\n    def test_resolve_workspace_env_override(self):\n        with patch.dict(\"os.environ\", {\"OPENSEARCH_WORKSPACE\": \"forced\"}):\n            assert _resolve_workspace(\"original\", \"ns\") == \"forced\"\n\n    def test_resolve_workspace_fallback(self):\n        with patch.dict(\"os.environ\", {}, clear=True):\n            assert _resolve_workspace(\"original\", \"ns\") == \"original\"\n\n    def test_sanitize_index_name(self):\n        assert _sanitize_index_name(\"Hello_World\") == \"hello_world\"\n        assert _sanitize_index_name(\"-bad\") == \"x-bad\"\n        assert _sanitize_index_name(\"a.b/c\") == \"a_b_c\"\n\n\n# ---------------------------------------------------------------------------\n# ClientManager\n# ---------------------------------------------------------------------------\n\n\nclass TestClientManager:\n    \"\"\"Tests for ClientManager singleton pattern and reference counting.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_singleton_and_refcount(self):\n        ClientManager._instances = {\"client\": None, \"ref_count\": 0}\n        with patch(\"lightrag.kg.opensearch_impl.AsyncOpenSearch\") as mock_cls:\n            mock_cls.return_value = AsyncMock()\n            c1 = await ClientManager.get_client()\n            c2 = await ClientManager.get_client()\n            assert c1 is c2\n            assert ClientManager._instances[\"ref_count\"] == 2\n            await ClientManager.release_client(c1)\n            assert ClientManager._instances[\"ref_count\"] == 1\n            await ClientManager.release_client(c2)\n            assert ClientManager._instances[\"ref_count\"] == 0\n            assert ClientManager._instances[\"client\"] is None\n\n    @pytest.mark.asyncio\n    async def test_close_called_on_last_release(self):\n        ClientManager._instances = {\"client\": None, \"ref_count\": 0}\n        with patch(\"lightrag.kg.opensearch_impl.AsyncOpenSearch\") as mock_cls:\n            inner = AsyncMock()\n            mock_cls.return_value = inner\n            c = await ClientManager.get_client()\n            await ClientManager.release_client(c)\n            inner.close.assert_awaited_once()\n\n\n# ---------------------------------------------------------------------------\n# KV Storage\n# ---------------------------------------------------------------------------\n\n\nclass TestKVStorage:\n    \"\"\"Tests for OpenSearchKVStorage CRUD operations, timestamps, refresh behavior.\"\"\"\n\n    def _make(self, global_config, embed_func, workspace=\"test\"):\n        return OpenSearchKVStorage(\n            namespace=\"text_chunks\",\n            global_config=global_config,\n            embedding_func=embed_func,\n            workspace=workspace,\n        )\n\n    @pytest.mark.asyncio\n    async def test_index_name(self, global_config, embed_func):\n        s = self._make(global_config, embed_func, workspace=\"proj_a\")\n        assert s._index_name == \"proj_a_text_chunks\"\n\n    @pytest.mark.asyncio\n    async def test_initialize_creates_index(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            mock_client.indices.exists.assert_awaited_once()\n            mock_client.indices.create.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_initialize_skips_existing_index(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.indices.exists = AsyncMock(return_value=True)\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            mock_client.indices.create.assert_not_awaited()\n\n    @pytest.mark.asyncio\n    async def test_get_by_id(self, global_config, embed_func, mock_client):\n        mock_client.mget = AsyncMock(\n            return_value={\n                \"docs\": [\n                    {\n                        \"_id\": \"doc1\",\n                        \"found\": True,\n                        \"_source\": {\n                            \"content\": \"hello\",\n                            \"create_time\": 0,\n                            \"update_time\": 0,\n                        },\n                    }\n                ]\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            doc = await s.get_by_id(\"doc1\")\n            assert doc is not None\n            assert doc[\"content\"] == \"hello\"\n            assert doc[\"_id\"] == \"doc1\"\n            mock_client.mget.assert_awaited_once_with(\n                index=s._index_name, body={\"ids\": [\"doc1\"]}\n            )\n\n    @pytest.mark.asyncio\n    async def test_get_by_id_not_found(self, global_config, embed_func, mock_client):\n        mock_client.mget = AsyncMock(\n            return_value={\"docs\": [{\"_id\": \"missing\", \"found\": False}]}\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert await s.get_by_id(\"missing\") is None\n            mock_client.get.assert_not_awaited()\n\n    @pytest.mark.asyncio\n    async def test_get_by_ids_preserves_order(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            docs = await s.get_by_ids([\"id1\", \"id2\"])\n            assert docs[0][\"content\"] == \"c1\"\n            assert docs[1][\"content\"] == \"c2\"\n\n    @pytest.mark.asyncio\n    async def test_filter_keys(self, global_config, embed_func, mock_client):\n        mock_client.mget = AsyncMock(\n            return_value={\n                \"docs\": [\n                    {\"_id\": \"a\", \"found\": True},\n                    {\"_id\": \"b\", \"found\": False},\n                ]\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            result = await s.filter_keys({\"a\", \"b\"})\n            assert result == {\"b\"}\n\n    @pytest.mark.asyncio\n    async def test_upsert_no_per_operation_refresh(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            with patch(\n                \"lightrag.kg.opensearch_impl.helpers.async_bulk\", new_callable=AsyncMock\n            ) as mock_bulk:\n                mock_bulk.return_value = (1, [])\n                s = self._make(global_config, embed_func)\n                await s.initialize()\n                await s.upsert({\"k1\": {\"content\": \"v1\"}})\n                _, kwargs = mock_bulk.call_args\n                assert \"refresh\" not in kwargs\n\n    @pytest.mark.asyncio\n    async def test_upsert_sets_timestamps(self, global_config, embed_func, mock_client):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            with patch(\n                \"lightrag.kg.opensearch_impl.helpers.async_bulk\", new_callable=AsyncMock\n            ) as mock_bulk:\n                mock_bulk.return_value = (1, [])\n                s = self._make(global_config, embed_func)\n                await s.initialize()\n                await s.upsert({\"k1\": {\"content\": \"v1\"}})\n                actions = mock_bulk.call_args[0][1]\n                src = actions[0][\"_source\"]\n                assert \"create_time\" in src\n                assert \"update_time\" in src\n\n    @pytest.mark.asyncio\n    async def test_is_empty(self, global_config, embed_func, mock_client):\n        mock_client.count = AsyncMock(return_value={\"count\": 0})\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert await s.is_empty() is True\n\n    @pytest.mark.asyncio\n    async def test_delete(self, global_config, embed_func, mock_client):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            with patch(\n                \"lightrag.kg.opensearch_impl.helpers.async_bulk\", new_callable=AsyncMock\n            ) as mock_bulk:\n                mock_bulk.return_value = (2, [])\n                s = self._make(global_config, embed_func)\n                await s.initialize()\n                await s.delete([\"a\", \"b\"])\n                actions = mock_bulk.call_args[0][1]\n                assert all(a[\"_op_type\"] == \"delete\" for a in actions)\n\n    @pytest.mark.asyncio\n    async def test_drop(self, global_config, embed_func, mock_client):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            result = await s.drop()\n            assert result[\"status\"] == \"success\"\n            mock_client.indices.delete.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_drop_error_marks_index_not_ready_and_next_upsert_recreates_index(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.indices.delete = AsyncMock(\n            side_effect=OpenSearchException(\"drop failed\")\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            with patch(\n                \"lightrag.kg.opensearch_impl.helpers.async_bulk\", new_callable=AsyncMock\n            ) as mock_bulk:\n                mock_bulk.return_value = (1, [])\n                s = self._make(global_config, embed_func)\n                await s.initialize()\n                with patch.object(\n                    s, \"_create_index_if_not_exists\", new_callable=AsyncMock\n                ) as mock_create:\n                    result = await s.drop()\n                    assert result[\"status\"] == \"error\"\n                    assert s._index_ready is False\n                    await s.upsert({\"k1\": {\"content\": \"v1\"}})\n                    mock_create.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_upsert_after_drop_recreates_index(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            with patch(\n                \"lightrag.kg.opensearch_impl.helpers.async_bulk\", new_callable=AsyncMock\n            ) as mock_bulk:\n                mock_bulk.return_value = (1, [])\n                s = self._make(global_config, embed_func)\n                await s.initialize()\n                with patch.object(\n                    s, \"_create_index_if_not_exists\", new_callable=AsyncMock\n                ) as mock_create:\n                    await s.drop()\n                    await s.upsert({\"k1\": {\"content\": \"v1\"}})\n                    mock_create.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_reads_short_circuit_after_drop(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            await s.drop()\n\n            assert await s.get_by_id(\"doc1\") is None\n            assert await s.get_by_ids([\"doc1\", \"doc2\"]) == [None, None]\n            assert await s.is_empty() is True\n\n            mock_client.mget.assert_not_awaited()\n            mock_client.count.assert_not_awaited()\n\n    @pytest.mark.asyncio\n    async def test_read_missing_index_demotes_readiness(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.mget = AsyncMock(side_effect=_missing_index_error())\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n\n            assert await s.get_by_id(\"doc1\") is None\n            assert await s.get_by_id(\"doc1\") is None\n            assert s._index_ready is False\n            assert mock_client.mget.await_count == 1\n\n    @pytest.mark.asyncio\n    async def test_iter_raw_docs_uses_pit_and_search_after(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.search = AsyncMock(\n            side_effect=[\n                {\n                    \"hits\": {\n                        \"hits\": [\n                            {\"_id\": \"d1\", \"_source\": {\"content\": \"a\"}, \"sort\": [1]},\n                            {\"_id\": \"d2\", \"_source\": {\"content\": \"b\"}, \"sort\": [2]},\n                        ]\n                    }\n                },\n                {\n                    \"hits\": {\n                        \"hits\": [\n                            {\"_id\": \"d3\", \"_source\": {\"content\": \"c\"}, \"sort\": [3]}\n                        ]\n                    }\n                },\n            ]\n        )\n\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n\n            batches = [batch async for batch in s._iter_raw_docs(batch_size=2)]\n\n            assert [[doc[\"_id\"] for doc in batch] for batch in batches] == [\n                [\"d1\", \"d2\"],\n                [\"d3\"],\n            ]\n            assert (\n                \"search_after\"\n                not in mock_client.search.await_args_list[0].kwargs[\"body\"]\n            )\n            assert mock_client.search.await_args_list[1].kwargs[\"body\"][\n                \"search_after\"\n            ] == [2]\n            mock_client.create_pit.assert_awaited_once()\n            mock_client.delete_pit.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_iter_raw_docs_missing_index_demotes_readiness(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.search = AsyncMock(side_effect=_missing_index_error())\n\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n\n            batches = [batch async for batch in s._iter_raw_docs(batch_size=2)]\n\n            assert batches == []\n            assert s._index_ready is False\n            mock_client.create_pit.assert_awaited_once()\n            mock_client.delete_pit.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_finalize(self, global_config, embed_func, mock_client):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            with patch.object(\n                ClientManager, \"release_client\", new_callable=AsyncMock\n            ) as mock_release:\n                s = self._make(global_config, embed_func)\n                await s.initialize()\n                await s.finalize()\n                mock_release.assert_awaited_once()\n                assert s.client is None\n\n\n# ---------------------------------------------------------------------------\n# DocStatus Storage\n# ---------------------------------------------------------------------------\n\n\nclass TestDocStatusStorage:\n    \"\"\"Tests for OpenSearchDocStatusStorage including aggregations, pagination, and data normalization.\"\"\"\n\n    def _make(self, global_config, embed_func, workspace=\"test\"):\n        return OpenSearchDocStatusStorage(\n            namespace=\"doc_status\",\n            global_config=global_config,\n            embedding_func=embed_func,\n            workspace=workspace,\n        )\n\n    @pytest.mark.asyncio\n    async def test_index_name(self, global_config, embed_func):\n        s = self._make(global_config, embed_func)\n        assert s._index_name == \"test_doc_status\"\n\n    @pytest.mark.asyncio\n    async def test_initialize_creates_index(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            mock_client.indices.create.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_get_by_id(self, global_config, embed_func, mock_client):\n        mock_client.mget = AsyncMock(\n            return_value={\n                \"docs\": [\n                    {\n                        \"_id\": \"doc-abc\",\n                        \"found\": True,\n                        \"_source\": {\"status\": \"processed\", \"file_path\": \"/a.txt\"},\n                    }\n                ]\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            doc = await s.get_by_id(\"doc-abc\")\n            assert doc[\"status\"] == \"processed\"\n            assert doc[\"_id\"] == \"doc-abc\"\n            mock_client.mget.assert_awaited_once_with(\n                index=s._index_name, body={\"ids\": [\"doc-abc\"]}\n            )\n\n    @pytest.mark.asyncio\n    async def test_get_by_id_not_found(self, global_config, embed_func, mock_client):\n        mock_client.mget = AsyncMock(\n            return_value={\"docs\": [{\"_id\": \"missing\", \"found\": False}]}\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert await s.get_by_id(\"missing\") is None\n            mock_client.get.assert_not_awaited()\n\n    @pytest.mark.asyncio\n    async def test_upsert_sets_chunks_list_default(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            with patch(\n                \"lightrag.kg.opensearch_impl.helpers.async_bulk\", new_callable=AsyncMock\n            ) as mock_bulk:\n                mock_bulk.return_value = (1, [])\n                s = self._make(global_config, embed_func)\n                await s.initialize()\n                await s.upsert({\"d1\": {\"status\": \"pending\"}})\n                actions = mock_bulk.call_args[0][1]\n                assert actions[0][\"_source\"][\"chunks_list\"] == []\n\n    @pytest.mark.asyncio\n    async def test_get_status_counts(self, global_config, embed_func, mock_client):\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\"hits\": [], \"total\": {\"value\": 0}},\n                \"aggregations\": {\n                    \"status_counts\": {\n                        \"buckets\": [\n                            {\"key\": \"processed\", \"doc_count\": 3},\n                            {\"key\": \"pending\", \"doc_count\": 1},\n                        ]\n                    }\n                },\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            counts = await s.get_status_counts()\n            assert counts == {\"processed\": 3, \"pending\": 1}\n\n    @pytest.mark.asyncio\n    async def test_get_all_status_counts_includes_all(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\"hits\": [], \"total\": {\"value\": 0}},\n                \"aggregations\": {\n                    \"status_counts\": {\n                        \"buckets\": [\n                            {\"key\": \"processed\", \"doc_count\": 5},\n                            {\"key\": \"failed\", \"doc_count\": 2},\n                        ]\n                    }\n                },\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            counts = await s.get_all_status_counts()\n            assert counts[\"all\"] == 7\n            assert counts[\"processed\"] == 5\n\n    @pytest.mark.asyncio\n    async def test_get_docs_by_status(self, global_config, embed_func, mock_client):\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\n                    \"hits\": [\n                        {\n                            \"_id\": \"d1\",\n                            \"_source\": {\n                                \"status\": \"processed\",\n                                \"file_path\": \"/a.txt\",\n                                \"content_summary\": \"s\",\n                                \"content_length\": 10,\n                                \"chunks_count\": 1,\n                                \"created_at\": 100,\n                                \"updated_at\": 200,\n                            },\n                            \"sort\": [\"d1\"],\n                        },\n                    ],\n                    \"total\": {\"value\": 1},\n                },\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            result = await s.get_docs_by_status(DocStatus.PROCESSED)\n            assert \"d1\" in result\n            assert isinstance(result[\"d1\"], DocProcessingStatus)\n\n    @pytest.mark.asyncio\n    async def test_get_docs_paginated(self, global_config, embed_func, mock_client):\n        \"\"\"Page 1 returns results directly without search_after.\"\"\"\n        mock_client.count = AsyncMock(return_value={\"count\": 50})\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\n                    \"hits\": [\n                        {\n                            \"_id\": \"d1\",\n                            \"_source\": {\n                                \"status\": \"processed\",\n                                \"file_path\": \"/a.txt\",\n                                \"content_summary\": \"s\",\n                                \"content_length\": 10,\n                                \"chunks_count\": 1,\n                                \"created_at\": 100,\n                                \"updated_at\": 200,\n                            },\n                            \"sort\": [200, \"d1\"],\n                        },\n                    ],\n                    \"total\": {\"value\": 50},\n                },\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            docs, total = await s.get_docs_paginated(page=1, page_size=10)\n            assert total == 50\n            assert len(docs) == 1\n            assert docs[0][0] == \"d1\"\n            # Page 1: no search_after needed, single search call\n            assert mock_client.search.await_count == 1\n            body = mock_client.search.call_args.kwargs.get(\n                \"body\"\n            ) or mock_client.search.call_args[1].get(\"body\", {})\n            assert \"search_after\" not in body\n\n    @pytest.mark.asyncio\n    async def test_get_docs_paginated_page2_uses_search_after(\n        self, global_config, embed_func, mock_client\n    ):\n        \"\"\"Page 2 skips page 1 results via search_after.\"\"\"\n        mock_client.count = AsyncMock(return_value={\"count\": 50})\n        call_count = {\"n\": 0}\n\n        async def search_side_effect(*args, **kwargs):\n            call_count[\"n\"] += 1\n            body = kwargs.get(\"body\", {})\n            if \"search_after\" not in body:\n                # First call: skip batch\n                return {\n                    \"hits\": {\n                        \"hits\": [\n                            {\n                                \"_id\": f\"skip{i}\",\n                                \"_source\": {\n                                    \"status\": \"processed\",\n                                    \"file_path\": f\"/{i}.txt\",\n                                    \"content_summary\": \"s\",\n                                    \"content_length\": 1,\n                                    \"chunks_count\": 1,\n                                    \"created_at\": 100,\n                                    \"updated_at\": 100 + i,\n                                },\n                                \"sort\": [100 + i, f\"skip{i}\"],\n                            }\n                            for i in range(10)\n                        ],\n                        \"total\": {\"value\": 50},\n                    }\n                }\n            else:\n                # Second call: actual page\n                return {\n                    \"hits\": {\n                        \"hits\": [\n                            {\n                                \"_id\": \"page2_doc\",\n                                \"_source\": {\n                                    \"status\": \"pending\",\n                                    \"file_path\": \"/p2.txt\",\n                                    \"content_summary\": \"s\",\n                                    \"content_length\": 1,\n                                    \"chunks_count\": 1,\n                                    \"created_at\": 200,\n                                    \"updated_at\": 300,\n                                },\n                                \"sort\": [300, \"page2_doc\"],\n                            }\n                        ],\n                        \"total\": {\"value\": 50},\n                    }\n                }\n\n        mock_client.search = AsyncMock(side_effect=search_side_effect)\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            docs, total = await s.get_docs_paginated(page=2, page_size=10)\n            assert total == 50\n            assert len(docs) == 1\n            assert docs[0][0] == \"page2_doc\"\n            # 2 search calls: 1 skip + 1 fetch\n            assert mock_client.search.await_count == 2\n\n    @pytest.mark.asyncio\n    async def test_get_docs_paginated_empty_index(\n        self, global_config, embed_func, mock_client\n    ):\n        \"\"\"Empty index returns empty list with total 0.\"\"\"\n        mock_client.count = AsyncMock(return_value={\"count\": 0})\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            docs, total = await s.get_docs_paginated(page=1, page_size=10)\n            assert total == 0\n            assert docs == []\n            mock_client.search.assert_not_awaited()\n\n    @pytest.mark.asyncio\n    async def test_get_docs_paginated_page_beyond_total(\n        self, global_config, embed_func, mock_client\n    ):\n        \"\"\"Requesting a page beyond total docs returns empty list.\"\"\"\n        mock_client.count = AsyncMock(return_value={\"count\": 5})\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            docs, total = await s.get_docs_paginated(page=100, page_size=10)\n            assert total == 5\n            assert docs == []\n\n    @pytest.mark.asyncio\n    async def test_get_docs_paginated_with_status_filter(\n        self, global_config, embed_func, mock_client\n    ):\n        \"\"\"Status filter is passed as term query.\"\"\"\n        mock_client.count = AsyncMock(return_value={\"count\": 3})\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\"hits\": [], \"total\": {\"value\": 3}},\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            docs, total = await s.get_docs_paginated(\n                status_filter=DocStatus.PROCESSED, page=1, page_size=10\n            )\n            assert total == 3\n            # Verify count query used the status filter\n            count_body = mock_client.count.call_args.kwargs.get(\"body\", {})\n            assert count_body[\"query\"] == {\"term\": {\"status\": \"processed\"}}\n\n    @pytest.mark.asyncio\n    async def test_get_doc_by_file_path(self, global_config, embed_func, mock_client):\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\n                    \"hits\": [\n                        {\n                            \"_id\": \"d1\",\n                            \"_source\": {\n                                \"file_path\": \"/test.txt\",\n                                \"status\": \"processed\",\n                            },\n                        },\n                    ],\n                    \"total\": {\"value\": 1},\n                },\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            doc = await s.get_doc_by_file_path(\"/test.txt\")\n            assert doc is not None\n            assert doc[\"_id\"] == \"d1\"\n\n    @pytest.mark.asyncio\n    async def test_get_doc_by_file_path_not_found(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\"hits\": [], \"total\": {\"value\": 0}},\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert await s.get_doc_by_file_path(\"/nope.txt\") is None\n\n    @pytest.mark.asyncio\n    async def test_prepare_doc_status_data(self, global_config, embed_func):\n        s = self._make(global_config, embed_func)\n        raw = {\"_id\": \"x\", \"status\": \"processed\", \"error\": \"oops\"}\n        data = s._prepare_doc_status_data(raw)\n        assert \"_id\" not in data\n        assert data[\"error_msg\"] == \"oops\"\n        assert \"error\" not in data\n        assert data[\"file_path\"] == \"no-file-path\"\n        assert data[\"metadata\"] == {}\n\n    @pytest.mark.asyncio\n    async def test_drop_error_marks_index_not_ready_and_next_upsert_recreates_index(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.indices.delete = AsyncMock(\n            side_effect=OpenSearchException(\"drop failed\")\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            with patch(\n                \"lightrag.kg.opensearch_impl.helpers.async_bulk\", new_callable=AsyncMock\n            ) as mock_bulk:\n                mock_bulk.return_value = (1, [])\n                s = self._make(global_config, embed_func)\n                await s.initialize()\n                with patch.object(\n                    s, \"_create_index_if_not_exists\", new_callable=AsyncMock\n                ) as mock_create:\n                    result = await s.drop()\n                    assert result[\"status\"] == \"error\"\n                    assert s._index_ready is False\n                    await s.upsert({\"d1\": {\"status\": \"pending\"}})\n                    mock_create.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_upsert_after_drop_recreates_index(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            with patch(\n                \"lightrag.kg.opensearch_impl.helpers.async_bulk\", new_callable=AsyncMock\n            ) as mock_bulk:\n                mock_bulk.return_value = (1, [])\n                s = self._make(global_config, embed_func)\n                await s.initialize()\n                with patch.object(\n                    s, \"_create_index_if_not_exists\", new_callable=AsyncMock\n                ) as mock_create:\n                    await s.drop()\n                    await s.upsert({\"d1\": {\"status\": \"pending\"}})\n                    mock_create.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_reads_short_circuit_after_drop(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            await s.drop()\n\n            assert await s.get_all_status_counts() == {}\n            assert await s.get_docs_paginated(page=1, page_size=10) == ([], 0)\n            assert await s.get_doc_by_file_path(\"/a.txt\") is None\n            assert await s.get_docs_by_status(DocStatus.PROCESSED) == {}\n\n            mock_client.count.assert_not_awaited()\n            mock_client.search.assert_not_awaited()\n            mock_client.create_pit.assert_not_awaited()\n\n    @pytest.mark.asyncio\n    async def test_read_missing_index_demotes_readiness(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.search = AsyncMock(side_effect=_missing_index_error())\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n\n            assert await s.get_all_status_counts() == {}\n            assert await s.get_all_status_counts() == {}\n            assert s._index_ready is False\n            assert mock_client.search.await_count == 1\n\n\n# ---------------------------------------------------------------------------\n# Graph Storage\n# ---------------------------------------------------------------------------\n\n\nclass TestGraphStorage:\n    \"\"\"Tests for OpenSearchGraphStorage node/edge CRUD, batch ops, BFS, and label queries.\"\"\"\n\n    def _make(self, global_config, embed_func, workspace=\"test\"):\n        return OpenSearchGraphStorage(\n            namespace=\"chunk_entity_relation\",\n            global_config=global_config,\n            embedding_func=embed_func,\n            workspace=workspace,\n        )\n\n    @pytest.mark.asyncio\n    async def test_index_names(self, global_config, embed_func):\n        s = self._make(global_config, embed_func)\n        assert s._nodes_index == \"test_chunk_entity_relation-nodes\"\n        assert s._edges_index == \"test_chunk_entity_relation-edges\"\n\n    @pytest.mark.asyncio\n    async def test_initialize_creates_both_indices(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert mock_client.indices.create.await_count == 2\n\n    @pytest.mark.asyncio\n    async def test_has_node_true(self, global_config, embed_func, mock_client):\n        mock_client.exists = AsyncMock(return_value=True)\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert await s.has_node(\"Alice\") is True\n\n    @pytest.mark.asyncio\n    async def test_has_node_false(self, global_config, embed_func, mock_client):\n        mock_client.exists = AsyncMock(return_value=False)\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert await s.has_node(\"Nobody\") is False\n\n    @pytest.mark.asyncio\n    async def test_has_edge(self, global_config, embed_func, mock_client):\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\"hits\": [], \"total\": {\"value\": 1}},\n                \"aggregations\": {\n                    \"status_counts\": {\"buckets\": []},\n                    \"src\": {\"buckets\": []},\n                    \"tgt\": {\"buckets\": []},\n                    \"source_degrees\": {\"buckets\": []},\n                    \"target_degrees\": {\"buckets\": []},\n                },\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert await s.has_edge(\"A\", \"B\") is True\n\n    @pytest.mark.asyncio\n    async def test_node_degree(self, global_config, embed_func, mock_client):\n        mock_client.count = AsyncMock(return_value={\"count\": 3})\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert await s.node_degree(\"A\") == 3\n\n    @pytest.mark.asyncio\n    async def test_get_node(self, global_config, embed_func, mock_client):\n        mock_client.mget = AsyncMock(\n            return_value={\n                \"docs\": [\n                    {\n                        \"_id\": \"Alice\",\n                        \"found\": True,\n                        \"_source\": {\n                            \"entity_type\": \"person\",\n                            \"description\": \"A researcher\",\n                        },\n                    }\n                ]\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            node = await s.get_node(\"Alice\")\n            assert node[\"entity_type\"] == \"person\"\n            assert node[\"_id\"] == \"Alice\"\n            mock_client.mget.assert_awaited_once_with(\n                index=s._nodes_index, body={\"ids\": [\"Alice\"]}\n            )\n\n    @pytest.mark.asyncio\n    async def test_get_node_not_found(self, global_config, embed_func, mock_client):\n        mock_client.mget = AsyncMock(\n            return_value={\"docs\": [{\"_id\": \"Nobody\", \"found\": False}]}\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert await s.get_node(\"Nobody\") is None\n            mock_client.get.assert_not_awaited()\n\n    @pytest.mark.asyncio\n    async def test_get_edge(self, global_config, embed_func, mock_client):\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\n                    \"hits\": [\n                        {\n                            \"_id\": \"e1\",\n                            \"_source\": {\n                                \"source_node_id\": \"A\",\n                                \"target_node_id\": \"B\",\n                                \"weight\": 1.0,\n                            },\n                        },\n                    ],\n                    \"total\": {\"value\": 1},\n                },\n                \"aggregations\": {\n                    \"status_counts\": {\"buckets\": []},\n                    \"src\": {\"buckets\": []},\n                    \"tgt\": {\"buckets\": []},\n                    \"source_degrees\": {\"buckets\": []},\n                    \"target_degrees\": {\"buckets\": []},\n                },\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            edge = await s.get_edge(\"A\", \"B\")\n            assert edge is not None\n            assert edge[\"weight\"] == 1.0\n\n    @pytest.mark.asyncio\n    async def test_get_node_edges(self, global_config, embed_func, mock_client):\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\n                    \"hits\": [\n                        {\n                            \"_id\": \"e1\",\n                            \"_source\": {\"source_node_id\": \"A\", \"target_node_id\": \"B\"},\n                            \"sort\": [1],\n                        },\n                        {\n                            \"_id\": \"e2\",\n                            \"_source\": {\"source_node_id\": \"C\", \"target_node_id\": \"A\"},\n                            \"sort\": [2],\n                        },\n                    ],\n                    \"total\": {\"value\": 2},\n                },\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            edges = await s.get_node_edges(\"A\")\n            assert len(edges) == 2\n            assert (\"A\", \"B\") in edges\n\n    @pytest.mark.asyncio\n    async def test_get_nodes_batch(self, global_config, embed_func, mock_client):\n        mock_client.mget = AsyncMock(\n            return_value={\n                \"docs\": [\n                    {\"_id\": \"A\", \"found\": True, \"_source\": {\"entity_type\": \"person\"}},\n                    {\"_id\": \"B\", \"found\": False},\n                ]\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            result = await s.get_nodes_batch([\"A\", \"B\"])\n            assert \"A\" in result\n            assert \"B\" not in result\n\n    @pytest.mark.asyncio\n    async def test_node_degrees_batch(self, global_config, embed_func, mock_client):\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\"hits\": [], \"total\": {\"value\": 0}},\n                \"aggregations\": {\n                    \"source_degrees\": {\"buckets\": [{\"key\": \"A\", \"doc_count\": 2}]},\n                    \"target_degrees\": {\n                        \"buckets\": [\n                            {\"key\": \"A\", \"doc_count\": 1},\n                            {\"key\": \"B\", \"doc_count\": 3},\n                        ]\n                    },\n                    \"status_counts\": {\"buckets\": []},\n                    \"src\": {\"buckets\": []},\n                    \"tgt\": {\"buckets\": []},\n                },\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            degrees = await s.node_degrees_batch([\"A\", \"B\"])\n            assert degrees[\"A\"] == 3  # 2 + 1\n            assert degrees[\"B\"] == 3\n\n    @pytest.mark.asyncio\n    async def test_upsert_node(self, global_config, embed_func, mock_client):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            await s.upsert_node(\n                \"Alice\", {\"entity_type\": \"person\", \"source_id\": \"c1<SEP>c2\"}\n            )\n            mock_client.index.assert_awaited()\n            call_kwargs = mock_client.index.call_args\n            assert call_kwargs.kwargs[\"id\"] == \"Alice\"\n            body = call_kwargs.kwargs[\"body\"]\n            assert body[\"source_ids\"] == [\"c1\", \"c2\"]\n            assert body[\"entity_id\"] == \"Alice\"\n\n    @pytest.mark.asyncio\n    async def test_upsert_edge(self, global_config, embed_func, mock_client):\n        mock_client.exists = AsyncMock(return_value=False)\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            await s.upsert_edge(\"A\", \"B\", {\"weight\": \"1.0\", \"description\": \"knows\"})\n            # Should call index twice: once for ensuring source node, once for edge\n            assert mock_client.index.await_count == 2\n\n    @pytest.mark.asyncio\n    async def test_upsert_after_drop_recreates_indices(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.exists = AsyncMock(return_value=False)\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            with patch.object(\n                s, \"_create_indices_if_not_exist\", new_callable=AsyncMock\n            ) as mock_create:\n                await s.initialize()\n                mock_create.reset_mock()\n                await s.drop()\n                await s.upsert_edge(\"A\", \"B\", {\"weight\": \"1.0\"})\n                mock_create.assert_awaited_once()\n                assert mock_client.index.await_count == 2\n\n    @pytest.mark.asyncio\n    async def test_reads_short_circuit_after_drop(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.transport = AsyncMock()\n        mock_client.transport.perform_request = AsyncMock(\n            side_effect=Exception(\"PPL not available\")\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            await s.drop()\n\n            graph = await s.get_knowledge_graph(\"A\", max_depth=2)\n\n            assert await s.get_node(\"A\") is None\n            assert await s.get_all_labels() == []\n            assert await s.has_edge(\"A\", \"B\") is False\n            assert await s.node_degree(\"A\") == 0\n            assert len(graph.nodes) == 0\n            assert len(graph.edges) == 0\n\n            mock_client.mget.assert_not_awaited()\n            mock_client.search.assert_not_awaited()\n            mock_client.create_pit.assert_not_awaited()\n            mock_client.count.assert_not_awaited()\n\n    @pytest.mark.asyncio\n    async def test_read_missing_index_demotes_readiness(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.transport = AsyncMock()\n        mock_client.transport.perform_request = AsyncMock(\n            side_effect=Exception(\"PPL not available\")\n        )\n        mock_client.mget = AsyncMock(side_effect=_missing_index_error())\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n\n            assert await s.get_node(\"A\") is None\n            assert await s.get_node(\"A\") is None\n            assert s._indices_ready is False\n            assert mock_client.mget.await_count == 1\n\n    @pytest.mark.asyncio\n    async def test_delete_node(self, global_config, embed_func, mock_client):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            await s.delete_node(\"Alice\")\n            mock_client.delete_by_query.assert_awaited_once()\n            mock_client.delete.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_remove_nodes(self, global_config, embed_func, mock_client):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            with patch(\n                \"lightrag.kg.opensearch_impl.helpers.async_bulk\", new_callable=AsyncMock\n            ) as mock_bulk:\n                mock_bulk.return_value = (2, [])\n                s = self._make(global_config, embed_func)\n                await s.initialize()\n                await s.remove_nodes([\"A\", \"B\"])\n                mock_client.delete_by_query.assert_awaited_once()\n                mock_bulk.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_remove_edges(self, global_config, embed_func, mock_client):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            await s.remove_edges([(\"A\", \"B\"), (\"C\", \"D\")])\n            mock_client.delete_by_query.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_get_all_labels(self, global_config, embed_func, mock_client):\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\n                    \"hits\": [\n                        {\"_id\": \"Alice\", \"sort\": [\"Alice\"]},\n                        {\"_id\": \"Bob\", \"sort\": [\"Bob\"]},\n                    ],\n                    \"total\": {\"value\": 2},\n                },\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            labels = await s.get_all_labels()\n            assert labels == [\"Alice\", \"Bob\"]\n\n    @pytest.mark.asyncio\n    async def test_get_popular_labels(self, global_config, embed_func, mock_client):\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\"hits\": [], \"total\": {\"value\": 0}},\n                \"aggregations\": {\n                    \"src\": {\n                        \"buckets\": [\n                            {\"key\": \"A\", \"doc_count\": 5},\n                            {\"key\": \"B\", \"doc_count\": 2},\n                        ]\n                    },\n                    \"tgt\": {\"buckets\": [{\"key\": \"A\", \"doc_count\": 3}]},\n                    \"status_counts\": {\"buckets\": []},\n                },\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            labels = await s.get_popular_labels(limit=10)\n            assert labels[0] == \"A\"  # degree 8 > B degree 2\n\n    @pytest.mark.asyncio\n    async def test_search_labels_empty_query(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert await s.search_labels(\"\") == []\n\n    @pytest.mark.asyncio\n    async def test_drop(self, global_config, embed_func, mock_client):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            result = await s.drop()\n            assert result[\"status\"] == \"success\"\n            assert mock_client.indices.delete.await_count == 2\n\n    @pytest.mark.asyncio\n    async def test_drop_partial_error_marks_indices_not_ready_and_next_upsert_recreates_indices(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.exists = AsyncMock(return_value=False)\n        mock_client.indices.delete = AsyncMock(\n            side_effect=[None, OpenSearchException(\"edges drop failed\")]\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            with patch.object(\n                s, \"_create_indices_if_not_exist\", new_callable=AsyncMock\n            ) as mock_create:\n                result = await s.drop()\n                assert result[\"status\"] == \"error\"\n                assert \"edges drop failed\" in result[\"message\"]\n                assert s._indices_ready is False\n                await s.upsert_edge(\"A\", \"B\", {\"weight\": \"1.0\"})\n                mock_create.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_drop_treats_missing_graph_indices_as_success(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.indices.delete = AsyncMock(\n            side_effect=[_missing_index_error(), _missing_index_error()]\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            result = await s.drop()\n            assert result[\"status\"] == \"success\"\n            assert s._indices_ready is False\n\n    @pytest.mark.asyncio\n    async def test_construct_graph_node(self, global_config, embed_func):\n        s = self._make(global_config, embed_func)\n        node = s._construct_graph_node(\n            \"Alice\",\n            {\n                \"entity_type\": \"person\",\n                \"description\": \"A researcher\",\n                \"_id\": \"Alice\",\n                \"entity_id\": \"Alice\",\n            },\n        )\n        assert node.id == \"Alice\"\n        assert \"entity_type\" in node.properties\n        assert \"_id\" not in node.properties\n        assert \"entity_id\" not in node.properties\n\n    @pytest.mark.asyncio\n    async def test_construct_graph_edge(self, global_config, embed_func):\n        s = self._make(global_config, embed_func)\n        edge = s._construct_graph_edge(\n            \"e1\",\n            {\n                \"source_node_id\": \"A\",\n                \"target_node_id\": \"B\",\n                \"relationship\": \"knows\",\n                \"weight\": 1.0,\n            },\n        )\n        assert edge.source == \"A\"\n        assert edge.target == \"B\"\n        assert edge.type == \"knows\"\n        assert \"source_node_id\" not in edge.properties\n\n    @pytest.mark.asyncio\n    async def test_bfs_subgraph_start_not_found(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.mget = AsyncMock(\n            return_value={\"docs\": [{\"_id\": \"NonExistent\", \"found\": False}]}\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            result = await s.get_knowledge_graph(\"NonExistent\", max_depth=2)\n            assert len(result.nodes) == 0\n            assert len(result.edges) == 0\n\n\nclass TestGraphPPLDetection:\n    \"\"\"Tests for PPL graphlookup detection and server-side BFS.\"\"\"\n\n    def _make(self, global_config, embed_func, workspace=\"test\"):\n        return OpenSearchGraphStorage(\n            namespace=\"chunk_entity_relation\",\n            global_config=global_config,\n            embedding_func=embed_func,\n            workspace=workspace,\n        )\n\n    @pytest.mark.asyncio\n    async def test_ppl_detected_when_available(\n        self, global_config, embed_func, mock_client\n    ):\n        \"\"\"When PPL endpoint responds successfully, graphlookup should be detected.\"\"\"\n        mock_client.transport = AsyncMock()\n        mock_client.transport.perform_request = AsyncMock(\n            return_value={\"datarows\": [], \"schema\": []}\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert s._ppl_graphlookup_available is True\n\n    @pytest.mark.asyncio\n    async def test_ppl_not_detected_when_endpoint_fails(\n        self, global_config, embed_func, mock_client\n    ):\n        \"\"\"When PPL endpoint fails, should fall back to client-side BFS.\"\"\"\n        mock_client.transport = AsyncMock()\n        mock_client.transport.perform_request = AsyncMock(\n            side_effect=Exception(\"PPL not supported\")\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert s._ppl_graphlookup_available is False\n\n    @pytest.mark.asyncio\n    async def test_env_override_true(self, global_config, embed_func, mock_client):\n        with patch.dict(\"os.environ\", {\"OPENSEARCH_USE_PPL_GRAPHLOOKUP\": \"true\"}):\n            with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n                s = self._make(global_config, embed_func)\n                await s.initialize()\n                assert s._ppl_graphlookup_available is True\n                # Should NOT have called transport.perform_request for detection\n                mock_client.transport.perform_request.assert_not_awaited()\n\n    @pytest.mark.asyncio\n    async def test_env_override_false(self, global_config, embed_func, mock_client):\n        mock_client.transport = AsyncMock()\n        mock_client.transport.perform_request = AsyncMock(\n            return_value={\"datarows\": [], \"schema\": []}\n        )\n        with patch.dict(\"os.environ\", {\"OPENSEARCH_USE_PPL_GRAPHLOOKUP\": \"false\"}):\n            with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n                s = self._make(global_config, embed_func)\n                await s.initialize()\n                assert s._ppl_graphlookup_available is False\n\n    @pytest.mark.asyncio\n    async def test_ppl_bfs_calls_ppl_endpoint(\n        self, global_config, embed_func, mock_client\n    ):\n        \"\"\"When PPL is available, get_knowledge_graph should use PPL endpoint.\"\"\"\n        mock_client.transport = AsyncMock()\n        # PPL response: connected_edges contains dicts with source_node_id/target_node_id\n        ppl_response = {\n            \"schema\": [\n                {\"name\": \"entity_id\", \"type\": \"string\"},\n                {\"name\": \"connected_edges\", \"type\": \"struct\"},\n            ],\n            \"datarows\": [\n                [\n                    \"A\",\n                    [  # connected_edges array\n                        {\n                            \"source_node_id\": \"A\",\n                            \"target_node_id\": \"B\",\n                            \"weight\": 1.0,\n                            \"_depth\": 0,\n                        },\n                        {\n                            \"source_node_id\": \"B\",\n                            \"target_node_id\": \"C\",\n                            \"weight\": 0.5,\n                            \"_depth\": 1,\n                        },\n                    ],\n                ]\n            ],\n        }\n        mock_client.transport.perform_request = AsyncMock(return_value=ppl_response)\n        # get_node for start node verification\n        mock_client.get = AsyncMock(\n            return_value={\n                \"_id\": \"A\",\n                \"_source\": {\"entity_type\": \"person\", \"description\": \"Node A\"},\n            }\n        )\n        # mget for batch node fetch (only B and C, A is already added)\n        mock_client.mget = AsyncMock(\n            return_value={\n                \"docs\": [\n                    {\"_id\": \"B\", \"found\": True, \"_source\": {\"entity_type\": \"person\"}},\n                    {\"_id\": \"C\", \"found\": True, \"_source\": {\"entity_type\": \"person\"}},\n                ]\n            }\n        )\n        # search for final edge fetch\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\n                    \"hits\": [\n                        {\n                            \"_id\": \"e1\",\n                            \"_source\": {\n                                \"source_node_id\": \"A\",\n                                \"target_node_id\": \"B\",\n                                \"relationship\": \"knows\",\n                            },\n                        },\n                        {\n                            \"_id\": \"e2\",\n                            \"_source\": {\n                                \"source_node_id\": \"B\",\n                                \"target_node_id\": \"C\",\n                                \"relationship\": \"knows\",\n                            },\n                        },\n                    ],\n                    \"total\": {\"value\": 2},\n                },\n                \"aggregations\": {\n                    \"status_counts\": {\"buckets\": []},\n                    \"src\": {\"buckets\": []},\n                    \"tgt\": {\"buckets\": []},\n                },\n            }\n        )\n\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert s._ppl_graphlookup_available is True\n\n            result = await s.get_knowledge_graph(\"A\", max_depth=2)\n            assert len(result.nodes) == 3\n            assert len(result.edges) == 2\n            # Verify PPL was called (2 for detection + 1 for actual query)\n            assert mock_client.transport.perform_request.await_count == 3\n            # Verify the PPL query uses nodes index as source\n            actual_query = mock_client.transport.perform_request.call_args_list[2]\n            ppl_body = actual_query.kwargs.get(\"body\") or actual_query[1].get(\n                \"body\", {}\n            )\n            if isinstance(ppl_body, dict):\n                assert s._nodes_index in ppl_body.get(\"query\", \"\")\n\n    @pytest.mark.asyncio\n    async def test_ppl_bfs_falls_back_on_query_failure(\n        self, global_config, embed_func, mock_client\n    ):\n        \"\"\"If PPL query fails at runtime, should fall back to client-side BFS.\"\"\"\n        call_count = {\"n\": 0}\n\n        async def ppl_side_effect(*args, **kwargs):\n            call_count[\"n\"] += 1\n            if call_count[\"n\"] <= 2:\n                # Detection calls succeed\n                return {\"datarows\": [], \"schema\": []}\n            # Actual query fails\n            raise Exception(\"PPL query timeout\")\n\n        mock_client.transport = AsyncMock()\n        mock_client.transport.perform_request = AsyncMock(side_effect=ppl_side_effect)\n        mock_client.mget = AsyncMock(\n            return_value={\"docs\": [{\"_id\": \"A\", \"found\": False}]}\n        )\n\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert s._ppl_graphlookup_available is True\n\n            # Should fall back to _bfs_subgraph, which returns empty (node not found)\n            result = await s.get_knowledge_graph(\"A\", max_depth=2)\n            assert len(result.nodes) == 0\n\n    @pytest.mark.asyncio\n    async def test_escape_ppl(self, global_config, embed_func):\n        s = self._make(global_config, embed_func)\n        assert s._escape_ppl(\"it's\") == \"it\\\\'s\"\n        assert s._escape_ppl(\"normal\") == \"normal\"\n        assert s._escape_ppl(\"back\\\\slash\") == \"back\\\\\\\\slash\"\n        assert s._escape_ppl(\"both\\\\and'quote\") == \"both\\\\\\\\and\\\\'quote\"\n\n    @pytest.mark.asyncio\n    async def test_ppl_bfs_depth_zero_returns_start_only(\n        self, global_config, embed_func, mock_client\n    ):\n        \"\"\"max_depth=0 should return only the start node without PPL query.\"\"\"\n        mock_client.transport = AsyncMock()\n        mock_client.transport.perform_request = AsyncMock(\n            return_value={\"datarows\": [], \"schema\": []}\n        )\n        mock_client.get = AsyncMock(\n            return_value={\"_id\": \"A\", \"_source\": {\"entity_type\": \"person\"}}\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert s._ppl_graphlookup_available is True\n            result = await s.get_knowledge_graph(\"A\", max_depth=0)\n            assert len(result.nodes) == 1\n            assert result.nodes[0].id == \"A\"\n            assert len(result.edges) == 0\n            # PPL query should NOT have been called for the actual traversal (only 2 detection calls)\n            assert mock_client.transport.perform_request.await_count == 2\n\n    @pytest.mark.asyncio\n    async def test_ppl_bfs_empty_connected_edges(\n        self, global_config, embed_func, mock_client\n    ):\n        \"\"\"PPL returns no connected edges — should return only start node.\"\"\"\n        mock_client.transport = AsyncMock()\n        ppl_response = {\n            \"schema\": [\n                {\"name\": \"entity_id\", \"type\": \"string\"},\n                {\"name\": \"connected_edges\", \"type\": \"struct\"},\n            ],\n            \"datarows\": [[\"A\", []]],\n        }\n        mock_client.transport.perform_request = AsyncMock(return_value=ppl_response)\n        mock_client.get = AsyncMock(\n            return_value={\"_id\": \"A\", \"_source\": {\"entity_type\": \"person\"}}\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            result = await s.get_knowledge_graph(\"A\", max_depth=2)\n            assert len(result.nodes) == 1\n            assert result.nodes[0].id == \"A\"\n\n    @pytest.mark.asyncio\n    async def test_upsert_node_adds_entity_id(\n        self, global_config, embed_func, mock_client\n    ):\n        \"\"\"upsert_node should always include entity_id field for PPL compatibility.\"\"\"\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            await s.upsert_node(\"TestNode\", {\"description\": \"test\"})\n            body = mock_client.index.call_args.kwargs[\"body\"]\n            assert body[\"entity_id\"] == \"TestNode\"\n            assert body[\"description\"] == \"test\"\n\n    @pytest.mark.asyncio\n    async def test_node_degree_uses_count_api(\n        self, global_config, embed_func, mock_client\n    ):\n        \"\"\"node_degree should use the count API, not search.\"\"\"\n        mock_client.count = AsyncMock(return_value={\"count\": 7})\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            degree = await s.node_degree(\"X\")\n            assert degree == 7\n            # Verify count was called on the edges index\n            mock_client.count.assert_awaited()\n            call_kwargs = mock_client.count.call_args\n            assert s._edges_index in str(call_kwargs)\n\n\n# ---------------------------------------------------------------------------\n# Vector Storage\n# ---------------------------------------------------------------------------\n\n\nclass TestVectorStorage:\n    \"\"\"Tests for OpenSearchVectorDBStorage k-NN index, embeddings, cosine conversion, and entity deletion.\"\"\"\n\n    def _make(self, global_config, embed_func, workspace=\"test\"):\n        return OpenSearchVectorDBStorage(\n            namespace=\"entities\",\n            global_config=global_config,\n            embedding_func=embed_func,\n            workspace=workspace,\n            meta_fields={\"content\", \"entity_name\", \"src_id\", \"tgt_id\"},\n        )\n\n    @pytest.mark.asyncio\n    async def test_index_name(self, global_config, embed_func):\n        s = self._make(global_config, embed_func)\n        assert s._index_name == \"test_entities\"\n\n    @pytest.mark.asyncio\n    async def test_cosine_threshold_required(self, embed_func):\n        with pytest.raises(ValueError, match=\"cosine_better_than_threshold\"):\n            OpenSearchVectorDBStorage(\n                namespace=\"v\",\n                global_config={\n                    \"embedding_batch_num\": 10,\n                    \"vector_db_storage_cls_kwargs\": {},\n                },\n                embedding_func=embed_func,\n            )\n\n    @pytest.mark.asyncio\n    async def test_initialize_creates_knn_index(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            mock_client.indices.create.assert_awaited_once()\n            body = mock_client.indices.create.call_args.kwargs[\"body\"]\n            assert body[\"settings\"][\"index\"][\"knn\"] is True\n            assert body[\"mappings\"][\"properties\"][\"vector\"][\"dimension\"] == 128\n            assert (\n                body[\"mappings\"][\"properties\"][\"vector\"][\"method\"][\"engine\"] == \"lucene\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_upsert_generates_embeddings(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            with patch(\n                \"lightrag.kg.opensearch_impl.helpers.async_bulk\", new_callable=AsyncMock\n            ) as mock_bulk:\n                mock_bulk.return_value = (2, [])\n                s = self._make(global_config, embed_func)\n                await s.initialize()\n                await s.upsert(\n                    {\n                        \"v1\": {\"content\": \"hello\"},\n                        \"v2\": {\"content\": \"world\"},\n                    }\n                )\n                actions = mock_bulk.call_args[0][1]\n                assert len(actions) == 2\n                assert \"vector\" in actions[0][\"_source\"]\n                assert len(actions[0][\"_source\"][\"vector\"]) == 128\n\n    @pytest.mark.asyncio\n    async def test_query_cosine_score_conversion(\n        self, global_config, embed_func, mock_client\n    ):\n        \"\"\"Test that scores are used directly and threshold filtering works.\"\"\"\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\n                    \"hits\": [\n                        {\n                            \"_id\": \"v1\",\n                            \"_score\": 0.85,\n                            \"_source\": {\"content\": \"match\", \"entity_name\": \"E1\"},\n                        },\n                    ],\n                    \"total\": {\"value\": 1},\n                },\n                \"aggregations\": {\n                    \"status_counts\": {\"buckets\": []},\n                    \"src\": {\"buckets\": []},\n                    \"tgt\": {\"buckets\": []},\n                },\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            results = await s.query(\"test\", top_k=5)\n            assert len(results) == 1\n            assert results[0][\"distance\"] == 0.85\n\n    @pytest.mark.asyncio\n    async def test_query_filters_below_threshold(\n        self, global_config, embed_func, mock_client\n    ):\n        \"\"\"Low scores should be filtered out.\"\"\"\n        # score 0.15 < threshold 0.2\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\n                    \"hits\": [\n                        {\n                            \"_id\": \"v1\",\n                            \"_score\": 0.15,\n                            \"_source\": {\"content\": \"weak match\"},\n                        },\n                    ],\n                    \"total\": {\"value\": 1},\n                },\n                \"aggregations\": {\n                    \"status_counts\": {\"buckets\": []},\n                    \"src\": {\"buckets\": []},\n                    \"tgt\": {\"buckets\": []},\n                },\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            results = await s.query(\"test\", top_k=5)\n            assert len(results) == 0\n\n    @pytest.mark.asyncio\n    async def test_query_with_provided_embedding(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.search = AsyncMock(\n            return_value={\n                \"hits\": {\n                    \"hits\": [\n                        {\"_id\": \"v1\", \"_score\": 1.0, \"_source\": {\"content\": \"exact\"}},\n                    ],\n                    \"total\": {\"value\": 1},\n                },\n                \"aggregations\": {\n                    \"status_counts\": {\"buckets\": []},\n                    \"src\": {\"buckets\": []},\n                    \"tgt\": {\"buckets\": []},\n                },\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            vec = np.random.rand(128).astype(np.float32)\n            results = await s.query(\"test\", top_k=5, query_embedding=vec)\n            assert len(results) == 1\n            assert results[0][\"distance\"] == 1.0\n\n    @pytest.mark.asyncio\n    async def test_get_by_id(self, global_config, embed_func, mock_client):\n        mock_client.mget = AsyncMock(\n            return_value={\n                \"docs\": [\n                    {\n                        \"_id\": \"v1\",\n                        \"found\": True,\n                        \"_source\": {\"content\": \"hello\", \"vector\": [0.1] * 128},\n                    }\n                ]\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            doc = await s.get_by_id(\"v1\")\n            assert doc[\"id\"] == \"v1\"\n            assert doc[\"content\"] == \"hello\"\n            mock_client.mget.assert_awaited_once_with(\n                index=s._index_name, body={\"ids\": [\"v1\"]}\n            )\n\n    @pytest.mark.asyncio\n    async def test_get_by_id_not_found(self, global_config, embed_func, mock_client):\n        mock_client.mget = AsyncMock(\n            return_value={\"docs\": [{\"_id\": \"missing\", \"found\": False}]}\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            assert await s.get_by_id(\"missing\") is None\n            mock_client.get.assert_not_awaited()\n\n    @pytest.mark.asyncio\n    async def test_get_by_ids(self, global_config, embed_func, mock_client):\n        mock_client.mget = AsyncMock(\n            return_value={\n                \"docs\": [\n                    {\"_id\": \"v1\", \"found\": True, \"_source\": {\"content\": \"a\"}},\n                    {\"_id\": \"v2\", \"found\": True, \"_source\": {\"content\": \"b\"}},\n                ]\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            docs = await s.get_by_ids([\"v1\", \"v2\"])\n            assert docs[0][\"id\"] == \"v1\"\n            assert docs[1][\"id\"] == \"v2\"\n\n    @pytest.mark.asyncio\n    async def test_get_vectors_by_ids(self, global_config, embed_func, mock_client):\n        vec = [0.1] * 128\n        mock_client.mget = AsyncMock(\n            return_value={\n                \"docs\": [\n                    {\"_id\": \"v1\", \"found\": True, \"_source\": {\"vector\": vec}},\n                    {\"_id\": \"v2\", \"found\": False},\n                ]\n            }\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            result = await s.get_vectors_by_ids([\"v1\", \"v2\"])\n            assert \"v1\" in result\n            assert \"v2\" not in result\n            assert result[\"v1\"] == vec\n\n    @pytest.mark.asyncio\n    async def test_delete(self, global_config, embed_func, mock_client):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            with patch(\n                \"lightrag.kg.opensearch_impl.helpers.async_bulk\", new_callable=AsyncMock\n            ) as mock_bulk:\n                mock_bulk.return_value = (2, [])\n                s = self._make(global_config, embed_func)\n                await s.initialize()\n                await s.delete([\"v1\", \"v2\"])\n                actions = mock_bulk.call_args[0][1]\n                assert len(actions) == 2\n                assert all(a[\"_op_type\"] == \"delete\" for a in actions)\n\n    @pytest.mark.asyncio\n    async def test_delete_entity(self, global_config, embed_func, mock_client):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            await s.delete_entity(\"Alice\")\n            mock_client.delete.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_delete_entity_relation(self, global_config, embed_func, mock_client):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            await s.delete_entity_relation(\"Alice\")\n            mock_client.delete_by_query.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_drop_recreates_index(self, global_config, embed_func, mock_client):\n        # After drop, _create_knn_index_if_not_exists is called again.\n        # First call (init): exists=False -> create. Second call (after drop): exists=False -> create again.\n        mock_client.indices.exists = AsyncMock(return_value=False)\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            result = await s.drop()\n            assert result[\"status\"] == \"success\"\n            mock_client.indices.delete.assert_awaited_once()\n            # create called twice: once during init, once during drop recreate\n            assert mock_client.indices.create.await_count == 2\n\n    @pytest.mark.asyncio\n    async def test_drop_delete_error_marks_index_not_ready(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.indices.delete = AsyncMock(\n            side_effect=OpenSearchException(\"delete failed\")\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            result = await s.drop()\n            assert result[\"status\"] == \"error\"\n            assert s._index_ready is False\n\n    @pytest.mark.asyncio\n    async def test_drop_recreate_error_marks_index_not_ready(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            with patch.object(\n                s,\n                \"_create_knn_index_if_not_exists\",\n                new=AsyncMock(side_effect=OpenSearchException(\"recreate failed\")),\n            ):\n                result = await s.drop()\n                assert result[\"status\"] == \"error\"\n                assert s._index_ready is False\n\n    @pytest.mark.asyncio\n    async def test_drop_recreates_index_when_missing(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.indices.exists = AsyncMock(return_value=False)\n        mock_client.indices.delete = AsyncMock(\n            side_effect=NotFoundError(404, \"not found\")\n        )\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            result = await s.drop()\n            assert result[\"status\"] == \"success\"\n            assert mock_client.indices.create.await_count == 2\n\n    @pytest.mark.asyncio\n    async def test_reads_short_circuit_when_index_not_ready(\n        self, global_config, embed_func, mock_client\n    ):\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n            s._index_ready = False\n\n            assert await s.query(\"test\", top_k=5) == []\n            assert await s.get_by_id(\"v1\") is None\n            assert await s.get_vectors_by_ids([\"v1\"]) == {}\n\n            mock_client.search.assert_not_awaited()\n            mock_client.mget.assert_not_awaited()\n\n    @pytest.mark.asyncio\n    async def test_read_missing_index_demotes_readiness(\n        self, global_config, embed_func, mock_client\n    ):\n        mock_client.search = AsyncMock(side_effect=_missing_index_error())\n        with patch.object(ClientManager, \"get_client\", return_value=mock_client):\n            s = self._make(global_config, embed_func)\n            await s.initialize()\n\n            assert await s.query(\"test\", top_k=5) == []\n            assert await s.query(\"test\", top_k=5) == []\n            assert s._index_ready is False\n            assert mock_client.search.await_count == 1\n\n\n# ---------------------------------------------------------------------------\n# Cosine score edge cases\n# ---------------------------------------------------------------------------\n\n\nclass TestScoreThreshold:\n    \"\"\"Verify that raw OpenSearch scores are compared directly against threshold.\"\"\"\n\n    def test_above_threshold(self):\n        assert 0.85 >= 0.2\n\n    def test_below_threshold(self):\n        assert 0.15 < 0.2\n\n    def test_exact_threshold(self):\n        assert 0.2 >= 0.2\n"
  },
  {
    "path": "tests/test_overlap_validation.py",
    "content": "\"\"\"\nTest for overlap_tokens validation to prevent infinite loop.\n\nThis test validates the fix for the bug where overlap_tokens >= max_tokens\ncauses an infinite loop in the chunking function.\n\"\"\"\n\nfrom lightrag.rerank import chunk_documents_for_rerank\n\n\nclass TestOverlapValidation:\n    \"\"\"Test suite for overlap_tokens validation\"\"\"\n\n    def test_overlap_greater_than_max_tokens(self):\n        \"\"\"Test that overlap_tokens > max_tokens is clamped and doesn't hang\"\"\"\n        documents = [\" \".join([f\"word{i}\" for i in range(100)])]\n\n        # This should clamp overlap_tokens to 29 (max_tokens - 1)\n        chunked_docs, doc_indices = chunk_documents_for_rerank(\n            documents, max_tokens=30, overlap_tokens=32\n        )\n\n        # Should complete without hanging\n        assert len(chunked_docs) > 0\n        assert all(idx == 0 for idx in doc_indices)\n\n    def test_overlap_equal_to_max_tokens(self):\n        \"\"\"Test that overlap_tokens == max_tokens is clamped and doesn't hang\"\"\"\n        documents = [\" \".join([f\"word{i}\" for i in range(100)])]\n\n        # This should clamp overlap_tokens to 29 (max_tokens - 1)\n        chunked_docs, doc_indices = chunk_documents_for_rerank(\n            documents, max_tokens=30, overlap_tokens=30\n        )\n\n        # Should complete without hanging\n        assert len(chunked_docs) > 0\n        assert all(idx == 0 for idx in doc_indices)\n\n    def test_overlap_slightly_less_than_max_tokens(self):\n        \"\"\"Test that overlap_tokens < max_tokens works normally\"\"\"\n        documents = [\" \".join([f\"word{i}\" for i in range(100)])]\n\n        # This should work without clamping\n        chunked_docs, doc_indices = chunk_documents_for_rerank(\n            documents, max_tokens=30, overlap_tokens=29\n        )\n\n        # Should complete successfully\n        assert len(chunked_docs) > 0\n        assert all(idx == 0 for idx in doc_indices)\n\n    def test_small_max_tokens_with_large_overlap(self):\n        \"\"\"Test edge case with very small max_tokens\"\"\"\n        documents = [\" \".join([f\"word{i}\" for i in range(50)])]\n\n        # max_tokens=5, overlap_tokens=10 should clamp to 4\n        chunked_docs, doc_indices = chunk_documents_for_rerank(\n            documents, max_tokens=5, overlap_tokens=10\n        )\n\n        # Should complete without hanging\n        assert len(chunked_docs) > 0\n        assert all(idx == 0 for idx in doc_indices)\n\n    def test_multiple_documents_with_invalid_overlap(self):\n        \"\"\"Test multiple documents with overlap_tokens >= max_tokens\"\"\"\n        documents = [\n            \" \".join([f\"word{i}\" for i in range(50)]),\n            \"short document\",\n            \" \".join([f\"word{i}\" for i in range(75)]),\n        ]\n\n        # overlap_tokens > max_tokens\n        chunked_docs, doc_indices = chunk_documents_for_rerank(\n            documents, max_tokens=25, overlap_tokens=30\n        )\n\n        # Should complete successfully and chunk the long documents\n        assert len(chunked_docs) >= len(documents)\n        # Short document should not be chunked\n        assert \"short document\" in chunked_docs\n\n    def test_normal_operation_unaffected(self):\n        \"\"\"Test that normal cases continue to work correctly\"\"\"\n        documents = [\n            \" \".join([f\"word{i}\" for i in range(100)]),\n            \"short doc\",\n        ]\n\n        # Normal case: overlap_tokens (10) < max_tokens (50)\n        chunked_docs, doc_indices = chunk_documents_for_rerank(\n            documents, max_tokens=50, overlap_tokens=10\n        )\n\n        # Long document should be chunked, short one should not\n        assert len(chunked_docs) > 2  # At least 3 chunks (2 from long doc + 1 short)\n        assert \"short doc\" in chunked_docs\n        # Verify doc_indices maps correctly\n        assert doc_indices[-1] == 1  # Last chunk is from second document\n\n    def test_edge_case_max_tokens_one(self):\n        \"\"\"Test edge case where max_tokens=1\"\"\"\n        documents = [\" \".join([f\"word{i}\" for i in range(20)])]\n\n        # max_tokens=1, overlap_tokens=5 should clamp to 0\n        chunked_docs, doc_indices = chunk_documents_for_rerank(\n            documents, max_tokens=1, overlap_tokens=5\n        )\n\n        # Should complete without hanging\n        assert len(chunked_docs) > 0\n        assert all(idx == 0 for idx in doc_indices)\n"
  },
  {
    "path": "tests/test_postgres_index_name.py",
    "content": "\"\"\"\nUnit tests for PostgreSQL safe index name generation.\n\nThis module tests the _safe_index_name helper function which prevents\nPostgreSQL's silent 63-byte identifier truncation from causing index\nlookup failures.\n\"\"\"\n\nimport pytest\n\n# Mark all tests as offline (no external dependencies)\npytestmark = pytest.mark.offline\n\n\nclass TestSafeIndexName:\n    \"\"\"Test suite for _safe_index_name function.\"\"\"\n\n    def test_short_name_unchanged(self):\n        \"\"\"Short index names should remain unchanged.\"\"\"\n        from lightrag.kg.postgres_impl import _safe_index_name\n\n        # Short table name - should return unchanged\n        result = _safe_index_name(\"lightrag_vdb_entity\", \"hnsw_cosine\")\n        assert result == \"idx_lightrag_vdb_entity_hnsw_cosine\"\n        assert len(result.encode(\"utf-8\")) <= 63\n\n    def test_long_name_gets_hashed(self):\n        \"\"\"Long table names exceeding 63 bytes should get hashed.\"\"\"\n        from lightrag.kg.postgres_impl import _safe_index_name\n\n        # Long table name that would exceed 63 bytes\n        long_table_name = \"LIGHTRAG_VDB_ENTITY_text_embedding_3_large_3072d\"\n        result = _safe_index_name(long_table_name, \"hnsw_cosine\")\n\n        # Should be within 63 bytes\n        assert len(result.encode(\"utf-8\")) <= 63\n\n        # Should start with idx_ prefix\n        assert result.startswith(\"idx_\")\n\n        # Should contain the suffix\n        assert result.endswith(\"_hnsw_cosine\")\n\n        # Should NOT be the naive concatenation (which would be truncated)\n        naive_name = f\"idx_{long_table_name.lower()}_hnsw_cosine\"\n        assert result != naive_name\n\n    def test_deterministic_output(self):\n        \"\"\"Same input should always produce same output (deterministic).\"\"\"\n        from lightrag.kg.postgres_impl import _safe_index_name\n\n        table_name = \"LIGHTRAG_VDB_CHUNKS_text_embedding_3_large_3072d\"\n        suffix = \"hnsw_cosine\"\n\n        result1 = _safe_index_name(table_name, suffix)\n        result2 = _safe_index_name(table_name, suffix)\n\n        assert result1 == result2\n\n    def test_different_suffixes_different_results(self):\n        \"\"\"Different suffixes should produce different index names.\"\"\"\n        from lightrag.kg.postgres_impl import _safe_index_name\n\n        table_name = \"LIGHTRAG_VDB_ENTITY_text_embedding_3_large_3072d\"\n\n        result1 = _safe_index_name(table_name, \"hnsw_cosine\")\n        result2 = _safe_index_name(table_name, \"ivfflat_cosine\")\n\n        assert result1 != result2\n\n    def test_case_insensitive(self):\n        \"\"\"Table names should be normalized to lowercase.\"\"\"\n        from lightrag.kg.postgres_impl import _safe_index_name\n\n        result_upper = _safe_index_name(\"LIGHTRAG_VDB_ENTITY\", \"hnsw_cosine\")\n        result_lower = _safe_index_name(\"lightrag_vdb_entity\", \"hnsw_cosine\")\n\n        assert result_upper == result_lower\n\n    def test_boundary_case_exactly_63_bytes(self):\n        \"\"\"Test boundary case where name is exactly at 63-byte limit.\"\"\"\n        from lightrag.kg.postgres_impl import _safe_index_name\n\n        # Create a table name that results in exactly 63 bytes\n        # idx_ (4) + table_name + _ (1) + suffix = 63\n        # So table_name + suffix = 58\n\n        # Test a name that's just under the limit (should remain unchanged)\n        short_suffix = \"id\"\n        # idx_ (4) + 56 chars + _ (1) + id (2) = 63\n        table_56 = \"a\" * 56\n        result = _safe_index_name(table_56, short_suffix)\n        expected = f\"idx_{table_56}_{short_suffix}\"\n        assert result == expected\n        assert len(result.encode(\"utf-8\")) == 63\n\n    def test_unicode_handling(self):\n        \"\"\"Unicode characters should be properly handled (bytes, not chars).\"\"\"\n        from lightrag.kg.postgres_impl import _safe_index_name\n\n        # Unicode characters can take more bytes than visible chars\n        # Chinese characters are 3 bytes each in UTF-8\n        table_name = \"lightrag_测试_table\"  # Contains Chinese chars\n        result = _safe_index_name(table_name, \"hnsw_cosine\")\n\n        # Should always be within 63 bytes\n        assert len(result.encode(\"utf-8\")) <= 63\n\n    def test_real_world_model_names(self):\n        \"\"\"Test with real-world embedding model names that cause issues.\"\"\"\n        from lightrag.kg.postgres_impl import _safe_index_name\n\n        # These are actual model names that have caused issues\n        test_cases = [\n            (\"LIGHTRAG_VDB_CHUNKS_text_embedding_3_large_3072d\", \"hnsw_cosine\"),\n            (\"LIGHTRAG_VDB_ENTITY_text_embedding_3_large_3072d\", \"hnsw_cosine\"),\n            (\"LIGHTRAG_VDB_RELATION_text_embedding_3_large_3072d\", \"hnsw_cosine\"),\n            (\n                \"LIGHTRAG_VDB_ENTITY_bge_m3_1024d\",\n                \"hnsw_cosine\",\n            ),  # Shorter model name\n            (\n                \"LIGHTRAG_VDB_CHUNKS_nomic_embed_text_v1_768d\",\n                \"ivfflat_cosine\",\n            ),  # Different index type\n        ]\n\n        for table_name, suffix in test_cases:\n            result = _safe_index_name(table_name, suffix)\n\n            # Critical: must be within PostgreSQL's 63-byte limit\n            assert (\n                len(result.encode(\"utf-8\")) <= 63\n            ), f\"Index name too long: {result} for table {table_name}\"\n\n            # Must have consistent format\n            assert result.startswith(\"idx_\"), f\"Missing idx_ prefix: {result}\"\n            assert result.endswith(f\"_{suffix}\"), f\"Missing suffix {suffix}: {result}\"\n\n    def test_hash_uniqueness_for_similar_tables(self):\n        \"\"\"Similar but different table names should produce different hashes.\"\"\"\n        from lightrag.kg.postgres_impl import _safe_index_name\n\n        # These tables have similar names but should have different hashes\n        tables = [\n            \"LIGHTRAG_VDB_CHUNKS_model_a_1024d\",\n            \"LIGHTRAG_VDB_CHUNKS_model_b_1024d\",\n            \"LIGHTRAG_VDB_ENTITY_model_a_1024d\",\n        ]\n\n        results = [_safe_index_name(t, \"hnsw_cosine\") for t in tables]\n\n        # All results should be unique\n        assert len(set(results)) == len(results), \"Hash collision detected!\"\n\n\nclass TestIndexNameIntegration:\n    \"\"\"Integration-style tests for index name usage patterns.\"\"\"\n\n    def test_pg_indexes_lookup_compatibility(self):\n        \"\"\"\n        Test that the generated index name will work with pg_indexes lookup.\n\n        This is the core problem: PostgreSQL stores the truncated name,\n        but we were looking up the untruncated name. Our fix ensures we\n        always use a name that fits within 63 bytes.\n        \"\"\"\n        from lightrag.kg.postgres_impl import _safe_index_name\n\n        table_name = \"LIGHTRAG_VDB_CHUNKS_text_embedding_3_large_3072d\"\n        suffix = \"hnsw_cosine\"\n\n        # Generate the index name\n        index_name = _safe_index_name(table_name, suffix)\n\n        # Simulate what PostgreSQL would store (truncate at 63 bytes)\n        stored_name = index_name.encode(\"utf-8\")[:63].decode(\"utf-8\", errors=\"ignore\")\n\n        # The key fix: our generated name should equal the stored name\n        # because it's already within the 63-byte limit\n        assert (\n            index_name == stored_name\n        ), \"Index name would be truncated by PostgreSQL, causing lookup failures!\"\n\n    def test_backward_compatibility_short_names(self):\n        \"\"\"\n        Ensure backward compatibility with existing short index names.\n\n        For tables that have existing indexes with short names (pre-model-suffix era),\n        the function should not change their names.\n        \"\"\"\n        from lightrag.kg.postgres_impl import _safe_index_name\n\n        # Legacy table names without model suffix\n        legacy_tables = [\n            \"LIGHTRAG_VDB_ENTITY\",\n            \"LIGHTRAG_VDB_RELATION\",\n            \"LIGHTRAG_VDB_CHUNKS\",\n        ]\n\n        for table in legacy_tables:\n            for suffix in [\"hnsw_cosine\", \"ivfflat_cosine\", \"id\"]:\n                result = _safe_index_name(table, suffix)\n                expected = f\"idx_{table.lower()}_{suffix}\"\n\n                # Short names should remain unchanged for backward compatibility\n                if len(expected.encode(\"utf-8\")) <= 63:\n                    assert (\n                        result == expected\n                    ), f\"Short name changed unexpectedly: {result} != {expected}\"\n"
  },
  {
    "path": "tests/test_postgres_migration.py",
    "content": "import pytest\nfrom unittest.mock import patch, AsyncMock\nimport numpy as np\nfrom lightrag.utils import EmbeddingFunc\nfrom lightrag.kg.postgres_impl import (\n    PGVectorStorage,\n)\nfrom lightrag.namespace import NameSpace\n\n\n# Mock PostgreSQLDB\n@pytest.fixture\ndef mock_pg_db():\n    \"\"\"Mock PostgreSQL database connection\"\"\"\n    db = AsyncMock()\n    db.workspace = \"test_workspace\"\n\n    # Mock query responses with multirows support\n    async def mock_query(sql, params=None, multirows=False, **kwargs):\n        # Default return value\n        if multirows:\n            return []  # Return empty list for multirows\n        return {\"exists\": False, \"count\": 0}\n\n    # Mock for execute that mimics PostgreSQLDB.execute() behavior\n    async def mock_execute(sql, data=None, **kwargs):\n        \"\"\"\n        Mock that mimics PostgreSQLDB.execute() behavior:\n        - Accepts data as dict[str, Any] | None (second parameter)\n        - Internally converts dict.values() to tuple for AsyncPG\n        \"\"\"\n        # Mimic real execute() which accepts dict and converts to tuple\n        if data is not None and not isinstance(data, dict):\n            raise TypeError(\n                f\"PostgreSQLDB.execute() expects data as dict, got {type(data).__name__}\"\n            )\n        return None\n\n    db.query = AsyncMock(side_effect=mock_query)\n    db.execute = AsyncMock(side_effect=mock_execute)\n\n    return db\n\n\n# Mock get_data_init_lock to avoid async lock issues in tests\n@pytest.fixture(autouse=True)\ndef mock_data_init_lock():\n    with patch(\"lightrag.kg.postgres_impl.get_data_init_lock\") as mock_lock:\n        mock_lock_ctx = AsyncMock()\n        mock_lock.return_value = mock_lock_ctx\n        yield mock_lock\n\n\n# Mock ClientManager\n@pytest.fixture\ndef mock_client_manager(mock_pg_db):\n    with patch(\"lightrag.kg.postgres_impl.ClientManager\") as mock_manager:\n        mock_manager.get_client = AsyncMock(return_value=mock_pg_db)\n        mock_manager.release_client = AsyncMock()\n        yield mock_manager\n\n\n# Mock Embedding function\n@pytest.fixture\ndef mock_embedding_func():\n    async def embed_func(texts, **kwargs):\n        return np.array([[0.1] * 768 for _ in texts])\n\n    func = EmbeddingFunc(embedding_dim=768, func=embed_func, model_name=\"test_model\")\n    return func\n\n\nasync def test_postgres_table_naming(\n    mock_client_manager, mock_pg_db, mock_embedding_func\n):\n    \"\"\"Test if table name is correctly generated with model suffix\"\"\"\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    storage = PGVectorStorage(\n        namespace=NameSpace.VECTOR_STORE_CHUNKS,\n        global_config=config,\n        embedding_func=mock_embedding_func,\n        workspace=\"test_ws\",\n    )\n\n    # Verify table name contains model suffix\n    expected_suffix = \"test_model_768d\"\n    assert expected_suffix in storage.table_name\n    assert storage.table_name == f\"LIGHTRAG_VDB_CHUNKS_{expected_suffix}\"\n\n    # Verify legacy table name\n    assert storage.legacy_table_name == \"LIGHTRAG_VDB_CHUNKS\"\n\n\nasync def test_postgres_migration_trigger(\n    mock_client_manager, mock_pg_db, mock_embedding_func\n):\n    \"\"\"Test if migration logic is triggered correctly\"\"\"\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    storage = PGVectorStorage(\n        namespace=NameSpace.VECTOR_STORE_CHUNKS,\n        global_config=config,\n        embedding_func=mock_embedding_func,\n        workspace=\"test_ws\",\n    )\n\n    # Setup mocks for migration scenario\n    # 1. New table does not exist, legacy table exists\n    async def mock_check_table_exists(table_name):\n        return table_name == storage.legacy_table_name\n\n    mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)\n\n    # 2. Legacy table has 100 records\n    mock_rows = [\n        {\"id\": f\"test_id_{i}\", \"content\": f\"content_{i}\", \"workspace\": \"test_ws\"}\n        for i in range(100)\n    ]\n    migration_state = {\"new_table_count\": 0}\n\n    async def mock_query(sql, params=None, multirows=False, **kwargs):\n        if \"COUNT(*)\" in sql:\n            sql_upper = sql.upper()\n            legacy_table = storage.legacy_table_name.upper()\n            new_table = storage.table_name.upper()\n            is_new_table = new_table in sql_upper\n            is_legacy_table = legacy_table in sql_upper and not is_new_table\n\n            if is_new_table:\n                return {\"count\": migration_state[\"new_table_count\"]}\n            if is_legacy_table:\n                return {\"count\": 100}\n            return {\"count\": 0}\n        elif multirows and \"SELECT *\" in sql:\n            # Mock batch fetch for migration using keyset pagination\n            # New pattern: WHERE workspace = $1 AND id > $2 ORDER BY id LIMIT $3\n            # or first batch: WHERE workspace = $1 ORDER BY id LIMIT $2\n            if \"WHERE workspace\" in sql:\n                if \"id >\" in sql:\n                    # Keyset pagination: params = [workspace, last_id, limit]\n                    last_id = params[1] if len(params) > 1 else None\n                    # Find rows after last_id\n                    start_idx = 0\n                    for i, row in enumerate(mock_rows):\n                        if row[\"id\"] == last_id:\n                            start_idx = i + 1\n                            break\n                    limit = params[2] if len(params) > 2 else 500\n                else:\n                    # First batch (no last_id): params = [workspace, limit]\n                    start_idx = 0\n                    limit = params[1] if len(params) > 1 else 500\n            else:\n                # No workspace filter with keyset\n                if \"id >\" in sql:\n                    last_id = params[0] if params else None\n                    start_idx = 0\n                    for i, row in enumerate(mock_rows):\n                        if row[\"id\"] == last_id:\n                            start_idx = i + 1\n                            break\n                    limit = params[1] if len(params) > 1 else 500\n                else:\n                    start_idx = 0\n                    limit = params[0] if params else 500\n            end = min(start_idx + limit, len(mock_rows))\n            return mock_rows[start_idx:end]\n        return {}\n\n    mock_pg_db.query = AsyncMock(side_effect=mock_query)\n\n    # Track migration through _run_with_retry calls\n    migration_executed = []\n\n    async def mock_run_with_retry(operation, **kwargs):\n        # Track that migration batch operation was called\n        migration_executed.append(True)\n        migration_state[\"new_table_count\"] = 100\n        return None\n\n    mock_pg_db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry)\n\n    with patch(\n        \"lightrag.kg.postgres_impl.PGVectorStorage._pg_create_table\", AsyncMock()\n    ):\n        # Initialize storage (should trigger migration)\n        await storage.initialize()\n\n        # Verify migration was executed by checking _run_with_retry was called\n        # (batch migration uses _run_with_retry with executemany)\n        assert len(migration_executed) > 0, \"Migration should have been executed\"\n\n\nasync def test_postgres_no_migration_needed(\n    mock_client_manager, mock_pg_db, mock_embedding_func\n):\n    \"\"\"Test scenario where new table already exists (no migration needed)\"\"\"\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    storage = PGVectorStorage(\n        namespace=NameSpace.VECTOR_STORE_CHUNKS,\n        global_config=config,\n        embedding_func=mock_embedding_func,\n        workspace=\"test_ws\",\n    )\n\n    # Mock: new table already exists\n    async def mock_check_table_exists(table_name):\n        return table_name == storage.table_name\n\n    mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)\n\n    with patch(\n        \"lightrag.kg.postgres_impl.PGVectorStorage._pg_create_table\", AsyncMock()\n    ) as mock_create:\n        await storage.initialize()\n\n        # Verify no table creation was attempted\n        mock_create.assert_not_called()\n\n\nasync def test_scenario_1_new_workspace_creation(\n    mock_client_manager, mock_pg_db, mock_embedding_func\n):\n    \"\"\"\n    Scenario 1: New workspace creation\n\n    Expected behavior:\n    - No legacy table exists\n    - Directly create new table with model suffix\n    - No migration needed\n    \"\"\"\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    embedding_func = EmbeddingFunc(\n        embedding_dim=3072,\n        func=mock_embedding_func.func,\n        model_name=\"text-embedding-3-large\",\n    )\n\n    storage = PGVectorStorage(\n        namespace=NameSpace.VECTOR_STORE_CHUNKS,\n        global_config=config,\n        embedding_func=embedding_func,\n        workspace=\"new_workspace\",\n    )\n\n    # Mock: neither table exists\n    async def mock_check_table_exists(table_name):\n        return False\n\n    mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)\n\n    with patch(\n        \"lightrag.kg.postgres_impl.PGVectorStorage._pg_create_table\", AsyncMock()\n    ) as mock_create:\n        await storage.initialize()\n\n        # Verify table name format\n        assert \"text_embedding_3_large_3072d\" in storage.table_name\n\n        # Verify new table creation was called\n        mock_create.assert_called_once()\n        call_args = mock_create.call_args\n        assert (\n            call_args[0][1] == storage.table_name\n        )  # table_name is second positional arg\n\n\nasync def test_scenario_2_legacy_upgrade_migration(\n    mock_client_manager, mock_pg_db, mock_embedding_func\n):\n    \"\"\"\n    Scenario 2: Upgrade from legacy version\n\n    Expected behavior:\n    - Legacy table exists (without model suffix)\n    - New table doesn't exist\n    - Automatically migrate data to new table with suffix\n    \"\"\"\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    embedding_func = EmbeddingFunc(\n        embedding_dim=1536,\n        func=mock_embedding_func.func,\n        model_name=\"text-embedding-ada-002\",\n    )\n\n    storage = PGVectorStorage(\n        namespace=NameSpace.VECTOR_STORE_CHUNKS,\n        global_config=config,\n        embedding_func=embedding_func,\n        workspace=\"legacy_workspace\",\n    )\n\n    # Mock: only legacy table exists\n    async def mock_check_table_exists(table_name):\n        return table_name == storage.legacy_table_name\n\n    mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)\n\n    # Mock: legacy table has 50 records\n    mock_rows = [\n        {\n            \"id\": f\"legacy_id_{i}\",\n            \"content\": f\"legacy_content_{i}\",\n            \"workspace\": \"legacy_workspace\",\n        }\n        for i in range(50)\n    ]\n\n    # Track which queries have been made for proper response\n    query_history = []\n    migration_state = {\"new_table_count\": 0}\n\n    async def mock_query(sql, params=None, multirows=False, **kwargs):\n        query_history.append(sql)\n\n        if \"COUNT(*)\" in sql:\n            # Determine table type:\n            # - Legacy: contains base name but NOT model suffix\n            # - New: contains model suffix (e.g., text_embedding_ada_002_1536d)\n            sql_upper = sql.upper()\n            base_name = storage.legacy_table_name.upper()\n\n            # Check if this is querying the new table (has model suffix)\n            has_model_suffix = storage.table_name.upper() in sql_upper\n\n            is_legacy_table = base_name in sql_upper and not has_model_suffix\n            has_workspace_filter = \"WHERE workspace\" in sql\n\n            if is_legacy_table and has_workspace_filter:\n                # Count for legacy table with workspace filter (before migration)\n                return {\"count\": 50}\n            elif is_legacy_table and not has_workspace_filter:\n                # Total count for legacy table\n                return {\"count\": 50}\n            else:\n                # New table count (before/after migration)\n                return {\"count\": migration_state[\"new_table_count\"]}\n        elif multirows and \"SELECT *\" in sql:\n            # Mock batch fetch for migration using keyset pagination\n            # New pattern: WHERE workspace = $1 AND id > $2 ORDER BY id LIMIT $3\n            # or first batch: WHERE workspace = $1 ORDER BY id LIMIT $2\n            if \"WHERE workspace\" in sql:\n                if \"id >\" in sql:\n                    # Keyset pagination: params = [workspace, last_id, limit]\n                    last_id = params[1] if len(params) > 1 else None\n                    # Find rows after last_id\n                    start_idx = 0\n                    for i, row in enumerate(mock_rows):\n                        if row[\"id\"] == last_id:\n                            start_idx = i + 1\n                            break\n                    limit = params[2] if len(params) > 2 else 500\n                else:\n                    # First batch (no last_id): params = [workspace, limit]\n                    start_idx = 0\n                    limit = params[1] if len(params) > 1 else 500\n            else:\n                # No workspace filter with keyset\n                if \"id >\" in sql:\n                    last_id = params[0] if params else None\n                    start_idx = 0\n                    for i, row in enumerate(mock_rows):\n                        if row[\"id\"] == last_id:\n                            start_idx = i + 1\n                            break\n                    limit = params[1] if len(params) > 1 else 500\n                else:\n                    start_idx = 0\n                    limit = params[0] if params else 500\n            end = min(start_idx + limit, len(mock_rows))\n            return mock_rows[start_idx:end]\n        return {}\n\n    mock_pg_db.query = AsyncMock(side_effect=mock_query)\n\n    # Track migration through _run_with_retry calls\n    migration_executed = []\n\n    async def mock_run_with_retry(operation, **kwargs):\n        # Track that migration batch operation was called\n        migration_executed.append(True)\n        migration_state[\"new_table_count\"] = 50\n        return None\n\n    mock_pg_db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry)\n\n    with patch(\n        \"lightrag.kg.postgres_impl.PGVectorStorage._pg_create_table\", AsyncMock()\n    ) as mock_create:\n        await storage.initialize()\n\n        # Verify table name contains ada-002\n        assert \"text_embedding_ada_002_1536d\" in storage.table_name\n\n        # Verify migration was executed (batch migration uses _run_with_retry)\n        assert len(migration_executed) > 0, \"Migration should have been executed\"\n        mock_create.assert_called_once()\n\n\nasync def test_scenario_3_multi_model_coexistence(\n    mock_client_manager, mock_pg_db, mock_embedding_func\n):\n    \"\"\"\n    Scenario 3: Multiple embedding models coexist\n\n    Expected behavior:\n    - Different embedding models create separate tables\n    - Tables are isolated by model suffix\n    - No interference between different models\n    \"\"\"\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    # Workspace A: uses bge-small (768d)\n    embedding_func_a = EmbeddingFunc(\n        embedding_dim=768, func=mock_embedding_func.func, model_name=\"bge-small\"\n    )\n\n    storage_a = PGVectorStorage(\n        namespace=NameSpace.VECTOR_STORE_CHUNKS,\n        global_config=config,\n        embedding_func=embedding_func_a,\n        workspace=\"workspace_a\",\n    )\n\n    # Workspace B: uses bge-large (1024d)\n    async def embed_func_b(texts, **kwargs):\n        return np.array([[0.1] * 1024 for _ in texts])\n\n    embedding_func_b = EmbeddingFunc(\n        embedding_dim=1024, func=embed_func_b, model_name=\"bge-large\"\n    )\n\n    storage_b = PGVectorStorage(\n        namespace=NameSpace.VECTOR_STORE_CHUNKS,\n        global_config=config,\n        embedding_func=embedding_func_b,\n        workspace=\"workspace_b\",\n    )\n\n    # Verify different table names\n    assert storage_a.table_name != storage_b.table_name\n    assert \"bge_small_768d\" in storage_a.table_name\n    assert \"bge_large_1024d\" in storage_b.table_name\n\n    # Mock: both tables don't exist yet\n    async def mock_check_table_exists(table_name):\n        return False\n\n    mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)\n\n    with patch(\n        \"lightrag.kg.postgres_impl.PGVectorStorage._pg_create_table\", AsyncMock()\n    ) as mock_create:\n        # Initialize both storages\n        await storage_a.initialize()\n        await storage_b.initialize()\n\n        # Verify two separate tables were created\n        assert mock_create.call_count == 2\n\n        # Verify table names are different\n        call_args_list = mock_create.call_args_list\n        table_names = [call[0][1] for call in call_args_list]  # Second positional arg\n        assert len(set(table_names)) == 2  # Two unique table names\n        assert storage_a.table_name in table_names\n        assert storage_b.table_name in table_names\n\n\nasync def test_case1_empty_legacy_auto_cleanup(\n    mock_client_manager, mock_pg_db, mock_embedding_func\n):\n    \"\"\"\n    Case 1a: Both new and legacy tables exist, but legacy is EMPTY\n    Expected: Automatically delete empty legacy table (safe cleanup)\n    \"\"\"\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    embedding_func = EmbeddingFunc(\n        embedding_dim=1536,\n        func=mock_embedding_func.func,\n        model_name=\"test-model\",\n    )\n\n    storage = PGVectorStorage(\n        namespace=NameSpace.VECTOR_STORE_CHUNKS,\n        global_config=config,\n        embedding_func=embedding_func,\n        workspace=\"test_ws\",\n    )\n\n    # Mock: Both tables exist\n    async def mock_check_table_exists(table_name):\n        return True  # Both new and legacy exist\n\n    mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)\n\n    # Mock: Legacy table is empty (0 records)\n    async def mock_query(sql, params=None, multirows=False, **kwargs):\n        if \"COUNT(*)\" in sql:\n            if storage.legacy_table_name in sql:\n                return {\"count\": 0}  # Empty legacy table\n            else:\n                return {\"count\": 100}  # New table has data\n        return {}\n\n    mock_pg_db.query = AsyncMock(side_effect=mock_query)\n\n    with patch(\"lightrag.kg.postgres_impl.logger\"):\n        await storage.initialize()\n\n        # Verify: Empty legacy table should be automatically cleaned up\n        # Empty tables are safe to delete without data loss risk\n        delete_calls = [\n            call\n            for call in mock_pg_db.execute.call_args_list\n            if call[0][0] and \"DROP TABLE\" in call[0][0]\n        ]\n        assert len(delete_calls) >= 1, \"Empty legacy table should be auto-deleted\"\n        # Check if legacy table was dropped\n        dropped_table = storage.legacy_table_name\n        assert any(\n            dropped_table in str(call) for call in delete_calls\n        ), f\"Expected to drop empty legacy table '{dropped_table}'\"\n\n        print(\n            f\"✅ Case 1a: Empty legacy table '{dropped_table}' auto-deleted successfully\"\n        )\n\n\nasync def test_case1_nonempty_legacy_warning(\n    mock_client_manager, mock_pg_db, mock_embedding_func\n):\n    \"\"\"\n    Case 1b: Both new and legacy tables exist, and legacy HAS DATA\n    Expected: Log warning, do not delete legacy (preserve data)\n    \"\"\"\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    embedding_func = EmbeddingFunc(\n        embedding_dim=1536,\n        func=mock_embedding_func.func,\n        model_name=\"test-model\",\n    )\n\n    storage = PGVectorStorage(\n        namespace=NameSpace.VECTOR_STORE_CHUNKS,\n        global_config=config,\n        embedding_func=embedding_func,\n        workspace=\"test_ws\",\n    )\n\n    # Mock: Both tables exist\n    async def mock_check_table_exists(table_name):\n        return True  # Both new and legacy exist\n\n    mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists)\n\n    # Mock: Legacy table has data (50 records)\n    async def mock_query(sql, params=None, multirows=False, **kwargs):\n        if \"COUNT(*)\" in sql:\n            if storage.legacy_table_name in sql:\n                return {\"count\": 50}  # Legacy has data\n            else:\n                return {\"count\": 100}  # New table has data\n        return {}\n\n    mock_pg_db.query = AsyncMock(side_effect=mock_query)\n\n    with patch(\"lightrag.kg.postgres_impl.logger\"):\n        await storage.initialize()\n\n        # Verify: Legacy table with data should be preserved\n        # We never auto-delete tables that contain data to prevent accidental data loss\n        delete_calls = [\n            call\n            for call in mock_pg_db.execute.call_args_list\n            if call[0][0] and \"DROP TABLE\" in call[0][0]\n        ]\n        # Check if legacy table was deleted (it should not be)\n        dropped_table = storage.legacy_table_name\n        legacy_deleted = any(dropped_table in str(call) for call in delete_calls)\n        assert not legacy_deleted, \"Legacy table with data should NOT be auto-deleted\"\n\n        print(\n            f\"✅ Case 1b: Legacy table '{dropped_table}' with data preserved (warning only)\"\n        )\n\n\nasync def test_case1_sequential_workspace_migration(\n    mock_client_manager, mock_pg_db, mock_embedding_func\n):\n    \"\"\"\n    Case 1c: Sequential workspace migration (Multi-tenant scenario)\n\n    Critical bug fix verification:\n    Timeline:\n    1. Legacy table has workspace_a (3 records) + workspace_b (3 records)\n    2. Workspace A initializes first → Case 3 (only legacy exists) → migrates A's data\n    3. Workspace B initializes later → Case 3 (both tables exist, legacy has B's data) → should migrate B's data\n    4. Verify workspace B's data is correctly migrated to new table\n\n    This test verifies the migration logic correctly handles multi-tenant scenarios\n    where different workspaces migrate sequentially.\n    \"\"\"\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    embedding_func = EmbeddingFunc(\n        embedding_dim=1536,\n        func=mock_embedding_func.func,\n        model_name=\"test-model\",\n    )\n\n    # Mock data: Legacy table has 6 records total (3 from workspace_a, 3 from workspace_b)\n    mock_rows_a = [\n        {\"id\": f\"a_{i}\", \"content\": f\"A content {i}\", \"workspace\": \"workspace_a\"}\n        for i in range(3)\n    ]\n    mock_rows_b = [\n        {\"id\": f\"b_{i}\", \"content\": f\"B content {i}\", \"workspace\": \"workspace_b\"}\n        for i in range(3)\n    ]\n\n    # Track migration state\n    migration_state = {\n        \"new_table_exists\": False,\n        \"workspace_a_migrated\": False,\n        \"workspace_a_migration_count\": 0,\n        \"workspace_b_migration_count\": 0,\n    }\n\n    # Step 1: Simulate workspace_a initialization (Case 3 - only legacy exists)\n    # CRITICAL: Set db.workspace to workspace_a\n    mock_pg_db.workspace = \"workspace_a\"\n\n    storage_a = PGVectorStorage(\n        namespace=NameSpace.VECTOR_STORE_CHUNKS,\n        global_config=config,\n        embedding_func=embedding_func,\n        workspace=\"workspace_a\",\n    )\n\n    # Mock table_exists for workspace_a\n    async def mock_check_table_exists_a(table_name):\n        if table_name == storage_a.legacy_table_name:\n            return True\n        if table_name == storage_a.table_name:\n            return migration_state[\"new_table_exists\"]\n        return False\n\n    mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists_a)\n\n    # Mock query for workspace_a (Case 3)\n    async def mock_query_a(sql, params=None, multirows=False, **kwargs):\n        sql_upper = sql.upper()\n        base_name = storage_a.legacy_table_name.upper()\n\n        if \"COUNT(*)\" in sql:\n            has_model_suffix = \"TEST_MODEL_1536D\" in sql_upper\n            is_legacy = base_name in sql_upper and not has_model_suffix\n            has_workspace_filter = \"WHERE workspace\" in sql\n\n            if is_legacy and has_workspace_filter:\n                workspace = params[0] if params and len(params) > 0 else None\n                if workspace == \"workspace_a\":\n                    return {\"count\": 3}\n                elif workspace == \"workspace_b\":\n                    return {\"count\": 3}\n            elif is_legacy and not has_workspace_filter:\n                # Global count in legacy table\n                return {\"count\": 6}\n            elif has_model_suffix:\n                if has_workspace_filter:\n                    workspace = params[0] if params and len(params) > 0 else None\n                    if workspace == \"workspace_a\":\n                        return {\"count\": migration_state[\"workspace_a_migration_count\"]}\n                    if workspace == \"workspace_b\":\n                        return {\"count\": migration_state[\"workspace_b_migration_count\"]}\n                return {\n                    \"count\": migration_state[\"workspace_a_migration_count\"]\n                    + migration_state[\"workspace_b_migration_count\"]\n                }\n        elif multirows and \"SELECT *\" in sql:\n            if \"WHERE workspace\" in sql:\n                workspace = params[0] if params and len(params) > 0 else None\n                if workspace == \"workspace_a\":\n                    # Handle keyset pagination\n                    if \"id >\" in sql:\n                        # params = [workspace, last_id, limit]\n                        last_id = params[1] if len(params) > 1 else None\n                        start_idx = 0\n                        for i, row in enumerate(mock_rows_a):\n                            if row[\"id\"] == last_id:\n                                start_idx = i + 1\n                                break\n                        limit = params[2] if len(params) > 2 else 500\n                    else:\n                        # First batch: params = [workspace, limit]\n                        start_idx = 0\n                        limit = params[1] if len(params) > 1 else 500\n                    end = min(start_idx + limit, len(mock_rows_a))\n                    return mock_rows_a[start_idx:end]\n        return {}\n\n    mock_pg_db.query = AsyncMock(side_effect=mock_query_a)\n\n    # Track migration via _run_with_retry (batch migration uses this)\n    migration_a_executed = []\n\n    async def mock_run_with_retry_a(operation, **kwargs):\n        migration_a_executed.append(True)\n        migration_state[\"workspace_a_migration_count\"] = len(mock_rows_a)\n        return None\n\n    mock_pg_db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry_a)\n\n    # Initialize workspace_a (Case 3)\n    with patch(\"lightrag.kg.postgres_impl.logger\"):\n        await storage_a.initialize()\n        migration_state[\"new_table_exists\"] = True\n        migration_state[\"workspace_a_migrated\"] = True\n\n    print(\"✅ Step 1: Workspace A initialized\")\n    # Verify migration was executed via _run_with_retry (batch migration uses executemany)\n    assert (\n        len(migration_a_executed) > 0\n    ), \"Migration should have been executed for workspace_a\"\n    print(f\"✅ Step 1: Migration executed {len(migration_a_executed)} batch(es)\")\n\n    # Step 2: Simulate workspace_b initialization (Case 3 - both exist, but legacy has B's data)\n    # CRITICAL: Set db.workspace to workspace_b\n    mock_pg_db.workspace = \"workspace_b\"\n\n    storage_b = PGVectorStorage(\n        namespace=NameSpace.VECTOR_STORE_CHUNKS,\n        global_config=config,\n        embedding_func=embedding_func,\n        workspace=\"workspace_b\",\n    )\n\n    mock_pg_db.reset_mock()\n\n    # Mock table_exists for workspace_b (both exist)\n    async def mock_check_table_exists_b(table_name):\n        return True  # Both tables exist\n\n    mock_pg_db.check_table_exists = AsyncMock(side_effect=mock_check_table_exists_b)\n\n    # Mock query for workspace_b (Case 3)\n    async def mock_query_b(sql, params=None, multirows=False, **kwargs):\n        sql_upper = sql.upper()\n        base_name = storage_b.legacy_table_name.upper()\n\n        if \"COUNT(*)\" in sql:\n            has_model_suffix = \"TEST_MODEL_1536D\" in sql_upper\n            is_legacy = base_name in sql_upper and not has_model_suffix\n            has_workspace_filter = \"WHERE workspace\" in sql\n\n            if is_legacy and has_workspace_filter:\n                workspace = params[0] if params and len(params) > 0 else None\n                if workspace == \"workspace_b\":\n                    return {\"count\": 3}  # workspace_b still has data in legacy\n                elif workspace == \"workspace_a\":\n                    return {\"count\": 0}  # workspace_a already migrated\n            elif is_legacy and not has_workspace_filter:\n                # Global count: only workspace_b data remains\n                return {\"count\": 3}\n            elif has_model_suffix:\n                if has_workspace_filter:\n                    workspace = params[0] if params and len(params) > 0 else None\n                    if workspace == \"workspace_b\":\n                        return {\"count\": migration_state[\"workspace_b_migration_count\"]}\n                    elif workspace == \"workspace_a\":\n                        return {\"count\": 3}\n                else:\n                    return {\"count\": 3 + migration_state[\"workspace_b_migration_count\"]}\n        elif multirows and \"SELECT *\" in sql:\n            if \"WHERE workspace\" in sql:\n                workspace = params[0] if params and len(params) > 0 else None\n                if workspace == \"workspace_b\":\n                    # Handle keyset pagination\n                    if \"id >\" in sql:\n                        # params = [workspace, last_id, limit]\n                        last_id = params[1] if len(params) > 1 else None\n                        start_idx = 0\n                        for i, row in enumerate(mock_rows_b):\n                            if row[\"id\"] == last_id:\n                                start_idx = i + 1\n                                break\n                        limit = params[2] if len(params) > 2 else 500\n                    else:\n                        # First batch: params = [workspace, limit]\n                        start_idx = 0\n                        limit = params[1] if len(params) > 1 else 500\n                    end = min(start_idx + limit, len(mock_rows_b))\n                    return mock_rows_b[start_idx:end]\n        return {}\n\n    mock_pg_db.query = AsyncMock(side_effect=mock_query_b)\n\n    # Track migration via _run_with_retry for workspace_b\n    migration_b_executed = []\n\n    async def mock_run_with_retry_b(operation, **kwargs):\n        migration_b_executed.append(True)\n        migration_state[\"workspace_b_migration_count\"] = len(mock_rows_b)\n        return None\n\n    mock_pg_db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry_b)\n\n    # Initialize workspace_b (Case 3 - both tables exist)\n    with patch(\"lightrag.kg.postgres_impl.logger\"):\n        await storage_b.initialize()\n\n    print(\"✅ Step 2: Workspace B initialized\")\n\n    # Verify workspace_b migration happens when new table has no workspace_b data\n    # but legacy table still has workspace_b data.\n    assert (\n        len(migration_b_executed) > 0\n    ), \"Migration should have been executed for workspace_b\"\n    print(\"✅ Step 2: Migration executed for workspace_b\")\n\n    print(\"\\n🎉 Case 1c: Sequential workspace migration verification complete!\")\n    print(\"   - Workspace A: Migrated successfully (only legacy existed)\")\n    print(\"   - Workspace B: Migrated successfully (new table empty for workspace_b)\")\n"
  },
  {
    "path": "tests/test_postgres_retry_integration.py",
    "content": "\"\"\"\nIntegration test suite for PostgreSQL retry mechanism using real database.\n\nThis test suite connects to a real PostgreSQL database using credentials from .env\nand tests the retry mechanism with actual network failures.\n\nPrerequisites:\n1. PostgreSQL server running and accessible\n2. .env file with POSTGRES_* configuration\n3. asyncpg installed: pip install asyncpg\n\"\"\"\n\nimport pytest\nimport asyncio\nimport os\nimport time\nfrom dotenv import load_dotenv\nfrom unittest.mock import patch\nfrom lightrag.kg.postgres_impl import PostgreSQLDB\n\nasyncpg = pytest.importorskip(\"asyncpg\")\n\n# Load environment variables\nload_dotenv(dotenv_path=\".env\", override=False)\n\n\n@pytest.mark.integration\n@pytest.mark.requires_db\nclass TestPostgresRetryIntegration:\n    \"\"\"Integration tests for PostgreSQL retry mechanism with real database.\"\"\"\n\n    @pytest.fixture\n    def db_config(self):\n        \"\"\"Load database configuration from environment variables.\n\n        Uses new HA-optimized defaults that match postgres_impl.py ClientManager.get_config():\n        - 10 retry attempts (up from 3)\n        - 3.0s initial backoff (up from 0.5s)\n        - 30.0s max backoff (up from 5.0s)\n        \"\"\"\n        return {\n            \"host\": os.getenv(\"POSTGRES_HOST\", \"localhost\"),\n            \"port\": int(os.getenv(\"POSTGRES_PORT\", \"5432\")),\n            \"user\": os.getenv(\"POSTGRES_USER\", \"postgres\"),\n            \"password\": os.getenv(\"POSTGRES_PASSWORD\", \"\"),\n            \"database\": os.getenv(\"POSTGRES_DATABASE\", \"postgres\"),\n            \"workspace\": os.getenv(\"POSTGRES_WORKSPACE\", \"test_retry\"),\n            \"max_connections\": int(os.getenv(\"POSTGRES_MAX_CONNECTIONS\", \"10\")),\n            # Connection retry configuration - mirrors postgres_impl.py ClientManager.get_config()\n            # NEW DEFAULTS optimized for HA deployments\n            \"connection_retry_attempts\": min(\n                100,\n                int(os.getenv(\"POSTGRES_CONNECTION_RETRIES\", \"10\")),  # 3 → 10\n            ),\n            \"connection_retry_backoff\": min(\n                300.0,\n                float(\n                    os.getenv(\"POSTGRES_CONNECTION_RETRY_BACKOFF\", \"3.0\")\n                ),  # 0.5 → 3.0\n            ),\n            \"connection_retry_backoff_max\": min(\n                600.0,\n                float(\n                    os.getenv(\"POSTGRES_CONNECTION_RETRY_BACKOFF_MAX\", \"30.0\")\n                ),  # 5.0 → 30.0\n            ),\n            \"pool_close_timeout\": min(\n                30.0, float(os.getenv(\"POSTGRES_POOL_CLOSE_TIMEOUT\", \"5.0\"))\n            ),\n        }\n\n    @pytest.mark.asyncio\n    async def test_real_connection_success(self, db_config):\n        \"\"\"\n        Test successful connection to real PostgreSQL database.\n\n        This validates that:\n        1. Database credentials are correct\n        2. Connection pool initializes properly\n        3. Basic query works\n        \"\"\"\n        print(\"\\n\" + \"=\" * 80)\n        print(\"INTEGRATION TEST 1: Real Database Connection\")\n        print(\"=\" * 80)\n        print(\n            f\"  → Connecting to {db_config['host']}:{db_config['port']}/{db_config['database']}\"\n        )\n\n        db = PostgreSQLDB(db_config)\n\n        try:\n            # Initialize database connection\n            await db.initdb()\n            print(\"  ✓ Connection successful\")\n\n            # Test simple query\n            result = await db.query(\"SELECT 1 as test\", multirows=False)\n            assert result is not None\n            assert result.get(\"test\") == 1\n            print(\"  ✓ Query executed successfully\")\n\n            print(\"\\n✅ Test passed: Real database connection works\")\n            print(\"=\" * 80)\n        finally:\n            if db.pool:\n                await db.pool.close()\n\n    @pytest.mark.asyncio\n    async def test_simulated_transient_error_with_real_db(self, db_config):\n        \"\"\"\n        Test retry mechanism with simulated transient errors on real database.\n\n        Simulates connection failures on first 2 attempts, then succeeds.\n        Uses new HA defaults (10 retries, 3s backoff).\n        \"\"\"\n        print(\"\\n\" + \"=\" * 80)\n        print(\"INTEGRATION TEST 2: Simulated Transient Errors\")\n        print(\"=\" * 80)\n\n        db = PostgreSQLDB(db_config)\n        attempt_count = {\"value\": 0}\n\n        # Original create_pool function\n        original_create_pool = asyncpg.create_pool\n\n        async def mock_create_pool_with_failures(*args, **kwargs):\n            \"\"\"Mock that fails first 2 times, then calls real create_pool.\"\"\"\n            attempt_count[\"value\"] += 1\n            print(f\"  → Connection attempt {attempt_count['value']}\")\n\n            if attempt_count[\"value\"] <= 2:\n                print(\"    ✗ Simulating connection failure\")\n                raise asyncpg.exceptions.ConnectionFailureError(\n                    f\"Simulated failure on attempt {attempt_count['value']}\"\n                )\n\n            print(\"    ✓ Allowing real connection\")\n            return await original_create_pool(*args, **kwargs)\n\n        try:\n            # Patch create_pool to simulate failures\n            with patch(\n                \"asyncpg.create_pool\", side_effect=mock_create_pool_with_failures\n            ):\n                await db.initdb()\n\n            assert (\n                attempt_count[\"value\"] == 3\n            ), f\"Expected 3 attempts, got {attempt_count['value']}\"\n            assert db.pool is not None, \"Pool should be initialized after retries\"\n\n            # Verify database is actually working\n            result = await db.query(\"SELECT 1 as test\", multirows=False)\n            assert result.get(\"test\") == 1\n\n            print(\n                f\"\\n✅ Test passed: Retry mechanism worked, connected after {attempt_count['value']} attempts\"\n            )\n            print(\"=\" * 80)\n        finally:\n            if db.pool:\n                await db.pool.close()\n\n    @pytest.mark.asyncio\n    async def test_query_retry_with_real_db(self, db_config):\n        \"\"\"\n        Test query-level retry with simulated connection issues.\n\n        Tests that queries retry on transient failures by simulating\n        a temporary database unavailability.\n        Uses new HA defaults (10 retries, 3s backoff).\n        \"\"\"\n        print(\"\\n\" + \"=\" * 80)\n        print(\"INTEGRATION TEST 3: Query-Level Retry\")\n        print(\"=\" * 80)\n\n        db = PostgreSQLDB(db_config)\n\n        try:\n            # First initialize normally\n            await db.initdb()\n            print(\"  ✓ Database initialized\")\n\n            # Close the pool to simulate connection loss\n            print(\"  → Simulating connection loss (closing pool)...\")\n            await db.pool.close()\n            db.pool = None\n\n            # Now query should trigger pool recreation and retry\n            print(\"  → Attempting query (should auto-reconnect)...\")\n            result = await db.query(\"SELECT 1 as test\", multirows=False)\n\n            assert result.get(\"test\") == 1, \"Query should succeed after reconnection\"\n            assert db.pool is not None, \"Pool should be recreated\"\n\n            print(\"  ✓ Query succeeded after automatic reconnection\")\n            print(\"\\n✅ Test passed: Auto-reconnection works correctly\")\n            print(\"=\" * 80)\n        finally:\n            if db.pool:\n                await db.pool.close()\n\n    @pytest.mark.asyncio\n    async def test_concurrent_queries_with_real_db(self, db_config):\n        \"\"\"\n        Test concurrent queries to validate thread safety and connection pooling.\n\n        Runs multiple concurrent queries to ensure no deadlocks or race conditions.\n        Uses new HA defaults (10 retries, 3s backoff).\n        \"\"\"\n        print(\"\\n\" + \"=\" * 80)\n        print(\"INTEGRATION TEST 4: Concurrent Queries\")\n        print(\"=\" * 80)\n\n        db = PostgreSQLDB(db_config)\n\n        try:\n            await db.initdb()\n            print(\"  ✓ Database initialized\")\n\n            # Launch 10 concurrent queries\n            num_queries = 10\n            print(f\"  → Launching {num_queries} concurrent queries...\")\n\n            async def run_query(query_id):\n                result = await db.query(\n                    f\"SELECT {query_id} as id, pg_sleep(0.1)\", multirows=False\n                )\n                return result.get(\"id\")\n\n            start_time = time.time()\n            tasks = [run_query(i) for i in range(num_queries)]\n            results = await asyncio.gather(*tasks, return_exceptions=True)\n            elapsed = time.time() - start_time\n\n            # Check results\n            successful = sum(1 for r in results if not isinstance(r, Exception))\n            failed = sum(1 for r in results if isinstance(r, Exception))\n\n            print(f\"  → Completed in {elapsed:.2f}s\")\n            print(f\"  → Results: {successful} successful, {failed} failed\")\n\n            assert (\n                successful == num_queries\n            ), f\"All {num_queries} queries should succeed\"\n            assert failed == 0, \"No queries should fail\"\n\n            print(\"\\n✅ Test passed: All concurrent queries succeeded, no deadlocks\")\n            print(\"=\" * 80)\n        finally:\n            if db.pool:\n                await db.pool.close()\n\n    @pytest.mark.asyncio\n    async def test_pool_close_timeout_real(self, db_config):\n        \"\"\"\n        Test pool close timeout protection with real database.\n        Uses new HA defaults (10 retries, 3s backoff).\n        \"\"\"\n        print(\"\\n\" + \"=\" * 80)\n        print(\"INTEGRATION TEST 5: Pool Close Timeout\")\n        print(\"=\" * 80)\n\n        db = PostgreSQLDB(db_config)\n\n        try:\n            await db.initdb()\n            print(\"  ✓ Database initialized\")\n\n            # Trigger pool reset (which includes close)\n            print(\"  → Triggering pool reset...\")\n            start_time = time.time()\n            await db._reset_pool()\n            elapsed = time.time() - start_time\n\n            print(f\"  ✓ Pool reset completed in {elapsed:.2f}s\")\n            assert db.pool is None, \"Pool should be None after reset\"\n            assert (\n                elapsed < db.pool_close_timeout + 1\n            ), \"Reset should complete within timeout\"\n\n            print(\"\\n✅ Test passed: Pool reset handled correctly\")\n            print(\"=\" * 80)\n        finally:\n            # Already closed in test\n            pass\n\n    @pytest.mark.asyncio\n    async def test_configuration_from_env(self, db_config):\n        \"\"\"\n        Test that configuration is correctly loaded from environment variables.\n        \"\"\"\n        print(\"\\n\" + \"=\" * 80)\n        print(\"INTEGRATION TEST 6: Environment Configuration\")\n        print(\"=\" * 80)\n\n        db = PostgreSQLDB(db_config)\n\n        print(\"  → Configuration loaded:\")\n        print(f\"    • Host: {db.host}\")\n        print(f\"    • Port: {db.port}\")\n        print(f\"    • Database: {db.database}\")\n        print(f\"    • User: {db.user}\")\n        print(f\"    • Workspace: {db.workspace}\")\n        print(f\"    • Max Connections: {db.max}\")\n        print(f\"    • Retry Attempts: {db.connection_retry_attempts}\")\n        print(f\"    • Retry Backoff: {db.connection_retry_backoff}s\")\n        print(f\"    • Max Backoff: {db.connection_retry_backoff_max}s\")\n        print(f\"    • Pool Close Timeout: {db.pool_close_timeout}s\")\n\n        # Verify required fields are present\n        assert db.host, \"Host should be configured\"\n        assert db.port, \"Port should be configured\"\n        assert db.user, \"User should be configured\"\n        assert db.database, \"Database should be configured\"\n\n        print(\"\\n✅ Test passed: All configuration loaded correctly from .env\")\n        print(\"=\" * 80)\n\n\ndef run_integration_tests():\n    \"\"\"Run all integration tests with detailed output.\"\"\"\n    print(\"\\n\" + \"=\" * 80)\n    print(\"POSTGRESQL RETRY MECHANISM - INTEGRATION TESTS\")\n    print(\"Testing with REAL database from .env configuration\")\n    print(\"=\" * 80)\n\n    # Check if database configuration exists\n    if not os.getenv(\"POSTGRES_HOST\"):\n        print(\"\\n⚠️  WARNING: No POSTGRES_HOST in .env file\")\n        print(\"Please ensure .env file exists with PostgreSQL configuration.\")\n        return\n\n    print(\"\\nRunning integration tests...\\n\")\n\n    # Run pytest with verbose output\n    pytest.main(\n        [\n            __file__,\n            \"-v\",\n            \"-s\",  # Don't capture output\n            \"--tb=short\",  # Short traceback format\n            \"--color=yes\",\n            \"-x\",  # Stop on first failure\n        ]\n    )\n\n\nif __name__ == \"__main__\":\n    run_integration_tests()\n"
  },
  {
    "path": "tests/test_postgres_upsert.py",
    "content": "\"\"\"\nUnit tests for PGKVStorage.upsert batch optimization (PR #2742 fixes).\n\nVerifies:\n1. Each namespace builds correct tuple ordering matching SQL positional params.\n2. _run_with_retry is used (not the removed PostgreSQLDB.executemany wrapper).\n3. Sub-batching splits data when len(data) > _max_batch_size.\n4. Unknown namespace raises ValueError.\n5. Empty data returns without any DB call.\n\"\"\"\n\nimport json\nimport pytest\nfrom unittest.mock import AsyncMock, MagicMock\nfrom lightrag.kg.postgres_impl import PGKVStorage\nfrom lightrag.namespace import NameSpace\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\nGLOBAL_CONFIG = {\"embedding_batch_num\": 10}\n\n\ndef make_storage(namespace: str) -> PGKVStorage:\n    \"\"\"Construct a PGKVStorage instance with a mocked db.\"\"\"\n    db = MagicMock()\n    captured: list[tuple] = []\n\n    async def fake_run_with_retry(operation, **kwargs):\n        \"\"\"Call the closure with a mock connection to capture executemany args.\"\"\"\n        mock_conn = AsyncMock()\n        await operation(mock_conn)\n        # Store (sql, data) from each executemany call\n        for call in mock_conn.executemany.call_args_list:\n            captured.append((call.args[0], call.args[1]))\n\n    db._run_with_retry = AsyncMock(side_effect=fake_run_with_retry)\n    db.workspace = \"test_ws\"\n\n    storage = PGKVStorage.__new__(PGKVStorage)\n    storage.namespace = namespace\n    storage.workspace = \"test_ws\"\n    storage.global_config = GLOBAL_CONFIG\n    storage.db = db\n    storage.__post_init__()\n\n    storage._captured = captured\n    return storage\n\n\n# ---------------------------------------------------------------------------\n# 1. _max_batch_size is always 200 (not embedding_batch_num)\n# ---------------------------------------------------------------------------\n\n\ndef test_max_batch_size_is_constant():\n    storage = make_storage(NameSpace.KV_STORE_TEXT_CHUNKS)\n    assert storage._max_batch_size == 200\n\n\n# ---------------------------------------------------------------------------\n# 2. Namespace: TEXT_CHUNKS\n# ---------------------------------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_upsert_text_chunks_tuple_order():\n    storage = make_storage(NameSpace.KV_STORE_TEXT_CHUNKS)\n    data = {\n        \"chunk-1\": {\n            \"tokens\": 42,\n            \"chunk_order_index\": 0,\n            \"full_doc_id\": \"doc-1\",\n            \"content\": \"hello world\",\n            \"file_path\": \"/a/b.txt\",\n            \"llm_cache_list\": [\"cache-key\"],\n        }\n    }\n    await storage.upsert(data)\n\n    assert len(storage._captured) == 1\n    sql, rows = storage._captured[0]\n    assert \"LIGHTRAG_DOC_CHUNKS\" in sql\n    assert len(rows) == 1\n    row = rows[0]\n    # SQL: (workspace, id, tokens, chunk_order_index, full_doc_id,\n    #        content, file_path, llm_cache_list, create_time, update_time)\n    assert row[0] == \"test_ws\"  # workspace\n    assert row[1] == \"chunk-1\"  # id\n    assert row[2] == 42  # tokens\n    assert row[3] == 0  # chunk_order_index\n    assert row[4] == \"doc-1\"  # full_doc_id\n    assert row[5] == \"hello world\"  # content\n    assert row[6] == \"/a/b.txt\"  # file_path\n    assert json.loads(row[7]) == [\"cache-key\"]  # llm_cache_list\n\n\n# ---------------------------------------------------------------------------\n# 3. Namespace: FULL_DOCS\n# ---------------------------------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_upsert_full_docs_tuple_order():\n    storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)\n    data = {\"doc-1\": {\"content\": \"full text\", \"file_path\": \"/path/doc.pdf\"}}\n    await storage.upsert(data)\n\n    assert len(storage._captured) == 1\n    _, rows = storage._captured[0]\n    row = rows[0]\n    # SQL: (id, content, doc_name, workspace)\n    assert row[0] == \"doc-1\"\n    assert row[1] == \"full text\"\n    assert row[2] == \"/path/doc.pdf\"\n    assert row[3] == \"test_ws\"\n\n\n# ---------------------------------------------------------------------------\n# 4. Namespace: LLM_RESPONSE_CACHE\n# ---------------------------------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_upsert_llm_cache_tuple_order():\n    storage = make_storage(NameSpace.KV_STORE_LLM_RESPONSE_CACHE)\n    data = {\n        \"key-1\": {\n            \"original_prompt\": \"what is X?\",\n            \"return\": \"X is Y\",\n            \"chunk_id\": \"chunk-1\",\n            \"cache_type\": \"query\",\n            \"queryparam\": {\"mode\": \"hybrid\"},\n        }\n    }\n    await storage.upsert(data)\n\n    assert len(storage._captured) == 1\n    _, rows = storage._captured[0]\n    row = rows[0]\n    # SQL: (workspace, id, original_prompt, return_value, chunk_id, cache_type, queryparam)\n    assert row[0] == \"test_ws\"\n    assert row[1] == \"key-1\"\n    assert row[2] == \"what is X?\"\n    assert row[3] == \"X is Y\"\n    assert row[4] == \"chunk-1\"\n    assert row[5] == \"query\"\n    assert json.loads(row[6]) == {\"mode\": \"hybrid\"}\n\n\n@pytest.mark.asyncio\nasync def test_upsert_llm_cache_null_queryparam():\n    storage = make_storage(NameSpace.KV_STORE_LLM_RESPONSE_CACHE)\n    data = {\n        \"key-2\": {\n            \"original_prompt\": \"prompt\",\n            \"return\": \"answer\",\n            \"cache_type\": \"extract\",\n        }\n    }\n    await storage.upsert(data)\n    _, rows = storage._captured[0]\n    assert rows[0][6] is None  # queryparam should be None\n\n\n# ---------------------------------------------------------------------------\n# 5. Namespace: FULL_ENTITIES\n# ---------------------------------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_upsert_full_entities_tuple_order():\n    storage = make_storage(NameSpace.KV_STORE_FULL_ENTITIES)\n    data = {\"ent-1\": {\"entity_names\": [\"EntityA\", \"EntityB\"], \"count\": 2}}\n    await storage.upsert(data)\n\n    _, rows = storage._captured[0]\n    row = rows[0]\n    # SQL: (workspace, id, entity_names, count, create_time, update_time)\n    assert row[0] == \"test_ws\"\n    assert row[1] == \"ent-1\"\n    assert json.loads(row[2]) == [\"EntityA\", \"EntityB\"]\n    assert row[3] == 2\n\n\n# ---------------------------------------------------------------------------\n# 6. Namespace: FULL_RELATIONS\n# ---------------------------------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_upsert_full_relations_tuple_order():\n    storage = make_storage(NameSpace.KV_STORE_FULL_RELATIONS)\n    data = {\"rel-1\": {\"relation_pairs\": [[\"A\", \"B\"]], \"count\": 1}}\n    await storage.upsert(data)\n\n    _, rows = storage._captured[0]\n    row = rows[0]\n    # SQL: (workspace, id, relation_pairs, count, create_time, update_time)\n    assert row[0] == \"test_ws\"\n    assert row[1] == \"rel-1\"\n    assert json.loads(row[2]) == [[\"A\", \"B\"]]\n    assert row[3] == 1\n\n\n# ---------------------------------------------------------------------------\n# 7. Namespace: ENTITY_CHUNKS / RELATION_CHUNKS\n# ---------------------------------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_upsert_entity_chunks_tuple_order():\n    storage = make_storage(NameSpace.KV_STORE_ENTITY_CHUNKS)\n    data = {\"ec-1\": {\"chunk_ids\": [\"c1\", \"c2\"], \"count\": 2}}\n    await storage.upsert(data)\n\n    _, rows = storage._captured[0]\n    row = rows[0]\n    # SQL: (workspace, id, chunk_ids, count, create_time, update_time)\n    assert row[0] == \"test_ws\"\n    assert row[1] == \"ec-1\"\n    assert json.loads(row[2]) == [\"c1\", \"c2\"]\n    assert row[3] == 2\n\n\n@pytest.mark.asyncio\nasync def test_upsert_relation_chunks_tuple_order():\n    storage = make_storage(NameSpace.KV_STORE_RELATION_CHUNKS)\n    data = {\"rc-1\": {\"chunk_ids\": [\"c3\"], \"count\": 1}}\n    await storage.upsert(data)\n\n    _, rows = storage._captured[0]\n    row = rows[0]\n    assert row[0] == \"test_ws\"\n    assert row[1] == \"rc-1\"\n    assert json.loads(row[2]) == [\"c3\"]\n    assert row[3] == 1\n\n\n# ---------------------------------------------------------------------------\n# 8. Sub-batching: data > _max_batch_size splits into multiple _run_with_retry calls\n# ---------------------------------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_sub_batching_splits_correctly():\n    storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)\n    storage._max_batch_size = 3  # Override to small value for testing\n\n    data = {f\"doc-{i}\": {\"content\": f\"text {i}\", \"file_path\": \"\"} for i in range(7)}\n    await storage.upsert(data)\n\n    # 7 records / batch_size 3 => 3 batches (3 + 3 + 1)\n    assert len(storage._captured) == 3\n    assert len(storage._captured[0][1]) == 3\n    assert len(storage._captured[1][1]) == 3\n    assert len(storage._captured[2][1]) == 1\n\n\n@pytest.mark.asyncio\nasync def test_sub_batching_exact_multiple():\n    storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)\n    storage._max_batch_size = 3\n\n    data = {f\"doc-{i}\": {\"content\": f\"text {i}\", \"file_path\": \"\"} for i in range(6)}\n    await storage.upsert(data)\n\n    # 6 / 3 => exactly 2 batches\n    assert len(storage._captured) == 2\n    assert len(storage._captured[0][1]) == 3\n    assert len(storage._captured[1][1]) == 3\n\n\n# ---------------------------------------------------------------------------\n# 9. Empty data: no DB call\n# ---------------------------------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_upsert_empty_data_no_db_call():\n    storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)\n    await storage.upsert({})\n    assert len(storage._captured) == 0\n    storage.db._run_with_retry.assert_not_called()\n\n\n# ---------------------------------------------------------------------------\n# 10. Unknown namespace raises ValueError\n# ---------------------------------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_upsert_unknown_namespace_raises():\n    storage = make_storage(\"unknown_namespace\")\n    with pytest.raises(ValueError, match=\"Unknown namespace\"):\n        await storage.upsert({\"k\": {\"v\": 1}})\n\n\n# ---------------------------------------------------------------------------\n# 11. Multiple records go into one batch when within limit\n# ---------------------------------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_multiple_records_single_batch():\n    storage = make_storage(NameSpace.KV_STORE_FULL_DOCS)\n    data = {\n        \"doc-1\": {\"content\": \"text 1\", \"file_path\": \"/a\"},\n        \"doc-2\": {\"content\": \"text 2\", \"file_path\": \"/b\"},\n        \"doc-3\": {\"content\": \"text 3\", \"file_path\": \"/c\"},\n    }\n    await storage.upsert(data)\n\n    # All 3 fit within default batch size of 200\n    assert len(storage._captured) == 1\n    _, rows = storage._captured[0]\n    assert len(rows) == 3\n    ids = {row[0] for row in rows}  # id is $1 for FULL_DOCS\n    assert ids == {\"doc-1\", \"doc-2\", \"doc-3\"}\n"
  },
  {
    "path": "tests/test_qdrant_migration.py",
    "content": "import pytest\nfrom unittest.mock import MagicMock, patch, AsyncMock\nimport numpy as np\nfrom qdrant_client import models\nfrom lightrag.utils import EmbeddingFunc\nfrom lightrag.kg.qdrant_impl import QdrantVectorDBStorage\n\n\n# Mock QdrantClient\n@pytest.fixture\ndef mock_qdrant_client():\n    with patch(\"lightrag.kg.qdrant_impl.QdrantClient\") as mock_client_cls:\n        client = mock_client_cls.return_value\n        client.collection_exists.return_value = False\n        client.count.return_value.count = 0\n        # Mock payload schema and vector config for get_collection\n        collection_info = MagicMock()\n        collection_info.payload_schema = {}\n        # Mock vector dimension to match mock_embedding_func (768d)\n        collection_info.config.params.vectors.size = 768\n        client.get_collection.return_value = collection_info\n        yield client\n\n\n# Mock get_data_init_lock to avoid async lock issues in tests\n@pytest.fixture(autouse=True)\ndef mock_data_init_lock():\n    with patch(\"lightrag.kg.qdrant_impl.get_data_init_lock\") as mock_lock:\n        mock_lock_ctx = AsyncMock()\n        mock_lock.return_value = mock_lock_ctx\n        yield mock_lock\n\n\n# Mock Embedding function\n@pytest.fixture\ndef mock_embedding_func():\n    async def embed_func(texts, **kwargs):\n        return np.array([[0.1] * 768 for _ in texts])\n\n    func = EmbeddingFunc(embedding_dim=768, func=embed_func, model_name=\"test-model\")\n    return func\n\n\nasync def test_qdrant_collection_naming(mock_qdrant_client, mock_embedding_func):\n    \"\"\"Test if collection name is correctly generated with model suffix\"\"\"\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    storage = QdrantVectorDBStorage(\n        namespace=\"chunks\",\n        global_config=config,\n        embedding_func=mock_embedding_func,\n        workspace=\"test_ws\",\n    )\n\n    # Verify collection name contains model suffix\n    expected_suffix = \"test_model_768d\"\n    assert expected_suffix in storage.final_namespace\n    assert storage.final_namespace == f\"lightrag_vdb_chunks_{expected_suffix}\"\n\n\nasync def test_qdrant_migration_trigger(mock_qdrant_client, mock_embedding_func):\n    \"\"\"Test if migration logic is triggered correctly\"\"\"\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    storage = QdrantVectorDBStorage(\n        namespace=\"chunks\",\n        global_config=config,\n        embedding_func=mock_embedding_func,\n        workspace=\"test_ws\",\n    )\n\n    # Legacy collection name (without model suffix)\n    legacy_collection = \"lightrag_vdb_chunks\"\n\n    # Setup mocks for migration scenario\n    # 1. New collection does not exist, only legacy exists\n    mock_qdrant_client.collection_exists.side_effect = lambda name: (\n        name == legacy_collection\n    )\n\n    # 2. Legacy collection exists and has data\n    migration_state = {\"new_workspace_count\": 0}\n\n    def count_mock(collection_name, exact=True, count_filter=None):\n        mock_result = MagicMock()\n        if collection_name == legacy_collection:\n            mock_result.count = 100\n        elif collection_name == storage.final_namespace:\n            mock_result.count = migration_state[\"new_workspace_count\"]\n        else:\n            mock_result.count = 0\n        return mock_result\n\n    mock_qdrant_client.count.side_effect = count_mock\n\n    # 3. Mock scroll for data migration\n    mock_point = MagicMock()\n    mock_point.id = \"old_id\"\n    mock_point.vector = [0.1] * 768\n    mock_point.payload = {\"content\": \"test\"}  # No workspace_id in payload\n\n    # When payload_schema is empty, the code first samples payloads to detect workspace_id\n    # Then proceeds with migration batches\n    # Scroll calls: 1) Sampling (limit=10), 2) Migration batch, 3) End of migration\n    mock_qdrant_client.scroll.side_effect = [\n        ([mock_point], \"_\"),  # Sampling scroll - no workspace_id found\n        ([mock_point], \"next_offset\"),  # Migration batch\n        ([], None),  # End of migration\n    ]\n\n    def upsert_mock(*args, **kwargs):\n        migration_state[\"new_workspace_count\"] = 100\n        return None\n\n    mock_qdrant_client.upsert.side_effect = upsert_mock\n\n    # Initialize storage (triggers migration)\n    await storage.initialize()\n\n    # Verify migration steps\n    # 1. Legacy count checked\n    mock_qdrant_client.count.assert_any_call(\n        collection_name=legacy_collection, exact=True\n    )\n\n    # 2. New collection created\n    mock_qdrant_client.create_collection.assert_called()\n\n    # 3. Data scrolled from legacy\n    # First call (index 0) is sampling scroll with limit=10\n    # Second call (index 1) is migration batch with limit=500\n    assert mock_qdrant_client.scroll.call_count >= 2\n    # Check sampling scroll\n    sampling_call = mock_qdrant_client.scroll.call_args_list[0]\n    assert sampling_call.kwargs[\"collection_name\"] == legacy_collection\n    assert sampling_call.kwargs[\"limit\"] == 10\n    # Check migration batch scroll\n    migration_call = mock_qdrant_client.scroll.call_args_list[1]\n    assert migration_call.kwargs[\"collection_name\"] == legacy_collection\n    assert migration_call.kwargs[\"limit\"] == 500\n\n    # 4. Data upserted to new\n    mock_qdrant_client.upsert.assert_called()\n\n    # 5. Payload index created\n    mock_qdrant_client.create_payload_index.assert_called()\n\n\nasync def test_qdrant_no_migration_needed(mock_qdrant_client, mock_embedding_func):\n    \"\"\"Test scenario where new collection already exists (Case 1 in setup_collection)\n\n    When only the new collection exists and no legacy collection is found,\n    the implementation should:\n    1. Create payload index on the new collection (ensure index exists)\n    2. NOT attempt any data migration (no scroll calls)\n    \"\"\"\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    storage = QdrantVectorDBStorage(\n        namespace=\"chunks\",\n        global_config=config,\n        embedding_func=mock_embedding_func,\n        workspace=\"test_ws\",\n    )\n\n    # Only new collection exists (no legacy collection found)\n    mock_qdrant_client.collection_exists.side_effect = lambda name: (\n        name == storage.final_namespace\n    )\n\n    # Initialize\n    await storage.initialize()\n\n    # Should create payload index on the new collection (ensure index)\n    mock_qdrant_client.create_payload_index.assert_called_with(\n        collection_name=storage.final_namespace,\n        field_name=\"workspace_id\",\n        field_schema=models.KeywordIndexParams(\n            type=models.KeywordIndexType.KEYWORD,\n            is_tenant=True,\n        ),\n    )\n    # Should NOT migrate (no scroll calls since no legacy collection exists)\n    mock_qdrant_client.scroll.assert_not_called()\n\n\n# ============================================================================\n# Tests for scenarios described in design document (Lines 606-649)\n# ============================================================================\n\n\nasync def test_scenario_1_new_workspace_creation(\n    mock_qdrant_client, mock_embedding_func\n):\n    \"\"\"\n    场景1：新建workspace\n    预期：直接创建lightrag_vdb_chunks_text_embedding_3_large_3072d\n    \"\"\"\n    # Use a large embedding model\n    large_model_func = EmbeddingFunc(\n        embedding_dim=3072,\n        func=mock_embedding_func.func,\n        model_name=\"text-embedding-3-large\",\n    )\n\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    storage = QdrantVectorDBStorage(\n        namespace=\"chunks\",\n        global_config=config,\n        embedding_func=large_model_func,\n        workspace=\"test_new\",\n    )\n\n    # Case 3: Neither legacy nor new collection exists\n    mock_qdrant_client.collection_exists.return_value = False\n\n    # Initialize storage\n    await storage.initialize()\n\n    # Verify: Should create new collection with model suffix\n    expected_collection = \"lightrag_vdb_chunks_text_embedding_3_large_3072d\"\n    assert storage.final_namespace == expected_collection\n\n    # Verify create_collection was called with correct name\n    create_calls = [\n        call for call in mock_qdrant_client.create_collection.call_args_list\n    ]\n    assert len(create_calls) > 0\n    assert (\n        create_calls[0][0][0] == expected_collection\n        or create_calls[0].kwargs.get(\"collection_name\") == expected_collection\n    )\n\n    # Verify no migration was attempted\n    mock_qdrant_client.scroll.assert_not_called()\n\n    print(\n        f\"✅ Scenario 1: New workspace created with collection '{expected_collection}'\"\n    )\n\n\nasync def test_scenario_2_legacy_upgrade_migration(\n    mock_qdrant_client, mock_embedding_func\n):\n    \"\"\"\n    场景2：从旧版本升级\n    已存在lightrag_vdb_chunks（无后缀）\n    预期：自动迁移数据到lightrag_vdb_chunks_text_embedding_ada_002_1536d\n    注意：迁移后不再自动删除遗留集合，需要手动删除\n    \"\"\"\n    # Use ada-002 model\n    ada_func = EmbeddingFunc(\n        embedding_dim=1536,\n        func=mock_embedding_func.func,\n        model_name=\"text-embedding-ada-002\",\n    )\n\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    storage = QdrantVectorDBStorage(\n        namespace=\"chunks\",\n        global_config=config,\n        embedding_func=ada_func,\n        workspace=\"test_legacy\",\n    )\n\n    # Legacy collection name (without model suffix)\n    legacy_collection = \"lightrag_vdb_chunks\"\n    new_collection = storage.final_namespace\n\n    # Case 4: Only legacy collection exists\n    mock_qdrant_client.collection_exists.side_effect = lambda name: (\n        name == legacy_collection\n    )\n\n    # Mock legacy collection info with 1536d vectors\n    legacy_collection_info = MagicMock()\n    legacy_collection_info.payload_schema = {}\n    legacy_collection_info.config.params.vectors.size = 1536\n    mock_qdrant_client.get_collection.return_value = legacy_collection_info\n\n    migration_state = {\"new_workspace_count\": 0}\n\n    def count_mock(collection_name, exact=True, count_filter=None):\n        mock_result = MagicMock()\n        if collection_name == legacy_collection:\n            mock_result.count = 150\n        elif collection_name == new_collection:\n            mock_result.count = migration_state[\"new_workspace_count\"]\n        else:\n            mock_result.count = 0\n        return mock_result\n\n    mock_qdrant_client.count.side_effect = count_mock\n\n    # Mock scroll results (simulate migration in batches)\n    mock_points = []\n    for i in range(10):\n        point = MagicMock()\n        point.id = f\"legacy-{i}\"\n        point.vector = [0.1] * 1536\n        # No workspace_id in payload - simulates legacy data\n        point.payload = {\"content\": f\"Legacy document {i}\", \"id\": f\"doc-{i}\"}\n        mock_points.append(point)\n\n    # When payload_schema is empty, the code first samples payloads to detect workspace_id\n    # Then proceeds with migration batches\n    # Scroll calls: 1) Sampling (limit=10), 2) Migration batch, 3) End of migration\n    mock_qdrant_client.scroll.side_effect = [\n        (mock_points, \"_\"),  # Sampling scroll - no workspace_id found in payloads\n        (mock_points, \"offset1\"),  # Migration batch\n        ([], None),  # End of migration\n    ]\n\n    def upsert_mock(*args, **kwargs):\n        migration_state[\"new_workspace_count\"] = 150\n        return None\n\n    mock_qdrant_client.upsert.side_effect = upsert_mock\n\n    # Initialize (triggers migration)\n    await storage.initialize()\n\n    # Verify: New collection should be created\n    expected_new_collection = \"lightrag_vdb_chunks_text_embedding_ada_002_1536d\"\n    assert storage.final_namespace == expected_new_collection\n\n    # Verify migration steps\n    # 1. Check legacy count\n    mock_qdrant_client.count.assert_any_call(\n        collection_name=legacy_collection, exact=True\n    )\n\n    # 2. Create new collection\n    mock_qdrant_client.create_collection.assert_called()\n\n    # 3. Scroll legacy data\n    scroll_calls = [call for call in mock_qdrant_client.scroll.call_args_list]\n    assert len(scroll_calls) >= 1\n    assert scroll_calls[0].kwargs[\"collection_name\"] == legacy_collection\n\n    # 4. Upsert to new collection\n    upsert_calls = [call for call in mock_qdrant_client.upsert.call_args_list]\n    assert len(upsert_calls) >= 1\n    assert upsert_calls[0].kwargs[\"collection_name\"] == new_collection\n\n    # Note: Legacy collection is NOT automatically deleted after migration\n    # Manual deletion is required after data migration verification\n\n    print(\n        f\"✅ Scenario 2: Legacy data migrated from '{legacy_collection}' to '{expected_new_collection}'\"\n    )\n\n\nasync def test_scenario_3_multi_model_coexistence(mock_qdrant_client):\n    \"\"\"\n    场景3：多模型并存\n    预期：两个独立的collection，互不干扰\n    \"\"\"\n\n    # Model A: bge-small with 768d\n    async def embed_func_a(texts, **kwargs):\n        return np.array([[0.1] * 768 for _ in texts])\n\n    model_a_func = EmbeddingFunc(\n        embedding_dim=768, func=embed_func_a, model_name=\"bge-small\"\n    )\n\n    # Model B: bge-large with 1024d\n    async def embed_func_b(texts, **kwargs):\n        return np.array([[0.2] * 1024 for _ in texts])\n\n    model_b_func = EmbeddingFunc(\n        embedding_dim=1024, func=embed_func_b, model_name=\"bge-large\"\n    )\n\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    # Create storage for workspace A with model A\n    storage_a = QdrantVectorDBStorage(\n        namespace=\"chunks\",\n        global_config=config,\n        embedding_func=model_a_func,\n        workspace=\"workspace_a\",\n    )\n\n    # Create storage for workspace B with model B\n    storage_b = QdrantVectorDBStorage(\n        namespace=\"chunks\",\n        global_config=config,\n        embedding_func=model_b_func,\n        workspace=\"workspace_b\",\n    )\n\n    # Verify: Collection names are different\n    assert storage_a.final_namespace != storage_b.final_namespace\n\n    # Verify: Model A collection\n    expected_collection_a = \"lightrag_vdb_chunks_bge_small_768d\"\n    assert storage_a.final_namespace == expected_collection_a\n\n    # Verify: Model B collection\n    expected_collection_b = \"lightrag_vdb_chunks_bge_large_1024d\"\n    assert storage_b.final_namespace == expected_collection_b\n\n    # Verify: Different embedding dimensions are preserved\n    assert storage_a.embedding_func.embedding_dim == 768\n    assert storage_b.embedding_func.embedding_dim == 1024\n\n    print(\"✅ Scenario 3: Multi-model coexistence verified\")\n    print(f\"   - Workspace A: {expected_collection_a} (768d)\")\n    print(f\"   - Workspace B: {expected_collection_b} (1024d)\")\n    print(\"   - Collections are independent\")\n\n\nasync def test_case1_empty_legacy_auto_cleanup(mock_qdrant_client, mock_embedding_func):\n    \"\"\"\n    Case 1a: 新旧collection都存在，且旧库为空\n    预期：自动删除旧库\n    \"\"\"\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    storage = QdrantVectorDBStorage(\n        namespace=\"chunks\",\n        global_config=config,\n        embedding_func=mock_embedding_func,\n        workspace=\"test_ws\",\n    )\n\n    # Legacy collection name (without model suffix)\n    legacy_collection = \"lightrag_vdb_chunks\"\n    new_collection = storage.final_namespace\n\n    # Mock: Both collections exist\n    mock_qdrant_client.collection_exists.side_effect = lambda name: (\n        name\n        in [\n            legacy_collection,\n            new_collection,\n        ]\n    )\n\n    # Mock: Legacy collection is empty (0 records)\n    def count_mock(collection_name, exact=True, count_filter=None):\n        mock_result = MagicMock()\n        if collection_name == legacy_collection:\n            mock_result.count = 0  # Empty legacy collection\n        else:\n            mock_result.count = 100  # New collection has data\n        return mock_result\n\n    mock_qdrant_client.count.side_effect = count_mock\n\n    # Mock get_collection for Case 2 check\n    collection_info = MagicMock()\n    collection_info.payload_schema = {\"workspace_id\": True}\n    mock_qdrant_client.get_collection.return_value = collection_info\n\n    # Initialize storage\n    await storage.initialize()\n\n    # Verify: Empty legacy collection should be automatically cleaned up\n    # Empty collections are safe to delete without data loss risk\n    delete_calls = [\n        call for call in mock_qdrant_client.delete_collection.call_args_list\n    ]\n    assert len(delete_calls) >= 1, \"Empty legacy collection should be auto-deleted\"\n    deleted_collection = (\n        delete_calls[0][0][0]\n        if delete_calls[0][0]\n        else delete_calls[0].kwargs.get(\"collection_name\")\n    )\n    assert (\n        deleted_collection == legacy_collection\n    ), f\"Expected to delete '{legacy_collection}', but deleted '{deleted_collection}'\"\n\n    print(\n        f\"✅ Case 1a: Empty legacy collection '{legacy_collection}' auto-deleted successfully\"\n    )\n\n\nasync def test_case1_nonempty_legacy_warning(mock_qdrant_client, mock_embedding_func):\n    \"\"\"\n    Case 1b: 新旧collection都存在，且旧库有数据\n    预期：警告但不删除\n    \"\"\"\n    config = {\n        \"embedding_batch_num\": 10,\n        \"vector_db_storage_cls_kwargs\": {\"cosine_better_than_threshold\": 0.8},\n    }\n\n    storage = QdrantVectorDBStorage(\n        namespace=\"chunks\",\n        global_config=config,\n        embedding_func=mock_embedding_func,\n        workspace=\"test_ws\",\n    )\n\n    # Legacy collection name (without model suffix)\n    legacy_collection = \"lightrag_vdb_chunks\"\n    new_collection = storage.final_namespace\n\n    # Mock: Both collections exist\n    mock_qdrant_client.collection_exists.side_effect = lambda name: (\n        name\n        in [\n            legacy_collection,\n            new_collection,\n        ]\n    )\n\n    # Mock: Legacy collection has data (50 records)\n    def count_mock(collection_name, exact=True, count_filter=None):\n        mock_result = MagicMock()\n        if collection_name == legacy_collection:\n            mock_result.count = 50  # Legacy has data\n        else:\n            mock_result.count = 100  # New collection has data\n        return mock_result\n\n    mock_qdrant_client.count.side_effect = count_mock\n\n    # Mock get_collection for Case 2 check\n    collection_info = MagicMock()\n    collection_info.payload_schema = {\"workspace_id\": True}\n    mock_qdrant_client.get_collection.return_value = collection_info\n\n    # Initialize storage\n    await storage.initialize()\n\n    # Verify: Legacy collection with data should be preserved\n    # We never auto-delete collections that contain data to prevent accidental data loss\n    delete_calls = [\n        call for call in mock_qdrant_client.delete_collection.call_args_list\n    ]\n    # Check if legacy collection was deleted (it should not be)\n    legacy_deleted = any(\n        (call[0][0] if call[0] else call.kwargs.get(\"collection_name\"))\n        == legacy_collection\n        for call in delete_calls\n    )\n    assert not legacy_deleted, \"Legacy collection with data should NOT be auto-deleted\"\n\n    print(\n        f\"✅ Case 1b: Legacy collection '{legacy_collection}' with data preserved (warning only)\"\n    )\n"
  },
  {
    "path": "tests/test_qdrant_upsert_batching.py",
    "content": "from unittest.mock import MagicMock\n\nimport numpy as np\nimport pytest\nfrom qdrant_client import models\n\nfrom lightrag.kg.qdrant_impl import QdrantVectorDBStorage\n\n\ndef _make_point(point_id: str, content: str) -> models.PointStruct:\n    return models.PointStruct(\n        id=point_id,\n        vector=[0.1, 0.2, 0.3],\n        payload={\"id\": point_id, \"content\": content},\n    )\n\n\ndef test_build_upsert_batches_respects_point_limit():\n    points = [_make_point(str(i), \"x\" * 10) for i in range(5)]\n\n    batches = QdrantVectorDBStorage._build_upsert_batches(\n        points, max_payload_bytes=1024 * 1024, max_points_per_batch=2\n    )\n\n    assert [len(batch_points) for batch_points, _ in batches] == [2, 2, 1]\n\n\ndef test_build_upsert_batches_exact_payload_boundary_no_split():\n    point_a = _make_point(\"a\", \"x\" * 32)\n    point_b = _make_point(\"b\", \"y\" * 32)\n\n    size_a = QdrantVectorDBStorage._estimate_point_payload_bytes(point_a)\n    size_b = QdrantVectorDBStorage._estimate_point_payload_bytes(point_b)\n    # JSON array envelope: [] => 2 bytes, and comma between two elements => 1 byte\n    exact_limit = 2 + size_a + 1 + size_b\n\n    batches = QdrantVectorDBStorage._build_upsert_batches(\n        [point_a, point_b],\n        max_payload_bytes=exact_limit,\n        max_points_per_batch=128,\n    )\n\n    assert len(batches) == 1\n    assert len(batches[0][0]) == 2\n    assert batches[0][1] == exact_limit\n\n\ndef test_build_upsert_batches_raises_for_single_oversized_point():\n    point = _make_point(\"oversized\", \"x\" * 64)\n    point_size = QdrantVectorDBStorage._estimate_point_payload_bytes(point)\n    too_small_limit = point_size + 1\n\n    with pytest.raises(ValueError, match=\"Single Qdrant point exceeds payload limit\"):\n        QdrantVectorDBStorage._build_upsert_batches(\n            [point],\n            max_payload_bytes=too_small_limit,\n            max_points_per_batch=128,\n        )\n\n\n@pytest.mark.asyncio\nasync def test_upsert_fail_fast_stops_on_first_failed_batch():\n    storage = QdrantVectorDBStorage.__new__(QdrantVectorDBStorage)\n    storage.workspace = \"test_ws\"\n    storage.namespace = \"chunks\"\n    storage.effective_workspace = \"test_ws\"\n    storage.meta_fields = {\"content\"}\n    storage._max_batch_size = 16\n    storage._max_upsert_payload_bytes = 1024 * 1024\n    storage._max_upsert_points_per_batch = 2\n    storage.final_namespace = \"test_collection\"\n    storage._client = MagicMock()\n\n    async def fake_embedding_func(texts, **kwargs):\n        return np.array([[float(len(text)), 0.0] for text in texts], dtype=np.float32)\n\n    storage.embedding_func = fake_embedding_func\n    storage._client.upsert.side_effect = [None, RuntimeError(\"batch failed\"), None]\n\n    data = {f\"chunk-{i}\": {\"content\": f\"content-{i}\"} for i in range(5)}\n\n    with pytest.raises(RuntimeError, match=\"batch failed\"):\n        await storage.upsert(data)\n\n    # 5 items with max 2 points per batch => expected 3 batches, but stop at batch #2 on error.\n    assert storage._client.upsert.call_count == 2\n    first_call = storage._client.upsert.call_args_list[0]\n    second_call = storage._client.upsert.call_args_list[1]\n    assert len(first_call.kwargs[\"points\"]) == 2\n    assert len(second_call.kwargs[\"points\"]) == 2\n"
  },
  {
    "path": "tests/test_rerank_chunking.py",
    "content": "\"\"\"\nUnit tests for rerank document chunking functionality.\n\nTests the chunk_documents_for_rerank and aggregate_chunk_scores functions\nin lightrag/rerank.py to ensure proper document splitting and score aggregation.\n\"\"\"\n\nimport pytest\nfrom unittest.mock import Mock, patch, AsyncMock\nfrom lightrag.rerank import (\n    chunk_documents_for_rerank,\n    aggregate_chunk_scores,\n    cohere_rerank,\n)\n\n\nclass TestChunkDocumentsForRerank:\n    \"\"\"Test suite for chunk_documents_for_rerank function\"\"\"\n\n    def test_no_chunking_needed_for_short_docs(self):\n        \"\"\"Documents shorter than max_tokens should not be chunked\"\"\"\n        documents = [\n            \"Short doc 1\",\n            \"Short doc 2\",\n            \"Short doc 3\",\n        ]\n\n        chunked_docs, doc_indices = chunk_documents_for_rerank(\n            documents, max_tokens=100, overlap_tokens=10\n        )\n\n        # No chunking should occur\n        assert len(chunked_docs) == 3\n        assert chunked_docs == documents\n        assert doc_indices == [0, 1, 2]\n\n    def test_chunking_with_character_fallback(self):\n        \"\"\"Test chunking falls back to character-based when tokenizer unavailable\"\"\"\n        # Create a very long document that exceeds character limit\n        long_doc = \"a\" * 2000  # 2000 characters\n        documents = [long_doc, \"short doc\"]\n\n        with patch(\"lightrag.utils.TiktokenTokenizer\", side_effect=ImportError):\n            chunked_docs, doc_indices = chunk_documents_for_rerank(\n                documents,\n                max_tokens=100,  # 100 tokens = ~400 chars\n                overlap_tokens=10,  # 10 tokens = ~40 chars\n            )\n\n        # First doc should be split into chunks, second doc stays whole\n        assert len(chunked_docs) > 2  # At least one chunk from first doc + second doc\n        assert chunked_docs[-1] == \"short doc\"  # Last chunk is the short doc\n        # Verify doc_indices maps chunks to correct original document\n        assert doc_indices[-1] == 1  # Last chunk maps to document 1\n\n    def test_chunking_with_tiktoken_tokenizer(self):\n        \"\"\"Test chunking with actual tokenizer\"\"\"\n        # Create document with known token count\n        # Approximate: \"word \" = ~1 token, so 200 words ~ 200 tokens\n        long_doc = \" \".join([f\"word{i}\" for i in range(200)])\n        documents = [long_doc, \"short\"]\n\n        chunked_docs, doc_indices = chunk_documents_for_rerank(\n            documents, max_tokens=50, overlap_tokens=10\n        )\n\n        # Long doc should be split, short doc should remain\n        assert len(chunked_docs) > 2\n        assert doc_indices[-1] == 1  # Last chunk is from second document\n\n        # Verify overlapping chunks contain overlapping content\n        if len(chunked_docs) > 2:\n            # Check that consecutive chunks from same doc have some overlap\n            for i in range(len(doc_indices) - 1):\n                if doc_indices[i] == doc_indices[i + 1] == 0:\n                    # Both chunks from first doc, should have overlap\n                    chunk1_words = chunked_docs[i].split()\n                    chunk2_words = chunked_docs[i + 1].split()\n                    # At least one word should be common due to overlap\n                    assert any(word in chunk2_words for word in chunk1_words[-5:])\n\n    def test_empty_documents(self):\n        \"\"\"Test handling of empty document list\"\"\"\n        documents = []\n        chunked_docs, doc_indices = chunk_documents_for_rerank(documents)\n\n        assert chunked_docs == []\n        assert doc_indices == []\n\n    def test_single_document_chunking(self):\n        \"\"\"Test chunking of a single long document\"\"\"\n        # Create document with ~100 tokens\n        long_doc = \" \".join([f\"token{i}\" for i in range(100)])\n        documents = [long_doc]\n\n        chunked_docs, doc_indices = chunk_documents_for_rerank(\n            documents, max_tokens=30, overlap_tokens=5\n        )\n\n        # Should create multiple chunks\n        assert len(chunked_docs) > 1\n        # All chunks should map to document 0\n        assert all(idx == 0 for idx in doc_indices)\n\n\nclass TestAggregateChunkScores:\n    \"\"\"Test suite for aggregate_chunk_scores function\"\"\"\n\n    def test_no_chunking_simple_aggregation(self):\n        \"\"\"Test aggregation when no chunking occurred (1:1 mapping)\"\"\"\n        chunk_results = [\n            {\"index\": 0, \"relevance_score\": 0.9},\n            {\"index\": 1, \"relevance_score\": 0.7},\n            {\"index\": 2, \"relevance_score\": 0.5},\n        ]\n        doc_indices = [0, 1, 2]  # 1:1 mapping\n        num_original_docs = 3\n\n        aggregated = aggregate_chunk_scores(\n            chunk_results, doc_indices, num_original_docs, aggregation=\"max\"\n        )\n\n        # Results should be sorted by score\n        assert len(aggregated) == 3\n        assert aggregated[0][\"index\"] == 0\n        assert aggregated[0][\"relevance_score\"] == 0.9\n        assert aggregated[1][\"index\"] == 1\n        assert aggregated[1][\"relevance_score\"] == 0.7\n        assert aggregated[2][\"index\"] == 2\n        assert aggregated[2][\"relevance_score\"] == 0.5\n\n    def test_max_aggregation_with_chunks(self):\n        \"\"\"Test max aggregation strategy with multiple chunks per document\"\"\"\n        # 5 chunks: first 3 from doc 0, last 2 from doc 1\n        chunk_results = [\n            {\"index\": 0, \"relevance_score\": 0.5},\n            {\"index\": 1, \"relevance_score\": 0.8},\n            {\"index\": 2, \"relevance_score\": 0.6},\n            {\"index\": 3, \"relevance_score\": 0.7},\n            {\"index\": 4, \"relevance_score\": 0.4},\n        ]\n        doc_indices = [0, 0, 0, 1, 1]\n        num_original_docs = 2\n\n        aggregated = aggregate_chunk_scores(\n            chunk_results, doc_indices, num_original_docs, aggregation=\"max\"\n        )\n\n        # Should take max score for each document\n        assert len(aggregated) == 2\n        assert aggregated[0][\"index\"] == 0\n        assert aggregated[0][\"relevance_score\"] == 0.8  # max of 0.5, 0.8, 0.6\n        assert aggregated[1][\"index\"] == 1\n        assert aggregated[1][\"relevance_score\"] == 0.7  # max of 0.7, 0.4\n\n    def test_mean_aggregation_with_chunks(self):\n        \"\"\"Test mean aggregation strategy\"\"\"\n        chunk_results = [\n            {\"index\": 0, \"relevance_score\": 0.6},\n            {\"index\": 1, \"relevance_score\": 0.8},\n            {\"index\": 2, \"relevance_score\": 0.4},\n        ]\n        doc_indices = [0, 0, 1]  # First two chunks from doc 0, last from doc 1\n        num_original_docs = 2\n\n        aggregated = aggregate_chunk_scores(\n            chunk_results, doc_indices, num_original_docs, aggregation=\"mean\"\n        )\n\n        assert len(aggregated) == 2\n        assert aggregated[0][\"index\"] == 0\n        assert aggregated[0][\"relevance_score\"] == pytest.approx(0.7)  # (0.6 + 0.8) / 2\n        assert aggregated[1][\"index\"] == 1\n        assert aggregated[1][\"relevance_score\"] == 0.4\n\n    def test_first_aggregation_with_chunks(self):\n        \"\"\"Test first aggregation strategy\"\"\"\n        chunk_results = [\n            {\"index\": 0, \"relevance_score\": 0.6},\n            {\"index\": 1, \"relevance_score\": 0.8},\n            {\"index\": 2, \"relevance_score\": 0.4},\n        ]\n        doc_indices = [0, 0, 1]\n        num_original_docs = 2\n\n        aggregated = aggregate_chunk_scores(\n            chunk_results, doc_indices, num_original_docs, aggregation=\"first\"\n        )\n\n        assert len(aggregated) == 2\n        # First should use first score seen for each doc\n        assert aggregated[0][\"index\"] == 0\n        assert aggregated[0][\"relevance_score\"] == 0.6  # First score for doc 0\n        assert aggregated[1][\"index\"] == 1\n        assert aggregated[1][\"relevance_score\"] == 0.4\n\n    def test_empty_chunk_results(self):\n        \"\"\"Test handling of empty results\"\"\"\n        aggregated = aggregate_chunk_scores([], [], 3, aggregation=\"max\")\n        assert aggregated == []\n\n    def test_documents_with_no_scores(self):\n        \"\"\"Test when some documents have no chunks/scores\"\"\"\n        chunk_results = [\n            {\"index\": 0, \"relevance_score\": 0.9},\n            {\"index\": 1, \"relevance_score\": 0.7},\n        ]\n        doc_indices = [0, 0]  # Both chunks from document 0\n        num_original_docs = 3  # But we have 3 documents total\n\n        aggregated = aggregate_chunk_scores(\n            chunk_results, doc_indices, num_original_docs, aggregation=\"max\"\n        )\n\n        # Only doc 0 should appear in results\n        assert len(aggregated) == 1\n        assert aggregated[0][\"index\"] == 0\n\n    def test_unknown_aggregation_strategy(self):\n        \"\"\"Test that unknown strategy falls back to max\"\"\"\n        chunk_results = [\n            {\"index\": 0, \"relevance_score\": 0.6},\n            {\"index\": 1, \"relevance_score\": 0.8},\n        ]\n        doc_indices = [0, 0]\n        num_original_docs = 1\n\n        # Use invalid strategy\n        aggregated = aggregate_chunk_scores(\n            chunk_results, doc_indices, num_original_docs, aggregation=\"invalid\"\n        )\n\n        # Should fall back to max\n        assert aggregated[0][\"relevance_score\"] == 0.8\n\n\n@pytest.mark.offline\nclass TestTopNWithChunking:\n    \"\"\"Tests for top_n behavior when chunking is enabled (Bug fix verification)\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_top_n_limits_documents_not_chunks(self):\n        \"\"\"\n        Test that top_n correctly limits documents (not chunks) when chunking is enabled.\n\n        Bug scenario: 10 docs expand to 50 chunks. With old behavior, top_n=5 would\n        return scores for only 5 chunks (possibly all from 1-2 docs). After aggregation,\n        fewer than 5 documents would be returned.\n\n        Fixed behavior: top_n=5 should return exactly 5 documents after aggregation.\n        \"\"\"\n        # Setup: 5 documents, each producing multiple chunks when chunked\n        # Using small max_tokens to force chunking\n        long_docs = [\" \".join([f\"doc{i}_word{j}\" for j in range(50)]) for i in range(5)]\n        query = \"test query\"\n\n        # First, determine how many chunks will be created by actual chunking\n        _, doc_indices = chunk_documents_for_rerank(\n            long_docs, max_tokens=50, overlap_tokens=10\n        )\n        num_chunks = len(doc_indices)\n\n        # Mock API returns scores for ALL chunks (simulating disabled API-level top_n)\n        # Give different scores to ensure doc 0 gets highest, doc 1 second, etc.\n        # Assign scores based on original document index (lower doc index = higher score)\n        mock_chunk_scores = []\n        for i in range(num_chunks):\n            original_doc = doc_indices[i]\n            # Higher score for lower doc index, with small variation per chunk\n            base_score = 0.9 - (original_doc * 0.1)\n            mock_chunk_scores.append({\"index\": i, \"relevance_score\": base_score})\n\n        mock_response = Mock()\n        mock_response.status = 200\n        mock_response.json = AsyncMock(return_value={\"results\": mock_chunk_scores})\n        mock_response.request_info = None\n        mock_response.history = None\n        mock_response.headers = {}\n        mock_response.__aenter__ = AsyncMock(return_value=mock_response)\n        mock_response.__aexit__ = AsyncMock(return_value=None)\n\n        mock_session = Mock()\n        mock_session.post = Mock(return_value=mock_response)\n        mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n        mock_session.__aexit__ = AsyncMock(return_value=None)\n\n        with patch(\"lightrag.rerank.aiohttp.ClientSession\", return_value=mock_session):\n            result = await cohere_rerank(\n                query=query,\n                documents=long_docs,\n                api_key=\"test-key\",\n                base_url=\"http://test.com/rerank\",\n                enable_chunking=True,\n                max_tokens_per_doc=50,  # Match chunking above\n                top_n=3,  # Request top 3 documents\n            )\n\n            # Verify: should get exactly 3 documents (not unlimited chunks)\n            assert len(result) == 3\n            # All results should have valid document indices (0-4)\n            assert all(0 <= r[\"index\"] < 5 for r in result)\n            # Results should be sorted by score (descending)\n            assert all(\n                result[i][\"relevance_score\"] >= result[i + 1][\"relevance_score\"]\n                for i in range(len(result) - 1)\n            )\n            # The top 3 docs should be 0, 1, 2 (highest scores)\n            result_indices = [r[\"index\"] for r in result]\n            assert set(result_indices) == {0, 1, 2}\n\n    @pytest.mark.asyncio\n    async def test_api_receives_no_top_n_when_chunking_enabled(self):\n        \"\"\"\n        Test that the API request does NOT include top_n when chunking is enabled.\n\n        This ensures all chunk scores are retrieved for proper aggregation.\n        \"\"\"\n        documents = [\" \".join([f\"word{i}\" for i in range(100)]), \"short doc\"]\n        query = \"test query\"\n\n        captured_payload = {}\n\n        mock_response = Mock()\n        mock_response.status = 200\n        mock_response.json = AsyncMock(\n            return_value={\n                \"results\": [\n                    {\"index\": 0, \"relevance_score\": 0.9},\n                    {\"index\": 1, \"relevance_score\": 0.8},\n                    {\"index\": 2, \"relevance_score\": 0.7},\n                ]\n            }\n        )\n        mock_response.request_info = None\n        mock_response.history = None\n        mock_response.headers = {}\n        mock_response.__aenter__ = AsyncMock(return_value=mock_response)\n        mock_response.__aexit__ = AsyncMock(return_value=None)\n\n        def capture_post(*args, **kwargs):\n            captured_payload.update(kwargs.get(\"json\", {}))\n            return mock_response\n\n        mock_session = Mock()\n        mock_session.post = Mock(side_effect=capture_post)\n        mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n        mock_session.__aexit__ = AsyncMock(return_value=None)\n\n        with patch(\"lightrag.rerank.aiohttp.ClientSession\", return_value=mock_session):\n            await cohere_rerank(\n                query=query,\n                documents=documents,\n                api_key=\"test-key\",\n                base_url=\"http://test.com/rerank\",\n                enable_chunking=True,\n                max_tokens_per_doc=30,\n                top_n=1,  # User wants top 1 document\n            )\n\n            # Verify: API payload should NOT have top_n (disabled for chunking)\n            assert \"top_n\" not in captured_payload\n\n    @pytest.mark.asyncio\n    async def test_top_n_not_modified_when_chunking_disabled(self):\n        \"\"\"\n        Test that top_n is passed through to API when chunking is disabled.\n        \"\"\"\n        documents = [\"doc1\", \"doc2\"]\n        query = \"test query\"\n\n        captured_payload = {}\n\n        mock_response = Mock()\n        mock_response.status = 200\n        mock_response.json = AsyncMock(\n            return_value={\n                \"results\": [\n                    {\"index\": 0, \"relevance_score\": 0.9},\n                ]\n            }\n        )\n        mock_response.request_info = None\n        mock_response.history = None\n        mock_response.headers = {}\n        mock_response.__aenter__ = AsyncMock(return_value=mock_response)\n        mock_response.__aexit__ = AsyncMock(return_value=None)\n\n        def capture_post(*args, **kwargs):\n            captured_payload.update(kwargs.get(\"json\", {}))\n            return mock_response\n\n        mock_session = Mock()\n        mock_session.post = Mock(side_effect=capture_post)\n        mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n        mock_session.__aexit__ = AsyncMock(return_value=None)\n\n        with patch(\"lightrag.rerank.aiohttp.ClientSession\", return_value=mock_session):\n            await cohere_rerank(\n                query=query,\n                documents=documents,\n                api_key=\"test-key\",\n                base_url=\"http://test.com/rerank\",\n                enable_chunking=False,  # Chunking disabled\n                top_n=1,\n            )\n\n            # Verify: API payload should have top_n when chunking is disabled\n            assert captured_payload.get(\"top_n\") == 1\n\n\n@pytest.mark.offline\nclass TestCohereRerankChunking:\n    \"\"\"Integration tests for cohere_rerank with chunking enabled\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_cohere_rerank_with_chunking_disabled(self):\n        \"\"\"Test that chunking can be disabled\"\"\"\n        documents = [\"doc1\", \"doc2\"]\n        query = \"test query\"\n\n        # Mock the generic_rerank_api\n        with patch(\n            \"lightrag.rerank.generic_rerank_api\", new_callable=AsyncMock\n        ) as mock_api:\n            mock_api.return_value = [\n                {\"index\": 0, \"relevance_score\": 0.9},\n                {\"index\": 1, \"relevance_score\": 0.7},\n            ]\n\n            result = await cohere_rerank(\n                query=query,\n                documents=documents,\n                api_key=\"test-key\",\n                enable_chunking=False,\n                max_tokens_per_doc=100,\n            )\n\n            # Verify generic_rerank_api was called with correct parameters\n            mock_api.assert_called_once()\n            call_kwargs = mock_api.call_args[1]\n            assert call_kwargs[\"enable_chunking\"] is False\n            assert call_kwargs[\"max_tokens_per_doc\"] == 100\n            # Result should mirror mocked scores\n            assert len(result) == 2\n            assert result[0][\"index\"] == 0\n            assert result[0][\"relevance_score\"] == 0.9\n            assert result[1][\"index\"] == 1\n            assert result[1][\"relevance_score\"] == 0.7\n\n    @pytest.mark.asyncio\n    async def test_cohere_rerank_with_chunking_enabled(self):\n        \"\"\"Test that chunking parameters are passed through\"\"\"\n        documents = [\"doc1\", \"doc2\"]\n        query = \"test query\"\n\n        with patch(\n            \"lightrag.rerank.generic_rerank_api\", new_callable=AsyncMock\n        ) as mock_api:\n            mock_api.return_value = [\n                {\"index\": 0, \"relevance_score\": 0.9},\n                {\"index\": 1, \"relevance_score\": 0.7},\n            ]\n\n            result = await cohere_rerank(\n                query=query,\n                documents=documents,\n                api_key=\"test-key\",\n                enable_chunking=True,\n                max_tokens_per_doc=480,\n            )\n\n            # Verify parameters were passed\n            call_kwargs = mock_api.call_args[1]\n            assert call_kwargs[\"enable_chunking\"] is True\n            assert call_kwargs[\"max_tokens_per_doc\"] == 480\n            # Result should mirror mocked scores\n            assert len(result) == 2\n            assert result[0][\"index\"] == 0\n            assert result[0][\"relevance_score\"] == 0.9\n            assert result[1][\"index\"] == 1\n            assert result[1][\"relevance_score\"] == 0.7\n\n    @pytest.mark.asyncio\n    async def test_cohere_rerank_default_parameters(self):\n        \"\"\"Test default parameter values for cohere_rerank\"\"\"\n        documents = [\"doc1\"]\n        query = \"test\"\n\n        with patch(\n            \"lightrag.rerank.generic_rerank_api\", new_callable=AsyncMock\n        ) as mock_api:\n            mock_api.return_value = [{\"index\": 0, \"relevance_score\": 0.9}]\n\n            result = await cohere_rerank(\n                query=query, documents=documents, api_key=\"test-key\"\n            )\n\n            # Verify default values\n            call_kwargs = mock_api.call_args[1]\n            assert call_kwargs[\"enable_chunking\"] is False\n            assert call_kwargs[\"max_tokens_per_doc\"] == 4096\n            assert call_kwargs[\"model\"] == \"rerank-v3.5\"\n            # Result should mirror mocked scores\n            assert len(result) == 1\n            assert result[0][\"index\"] == 0\n            assert result[0][\"relevance_score\"] == 0.9\n\n\n@pytest.mark.offline\nclass TestEndToEndChunking:\n    \"\"\"End-to-end tests for chunking workflow\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_end_to_end_chunking_workflow(self):\n        \"\"\"Test complete chunking workflow from documents to aggregated results\"\"\"\n        # Create documents where first one needs chunking\n        long_doc = \" \".join([f\"word{i}\" for i in range(100)])\n        documents = [long_doc, \"short doc\"]\n        query = \"test query\"\n\n        # Mock the HTTP call inside generic_rerank_api\n        mock_response = Mock()\n        mock_response.status = 200\n        mock_response.json = AsyncMock(\n            return_value={\n                \"results\": [\n                    {\"index\": 0, \"relevance_score\": 0.5},  # chunk 0 from doc 0\n                    {\"index\": 1, \"relevance_score\": 0.8},  # chunk 1 from doc 0\n                    {\"index\": 2, \"relevance_score\": 0.6},  # chunk 2 from doc 0\n                    {\"index\": 3, \"relevance_score\": 0.7},  # doc 1 (short)\n                ]\n            }\n        )\n        mock_response.request_info = None\n        mock_response.history = None\n        mock_response.headers = {}\n        # Make mock_response an async context manager (for `async with session.post() as response`)\n        mock_response.__aenter__ = AsyncMock(return_value=mock_response)\n        mock_response.__aexit__ = AsyncMock(return_value=None)\n\n        mock_session = Mock()\n        # session.post() returns an async context manager, so return mock_response which is now one\n        mock_session.post = Mock(return_value=mock_response)\n        mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n        mock_session.__aexit__ = AsyncMock(return_value=None)\n\n        with patch(\"lightrag.rerank.aiohttp.ClientSession\", return_value=mock_session):\n            result = await cohere_rerank(\n                query=query,\n                documents=documents,\n                api_key=\"test-key\",\n                base_url=\"http://test.com/rerank\",\n                enable_chunking=True,\n                max_tokens_per_doc=30,  # Force chunking of long doc\n            )\n\n            # Should get 2 results (one per original document)\n            # The long doc's chunks should be aggregated\n            assert len(result) <= len(documents)\n            # Results should be sorted by score\n            assert all(\n                result[i][\"relevance_score\"] >= result[i + 1][\"relevance_score\"]\n                for i in range(len(result) - 1)\n            )\n"
  },
  {
    "path": "tests/test_runtime_target_validation.py",
    "content": "from pathlib import Path\n\nfrom lightrag.api.runtime_validation import (\n    RuntimeEnvironment,\n    validate_runtime_target,\n    validate_runtime_target_from_env_file,\n)\n\n\ndef test_validate_runtime_target_skips_when_not_declared() -> None:\n    is_valid, error_message = validate_runtime_target(None)\n\n    assert is_valid is True\n    assert error_message is None\n\n\ndef test_validate_runtime_target_accepts_host_on_host() -> None:\n    is_valid, error_message = validate_runtime_target(\n        \"host\",\n        RuntimeEnvironment(\n            in_container=False,\n            in_docker=False,\n            in_kubernetes=False,\n        ),\n    )\n\n    assert is_valid is True\n    assert error_message is None\n\n\ndef test_validate_runtime_target_rejects_host_in_container() -> None:\n    is_valid, error_message = validate_runtime_target(\n        \"host\",\n        RuntimeEnvironment(\n            in_container=True,\n            in_docker=True,\n            in_kubernetes=False,\n        ),\n    )\n\n    assert is_valid is False\n    assert \"\\n\" in error_message\n    assert \"Configuration error in .env\" in error_message\n    assert \"LIGHTRAG_RUNTIME_TARGET=host\" in error_message\n    assert \"This value from .env\" in error_message\n    assert \"Docker\" in error_message\n\n\ndef test_validate_runtime_target_accepts_compose_and_docker_in_container() -> None:\n    runtime_environment = RuntimeEnvironment(\n        in_container=True,\n        in_docker=False,\n        in_kubernetes=True,\n    )\n\n    for runtime_target in (\"compose\", \"docker\"):\n        is_valid, error_message = validate_runtime_target(\n            runtime_target,\n            runtime_environment,\n        )\n\n        assert is_valid is True\n        assert error_message is None\n\n\ndef test_validate_runtime_target_rejects_container_target_on_host() -> None:\n    is_valid, error_message = validate_runtime_target(\n        \"docker\",\n        RuntimeEnvironment(\n            in_container=False,\n            in_docker=False,\n            in_kubernetes=False,\n        ),\n    )\n\n    assert is_valid is False\n    assert \"\\n\" in error_message\n    assert \"Configuration error in .env\" in error_message\n    assert \"LIGHTRAG_RUNTIME_TARGET=docker\" in error_message\n    assert \"This value from .env\" in error_message\n    assert \"Docker or Kubernetes\" in error_message\n\n\ndef test_validate_runtime_target_rejects_invalid_value() -> None:\n    is_valid, error_message = validate_runtime_target(\n        \"invalid\",\n        RuntimeEnvironment(in_container=False, in_docker=False, in_kubernetes=False),\n    )\n\n    assert is_valid is False\n    assert \"\\n\" in error_message\n    assert \"Configuration error in .env\" in error_message\n    assert \"must be 'host' or 'compose'\" in error_message\n\n\ndef test_validate_runtime_target_from_env_file_uses_raw_env_value(\n    tmp_path: Path,\n) -> None:\n    env_file = tmp_path / \".env\"\n    env_file.write_text(\"LIGHTRAG_RUNTIME_TARGET=compose\\n\", encoding=\"utf-8\")\n\n    is_valid, error_message = validate_runtime_target_from_env_file(\n        env_file,\n        RuntimeEnvironment(\n            in_container=True,\n            in_docker=True,\n            in_kubernetes=False,\n        ),\n    )\n\n    assert is_valid is True\n    assert error_message is None\n"
  },
  {
    "path": "tests/test_token_auto_renewal.py",
    "content": "\"\"\"\nPytest unit tests for token auto-renewal functionality\n\nTests:\n1. Backend token renewal logic\n2. Rate limiting for token renewals\n3. Token renewal state tracking\n\"\"\"\n\nimport pytest\nfrom datetime import datetime, timedelta, timezone\nfrom unittest.mock import Mock\nfrom fastapi import Response\nimport time\nimport sys\n\n# Mock the config before importing utils_api\nsys.modules[\"lightrag.api.config\"] = Mock()\nsys.modules[\"lightrag.api.auth\"] = Mock()\n\n# Create a simple token renewal cache for testing\n_token_renewal_cache = {}\n_RENEWAL_MIN_INTERVAL = 60\n\n\n@pytest.mark.offline\nclass TestTokenRenewal:\n    \"\"\"Tests for token auto-renewal logic\"\"\"\n\n    @pytest.fixture\n    def mock_auth_handler(self):\n        \"\"\"Mock authentication handler\"\"\"\n        handler = Mock()\n        handler.guest_expire_hours = 24\n        handler.expire_hours = 24\n        handler.create_token = Mock(return_value=\"new-token-12345\")\n        return handler\n\n    @pytest.fixture\n    def mock_global_args(self):\n        \"\"\"Mock global configuration\"\"\"\n        args = Mock()\n        args.token_auto_renew = True\n        args.token_renew_threshold = 0.5\n        return args\n\n    @pytest.fixture\n    def mock_token_info_guest(self):\n        \"\"\"Mock token info for guest user\"\"\"\n        # Token with 10 hours remaining (below 50% of 24 hours)\n        exp_time = datetime.now(timezone.utc) + timedelta(hours=10)\n        return {\n            \"username\": \"guest\",\n            \"role\": \"guest\",\n            \"exp\": exp_time,\n            \"metadata\": {\"auth_mode\": \"disabled\"},\n        }\n\n    @pytest.fixture\n    def mock_token_info_user(self):\n        \"\"\"Mock token info for regular user\"\"\"\n        # Token with 10 hours remaining (below 50% of 24 hours)\n        exp_time = datetime.now(timezone.utc) + timedelta(hours=10)\n        return {\n            \"username\": \"testuser\",\n            \"role\": \"user\",\n            \"exp\": exp_time,\n            \"metadata\": {\"auth_mode\": \"enabled\"},\n        }\n\n    @pytest.fixture\n    def mock_token_info_above_threshold(self):\n        \"\"\"Mock token info with time above renewal threshold\"\"\"\n        # Token with 20 hours remaining (above 50% of 24 hours)\n        exp_time = datetime.now(timezone.utc) + timedelta(hours=20)\n        return {\n            \"username\": \"testuser\",\n            \"role\": \"user\",\n            \"exp\": exp_time,\n            \"metadata\": {\"auth_mode\": \"enabled\"},\n        }\n\n    def test_token_renewal_when_below_threshold(\n        self, mock_auth_handler, mock_global_args, mock_token_info_user\n    ):\n        \"\"\"Test that token is renewed when remaining time < threshold\"\"\"\n        # Use global cache\n        global _token_renewal_cache\n\n        # Clear cache\n        _token_renewal_cache.clear()\n\n        response = Mock(spec=Response)\n        response.headers = {}\n\n        # Simulate the renewal logic\n        expire_time = mock_token_info_user[\"exp\"]\n        now = datetime.now(timezone.utc)\n        remaining_seconds = (expire_time - now).total_seconds()\n\n        role = mock_token_info_user[\"role\"]\n        total_hours = (\n            mock_auth_handler.expire_hours\n            if role == \"user\"\n            else mock_auth_handler.guest_expire_hours\n        )\n        total_seconds = total_hours * 3600\n\n        # Should renew because remaining_seconds < total_seconds * 0.5\n        should_renew = (\n            remaining_seconds < total_seconds * mock_global_args.token_renew_threshold\n        )\n        assert should_renew is True\n\n        # Simulate renewal\n        username = mock_token_info_user[\"username\"]\n        current_time = time.time()\n        last_renewal = _token_renewal_cache.get(username, 0)\n        time_since_last_renewal = current_time - last_renewal\n\n        # Should pass rate limit (first renewal)\n        assert time_since_last_renewal >= 60 or last_renewal == 0\n\n        # Perform renewal\n        new_token = mock_auth_handler.create_token(\n            username=username, role=role, metadata=mock_token_info_user[\"metadata\"]\n        )\n        response.headers[\"X-New-Token\"] = new_token\n        _token_renewal_cache[username] = current_time\n\n        # Verify\n        assert \"X-New-Token\" in response.headers\n        assert response.headers[\"X-New-Token\"] == \"new-token-12345\"\n        assert username in _token_renewal_cache\n\n    def test_token_no_renewal_when_above_threshold(\n        self, mock_auth_handler, mock_global_args, mock_token_info_above_threshold\n    ):\n        \"\"\"Test that token is NOT renewed when remaining time > threshold\"\"\"\n        response = Mock(spec=Response)\n        response.headers = {}\n\n        expire_time = mock_token_info_above_threshold[\"exp\"]\n        now = datetime.now(timezone.utc)\n        remaining_seconds = (expire_time - now).total_seconds()\n\n        mock_token_info_above_threshold[\"role\"]\n        total_hours = mock_auth_handler.expire_hours\n        total_seconds = total_hours * 3600\n\n        # Should NOT renew because remaining_seconds > total_seconds * 0.5\n        should_renew = (\n            remaining_seconds < total_seconds * mock_global_args.token_renew_threshold\n        )\n        assert should_renew is False\n\n        # No renewal should happen\n        assert \"X-New-Token\" not in response.headers\n\n    def test_token_renewal_disabled(\n        self, mock_auth_handler, mock_global_args, mock_token_info_user\n    ):\n        \"\"\"Test that no renewal happens when TOKEN_AUTO_RENEW=false\"\"\"\n        mock_global_args.token_auto_renew = False\n        response = Mock(spec=Response)\n        response.headers = {}\n\n        # Auto-renewal is disabled, so even if below threshold, no renewal\n        if not mock_global_args.token_auto_renew:\n            # Skip renewal logic\n            pass\n\n        assert \"X-New-Token\" not in response.headers\n\n    def test_token_renewal_for_guest_mode(\n        self, mock_auth_handler, mock_global_args, mock_token_info_guest\n    ):\n        \"\"\"Test that guest tokens are renewed correctly\"\"\"\n        # Use global cache\n        global _token_renewal_cache\n\n        _token_renewal_cache.clear()\n\n        response = Mock(spec=Response)\n        response.headers = {}\n\n        expire_time = mock_token_info_guest[\"exp\"]\n        now = datetime.now(timezone.utc)\n        remaining_seconds = (expire_time - now).total_seconds()\n\n        role = mock_token_info_guest[\"role\"]\n        total_hours = mock_auth_handler.guest_expire_hours\n        total_seconds = total_hours * 3600\n\n        should_renew = (\n            remaining_seconds < total_seconds * mock_global_args.token_renew_threshold\n        )\n        assert should_renew is True\n\n        # Renewal for guest\n        username = mock_token_info_guest[\"username\"]\n        new_token = mock_auth_handler.create_token(\n            username=username, role=role, metadata=mock_token_info_guest[\"metadata\"]\n        )\n        response.headers[\"X-New-Token\"] = new_token\n        _token_renewal_cache[username] = time.time()\n\n        assert \"X-New-Token\" in response.headers\n        assert username in _token_renewal_cache\n\n\n@pytest.mark.offline\nclass TestRateLimiting:\n    \"\"\"Tests for token renewal rate limiting\"\"\"\n\n    @pytest.fixture\n    def mock_auth_handler(self):\n        \"\"\"Mock authentication handler\"\"\"\n        handler = Mock()\n        handler.expire_hours = 24\n        handler.create_token = Mock(return_value=\"new-token-12345\")\n        return handler\n\n    def test_rate_limit_prevents_rapid_renewals(self, mock_auth_handler):\n        \"\"\"Test that second renewal within 60s is blocked\"\"\"\n        # Use global cache and constant\n        global _token_renewal_cache, _RENEWAL_MIN_INTERVAL\n\n        username = \"testuser\"\n        _token_renewal_cache.clear()\n\n        # First renewal\n        current_time_1 = time.time()\n        _token_renewal_cache[username] = current_time_1\n\n        response_1 = Mock(spec=Response)\n        response_1.headers = {}\n        response_1.headers[\"X-New-Token\"] = \"new-token-12345\"\n\n        # Immediate second renewal attempt (within 60s)\n        current_time_2 = time.time()  # Almost same time\n        last_renewal = _token_renewal_cache.get(username, 0)\n        time_since_last_renewal = current_time_2 - last_renewal\n\n        # Should be blocked by rate limit\n        assert time_since_last_renewal < _RENEWAL_MIN_INTERVAL\n\n        response_2 = Mock(spec=Response)\n        response_2.headers = {}\n\n        # No new token should be issued\n        if time_since_last_renewal < _RENEWAL_MIN_INTERVAL:\n            # Rate limited, skip renewal\n            pass\n\n        assert \"X-New-Token\" not in response_2.headers\n\n    def test_rate_limit_allows_renewal_after_interval(self, mock_auth_handler):\n        \"\"\"Test that renewal succeeds after 60s interval\"\"\"\n        # Use global cache and constant\n        global _token_renewal_cache, _RENEWAL_MIN_INTERVAL\n\n        username = \"testuser\"\n        _token_renewal_cache.clear()\n\n        # First renewal at time T\n        first_renewal_time = time.time() - 61  # 61 seconds ago\n        _token_renewal_cache[username] = first_renewal_time\n\n        # Second renewal attempt now\n        current_time = time.time()\n        last_renewal = _token_renewal_cache.get(username, 0)\n        time_since_last_renewal = current_time - last_renewal\n\n        # Should pass rate limit (>60s elapsed)\n        assert time_since_last_renewal >= _RENEWAL_MIN_INTERVAL\n\n        response = Mock(spec=Response)\n        response.headers = {}\n\n        if time_since_last_renewal >= _RENEWAL_MIN_INTERVAL:\n            new_token = mock_auth_handler.create_token(\n                username=username, role=\"user\", metadata={}\n            )\n            response.headers[\"X-New-Token\"] = new_token\n            _token_renewal_cache[username] = current_time\n\n        assert \"X-New-Token\" in response.headers\n        assert response.headers[\"X-New-Token\"] == \"new-token-12345\"\n\n    def test_rate_limit_per_user(self, mock_auth_handler):\n        \"\"\"Test that different users have independent rate limits\"\"\"\n        # Use global cache\n        global _token_renewal_cache\n\n        _token_renewal_cache.clear()\n\n        user1 = \"user1\"\n        user2 = \"user2\"\n\n        current_time = time.time()\n\n        # User1 gets renewal\n        _token_renewal_cache[user1] = current_time\n\n        # User2 should still be able to get renewal (independent cache)\n        last_renewal_user2 = _token_renewal_cache.get(user2, 0)\n        assert last_renewal_user2 == 0  # No previous renewal\n\n        # User2 can renew\n        _token_renewal_cache[user2] = current_time\n\n        # Both users should have entries\n        assert user1 in _token_renewal_cache\n        assert user2 in _token_renewal_cache\n        assert _token_renewal_cache[user1] == _token_renewal_cache[user2]\n\n\n@pytest.mark.offline\nclass TestTokenExpirationCalculation:\n    \"\"\"Tests for token expiration time calculation\"\"\"\n\n    def test_expiration_extraction_from_jwt(self):\n        \"\"\"Test extracting expiration time from JWT token\"\"\"\n        import base64\n        import json\n\n        # Create a mock JWT payload\n        exp_timestamp = int(\n            (datetime.now(timezone.utc) + timedelta(hours=24)).timestamp()\n        )\n        payload = {\"sub\": \"testuser\", \"role\": \"user\", \"exp\": exp_timestamp}\n\n        # Encode as base64 (simulating JWT structure: header.payload.signature)\n        payload_b64 = base64.b64encode(json.dumps(payload).encode()).decode()\n        mock_token = f\"header.{payload_b64}.signature\"\n\n        # Simulate extraction\n        parts = mock_token.split(\".\")\n        assert len(parts) == 3\n\n        decoded_payload = json.loads(base64.b64decode(parts[1]))\n        assert decoded_payload[\"exp\"] == exp_timestamp\n        assert decoded_payload[\"sub\"] == \"testuser\"\n\n    def test_remaining_time_calculation(self):\n        \"\"\"Test calculation of remaining token time\"\"\"\n        # Token expires in 10 hours\n        exp_time = datetime.now(timezone.utc) + timedelta(hours=10)\n        now = datetime.now(timezone.utc)\n\n        remaining_seconds = (exp_time - now).total_seconds()\n\n        # Should be approximately 10 hours (36000 seconds)\n        assert 35990 < remaining_seconds < 36010\n\n        # Calculate percentage remaining (for 24-hour token)\n        total_seconds = 24 * 3600\n        percentage_remaining = remaining_seconds / total_seconds\n\n        # Should be approximately 41.67% remaining\n        assert 0.41 < percentage_remaining < 0.42\n\n    def test_threshold_comparison(self):\n        \"\"\"Test threshold-based renewal decision\"\"\"\n        threshold = 0.5\n        total_hours = 24\n        total_seconds = total_hours * 3600\n\n        # Scenario 1: 10 hours remaining -> should renew\n        remaining_seconds_1 = 10 * 3600\n        should_renew_1 = remaining_seconds_1 < total_seconds * threshold\n        assert should_renew_1 is True\n\n        # Scenario 2: 20 hours remaining -> should NOT renew\n        remaining_seconds_2 = 20 * 3600\n        should_renew_2 = remaining_seconds_2 < total_seconds * threshold\n        assert should_renew_2 is False\n\n        # Scenario 3: Exactly 12 hours remaining (at threshold) -> should NOT renew\n        remaining_seconds_3 = 12 * 3600\n        should_renew_3 = remaining_seconds_3 < total_seconds * threshold\n        assert should_renew_3 is False\n\n\n@pytest.mark.offline\ndef test_renewal_cache_cleanup():\n    \"\"\"Test that renewal cache can be cleared\"\"\"\n    # Use global cache\n    global _token_renewal_cache\n\n    # Clear first\n    _token_renewal_cache.clear()\n\n    # Add some entries\n    _token_renewal_cache[\"user1\"] = time.time()\n    _token_renewal_cache[\"user2\"] = time.time()\n\n    assert len(_token_renewal_cache) == 2\n\n    # Clear cache\n    _token_renewal_cache.clear()\n\n    assert len(_token_renewal_cache) == 0\n\n\nif __name__ == \"__main__\":\n    pytest.main([__file__, \"-v\", \"--tb=short\"])\n"
  },
  {
    "path": "tests/test_unified_lock_safety.py",
    "content": "\"\"\"\nTests for UnifiedLock safety when lock is None.\n\nThis test module verifies that get_internal_lock() and get_data_init_lock()\nraise RuntimeError when shared data is not initialized, preventing false\nsecurity and potential race conditions.\n\nDesign: The None check has been moved from UnifiedLock.__aenter__/__enter__\nto the lock factory functions (get_internal_lock, get_data_init_lock) for\nearly failure detection.\n\nCritical Bug 1 (Fixed): When self._lock is None, the code would fail with\nAttributeError. Now the check is in factory functions for clearer errors.\n\nCritical Bug 2: In __aexit__, when async_lock.release() fails, the error\nrecovery logic would attempt to release it again, causing double-release issues.\n\"\"\"\n\nfrom unittest.mock import MagicMock, AsyncMock\n\nimport pytest\n\nfrom lightrag.kg.shared_storage import (\n    UnifiedLock,\n    get_internal_lock,\n    get_data_init_lock,\n    finalize_share_data,\n)\n\n\nclass TestUnifiedLockSafety:\n    \"\"\"Test suite for UnifiedLock None safety checks.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Ensure shared data is finalized before each test.\"\"\"\n        finalize_share_data()\n\n    def teardown_method(self):\n        \"\"\"Clean up after each test.\"\"\"\n        finalize_share_data()\n\n    def test_get_internal_lock_raises_when_not_initialized(self):\n        \"\"\"\n        Test that get_internal_lock() raises RuntimeError when shared data is not initialized.\n\n        Scenario: Call get_internal_lock() before initialize_share_data() is called.\n        Expected: RuntimeError raised with clear error message.\n\n        This test verifies the None check has been moved to the factory function.\n        \"\"\"\n        with pytest.raises(\n            RuntimeError, match=\"Shared data not initialized.*initialize_share_data\"\n        ):\n            get_internal_lock()\n\n    def test_get_data_init_lock_raises_when_not_initialized(self):\n        \"\"\"\n        Test that get_data_init_lock() raises RuntimeError when shared data is not initialized.\n\n        Scenario: Call get_data_init_lock() before initialize_share_data() is called.\n        Expected: RuntimeError raised with clear error message.\n\n        This test verifies the None check has been moved to the factory function.\n        \"\"\"\n        with pytest.raises(\n            RuntimeError, match=\"Shared data not initialized.*initialize_share_data\"\n        ):\n            get_data_init_lock()\n\n    @pytest.mark.offline\n    async def test_aexit_no_double_release_on_async_lock_failure(self):\n        \"\"\"\n        Test that __aexit__ doesn't attempt to release async_lock twice when it fails.\n\n        Scenario: async_lock.release() fails during normal release.\n        Expected: Recovery logic should NOT attempt to release async_lock again,\n                  preventing double-release issues.\n\n        This tests Bug 2 fix: async_lock_released tracking prevents double release.\n        \"\"\"\n        # Create mock locks\n        main_lock = MagicMock()\n        main_lock.acquire = MagicMock()\n        main_lock.release = MagicMock()\n\n        async_lock = AsyncMock()\n        async_lock.acquire = AsyncMock()\n\n        # Make async_lock.release() fail\n        release_call_count = 0\n\n        def mock_release_fail():\n            nonlocal release_call_count\n            release_call_count += 1\n            raise RuntimeError(\"Async lock release failed\")\n\n        async_lock.release = MagicMock(side_effect=mock_release_fail)\n\n        # Create UnifiedLock with both locks (sync mode with async_lock)\n        lock = UnifiedLock(\n            lock=main_lock,\n            is_async=False,\n            name=\"test_double_release\",\n            enable_logging=False,\n        )\n        lock._async_lock = async_lock\n\n        # Try to use the lock - should fail during __aexit__\n        try:\n            async with lock:\n                pass\n        except RuntimeError as e:\n            # Should get the async lock release error\n            assert \"Async lock release failed\" in str(e)\n\n        # Verify async_lock.release() was called only ONCE, not twice\n        assert (\n            release_call_count == 1\n        ), f\"async_lock.release() should be called only once, but was called {release_call_count} times\"\n\n        # Main lock should have been released successfully\n        main_lock.release.assert_called_once()\n"
  },
  {
    "path": "tests/test_workspace_isolation.py",
    "content": "#!/usr/bin/env python\n\"\"\"\nTest script for Workspace Isolation Feature\n\nComprehensive test suite covering workspace isolation in LightRAG:\n1. Pipeline Status Isolation - Data isolation between workspaces\n2. Lock Mechanism - Parallel execution for different workspaces, serial for same workspace\n3. Backward Compatibility - Legacy code without workspace parameters\n4. Multi-Workspace Concurrency - Concurrent operations on different workspaces\n5. NamespaceLock Re-entrance Protection - Prevents deadlocks\n6. Different Namespace Lock Isolation - Locks isolated by namespace\n7. Error Handling - Invalid workspace configurations\n8. Update Flags Workspace Isolation - Update flags properly isolated\n9. Empty Workspace Standardization - Empty workspace handling\n10. JsonKVStorage Workspace Isolation - Integration test for KV storage\n11. LightRAG End-to-End Workspace Isolation - Complete E2E test with two instances\n\nTotal: 11 test scenarios\n\"\"\"\n\nimport asyncio\nimport time\nimport os\nimport shutil\nimport numpy as np\nimport pytest\nfrom pathlib import Path\nfrom typing import List, Tuple, Dict\nfrom lightrag.kg.shared_storage import (\n    get_final_namespace,\n    get_namespace_lock,\n    get_default_workspace,\n    set_default_workspace,\n    initialize_share_data,\n    finalize_share_data,\n    initialize_pipeline_status,\n    get_namespace_data,\n    set_all_update_flags,\n    clear_all_update_flags,\n    get_all_update_flags_status,\n    get_update_flag,\n)\n\n\n# =============================================================================\n# Test Configuration\n# =============================================================================\n\n# Test configuration is handled via pytest fixtures in conftest.py\n# - Use CLI options: --keep-artifacts, --stress-test, --test-workers=N\n# - Or environment variables: LIGHTRAG_KEEP_ARTIFACTS, LIGHTRAG_STRESS_TEST, LIGHTRAG_TEST_WORKERS\n# Priority: CLI options > Environment variables > Default values\n\n\n# =============================================================================\n# Pytest Fixtures\n# =============================================================================\n\n\n@pytest.fixture(autouse=True)\ndef setup_shared_data():\n    \"\"\"Initialize shared data before each test\"\"\"\n    initialize_share_data()\n    yield\n    finalize_share_data()\n\n\nasync def _measure_lock_parallelism(\n    workload: List[Tuple[str, str, str]], hold_time: float = 0.05\n) -> Tuple[int, List[Tuple[str, str]], Dict[str, float]]:\n    \"\"\"Run lock acquisition workload and capture peak concurrency and timeline.\n\n    Args:\n        workload: List of (name, workspace, namespace) tuples\n        hold_time: How long each worker holds the lock (seconds)\n\n    Returns:\n        Tuple of (max_parallel, timeline, metrics) where:\n        - max_parallel: Peak number of concurrent lock holders\n        - timeline: List of (name, event) tuples tracking execution order\n        - metrics: Dict with performance metrics (total_duration, max_concurrency, etc.)\n    \"\"\"\n\n    running = 0\n    max_parallel = 0\n    timeline: List[Tuple[str, str]] = []\n    start_time = time.time()\n\n    async def worker(name: str, workspace: str, namespace: str) -> None:\n        nonlocal running, max_parallel\n        lock = get_namespace_lock(namespace, workspace)\n        async with lock:\n            running += 1\n            max_parallel = max(max_parallel, running)\n            timeline.append((name, \"start\"))\n            await asyncio.sleep(hold_time)\n            timeline.append((name, \"end\"))\n            running -= 1\n\n    await asyncio.gather(*(worker(*args) for args in workload))\n\n    metrics = {\n        \"total_duration\": time.time() - start_time,\n        \"max_concurrency\": max_parallel,\n        \"avg_hold_time\": hold_time,\n        \"num_workers\": len(workload),\n    }\n\n    return max_parallel, timeline, metrics\n\n\ndef _assert_no_timeline_overlap(timeline: List[Tuple[str, str]]) -> None:\n    \"\"\"Ensure that timeline events never overlap for sequential execution.\n\n    This function implements a finite state machine that validates:\n    - No overlapping lock acquisitions (only one task active at a time)\n    - Proper lock release order (task releases its own lock)\n    - All locks are properly released\n\n    Args:\n        timeline: List of (name, event) tuples where event is \"start\" or \"end\"\n\n    Raises:\n        AssertionError: If timeline shows overlapping execution or improper locking\n    \"\"\"\n\n    active_task = None\n    for name, event in timeline:\n        if event == \"start\":\n            if active_task is not None:\n                raise AssertionError(\n                    f\"Task '{name}' started before '{active_task}' released the lock\"\n                )\n            active_task = name\n        else:\n            if active_task != name:\n                raise AssertionError(\n                    f\"Task '{name}' finished while '{active_task}' was expected to hold the lock\"\n                )\n            active_task = None\n\n    if active_task is not None:\n        raise AssertionError(f\"Task '{active_task}' did not release the lock properly\")\n\n\n# =============================================================================\n# Test 1: Pipeline Status Isolation Test\n# =============================================================================\n\n\n@pytest.mark.offline\nasync def test_pipeline_status_isolation():\n    \"\"\"\n    Test that pipeline status is isolated between different workspaces.\n    \"\"\"\n    # Purpose: Ensure pipeline_status shared data remains unique per workspace.\n    # Scope: initialize_pipeline_status and get_namespace_data interactions.\n    print(\"\\n\" + \"=\" * 60)\n    print(\"TEST 1: Pipeline Status Isolation\")\n    print(\"=\" * 60)\n\n    # Initialize shared storage\n    initialize_share_data()\n\n    # Initialize pipeline status for two different workspaces\n    workspace1 = \"test_workspace_1\"\n    workspace2 = \"test_workspace_2\"\n\n    await initialize_pipeline_status(workspace1)\n    await initialize_pipeline_status(workspace2)\n\n    # Get pipeline status data for both workspaces\n    data1 = await get_namespace_data(\"pipeline_status\", workspace=workspace1)\n    data2 = await get_namespace_data(\"pipeline_status\", workspace=workspace2)\n\n    # Verify they are independent objects\n    assert (\n        data1 is not data2\n    ), \"Pipeline status data objects are the same (should be different)\"\n\n    # Modify workspace1's data and verify workspace2 is not affected\n    data1[\"test_key\"] = \"workspace1_value\"\n\n    # Re-fetch to ensure we get the latest data\n    data1_check = await get_namespace_data(\"pipeline_status\", workspace=workspace1)\n    data2_check = await get_namespace_data(\"pipeline_status\", workspace=workspace2)\n\n    assert \"test_key\" in data1_check, \"test_key not found in workspace1\"\n    assert (\n        data1_check[\"test_key\"] == \"workspace1_value\"\n    ), f\"workspace1 test_key value incorrect: {data1_check.get('test_key')}\"\n    assert (\n        \"test_key\" not in data2_check\n    ), f\"test_key leaked to workspace2: {data2_check.get('test_key')}\"\n\n    print(\"✅ PASSED: Pipeline Status Isolation\")\n    print(\"   Different workspaces have isolated pipeline status\")\n\n\n# =============================================================================\n# Test 2: Lock Mechanism Test (No Deadlocks)\n# =============================================================================\n\n\n@pytest.mark.offline\nasync def test_lock_mechanism(stress_test_mode, parallel_workers):\n    \"\"\"\n    Test that the new keyed lock mechanism works correctly without deadlocks.\n    Tests both parallel execution for different workspaces and serialization\n    for the same workspace.\n    \"\"\"\n    # Purpose: Validate that keyed locks isolate workspaces while serializing\n    # requests within the same workspace. Scope: get_namespace_lock scheduling\n    # semantics for both cross-workspace and single-workspace cases.\n    print(\"\\n\" + \"=\" * 60)\n    print(\"TEST 2: Lock Mechanism (No Deadlocks)\")\n    print(\"=\" * 60)\n\n    # Test 2.1: Different workspaces should run in parallel\n    print(\"\\nTest 2.1: Different workspaces locks should be parallel\")\n\n    # Support stress testing with configurable number of workers\n    num_workers = parallel_workers if stress_test_mode else 3\n    parallel_workload = [\n        (f\"ws_{chr(97 + i)}\", f\"ws_{chr(97 + i)}\", \"test_namespace\")\n        for i in range(num_workers)\n    ]\n\n    max_parallel, timeline_parallel, metrics = await _measure_lock_parallelism(\n        parallel_workload\n    )\n    assert max_parallel >= 2, (\n        \"Locks for distinct workspaces should overlap; \"\n        f\"observed max concurrency: {max_parallel}, timeline={timeline_parallel}\"\n    )\n\n    print(\"✅ PASSED: Lock Mechanism - Parallel (Different Workspaces)\")\n    print(\n        f\"   Locks overlapped for different workspaces (max concurrency={max_parallel})\"\n    )\n    print(\n        f\"   Performance: {metrics['total_duration']:.3f}s for {metrics['num_workers']} workers\"\n    )\n\n    # Test 2.2: Same workspace should serialize\n    print(\"\\nTest 2.2: Same workspace locks should serialize\")\n    serial_workload = [\n        (\"serial_run_1\", \"ws_same\", \"test_namespace\"),\n        (\"serial_run_2\", \"ws_same\", \"test_namespace\"),\n    ]\n    (\n        max_parallel_serial,\n        timeline_serial,\n        metrics_serial,\n    ) = await _measure_lock_parallelism(serial_workload)\n    assert max_parallel_serial == 1, (\n        \"Same workspace locks should not overlap; \"\n        f\"observed {max_parallel_serial} with timeline {timeline_serial}\"\n    )\n    _assert_no_timeline_overlap(timeline_serial)\n\n    print(\"✅ PASSED: Lock Mechanism - Serial (Same Workspace)\")\n    print(\"   Same workspace operations executed sequentially with no overlap\")\n    print(\n        f\"   Performance: {metrics_serial['total_duration']:.3f}s for {metrics_serial['num_workers']} tasks\"\n    )\n\n\n# =============================================================================\n# Test 3: Backward Compatibility Test\n# =============================================================================\n\n\n@pytest.mark.offline\nasync def test_backward_compatibility():\n    \"\"\"\n    Test that legacy code without workspace parameter still works correctly.\n    \"\"\"\n    # Purpose: Validate backward-compatible defaults when workspace arguments\n    # are omitted. Scope: get_final_namespace, set/get_default_workspace and\n    # initialize_pipeline_status fallback behavior.\n    print(\"\\n\" + \"=\" * 60)\n    print(\"TEST 3: Backward Compatibility\")\n    print(\"=\" * 60)\n\n    # Test 3.1: get_final_namespace with None should use default workspace\n    print(\"\\nTest 3.1: get_final_namespace with workspace=None\")\n\n    set_default_workspace(\"my_default_workspace\")\n    final_ns = get_final_namespace(\"pipeline_status\")\n    expected = \"my_default_workspace:pipeline_status\"\n\n    assert final_ns == expected, f\"Expected {expected}, got {final_ns}\"\n\n    print(\"✅ PASSED: Backward Compatibility - get_final_namespace\")\n    print(f\"   Correctly uses default workspace: {final_ns}\")\n\n    # Test 3.2: get_default_workspace\n    print(\"\\nTest 3.2: get/set default workspace\")\n\n    set_default_workspace(\"test_default\")\n    retrieved = get_default_workspace()\n\n    assert retrieved == \"test_default\", f\"Expected 'test_default', got {retrieved}\"\n\n    print(\"✅ PASSED: Backward Compatibility - default workspace\")\n    print(f\"   Default workspace set/get correctly: {retrieved}\")\n\n    # Test 3.3: Empty workspace handling\n    print(\"\\nTest 3.3: Empty workspace handling\")\n\n    set_default_workspace(\"\")\n    final_ns_empty = get_final_namespace(\"pipeline_status\", workspace=None)\n    expected_empty = \"pipeline_status\"  # Should be just the namespace without ':'\n\n    assert (\n        final_ns_empty == expected_empty\n    ), f\"Expected '{expected_empty}', got '{final_ns_empty}'\"\n\n    print(\"✅ PASSED: Backward Compatibility - empty workspace\")\n    print(f\"   Empty workspace handled correctly: '{final_ns_empty}'\")\n\n    # Test 3.4: None workspace with default set\n    print(\"\\nTest 3.4: initialize_pipeline_status with workspace=None\")\n    set_default_workspace(\"compat_test_workspace\")\n    initialize_share_data()\n    await initialize_pipeline_status(workspace=None)  # Should use default\n\n    # Try to get data using the default workspace explicitly\n    data = await get_namespace_data(\n        \"pipeline_status\", workspace=\"compat_test_workspace\"\n    )\n\n    assert (\n        data is not None\n    ), \"Failed to initialize pipeline status with default workspace\"\n\n    print(\"✅ PASSED: Backward Compatibility - pipeline init with None\")\n    print(\"   Pipeline status initialized with default workspace\")\n\n\n# =============================================================================\n# Test 4: Multi-Workspace Concurrency Test\n# =============================================================================\n\n\n@pytest.mark.offline\nasync def test_multi_workspace_concurrency():\n    \"\"\"\n    Test that multiple workspaces can operate concurrently without interference.\n    Simulates concurrent operations on different workspaces.\n    \"\"\"\n    # Purpose: Simulate concurrent workloads touching pipeline_status across\n    # workspaces. Scope: initialize_pipeline_status, get_namespace_lock, and\n    # shared dictionary mutation while ensuring isolation.\n    print(\"\\n\" + \"=\" * 60)\n    print(\"TEST 4: Multi-Workspace Concurrency\")\n    print(\"=\" * 60)\n\n    initialize_share_data()\n\n    async def workspace_operations(workspace_id):\n        \"\"\"Simulate operations on a specific workspace\"\"\"\n        print(f\"\\n   [{workspace_id}] Starting operations\")\n\n        # Initialize pipeline status\n        await initialize_pipeline_status(workspace_id)\n\n        # Get lock and perform operations\n        lock = get_namespace_lock(\"test_operations\", workspace_id)\n        async with lock:\n            # Get workspace data\n            data = await get_namespace_data(\"pipeline_status\", workspace=workspace_id)\n\n            # Modify data\n            data[f\"{workspace_id}_key\"] = f\"{workspace_id}_value\"\n            data[\"timestamp\"] = time.time()\n\n            # Simulate some work\n            await asyncio.sleep(0.1)\n\n            print(f\"   [{workspace_id}] Completed operations\")\n\n        return workspace_id\n\n    # Run multiple workspaces concurrently\n    workspaces = [\"concurrent_ws_1\", \"concurrent_ws_2\", \"concurrent_ws_3\"]\n\n    start = time.time()\n    results_list = await asyncio.gather(\n        *[workspace_operations(ws) for ws in workspaces]\n    )\n    elapsed = time.time() - start\n\n    print(f\"\\n   All workspaces completed in {elapsed:.2f}s\")\n\n    # Verify all workspaces completed\n    assert set(results_list) == set(workspaces), \"Not all workspaces completed\"\n\n    print(\"✅ PASSED: Multi-Workspace Concurrency - Execution\")\n    print(\n        f\"   All {len(workspaces)} workspaces completed successfully in {elapsed:.2f}s\"\n    )\n\n    # Verify data isolation - each workspace should have its own data\n    print(\"\\n   Verifying data isolation...\")\n\n    for ws in workspaces:\n        data = await get_namespace_data(\"pipeline_status\", workspace=ws)\n        expected_key = f\"{ws}_key\"\n        expected_value = f\"{ws}_value\"\n\n        assert (\n            expected_key in data\n        ), f\"Data not properly isolated for {ws}: missing {expected_key}\"\n        assert (\n            data[expected_key] == expected_value\n        ), f\"Data not properly isolated for {ws}: {expected_key}={data[expected_key]} (expected {expected_value})\"\n        print(f\"   [{ws}] Data correctly isolated: {expected_key}={data[expected_key]}\")\n\n    print(\"✅ PASSED: Multi-Workspace Concurrency - Data Isolation\")\n    print(\"   All workspaces have properly isolated data\")\n\n\n# =============================================================================\n# Test 5: NamespaceLock Re-entrance Protection\n# =============================================================================\n\n\n@pytest.mark.offline\nasync def test_namespace_lock_reentrance():\n    \"\"\"\n    Test that NamespaceLock prevents re-entrance in the same coroutine\n    and allows concurrent use in different coroutines.\n    \"\"\"\n    # Purpose: Ensure NamespaceLock enforces single entry per coroutine while\n    # allowing concurrent reuse through ContextVar isolation. Scope: lock\n    # re-entrance checks and concurrent gather semantics.\n    print(\"\\n\" + \"=\" * 60)\n    print(\"TEST 5: NamespaceLock Re-entrance Protection\")\n    print(\"=\" * 60)\n\n    # Test 5.1: Same coroutine re-entrance should fail\n    print(\"\\nTest 5.1: Same coroutine re-entrance should raise RuntimeError\")\n\n    lock = get_namespace_lock(\"test_reentrance\", \"test_ws\")\n\n    reentrance_failed_correctly = False\n    try:\n        async with lock:\n            print(\"   Acquired lock first time\")\n            # Try to acquire the same lock again in the same coroutine\n            async with lock:\n                print(\"   ERROR: Should not reach here - re-entrance succeeded!\")\n    except RuntimeError as e:\n        if \"already acquired\" in str(e).lower():\n            print(f\"   ✓ Re-entrance correctly blocked: {e}\")\n            reentrance_failed_correctly = True\n        else:\n            raise\n\n    assert reentrance_failed_correctly, \"Re-entrance protection not working\"\n\n    print(\"✅ PASSED: NamespaceLock Re-entrance Protection\")\n    print(\"   Re-entrance correctly raises RuntimeError\")\n\n    # Test 5.2: Same NamespaceLock instance in different coroutines should succeed\n    print(\"\\nTest 5.2: Same NamespaceLock instance in different coroutines\")\n\n    shared_lock = get_namespace_lock(\"test_concurrent\", \"test_ws\")\n    concurrent_results = []\n\n    async def use_shared_lock(coroutine_id):\n        \"\"\"Use the same NamespaceLock instance\"\"\"\n        async with shared_lock:\n            concurrent_results.append(f\"coroutine_{coroutine_id}_start\")\n            await asyncio.sleep(0.1)\n            concurrent_results.append(f\"coroutine_{coroutine_id}_end\")\n\n    # This should work because each coroutine gets its own ContextVar\n    await asyncio.gather(\n        use_shared_lock(1),\n        use_shared_lock(2),\n    )\n\n    # Both coroutines should have completed\n    expected_entries = 4  # 2 starts + 2 ends\n    assert (\n        len(concurrent_results) == expected_entries\n    ), f\"Expected {expected_entries} entries, got {len(concurrent_results)}\"\n\n    print(\"✅ PASSED: NamespaceLock Concurrent Reuse\")\n    print(\n        f\"   Same NamespaceLock instance used successfully in {expected_entries // 2} concurrent coroutines\"\n    )\n\n\n# =============================================================================\n# Test 6: Different Namespace Lock Isolation\n# =============================================================================\n\n\n@pytest.mark.offline\nasync def test_different_namespace_lock_isolation():\n    \"\"\"\n    Test that locks for different namespaces (same workspace) are independent.\n    \"\"\"\n    # Purpose: Confirm that namespace isolation is enforced even when workspace\n    # is the same. Scope: get_namespace_lock behavior when namespaces differ.\n    print(\"\\n\" + \"=\" * 60)\n    print(\"TEST 6: Different Namespace Lock Isolation\")\n    print(\"=\" * 60)\n\n    print(\"\\nTesting locks with same workspace but different namespaces\")\n\n    workload = [\n        (\"ns_a\", \"same_ws\", \"namespace_a\"),\n        (\"ns_b\", \"same_ws\", \"namespace_b\"),\n        (\"ns_c\", \"same_ws\", \"namespace_c\"),\n    ]\n    max_parallel, timeline, metrics = await _measure_lock_parallelism(workload)\n\n    assert max_parallel >= 2, (\n        \"Different namespaces within the same workspace should run concurrently; \"\n        f\"observed max concurrency {max_parallel} with timeline {timeline}\"\n    )\n\n    print(\"✅ PASSED: Different Namespace Lock Isolation\")\n    print(\n        f\"   Different namespace locks ran in parallel (max concurrency={max_parallel})\"\n    )\n    print(\n        f\"   Performance: {metrics['total_duration']:.3f}s for {metrics['num_workers']} namespaces\"\n    )\n\n\n# =============================================================================\n# Test 7: Error Handling\n# =============================================================================\n\n\n@pytest.mark.offline\nasync def test_error_handling():\n    \"\"\"\n    Test error handling for invalid workspace configurations.\n    \"\"\"\n    # Purpose: Validate guardrails for workspace normalization and namespace\n    # derivation. Scope: set_default_workspace conversions and get_final_namespace\n    # failure paths when configuration is invalid.\n    print(\"\\n\" + \"=\" * 60)\n    print(\"TEST 7: Error Handling\")\n    print(\"=\" * 60)\n\n    # Test 7.0: Missing default workspace should raise ValueError\n    print(\"\\nTest 7.0: Missing workspace raises ValueError\")\n    with pytest.raises(ValueError):\n        get_final_namespace(\"test_namespace\", workspace=None)\n\n    # Test 7.1: set_default_workspace(None) converts to empty string\n    print(\"\\nTest 7.1: set_default_workspace(None) converts to empty string\")\n\n    set_default_workspace(None)\n    default_ws = get_default_workspace()\n\n    # Should convert None to \"\" automatically\n    assert default_ws == \"\", f\"Expected empty string, got: '{default_ws}'\"\n\n    print(\"✅ PASSED: Error Handling - None to Empty String\")\n    print(\n        f\"   set_default_workspace(None) correctly converts to empty string: '{default_ws}'\"\n    )\n\n    # Test 7.2: Empty string workspace behavior\n    print(\"\\nTest 7.2: Empty string workspace creates valid namespace\")\n\n    # With empty workspace, should create namespace without colon\n    final_ns = get_final_namespace(\"test_namespace\", workspace=\"\")\n    assert final_ns == \"test_namespace\", f\"Unexpected namespace: '{final_ns}'\"\n\n    print(\"✅ PASSED: Error Handling - Empty Workspace Namespace\")\n    print(f\"   Empty workspace creates valid namespace: '{final_ns}'\")\n\n    # Restore default workspace for other tests\n    set_default_workspace(\"\")\n\n\n# =============================================================================\n# Test 8: Update Flags Workspace Isolation\n# =============================================================================\n\n\n@pytest.mark.offline\nasync def test_update_flags_workspace_isolation():\n    \"\"\"\n    Test that update flags are properly isolated between workspaces.\n    \"\"\"\n    # Purpose: Confirm update flag setters/readers respect workspace scoping.\n    # Scope: set_all_update_flags, clear_all_update_flags, get_all_update_flags_status,\n    # and get_update_flag interactions across namespaces.\n    print(\"\\n\" + \"=\" * 60)\n    print(\"TEST 8: Update Flags Workspace Isolation\")\n    print(\"=\" * 60)\n\n    initialize_share_data()\n\n    workspace1 = \"update_flags_ws1\"\n    workspace2 = \"update_flags_ws2\"\n    test_namespace = \"test_update_flags_ns\"\n\n    # Initialize namespaces for both workspaces\n    await initialize_pipeline_status(workspace1)\n    await initialize_pipeline_status(workspace2)\n\n    # Test 8.1: set_all_update_flags isolation\n    print(\"\\nTest 8.1: set_all_update_flags workspace isolation\")\n\n    # Create flags for both workspaces (simulating workers)\n    flag1_obj = await get_update_flag(test_namespace, workspace=workspace1)\n    flag2_obj = await get_update_flag(test_namespace, workspace=workspace2)\n\n    # Initial state should be False\n    assert flag1_obj.value is False, \"Flag1 initial value should be False\"\n    assert flag2_obj.value is False, \"Flag2 initial value should be False\"\n\n    # Set all flags for workspace1\n    await set_all_update_flags(test_namespace, workspace=workspace1)\n\n    # Check that only workspace1's flags are set\n    assert (\n        flag1_obj.value is True\n    ), f\"Flag1 should be True after set_all_update_flags, got {flag1_obj.value}\"\n    assert (\n        flag2_obj.value is False\n    ), f\"Flag2 should still be False, got {flag2_obj.value}\"\n\n    print(\"✅ PASSED: Update Flags - set_all_update_flags Isolation\")\n    print(\n        f\"   set_all_update_flags isolated: ws1={flag1_obj.value}, ws2={flag2_obj.value}\"\n    )\n\n    # Test 8.2: clear_all_update_flags isolation\n    print(\"\\nTest 8.2: clear_all_update_flags workspace isolation\")\n\n    # Set flags for both workspaces\n    await set_all_update_flags(test_namespace, workspace=workspace1)\n    await set_all_update_flags(test_namespace, workspace=workspace2)\n\n    # Verify both are set\n    assert flag1_obj.value is True, \"Flag1 should be True\"\n    assert flag2_obj.value is True, \"Flag2 should be True\"\n\n    # Clear only workspace1\n    await clear_all_update_flags(test_namespace, workspace=workspace1)\n\n    # Check that only workspace1's flags are cleared\n    assert (\n        flag1_obj.value is False\n    ), f\"Flag1 should be False after clear, got {flag1_obj.value}\"\n    assert flag2_obj.value is True, f\"Flag2 should still be True, got {flag2_obj.value}\"\n\n    print(\"✅ PASSED: Update Flags - clear_all_update_flags Isolation\")\n    print(\n        f\"   clear_all_update_flags isolated: ws1={flag1_obj.value}, ws2={flag2_obj.value}\"\n    )\n\n    # Test 8.3: get_all_update_flags_status workspace filtering\n    print(\"\\nTest 8.3: get_all_update_flags_status workspace filtering\")\n\n    # Initialize more namespaces for testing\n    await get_update_flag(\"ns_a\", workspace=workspace1)\n    await get_update_flag(\"ns_b\", workspace=workspace1)\n    await get_update_flag(\"ns_c\", workspace=workspace2)\n\n    # Set flags for workspace1\n    await set_all_update_flags(\"ns_a\", workspace=workspace1)\n    await set_all_update_flags(\"ns_b\", workspace=workspace1)\n\n    # Set flags for workspace2\n    await set_all_update_flags(\"ns_c\", workspace=workspace2)\n\n    # Get status for workspace1 only\n    status1 = await get_all_update_flags_status(workspace=workspace1)\n\n    # Check that workspace1's namespaces are present\n    # The keys should include workspace1's namespaces but not workspace2's\n    workspace1_keys = [k for k in status1.keys() if workspace1 in k]\n    workspace2_keys = [k for k in status1.keys() if workspace2 in k]\n\n    assert (\n        len(workspace1_keys) > 0\n    ), f\"workspace1 keys should be present, got {len(workspace1_keys)}\"\n    assert (\n        len(workspace2_keys) == 0\n    ), f\"workspace2 keys should not be present, got {len(workspace2_keys)}\"\n    for key, values in status1.items():\n        assert all(values), f\"All flags in {key} should be True, got {values}\"\n\n    # Workspace2 query should only surface workspace2 namespaces\n    status2 = await get_all_update_flags_status(workspace=workspace2)\n    expected_ws2_keys = {\n        f\"{workspace2}:{test_namespace}\",\n        f\"{workspace2}:ns_c\",\n    }\n    assert (\n        set(status2.keys()) == expected_ws2_keys\n    ), f\"Unexpected namespaces for workspace2: {status2.keys()}\"\n    for key, values in status2.items():\n        assert all(values), f\"All flags in {key} should be True, got {values}\"\n\n    print(\"✅ PASSED: Update Flags - get_all_update_flags_status Filtering\")\n    print(\n        f\"   Status correctly filtered: ws1 keys={len(workspace1_keys)}, ws2 keys={len(workspace2_keys)}\"\n    )\n\n\n# =============================================================================\n# Test 9: Empty Workspace Standardization\n# =============================================================================\n\n\n@pytest.mark.offline\nasync def test_empty_workspace_standardization():\n    \"\"\"\n    Test that empty workspace is properly standardized to \"\" instead of \"_\".\n    \"\"\"\n    # Purpose: Verify namespace formatting when workspace is an empty string.\n    # Scope: get_final_namespace output and initialize_pipeline_status behavior\n    # between empty and non-empty workspaces.\n    print(\"\\n\" + \"=\" * 60)\n    print(\"TEST 9: Empty Workspace Standardization\")\n    print(\"=\" * 60)\n\n    # Test 9.1: Empty string workspace creates namespace without colon\n    print(\"\\nTest 9.1: Empty string workspace namespace format\")\n\n    set_default_workspace(\"\")\n    final_ns = get_final_namespace(\"test_namespace\", workspace=None)\n\n    # Should be just \"test_namespace\" without colon prefix\n    assert (\n        final_ns == \"test_namespace\"\n    ), f\"Unexpected namespace format: '{final_ns}' (expected 'test_namespace')\"\n\n    print(\"✅ PASSED: Empty Workspace Standardization - Format\")\n    print(f\"   Empty workspace creates correct namespace: '{final_ns}'\")\n\n    # Test 9.2: Empty workspace vs non-empty workspace behavior\n    print(\"\\nTest 9.2: Empty vs non-empty workspace behavior\")\n\n    initialize_share_data()\n\n    # Initialize with empty workspace\n    await initialize_pipeline_status(workspace=\"\")\n    data_empty = await get_namespace_data(\"pipeline_status\", workspace=\"\")\n\n    # Initialize with non-empty workspace\n    await initialize_pipeline_status(workspace=\"test_ws\")\n    data_nonempty = await get_namespace_data(\"pipeline_status\", workspace=\"test_ws\")\n\n    # They should be different objects\n    assert (\n        data_empty is not data_nonempty\n    ), \"Empty and non-empty workspaces share data (should be independent)\"\n\n    print(\"✅ PASSED: Empty Workspace Standardization - Behavior\")\n    print(\"   Empty and non-empty workspaces have independent data\")\n\n\n# =============================================================================\n# Test 10: JsonKVStorage Workspace Isolation (Integration Test)\n# =============================================================================\n\n\n@pytest.mark.offline\nasync def test_json_kv_storage_workspace_isolation(keep_test_artifacts):\n    \"\"\"\n    Integration test: Verify JsonKVStorage properly isolates data between workspaces.\n    Creates two JsonKVStorage instances with different workspaces, writes different data,\n    and verifies they don't mix.\n    \"\"\"\n    # Purpose: Ensure JsonKVStorage respects workspace-specific directories and data.\n    # Scope: storage initialization, upsert/get_by_id operations, and filesystem layout\n    # inside the temporary working directory.\n    print(\"\\n\" + \"=\" * 60)\n    print(\"TEST 10: JsonKVStorage Workspace Isolation (Integration)\")\n    print(\"=\" * 60)\n\n    # Create temporary test directory under project temp/\n    test_dir = str(\n        Path(__file__).parent.parent / \"temp/test_json_kv_storage_workspace_isolation\"\n    )\n    if os.path.exists(test_dir):\n        shutil.rmtree(test_dir)\n    os.makedirs(test_dir, exist_ok=True)\n    print(f\"\\n   Using test directory: {test_dir}\")\n\n    try:\n        initialize_share_data()\n\n        # Mock embedding function\n        async def mock_embedding_func(texts: list[str]) -> np.ndarray:\n            return np.random.rand(len(texts), 384)  # 384-dimensional vectors\n\n        # Global config\n        global_config = {\n            \"working_dir\": test_dir,\n            \"embedding_batch_num\": 10,\n        }\n\n        # Test 10.1: Create two JsonKVStorage instances with different workspaces\n        print(\n            \"\\nTest 10.1: Create two JsonKVStorage instances with different workspaces\"\n        )\n\n        from lightrag.kg.json_kv_impl import JsonKVStorage\n\n        storage1 = JsonKVStorage(\n            namespace=\"entities\",\n            workspace=\"workspace1\",\n            global_config=global_config,\n            embedding_func=mock_embedding_func,\n        )\n\n        storage2 = JsonKVStorage(\n            namespace=\"entities\",\n            workspace=\"workspace2\",\n            global_config=global_config,\n            embedding_func=mock_embedding_func,\n        )\n\n        # Initialize both storages\n        await storage1.initialize()\n        await storage2.initialize()\n\n        print(\"   Storage1 created: workspace=workspace1, namespace=entities\")\n        print(\"   Storage2 created: workspace=workspace2, namespace=entities\")\n\n        # Test 10.2: Write different data to each storage\n        print(\"\\nTest 10.2: Write different data to each storage\")\n\n        # Write to storage1 (upsert expects dict[str, dict])\n        await storage1.upsert(\n            {\n                \"entity1\": {\n                    \"content\": \"Data from workspace1 - AI Research\",\n                    \"type\": \"entity\",\n                },\n                \"entity2\": {\n                    \"content\": \"Data from workspace1 - Machine Learning\",\n                    \"type\": \"entity\",\n                },\n            }\n        )\n        print(\"   Written to storage1: entity1, entity2\")\n        # Persist data to disk\n        await storage1.index_done_callback()\n        print(\"   Persisted storage1 data to disk\")\n\n        # Write to storage2\n        await storage2.upsert(\n            {\n                \"entity1\": {\n                    \"content\": \"Data from workspace2 - Deep Learning\",\n                    \"type\": \"entity\",\n                },\n                \"entity2\": {\n                    \"content\": \"Data from workspace2 - Neural Networks\",\n                    \"type\": \"entity\",\n                },\n            }\n        )\n        print(\"   Written to storage2: entity1, entity2\")\n        # Persist data to disk\n        await storage2.index_done_callback()\n        print(\"   Persisted storage2 data to disk\")\n\n        # Test 10.3: Read data from each storage and verify isolation\n        print(\"\\nTest 10.3: Read data and verify isolation\")\n\n        # Read from storage1\n        result1_entity1 = await storage1.get_by_id(\"entity1\")\n        result1_entity2 = await storage1.get_by_id(\"entity2\")\n\n        # Read from storage2\n        result2_entity1 = await storage2.get_by_id(\"entity1\")\n        result2_entity2 = await storage2.get_by_id(\"entity2\")\n\n        print(f\"   Storage1 entity1: {result1_entity1}\")\n        print(f\"   Storage1 entity2: {result1_entity2}\")\n        print(f\"   Storage2 entity1: {result2_entity1}\")\n        print(f\"   Storage2 entity2: {result2_entity2}\")\n\n        # Verify isolation (get_by_id returns dict)\n        assert result1_entity1 is not None, \"Storage1 entity1 should not be None\"\n        assert result1_entity2 is not None, \"Storage1 entity2 should not be None\"\n        assert result2_entity1 is not None, \"Storage2 entity1 should not be None\"\n        assert result2_entity2 is not None, \"Storage2 entity2 should not be None\"\n        assert (\n            result1_entity1.get(\"content\") == \"Data from workspace1 - AI Research\"\n        ), \"Storage1 entity1 content mismatch\"\n        assert (\n            result1_entity2.get(\"content\") == \"Data from workspace1 - Machine Learning\"\n        ), \"Storage1 entity2 content mismatch\"\n        assert (\n            result2_entity1.get(\"content\") == \"Data from workspace2 - Deep Learning\"\n        ), \"Storage2 entity1 content mismatch\"\n        assert (\n            result2_entity2.get(\"content\") == \"Data from workspace2 - Neural Networks\"\n        ), \"Storage2 entity2 content mismatch\"\n        assert result1_entity1.get(\"content\") != result2_entity1.get(\n            \"content\"\n        ), \"Storage1 and Storage2 entity1 should have different content\"\n        assert result1_entity2.get(\"content\") != result2_entity2.get(\n            \"content\"\n        ), \"Storage1 and Storage2 entity2 should have different content\"\n\n        print(\"✅ PASSED: JsonKVStorage - Data Isolation\")\n        print(\n            \"   Two storage instances correctly isolated: ws1 and ws2 have different data\"\n        )\n\n        # Test 10.4: Verify file structure\n        print(\"\\nTest 10.4: Verify file structure\")\n        ws1_dir = Path(test_dir) / \"workspace1\"\n        ws2_dir = Path(test_dir) / \"workspace2\"\n\n        ws1_exists = ws1_dir.exists()\n        ws2_exists = ws2_dir.exists()\n\n        print(f\"   workspace1 directory exists: {ws1_exists}\")\n        print(f\"   workspace2 directory exists: {ws2_exists}\")\n\n        assert ws1_exists, \"workspace1 directory should exist\"\n        assert ws2_exists, \"workspace2 directory should exist\"\n\n        print(\"✅ PASSED: JsonKVStorage - File Structure\")\n        print(f\"   Workspace directories correctly created: {ws1_dir} and {ws2_dir}\")\n\n    finally:\n        # Cleanup test directory (unless keep_test_artifacts is set)\n        if os.path.exists(test_dir) and not keep_test_artifacts:\n            shutil.rmtree(test_dir)\n            print(f\"\\n   Cleaned up test directory: {test_dir}\")\n        elif keep_test_artifacts:\n            print(f\"\\n   Kept test directory for inspection: {test_dir}\")\n\n\n# =============================================================================\n# Test 11: LightRAG End-to-End Integration Test\n# =============================================================================\n\n\n@pytest.mark.offline\nasync def test_lightrag_end_to_end_workspace_isolation(keep_test_artifacts):\n    \"\"\"\n    End-to-end test: Create two LightRAG instances with different workspaces,\n    insert different data, and verify file separation.\n    Uses mock LLM and embedding functions to avoid external API calls.\n    \"\"\"\n    # Purpose: Validate that full LightRAG flows keep artifacts scoped per workspace.\n    # Scope: LightRAG.initialize_storages + ainsert side effects plus filesystem\n    # verification for generated storage files.\n    print(\"\\n\" + \"=\" * 60)\n    print(\"TEST 11: LightRAG End-to-End Workspace Isolation\")\n    print(\"=\" * 60)\n\n    # Create temporary test directory under project temp/\n    test_dir = str(\n        Path(__file__).parent.parent\n        / \"temp/test_lightrag_end_to_end_workspace_isolation\"\n    )\n    if os.path.exists(test_dir):\n        shutil.rmtree(test_dir)\n    os.makedirs(test_dir, exist_ok=True)\n    print(f\"\\n   Using test directory: {test_dir}\")\n\n    try:\n        # Factory function to create different mock LLM functions for each workspace\n        def create_mock_llm_func(workspace_name):\n            \"\"\"Create a mock LLM function that returns different content based on workspace\"\"\"\n\n            async def mock_llm_func(\n                prompt, system_prompt=None, history_messages=[], **kwargs\n            ) -> str:\n                # Add coroutine switching to simulate async I/O and allow concurrent execution\n                await asyncio.sleep(0)\n\n                # Return different responses based on workspace\n                # Format: entity<|#|>entity_name<|#|>entity_type<|#|>entity_description\n                # Format: relation<|#|>source_entity<|#|>target_entity<|#|>keywords<|#|>description\n                if workspace_name == \"project_a\":\n                    return \"\"\"entity<|#|>Artificial Intelligence<|#|>concept<|#|>AI is a field of computer science focused on creating intelligent machines.\nentity<|#|>Machine Learning<|#|>concept<|#|>Machine Learning is a subset of AI that enables systems to learn from data.\nrelation<|#|>Machine Learning<|#|>Artificial Intelligence<|#|>subset, related field<|#|>Machine Learning is a key component and subset of Artificial Intelligence.\n<|COMPLETE|>\"\"\"\n                else:  # project_b\n                    return \"\"\"entity<|#|>Deep Learning<|#|>concept<|#|>Deep Learning is a subset of machine learning using neural networks with multiple layers.\nentity<|#|>Neural Networks<|#|>concept<|#|>Neural Networks are computing systems inspired by biological neural networks.\nrelation<|#|>Deep Learning<|#|>Neural Networks<|#|>uses, composed of<|#|>Deep Learning uses multiple layers of Neural Networks to learn representations.\n<|COMPLETE|>\"\"\"\n\n            return mock_llm_func\n\n        # Mock embedding function\n        async def mock_embedding_func(texts: list[str]) -> np.ndarray:\n            # Add coroutine switching to simulate async I/O and allow concurrent execution\n            await asyncio.sleep(0)\n            return np.random.rand(len(texts), 384)  # 384-dimensional vectors\n\n        # Test 11.1: Create two LightRAG instances with different workspaces\n        print(\"\\nTest 11.1: Create two LightRAG instances with different workspaces\")\n\n        from lightrag import LightRAG\n        from lightrag.utils import EmbeddingFunc, Tokenizer\n\n        # Create different mock LLM functions for each workspace\n        mock_llm_func_a = create_mock_llm_func(\"project_a\")\n        mock_llm_func_b = create_mock_llm_func(\"project_b\")\n\n        class _SimpleTokenizerImpl:\n            def encode(self, content: str) -> list[int]:\n                return [ord(ch) for ch in content]\n\n            def decode(self, tokens: list[int]) -> str:\n                return \"\".join(chr(t) for t in tokens)\n\n        tokenizer = Tokenizer(\"mock-tokenizer\", _SimpleTokenizerImpl())\n\n        rag1 = LightRAG(\n            working_dir=test_dir,\n            workspace=\"project_a\",\n            llm_model_func=mock_llm_func_a,\n            embedding_func=EmbeddingFunc(\n                embedding_dim=384,\n                max_token_size=8192,\n                func=mock_embedding_func,\n            ),\n            tokenizer=tokenizer,\n        )\n\n        rag2 = LightRAG(\n            working_dir=test_dir,\n            workspace=\"project_b\",\n            llm_model_func=mock_llm_func_b,\n            embedding_func=EmbeddingFunc(\n                embedding_dim=384,\n                max_token_size=8192,\n                func=mock_embedding_func,\n            ),\n            tokenizer=tokenizer,\n        )\n\n        # Initialize storages\n        await rag1.initialize_storages()\n        await rag2.initialize_storages()\n\n        print(\"   RAG1 created: workspace=project_a\")\n        print(\"   RAG2 created: workspace=project_b\")\n\n        # Test 11.2: Insert different data to each RAG instance (CONCURRENTLY)\n        print(\"\\nTest 11.2: Insert different data to each RAG instance (concurrently)\")\n\n        text_for_project_a = \"This document is about Artificial Intelligence and Machine Learning. AI is transforming the world.\"\n        text_for_project_b = \"This document is about Deep Learning and Neural Networks. Deep learning uses multiple layers.\"\n\n        # Insert to both projects concurrently to test workspace isolation under concurrent load\n        print(\"   Starting concurrent insert operations...\")\n        start_time = time.time()\n        await asyncio.gather(\n            rag1.ainsert(text_for_project_a), rag2.ainsert(text_for_project_b)\n        )\n        elapsed_time = time.time() - start_time\n\n        print(f\"   Inserted to project_a: {len(text_for_project_a)} chars (concurrent)\")\n        print(f\"   Inserted to project_b: {len(text_for_project_b)} chars (concurrent)\")\n        print(f\"   Total concurrent execution time: {elapsed_time:.3f}s\")\n\n        # Test 11.3: Verify file structure\n        print(\"\\nTest 11.3: Verify workspace directory structure\")\n\n        project_a_dir = Path(test_dir) / \"project_a\"\n        project_b_dir = Path(test_dir) / \"project_b\"\n\n        project_a_exists = project_a_dir.exists()\n        project_b_exists = project_b_dir.exists()\n\n        print(f\"   project_a directory: {project_a_dir}\")\n        print(f\"   project_a exists: {project_a_exists}\")\n        print(f\"   project_b directory: {project_b_dir}\")\n        print(f\"   project_b exists: {project_b_exists}\")\n\n        assert project_a_exists, \"project_a directory should exist\"\n        assert project_b_exists, \"project_b directory should exist\"\n\n        # List files in each directory\n        print(\"\\n   Files in project_a/:\")\n        for file in sorted(project_a_dir.glob(\"*\")):\n            if file.is_file():\n                size = file.stat().st_size\n                print(f\"     - {file.name} ({size} bytes)\")\n\n        print(\"\\n   Files in project_b/:\")\n        for file in sorted(project_b_dir.glob(\"*\")):\n            if file.is_file():\n                size = file.stat().st_size\n                print(f\"     - {file.name} ({size} bytes)\")\n\n        print(\"✅ PASSED: LightRAG E2E - File Structure\")\n        print(\"   Workspace directories correctly created and separated\")\n\n        # Test 11.4: Verify data isolation by checking file contents\n        print(\"\\nTest 11.4: Verify data isolation (check file contents)\")\n\n        # Check if full_docs storage files exist and contain different content\n        docs_a_file = project_a_dir / \"kv_store_full_docs.json\"\n        docs_b_file = project_b_dir / \"kv_store_full_docs.json\"\n\n        if docs_a_file.exists() and docs_b_file.exists():\n            import json\n\n            with open(docs_a_file, \"r\") as f:\n                docs_a_content = json.load(f)\n\n            with open(docs_b_file, \"r\") as f:\n                docs_b_content = json.load(f)\n\n            print(f\"   project_a doc count: {len(docs_a_content)}\")\n            print(f\"   project_b doc count: {len(docs_b_content)}\")\n\n            # Verify they contain different data\n            assert (\n                docs_a_content != docs_b_content\n            ), \"Document storage not properly isolated\"\n\n            # Verify each workspace contains its own text content\n            docs_a_str = json.dumps(docs_a_content)\n            docs_b_str = json.dumps(docs_b_content)\n\n            # Check project_a contains its text and NOT project_b's text\n            assert (\n                \"Artificial Intelligence\" in docs_a_str\n            ), \"project_a should contain 'Artificial Intelligence'\"\n            assert (\n                \"Machine Learning\" in docs_a_str\n            ), \"project_a should contain 'Machine Learning'\"\n            assert (\n                \"Deep Learning\" not in docs_a_str\n            ), \"project_a should NOT contain 'Deep Learning' from project_b\"\n            assert (\n                \"Neural Networks\" not in docs_a_str\n            ), \"project_a should NOT contain 'Neural Networks' from project_b\"\n\n            # Check project_b contains its text and NOT project_a's text\n            assert (\n                \"Deep Learning\" in docs_b_str\n            ), \"project_b should contain 'Deep Learning'\"\n            assert (\n                \"Neural Networks\" in docs_b_str\n            ), \"project_b should contain 'Neural Networks'\"\n            assert (\n                \"Artificial Intelligence\" not in docs_b_str\n            ), \"project_b should NOT contain 'Artificial Intelligence' from project_a\"\n            # Note: \"Machine Learning\" might appear in project_b's text, so we skip that check\n\n            print(\"✅ PASSED: LightRAG E2E - Data Isolation\")\n            print(\"   Document storage correctly isolated between workspaces\")\n            print(\"   project_a contains only its own data\")\n            print(\"   project_b contains only its own data\")\n        else:\n            print(\"   Document storage files not found (may not be created yet)\")\n            print(\"✅ PASSED: LightRAG E2E - Data Isolation\")\n            print(\"   Skipped file content check (files not created)\")\n\n        print(\"\\n   ✓ Test complete - workspace isolation verified at E2E level\")\n\n    finally:\n        # Cleanup test directory (unless keep_test_artifacts is set)\n        if os.path.exists(test_dir) and not keep_test_artifacts:\n            shutil.rmtree(test_dir)\n            print(f\"\\n   Cleaned up test directory: {test_dir}\")\n        elif keep_test_artifacts:\n            print(f\"\\n   Kept test directory for inspection: {test_dir}\")\n"
  },
  {
    "path": "tests/test_workspace_migration_isolation.py",
    "content": "\"\"\"\nTests for workspace isolation during PostgreSQL migration.\n\nThis test module verifies that setup_table() properly filters migration data\nby workspace, preventing cross-workspace data leakage during legacy table migration.\n\nCritical Bug: Migration copied ALL records from legacy table regardless of workspace,\ncausing workspace A to receive workspace B's data, violating multi-tenant isolation.\n\"\"\"\n\nimport pytest\nfrom unittest.mock import AsyncMock\n\nfrom lightrag.kg.postgres_impl import PGVectorStorage\n\n\nclass TestWorkspaceMigrationIsolation:\n    \"\"\"Test suite for workspace-scoped migration in PostgreSQL.\"\"\"\n\n    async def test_migration_filters_by_workspace(self):\n        \"\"\"\n        Test that migration only copies data from the specified workspace.\n\n        Scenario: Legacy table contains data from multiple workspaces.\n                  Migrate only workspace_a's data to new table.\n        Expected: New table contains only workspace_a data, workspace_b data excluded.\n        \"\"\"\n        db = AsyncMock()\n\n        # Configure mock return values to avoid unawaited coroutine warnings\n        db._create_vector_index.return_value = None\n\n        # Track state for new table count (starts at 0, increases after migration)\n        new_table_record_count = {\"count\": 0}\n\n        # Mock table existence checks\n        async def table_exists_side_effect(db_instance, name):\n            if name.lower() == \"lightrag_doc_chunks\":  # legacy\n                return True\n            elif name.lower() == \"lightrag_doc_chunks_model_1536d\":  # new\n                return False  # New table doesn't exist initially\n            return False\n\n        # Mock data for workspace_a\n        mock_records_a = [\n            {\n                \"id\": \"a1\",\n                \"workspace\": \"workspace_a\",\n                \"content\": \"content_a1\",\n                \"content_vector\": [0.1] * 1536,\n            },\n            {\n                \"id\": \"a2\",\n                \"workspace\": \"workspace_a\",\n                \"content\": \"content_a2\",\n                \"content_vector\": [0.2] * 1536,\n            },\n        ]\n\n        # Mock query responses\n        async def query_side_effect(sql, params, **kwargs):\n            multirows = kwargs.get(\"multirows\", False)\n            sql_upper = sql.upper()\n\n            # Count query for new table workspace data (verification before migration)\n            if (\n                \"COUNT(*)\" in sql_upper\n                and \"MODEL_1536D\" in sql_upper\n                and \"WHERE WORKSPACE\" in sql_upper\n            ):\n                return new_table_record_count  # Initially 0\n\n            # Count query with workspace filter (legacy table) - for workspace count\n            elif \"COUNT(*)\" in sql_upper and \"WHERE WORKSPACE\" in sql_upper:\n                if params and params[0] == \"workspace_a\":\n                    return {\"count\": 2}  # workspace_a has 2 records\n                elif params and params[0] == \"workspace_b\":\n                    return {\"count\": 3}  # workspace_b has 3 records\n                return {\"count\": 0}\n\n            # Count query for legacy table (total, no workspace filter)\n            elif (\n                \"COUNT(*)\" in sql_upper\n                and \"LIGHTRAG\" in sql_upper\n                and \"WHERE WORKSPACE\" not in sql_upper\n            ):\n                return {\"count\": 5}  # Total records in legacy\n\n            # SELECT with workspace filter for migration (multirows)\n            elif \"SELECT\" in sql_upper and \"FROM\" in sql_upper and multirows:\n                workspace = params[0] if params else None\n                if workspace == \"workspace_a\":\n                    # Handle keyset pagination: check for \"id >\" pattern\n                    if \"id >\" in sql.lower():\n                        # Keyset pagination: params = [workspace, last_id, limit]\n                        last_id = params[1] if len(params) > 1 else None\n                        # Find records after last_id\n                        found_idx = -1\n                        for i, rec in enumerate(mock_records_a):\n                            if rec[\"id\"] == last_id:\n                                found_idx = i\n                                break\n                        if found_idx >= 0:\n                            return mock_records_a[found_idx + 1 :]\n                        return []\n                    else:\n                        # First batch: params = [workspace, limit]\n                        return mock_records_a\n                return []  # No data for other workspaces\n\n            return {}\n\n        db.query.side_effect = query_side_effect\n        db.execute = AsyncMock()\n\n        # Mock check_table_exists on db\n        async def check_table_exists_side_effect(name):\n            if name.lower() == \"lightrag_doc_chunks\":  # legacy\n                return True\n            elif name.lower() == \"lightrag_doc_chunks_model_1536d\":  # new\n                return False  # New table doesn't exist initially\n            return False\n\n        db.check_table_exists = AsyncMock(side_effect=check_table_exists_side_effect)\n\n        # Track migration through _run_with_retry calls\n        migration_executed = []\n\n        async def mock_run_with_retry(operation, *args, **kwargs):\n            migration_executed.append(True)\n            new_table_record_count[\"count\"] = 2  # Simulate 2 records migrated\n            return None\n\n        db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry)\n\n        # Migrate for workspace_a only - correct parameter order\n        await PGVectorStorage.setup_table(\n            db,\n            \"LIGHTRAG_DOC_CHUNKS_model_1536d\",\n            workspace=\"workspace_a\",  # CRITICAL: Only migrate workspace_a\n            embedding_dim=1536,\n            legacy_table_name=\"LIGHTRAG_DOC_CHUNKS\",\n            base_table=\"LIGHTRAG_DOC_CHUNKS\",\n        )\n\n        # Verify the migration was triggered\n        assert (\n            len(migration_executed) > 0\n        ), \"Migration should have been executed for workspace_a\"\n\n    async def test_migration_without_workspace_raises_error(self):\n        \"\"\"\n        Test that migration without workspace parameter raises ValueError.\n\n        Scenario: setup_table called without workspace parameter.\n        Expected: ValueError is raised because workspace is required.\n        \"\"\"\n        db = AsyncMock()\n\n        # workspace is now a required parameter - calling with None should raise ValueError\n        with pytest.raises(ValueError, match=\"workspace must be provided\"):\n            await PGVectorStorage.setup_table(\n                db,\n                \"lightrag_doc_chunks_model_1536d\",\n                workspace=None,  # No workspace - should raise ValueError\n                embedding_dim=1536,\n                legacy_table_name=\"lightrag_doc_chunks\",\n                base_table=\"lightrag_doc_chunks\",\n            )\n\n    async def test_no_cross_workspace_contamination(self):\n        \"\"\"\n        Test that workspace B's migration doesn't include workspace A's data.\n\n        Scenario: Migration for workspace_b only.\n        Expected: Only workspace_b data is queried, workspace_a data excluded.\n        \"\"\"\n        db = AsyncMock()\n\n        # Configure mock return values to avoid unawaited coroutine warnings\n        db._create_vector_index.return_value = None\n\n        # Track which workspace is being queried\n        queried_workspace = None\n        new_table_count = {\"count\": 0}\n\n        # Mock data for workspace_b\n        mock_records_b = [\n            {\n                \"id\": \"b1\",\n                \"workspace\": \"workspace_b\",\n                \"content\": \"content_b1\",\n                \"content_vector\": [0.3] * 1536,\n            },\n        ]\n\n        async def table_exists_side_effect(db_instance, name):\n            if name.lower() == \"lightrag_doc_chunks\":  # legacy\n                return True\n            elif name.lower() == \"lightrag_doc_chunks_model_1536d\":  # new\n                return False\n            return False\n\n        async def query_side_effect(sql, params, **kwargs):\n            nonlocal queried_workspace\n            multirows = kwargs.get(\"multirows\", False)\n            sql_upper = sql.upper()\n\n            # Count query for new table workspace data (should be 0 initially)\n            if (\n                \"COUNT(*)\" in sql_upper\n                and \"MODEL_1536D\" in sql_upper\n                and \"WHERE WORKSPACE\" in sql_upper\n            ):\n                return new_table_count\n\n            # Count query with workspace filter (legacy table)\n            elif \"COUNT(*)\" in sql_upper and \"WHERE WORKSPACE\" in sql_upper:\n                queried_workspace = params[0] if params else None\n                return {\"count\": 1}  # 1 record for the queried workspace\n\n            # Count query for legacy table total (no workspace filter)\n            elif (\n                \"COUNT(*)\" in sql_upper\n                and \"LIGHTRAG\" in sql_upper\n                and \"WHERE WORKSPACE\" not in sql_upper\n            ):\n                return {\"count\": 3}  # 3 total records in legacy\n\n            # SELECT with workspace filter for migration (multirows)\n            elif \"SELECT\" in sql_upper and \"FROM\" in sql_upper and multirows:\n                workspace = params[0] if params else None\n                if workspace == \"workspace_b\":\n                    # Handle keyset pagination: check for \"id >\" pattern\n                    if \"id >\" in sql.lower():\n                        # Keyset pagination: params = [workspace, last_id, limit]\n                        last_id = params[1] if len(params) > 1 else None\n                        # Find records after last_id\n                        found_idx = -1\n                        for i, rec in enumerate(mock_records_b):\n                            if rec[\"id\"] == last_id:\n                                found_idx = i\n                                break\n                        if found_idx >= 0:\n                            return mock_records_b[found_idx + 1 :]\n                        return []\n                    else:\n                        # First batch: params = [workspace, limit]\n                        return mock_records_b\n                return []  # No data for other workspaces\n\n            return {}\n\n        db.query.side_effect = query_side_effect\n        db.execute = AsyncMock()\n\n        # Mock check_table_exists on db\n        async def check_table_exists_side_effect(name):\n            if name.lower() == \"lightrag_doc_chunks\":  # legacy\n                return True\n            elif name.lower() == \"lightrag_doc_chunks_model_1536d\":  # new\n                return False\n            return False\n\n        db.check_table_exists = AsyncMock(side_effect=check_table_exists_side_effect)\n\n        # Track migration through _run_with_retry calls\n        migration_executed = []\n\n        async def mock_run_with_retry(operation, *args, **kwargs):\n            migration_executed.append(True)\n            new_table_count[\"count\"] = 1  # Simulate migration\n            return None\n\n        db._run_with_retry = AsyncMock(side_effect=mock_run_with_retry)\n\n        # Migrate workspace_b - correct parameter order\n        await PGVectorStorage.setup_table(\n            db,\n            \"LIGHTRAG_DOC_CHUNKS_model_1536d\",\n            workspace=\"workspace_b\",  # Only migrate workspace_b\n            embedding_dim=1536,\n            legacy_table_name=\"LIGHTRAG_DOC_CHUNKS\",\n            base_table=\"LIGHTRAG_DOC_CHUNKS\",\n        )\n\n        # Verify only workspace_b was queried\n        assert queried_workspace == \"workspace_b\", \"Should only query workspace_b\"\n"
  },
  {
    "path": "tests/test_workspace_sanitization.py",
    "content": "\"\"\"\nUnit tests for workspace label sanitization in Memgraph and Neo4j implementations.\n\nThis module tests that `_get_workspace_label()` properly sanitizes workspace names\nto prevent Cypher injection via the LIGHTRAG-WORKSPACE HTTP header.\n\nIt verifies that we preserve non-alphanumeric characters for 1-to-1 workspace mapping\nwhile successfully neutralizing Cypher injection by escaping backticks.\n\nThis test is designed to be dependency-independent by extracting the logic directly\nfrom the source files, as the full LightRAG package has many AI-related dependencies.\n\nReferences: GitHub Issue #2698\n\"\"\"\n\nimport re\nimport os\nimport pytest\n\n# Mark all tests as offline (no external dependencies)\npytestmark = pytest.mark.offline\n\n\ndef get_actual_sanitization_logic():\n    \"\"\"Extract the sanitization logic from the source files to ensure we test the real code.\"\"\"\n    base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n    files = [\n        os.path.join(base_path, \"lightrag/kg/memgraph_impl.py\"),\n        os.path.join(base_path, \"lightrag/kg/neo4j_impl.py\"),\n    ]\n\n    logics = []\n    for file_path in files:\n        with open(file_path, \"r\", encoding=\"utf-8\") as f:\n            content = f.read()\n            # Find the _get_workspace_label method body\n            # We look for the specific line: return workspace.replace(\"`\", \"``\")\n            match = re.search(r\"return workspace\\.replace\\(\\\"`\\\", \\\"``\\\"\\)\", content)\n            if not match:\n                raise RuntimeError(f\"Could not find sanitization logic in {file_path}\")\n            logics.append(file_path)\n\n    # All backends should have identical logic for this helper\n    def sanitize(workspace: str) -> str:\n        safe = workspace.strip()\n        if not safe:\n            safe = \"base\"\n        return safe.replace(\"`\", \"``\")\n\n    return sanitize\n\n\nsanitize = get_actual_sanitization_logic()\n\n\nclass TestWorkspaceLabelSanitization:\n    \"\"\"Test suite for _get_workspace_label() sanitization logic.\"\"\"\n\n    def assert_logic(self, workspace: str, expected: str):\n        \"\"\"Helper to assert sanitization logic.\"\"\"\n        assert sanitize(workspace) == expected\n\n    # --- Normal inputs ---\n\n    def test_alphanumeric_unchanged(self):\n        \"\"\"Pure alphanumeric workspace names should pass through unchanged.\"\"\"\n        self.assert_logic(\"myworkspace\", \"myworkspace\")\n\n    def test_alphanumeric_with_underscore(self):\n        \"\"\"Underscores are allowed and should remain.\"\"\"\n        self.assert_logic(\"my_workspace_1\", \"my_workspace_1\")\n\n    def test_uppercase_preserved(self):\n        \"\"\"Case should be preserved.\"\"\"\n        self.assert_logic(\"MyWorkSpace\", \"MyWorkSpace\")\n\n    def test_numeric_only(self):\n        \"\"\"Numeric-only workspaces are valid.\"\"\"\n        self.assert_logic(\"12345\", \"12345\")\n\n    # --- Special characters preserved (unlike PostgreSQL regex stripping) ---\n\n    def test_spaces_preserved(self):\n        \"\"\"Spaces in workspace names should be preserved.\"\"\"\n        self.assert_logic(\"my workspace\", \"my workspace\")\n\n    def test_hyphens_preserved(self):\n        \"\"\"Hyphens should be preserved (solves collision issue).\"\"\"\n        self.assert_logic(\"my-workspace\", \"my-workspace\")\n\n    def test_dots_preserved(self):\n        \"\"\"Dots should be preserved.\"\"\"\n        self.assert_logic(\"my.workspace\", \"my.workspace\")\n\n    def test_mixed_special_chars_preserved(self):\n        \"\"\"Multiple different special characters should be preserved.\"\"\"\n        self.assert_logic(\"a-b.c d@e!f\", \"a-b.c d@e!f\")\n\n    # --- Cypher injection payloads ---\n\n    def test_cypher_injection_backtick_escaped(self):\n        \"\"\"Backtick injection attempt should be neutralized by doubling backticks.\"\"\"\n        malicious = \"test`}) MATCH (n) DETACH DELETE n //\"\n        # The single backtick should become a double backtick\n        expected = \"test``}) MATCH (n) DETACH DELETE n //\"\n        self.assert_logic(malicious, expected)\n\n    def test_cypher_injection_multiple_backticks(self):\n        \"\"\"Multiple backticks should all be escaped.\"\"\"\n        malicious = \"`DROP`DATABASE`\"\n        expected = \"``DROP``DATABASE``\"\n        self.assert_logic(malicious, expected)\n\n    def test_cypher_injection_curly_braces_preserved(self):\n        \"\"\"Curly brace injection is harmless when enclosed in backticks, so preserved.\"\"\"\n        malicious = \"test}) RETURN 1 //\"\n        self.assert_logic(malicious, malicious)\n\n    def test_cypher_injection_semicolon_preserved(self):\n        \"\"\"Semicolon injection is harmless when enclosed in backticks, so preserved.\"\"\"\n        malicious = \"test; DROP DATABASE neo4j\"\n        self.assert_logic(malicious, malicious)\n\n    def test_cypher_injection_quotes_preserved(self):\n        \"\"\"Quote injection is harmless when enclosed in backticks, so preserved.\"\"\"\n        malicious = 'test\" OR 1=1 //'\n        self.assert_logic(malicious, malicious)\n\n    # --- Empty / whitespace fallback ---\n\n    def test_empty_string_fallback(self):\n        \"\"\"Empty workspace should fall back to 'base'.\"\"\"\n        self.assert_logic(\"\", \"base\")\n\n    def test_whitespace_only_fallback(self):\n        \"\"\"Whitespace-only workspace should fall back to 'base'.\"\"\"\n        self.assert_logic(\"   \", \"base\")\n\n    def test_special_chars_only_preserved(self):\n        \"\"\"Workspace with only special characters should be preserved.\"\"\"\n        self.assert_logic(\"---\", \"---\")\n\n    # --- Edge cases ---\n\n    def test_leading_trailing_whitespace_stripped(self):\n        \"\"\"Leading/trailing whitespace should be stripped before sanitization.\"\"\"\n        self.assert_logic(\"  myworkspace  \", \"myworkspace\")\n\n    def test_unicode_characters_preserved(self):\n        \"\"\"Non-ASCII/Chinese characters should be preserved.\"\"\"\n        self.assert_logic(\"工作区_test\", \"工作区_test\")\n\n    def test_very_long_workspace(self):\n        \"\"\"Very long workspace names should still be sanitized correctly.\"\"\"\n        long_name = \"a\" * 1000 + \"`\"\n        expected = \"a\" * 1000 + \"``\"\n        self.assert_logic(long_name, expected)\n\n    def test_single_underscore(self):\n        \"\"\"Single underscore should be valid.\"\"\"\n        self.assert_logic(\"_\", \"_\")\n\n    def test_result_always_escapes_backticks(self):\n        \"\"\"Parametric check: any output must not contain unescaped single backticks.\"\"\"\n        dangerous_inputs = [\n            \"normal\",\n            \"with spaces\",\n            \"with-dashes\",\n            \"with.dots\",\n            \"`) DETACH DELETE n //\",\n            \"'; DROP TABLE users; --\",\n            \"test\\nMATCH (n) DELETE n\",\n            \"\\t\\ttabs\",\n            \"emoji🚀test\",\n        ]\n        for inp in dangerous_inputs:\n            result = sanitize(inp)\n            backtick_sequences = re.findall(r\"`+\", result)\n            for seq in backtick_sequences:\n                # Any sequence of backticks should have an EVEN length because each ` becomes ``\n                assert (\n                    len(seq) % 2 == 0\n                ), f\"Unescaped backtick found in result '{result}' for input '{inp}'\"\n"
  },
  {
    "path": "tests/test_write_json_optimization.py",
    "content": "\"\"\"\nTest suite for write_json optimization\n\nThis test verifies:\n1. Fast path works for clean data (no sanitization)\n2. Slow path applies sanitization for dirty data\n3. Sanitization is done during encoding (memory-efficient)\n4. Reloading updates shared memory with cleaned data\n\"\"\"\n\nimport os\nimport json\nimport tempfile\nimport pytest\nfrom lightrag.utils import write_json, load_json, SanitizingJSONEncoder\n\n\n@pytest.mark.offline\nclass TestWriteJsonOptimization:\n    \"\"\"Test write_json optimization with two-stage approach\"\"\"\n\n    def test_fast_path_clean_data(self):\n        \"\"\"Test that clean data takes the fast path without sanitization\"\"\"\n        clean_data = {\n            \"name\": \"John Doe\",\n            \"age\": 30,\n            \"items\": [\"apple\", \"banana\", \"cherry\"],\n            \"nested\": {\"key\": \"value\", \"number\": 42},\n        }\n\n        with tempfile.NamedTemporaryFile(mode=\"w\", delete=False, suffix=\".json\") as f:\n            temp_file = f.name\n\n        try:\n            # Write clean data - should return False (no sanitization)\n            needs_reload = write_json(clean_data, temp_file)\n            assert not needs_reload, \"Clean data should not require sanitization\"\n\n            # Verify data was written correctly\n            loaded_data = load_json(temp_file)\n            assert loaded_data == clean_data, \"Loaded data should match original\"\n        finally:\n            os.unlink(temp_file)\n\n    def test_slow_path_dirty_data(self):\n        \"\"\"Test that dirty data triggers sanitization\"\"\"\n        # Create data with surrogate characters (U+D800 to U+DFFF)\n        dirty_string = \"Hello\\ud800World\"  # Contains surrogate character\n        dirty_data = {\"text\": dirty_string, \"number\": 123}\n\n        with tempfile.NamedTemporaryFile(mode=\"w\", delete=False, suffix=\".json\") as f:\n            temp_file = f.name\n\n        try:\n            # Write dirty data - should return True (sanitization applied)\n            needs_reload = write_json(dirty_data, temp_file)\n            assert needs_reload, \"Dirty data should trigger sanitization\"\n\n            # Verify data was written and sanitized\n            loaded_data = load_json(temp_file)\n            assert loaded_data is not None, \"Data should be written\"\n            assert loaded_data[\"number\"] == 123, \"Clean fields should remain unchanged\"\n            # Surrogate character should be removed\n            assert (\n                \"\\ud800\" not in loaded_data[\"text\"]\n            ), \"Surrogate character should be removed\"\n        finally:\n            os.unlink(temp_file)\n\n    def test_sanitizing_encoder_removes_surrogates(self):\n        \"\"\"Test that SanitizingJSONEncoder removes surrogate characters\"\"\"\n        data_with_surrogates = {\n            \"text\": \"Hello\\ud800\\udc00World\",  # Contains surrogate pair\n            \"clean\": \"Clean text\",\n            \"nested\": {\"dirty_key\\ud801\": \"value\", \"clean_key\": \"clean\\ud802value\"},\n        }\n\n        # Encode using custom encoder\n        encoded = json.dumps(\n            data_with_surrogates, cls=SanitizingJSONEncoder, ensure_ascii=False\n        )\n\n        # Verify no surrogate characters in output\n        assert \"\\ud800\" not in encoded, \"Surrogate U+D800 should be removed\"\n        assert \"\\udc00\" not in encoded, \"Surrogate U+DC00 should be removed\"\n        assert \"\\ud801\" not in encoded, \"Surrogate U+D801 should be removed\"\n        assert \"\\ud802\" not in encoded, \"Surrogate U+D802 should be removed\"\n\n        # Verify clean parts remain\n        assert \"Clean text\" in encoded, \"Clean text should remain\"\n        assert \"clean_key\" in encoded, \"Clean keys should remain\"\n\n    def test_nested_structure_sanitization(self):\n        \"\"\"Test sanitization of deeply nested structures\"\"\"\n        nested_data = {\n            \"level1\": {\n                \"level2\": {\n                    \"level3\": {\"dirty\": \"text\\ud800here\", \"clean\": \"normal text\"},\n                    \"list\": [\"item1\", \"item\\ud801dirty\", \"item3\"],\n                }\n            }\n        }\n\n        with tempfile.NamedTemporaryFile(mode=\"w\", delete=False, suffix=\".json\") as f:\n            temp_file = f.name\n\n        try:\n            needs_reload = write_json(nested_data, temp_file)\n            assert needs_reload, \"Nested dirty data should trigger sanitization\"\n\n            # Verify nested structure is preserved\n            loaded_data = load_json(temp_file)\n            assert \"level1\" in loaded_data\n            assert \"level2\" in loaded_data[\"level1\"]\n            assert \"level3\" in loaded_data[\"level1\"][\"level2\"]\n\n            # Verify surrogates are removed\n            dirty_text = loaded_data[\"level1\"][\"level2\"][\"level3\"][\"dirty\"]\n            assert \"\\ud800\" not in dirty_text, \"Nested surrogate should be removed\"\n\n            # Verify list items are sanitized\n            list_items = loaded_data[\"level1\"][\"level2\"][\"list\"]\n            assert (\n                \"\\ud801\" not in list_items[1]\n            ), \"List item surrogates should be removed\"\n        finally:\n            os.unlink(temp_file)\n\n    def test_unicode_non_characters_removed(self):\n        \"\"\"Test that Unicode non-characters (U+FFFE, U+FFFF) don't cause encoding errors\n\n        Note: U+FFFE and U+FFFF are valid UTF-8 characters (though discouraged),\n        so they don't trigger sanitization. They only get removed when explicitly\n        using the SanitizingJSONEncoder.\n        \"\"\"\n        data_with_nonchars = {\"text1\": \"Hello\\ufffeWorld\", \"text2\": \"Test\\uffffString\"}\n\n        with tempfile.NamedTemporaryFile(mode=\"w\", delete=False, suffix=\".json\") as f:\n            temp_file = f.name\n\n        try:\n            # These characters are valid UTF-8, so they take the fast path\n            needs_reload = write_json(data_with_nonchars, temp_file)\n            assert not needs_reload, \"U+FFFE/U+FFFF are valid UTF-8 characters\"\n\n            loaded_data = load_json(temp_file)\n            # They're written as-is in the fast path\n            assert loaded_data == data_with_nonchars\n        finally:\n            os.unlink(temp_file)\n\n    def test_mixed_clean_dirty_data(self):\n        \"\"\"Test data with both clean and dirty fields\"\"\"\n        mixed_data = {\n            \"clean_field\": \"This is perfectly fine\",\n            \"dirty_field\": \"This has\\ud800issues\",\n            \"number\": 42,\n            \"boolean\": True,\n            \"null_value\": None,\n            \"clean_list\": [1, 2, 3],\n            \"dirty_list\": [\"clean\", \"dirty\\ud801item\"],\n        }\n\n        with tempfile.NamedTemporaryFile(mode=\"w\", delete=False, suffix=\".json\") as f:\n            temp_file = f.name\n\n        try:\n            needs_reload = write_json(mixed_data, temp_file)\n            assert (\n                needs_reload\n            ), \"Mixed data with dirty fields should trigger sanitization\"\n\n            loaded_data = load_json(temp_file)\n\n            # Clean fields should remain unchanged\n            assert loaded_data[\"clean_field\"] == \"This is perfectly fine\"\n            assert loaded_data[\"number\"] == 42\n            assert loaded_data[\"boolean\"]\n            assert loaded_data[\"null_value\"] is None\n            assert loaded_data[\"clean_list\"] == [1, 2, 3]\n\n            # Dirty fields should be sanitized\n            assert \"\\ud800\" not in loaded_data[\"dirty_field\"]\n            assert \"\\ud801\" not in loaded_data[\"dirty_list\"][1]\n        finally:\n            os.unlink(temp_file)\n\n    def test_empty_and_none_strings(self):\n        \"\"\"Test handling of empty and None values\"\"\"\n        data = {\n            \"empty\": \"\",\n            \"none\": None,\n            \"zero\": 0,\n            \"false\": False,\n            \"empty_list\": [],\n            \"empty_dict\": {},\n        }\n\n        with tempfile.NamedTemporaryFile(mode=\"w\", delete=False, suffix=\".json\") as f:\n            temp_file = f.name\n\n        try:\n            needs_reload = write_json(data, temp_file)\n            assert (\n                not needs_reload\n            ), \"Clean empty values should not trigger sanitization\"\n\n            loaded_data = load_json(temp_file)\n            assert loaded_data == data, \"Empty/None values should be preserved\"\n        finally:\n            os.unlink(temp_file)\n\n    def test_specific_surrogate_udc9a(self):\n        \"\"\"Test specific surrogate character \\\\udc9a mentioned in the issue\"\"\"\n        # Test the exact surrogate character from the error message:\n        # UnicodeEncodeError: 'utf-8' codec can't encode character '\\\\udc9a'\n        data_with_udc9a = {\n            \"text\": \"Some text with surrogate\\udc9acharacter\",\n            \"position\": 201,  # As mentioned in the error\n            \"clean_field\": \"Normal text\",\n        }\n\n        with tempfile.NamedTemporaryFile(mode=\"w\", delete=False, suffix=\".json\") as f:\n            temp_file = f.name\n\n        try:\n            # Write data - should trigger sanitization\n            needs_reload = write_json(data_with_udc9a, temp_file)\n            assert needs_reload, \"Data with \\\\udc9a should trigger sanitization\"\n\n            # Verify surrogate was removed\n            loaded_data = load_json(temp_file)\n            assert loaded_data is not None\n            assert \"\\udc9a\" not in loaded_data[\"text\"], \"\\\\udc9a should be removed\"\n            assert (\n                loaded_data[\"clean_field\"] == \"Normal text\"\n            ), \"Clean fields should remain\"\n        finally:\n            os.unlink(temp_file)\n\n    def test_migration_with_surrogate_sanitization(self):\n        \"\"\"Test that migration process handles surrogate characters correctly\n\n        This test simulates the scenario where legacy cache contains surrogate\n        characters and ensures they are cleaned during migration.\n        \"\"\"\n        # Simulate legacy cache data with surrogate characters\n        legacy_data_with_surrogates = {\n            \"cache_entry_1\": {\n                \"return\": \"Result with\\ud800surrogate\",\n                \"cache_type\": \"extract\",\n                \"original_prompt\": \"Some\\udc9aprompt\",\n            },\n            \"cache_entry_2\": {\n                \"return\": \"Clean result\",\n                \"cache_type\": \"query\",\n                \"original_prompt\": \"Clean prompt\",\n            },\n        }\n\n        with tempfile.NamedTemporaryFile(mode=\"w\", delete=False, suffix=\".json\") as f:\n            temp_file = f.name\n\n        try:\n            # First write the dirty data directly (simulating legacy cache file)\n            # Use custom encoder to force write even with surrogates\n            with open(temp_file, \"w\", encoding=\"utf-8\") as f:\n                json.dump(\n                    legacy_data_with_surrogates,\n                    f,\n                    cls=SanitizingJSONEncoder,\n                    ensure_ascii=False,\n                )\n\n            # Load and verify surrogates were cleaned during initial write\n            loaded_data = load_json(temp_file)\n            assert loaded_data is not None\n\n            # The data should be sanitized\n            assert (\n                \"\\ud800\" not in loaded_data[\"cache_entry_1\"][\"return\"]\n            ), \"Surrogate in return should be removed\"\n            assert (\n                \"\\udc9a\" not in loaded_data[\"cache_entry_1\"][\"original_prompt\"]\n            ), \"Surrogate in prompt should be removed\"\n\n            # Clean data should remain unchanged\n            assert (\n                loaded_data[\"cache_entry_2\"][\"return\"] == \"Clean result\"\n            ), \"Clean data should remain\"\n\n        finally:\n            os.unlink(temp_file)\n\n    def test_empty_values_after_sanitization(self):\n        \"\"\"Test that data with empty values after sanitization is properly handled\n\n        Critical edge case: When sanitization results in data with empty string values,\n        we must use 'if cleaned_data is not None' instead of 'if cleaned_data' to ensure\n        proper reload, since truthy check on dict depends on content, not just existence.\n        \"\"\"\n        # Create data where ALL values are only surrogate characters\n        all_dirty_data = {\n            \"key1\": \"\\ud800\\udc00\\ud801\",\n            \"key2\": \"\\ud802\\ud803\",\n        }\n\n        with tempfile.NamedTemporaryFile(mode=\"w\", delete=False, suffix=\".json\") as f:\n            temp_file = f.name\n\n        try:\n            # Write dirty data - should trigger sanitization\n            needs_reload = write_json(all_dirty_data, temp_file)\n            assert needs_reload, \"All-dirty data should trigger sanitization\"\n\n            # Load the sanitized data\n            cleaned_data = load_json(temp_file)\n\n            # Critical assertions for the edge case\n            assert cleaned_data is not None, \"Cleaned data should not be None\"\n            # Sanitization removes surrogates but preserves keys with empty values\n            assert cleaned_data == {\n                \"key1\": \"\",\n                \"key2\": \"\",\n            }, \"Surrogates should be removed, keys preserved\"\n            # This dict is truthy because it has keys (even with empty values)\n            assert cleaned_data, \"Dict with keys is truthy\"\n\n            # Test the actual edge case: empty dict\n            empty_data = {}\n            needs_reload2 = write_json(empty_data, temp_file)\n            assert not needs_reload2, \"Empty dict is clean\"\n\n            reloaded_empty = load_json(temp_file)\n            assert reloaded_empty is not None, \"Empty dict should not be None\"\n            assert reloaded_empty == {}, \"Empty dict should remain empty\"\n            assert (\n                not reloaded_empty\n            ), \"Empty dict evaluates to False (the critical check)\"\n\n        finally:\n            os.unlink(temp_file)\n\n\nif __name__ == \"__main__\":\n    # Run tests\n    test = TestWriteJsonOptimization()\n\n    print(\"Running test_fast_path_clean_data...\")\n    test.test_fast_path_clean_data()\n    print(\"✓ Passed\")\n\n    print(\"Running test_slow_path_dirty_data...\")\n    test.test_slow_path_dirty_data()\n    print(\"✓ Passed\")\n\n    print(\"Running test_sanitizing_encoder_removes_surrogates...\")\n    test.test_sanitizing_encoder_removes_surrogates()\n    print(\"✓ Passed\")\n\n    print(\"Running test_nested_structure_sanitization...\")\n    test.test_nested_structure_sanitization()\n    print(\"✓ Passed\")\n\n    print(\"Running test_unicode_non_characters_removed...\")\n    test.test_unicode_non_characters_removed()\n    print(\"✓ Passed\")\n\n    print(\"Running test_mixed_clean_dirty_data...\")\n    test.test_mixed_clean_dirty_data()\n    print(\"✓ Passed\")\n\n    print(\"Running test_empty_and_none_strings...\")\n    test.test_empty_and_none_strings()\n    print(\"✓ Passed\")\n\n    print(\"Running test_specific_surrogate_udc9a...\")\n    test.test_specific_surrogate_udc9a()\n    print(\"✓ Passed\")\n\n    print(\"Running test_migration_with_surrogate_sanitization...\")\n    test.test_migration_with_surrogate_sanitization()\n    print(\"✓ Passed\")\n\n    print(\"Running test_empty_values_after_sanitization...\")\n    test.test_empty_values_after_sanitization()\n    print(\"✓ Passed\")\n\n    print(\"\\n✅ All tests passed!\")\n"
  },
  {
    "path": "tests/test_zhipu_llm.py",
    "content": "import importlib\nimport sys\nfrom types import SimpleNamespace\n\nimport numpy as np\nimport pytest\n\n\ndef _fake_embedding_vector(dim=1024):\n    return [0.1] * dim\n\n\ndef _fake_chat_response(content=\"\", reasoning_content=\"\"):\n    message = SimpleNamespace(\n        content=content,\n        reasoning_content=reasoning_content,\n    )\n    return SimpleNamespace(choices=[SimpleNamespace(message=message)])\n\n\ndef _load_zhipu_module(monkeypatch, client_factory):\n    fake_pm = SimpleNamespace(\n        is_installed=lambda name: True,\n        install=lambda name: None,\n    )\n    fake_openai = SimpleNamespace(\n        APIConnectionError=type(\"APIConnectionError\", (Exception,), {}),\n        RateLimitError=type(\"RateLimitError\", (Exception,), {}),\n        APITimeoutError=type(\"APITimeoutError\", (Exception,), {}),\n    )\n    fake_zhipuai = SimpleNamespace(ZhipuAI=client_factory)\n\n    monkeypatch.setitem(sys.modules, \"pipmaster\", fake_pm)\n    monkeypatch.setitem(sys.modules, \"openai\", fake_openai)\n    monkeypatch.setitem(sys.modules, \"zhipuai\", fake_zhipuai)\n    sys.modules.pop(\"lightrag.llm.zhipu\", None)\n\n    return importlib.import_module(\"lightrag.llm.zhipu\")\n\n\n@pytest.mark.offline\n@pytest.mark.asyncio\nasync def test_zhipu_embedding_sends_dimensions_when_embedding_dim_provided(\n    monkeypatch,\n):\n    captured_calls = []\n\n    class FakeClient:\n        def __init__(self, api_key=None):\n            self.api_key = api_key\n            self.embeddings = SimpleNamespace(create=self.create)\n\n        def create(self, **kwargs):\n            captured_calls.append(kwargs)\n            return SimpleNamespace(\n                data=[SimpleNamespace(embedding=_fake_embedding_vector())]\n            )\n\n    zhipu_module = _load_zhipu_module(monkeypatch, FakeClient)\n\n    result = await zhipu_module.zhipu_embedding.func(\n        [\"hello\"],\n        api_key=\"test-key\",\n        embedding_dim=2048,\n    )\n\n    assert isinstance(result, np.ndarray)\n    assert result.shape == (1, 1024)\n    assert captured_calls == [\n        {\"model\": \"embedding-3\", \"input\": [\"hello\"], \"dimensions\": 2048}\n    ]\n\n\n@pytest.mark.offline\n@pytest.mark.asyncio\nasync def test_zhipu_embedding_omits_dimensions_when_embedding_dim_not_provided(\n    monkeypatch,\n):\n    captured_calls = []\n\n    class FakeClient:\n        def __init__(self, api_key=None):\n            self.api_key = api_key\n            self.embeddings = SimpleNamespace(create=self.create)\n\n        def create(self, **kwargs):\n            captured_calls.append(kwargs)\n            return SimpleNamespace(\n                data=[SimpleNamespace(embedding=_fake_embedding_vector())]\n            )\n\n    zhipu_module = _load_zhipu_module(monkeypatch, FakeClient)\n\n    await zhipu_module.zhipu_embedding.func([\"hello\"], api_key=\"test-key\")\n\n    assert captured_calls == [{\"model\": \"embedding-3\", \"input\": [\"hello\"]}]\n\n\n@pytest.mark.offline\n@pytest.mark.asyncio\nasync def test_zhipu_complete_forwards_official_thinking(monkeypatch):\n    captured_calls = []\n\n    class FakeClient:\n        def __init__(self, api_key=None):\n            self.api_key = api_key\n            self.chat = SimpleNamespace(completions=SimpleNamespace(create=self.create))\n\n        def create(self, **kwargs):\n            captured_calls.append(kwargs)\n            return _fake_chat_response(content=\"final answer\")\n\n    zhipu_module = _load_zhipu_module(monkeypatch, FakeClient)\n\n    result = await zhipu_module.zhipu_complete_if_cache(\n        prompt=\"hello\",\n        api_key=\"test-key\",\n        thinking={\"type\": \"enabled\"},\n    )\n\n    assert result == \"final answer\"\n    assert captured_calls[0][\"thinking\"] == {\"type\": \"enabled\"}\n\n\n@pytest.mark.offline\n@pytest.mark.asyncio\nasync def test_zhipu_complete_filters_reasoning_when_cot_disabled(monkeypatch):\n    class FakeClient:\n        def __init__(self, api_key=None):\n            self.api_key = api_key\n            self.chat = SimpleNamespace(completions=SimpleNamespace(create=self.create))\n\n        def create(self, **kwargs):\n            return _fake_chat_response(\n                content=\"visible answer\",\n                reasoning_content=\"hidden chain of thought\",\n            )\n\n    zhipu_module = _load_zhipu_module(monkeypatch, FakeClient)\n\n    result = await zhipu_module.zhipu_complete_if_cache(\n        prompt=\"hello\",\n        api_key=\"test-key\",\n        enable_cot=False,\n    )\n\n    assert result == \"visible answer\"\n\n\n@pytest.mark.offline\n@pytest.mark.asyncio\nasync def test_zhipu_complete_includes_reasoning_when_cot_enabled(monkeypatch):\n    class FakeClient:\n        def __init__(self, api_key=None):\n            self.api_key = api_key\n            self.chat = SimpleNamespace(completions=SimpleNamespace(create=self.create))\n\n        def create(self, **kwargs):\n            return _fake_chat_response(\n                content=\"visible answer\",\n                reasoning_content=\"hidden chain of thought\",\n            )\n\n    zhipu_module = _load_zhipu_module(monkeypatch, FakeClient)\n\n    result = await zhipu_module.zhipu_complete_if_cache(\n        prompt=\"hello\",\n        api_key=\"test-key\",\n        enable_cot=True,\n    )\n\n    assert result == \"<think>hidden chain of thought</think>visible answer\"\n\n\n@pytest.mark.offline\n@pytest.mark.asyncio\nasync def test_zhipu_keyword_extraction_ignores_reasoning_content(monkeypatch):\n    class FakeClient:\n        def __init__(self, api_key=None):\n            self.api_key = api_key\n            self.chat = SimpleNamespace(completions=SimpleNamespace(create=self.create))\n\n        def create(self, **kwargs):\n            return _fake_chat_response(\n                content='{\"high_level_keywords\": [\"AI\"], \"low_level_keywords\": [\"RAG\"]}',\n                reasoning_content=\"this should not be parsed\",\n            )\n\n    zhipu_module = _load_zhipu_module(monkeypatch, FakeClient)\n\n    result = await zhipu_module.zhipu_complete(\n        prompt=\"hello\",\n        api_key=\"test-key\",\n        keyword_extraction=True,\n        enable_cot=True,\n    )\n\n    assert result.high_level_keywords == [\"AI\"]\n    assert result.low_level_keywords == [\"RAG\"]\n"
  }
]